From 49d3fae4bb2e494e1352f5ce3e7a92cbb3d30d0b Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:23:09 +0100 Subject: [PATCH 01/30] feat(slicing): thumbnails are added after every slice, in the slicer 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. --- AGENTS.md | 5 + docs/architecture/lib-map.md | 3 +- docs/commands/gcode-thumbnails.md | 29 +- docs/printers/u1-orca-cli.md | 2 +- docs/workflows/print-pipeline.md | 4 +- lib/commands/gcode_resume.py | 68 ++-- lib/commands/gcode_thumbnails.py | 12 +- lib/commands/retarget.py | 259 +++++++++------ lib/commands/slice.py | 233 +++++++++----- lib/gcode_thumbnails.py | 501 +++++++++++++++++++++--------- lib/slicing/orca_cli.py | 191 +++++++----- lib/slicing/thumbnails.py | 341 ++++++++++++++++++++ tests/test_gcode_thumbnails.py | 9 +- tests/test_thumbnail_guard.py | 74 +++++ tests/test_thumbnail_layers.py | 320 +++++++++++++++++++ tests/thumbnail_fixtures.py | 75 +++++ 16 files changed, 1692 insertions(+), 434 deletions(-) create mode 100644 lib/slicing/thumbnails.py create mode 100644 tests/test_thumbnail_guard.py create mode 100644 tests/test_thumbnail_layers.py create mode 100644 tests/thumbnail_fixtures.py diff --git a/AGENTS.md b/AGENTS.md index 4a9b497a..c4fea61d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -405,6 +405,11 @@ skills (`atomic-commits`, `ai-review-before-commit`, `pre-commit-gate`, recorded in `NAME.slice-warnings.json` (`3d slice-warnings record`, or `3d slice`) and resolved or accepted with a reason. The full model → slice → G-code → start order is `docs/workflows/print-pipeline.md`. +- A G-code never lacks its thumbnails (the Orca CLI writes none): every code path that runs a + slicer calls `slicing.thumbnails.finalize_*` on its output before the G-code is hashed or moved, + `3d printer upload|start` call `fix_in_place`, `print-preflight --fix` repairs. A new slicing + entry point without it fails `tests/test_thumbnail_guard.py`; layers in + `docs/commands/gcode-thumbnails.md`. ## Verification before "done" diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 0b29ff75..977e3528 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -98,6 +98,7 @@ Target package: `lib/geometry/`, with subpackages only when a real boundary appe ### Slicing / Print Workflow - `lib/slicing/printing.py` +- `lib/slicing/thumbnails.py` — the layers that keep a sliced G-code from missing its thumbnails: slicer settings that ask for 48x48/96x96/300x300 (`spec_for`, Prusa flags), `finalize_*` after every slice (G-code blocks, `.3mf` plate pictures), `fix_in_place` for `printer upload|start`; `tests/test_thumbnail_guard.py` fails a slicing module that skips it. - `lib/slicing/orca_cli.py` — the one place for Snapmaker Orca / OrcaSlicer CLI knowledge: binary discovery, the slicing copy (no `` block), headless `slice_project` under the heavy lock, failure classification and the `KNOWN_QUIRKS` catalogue behind `docs/printers/u1-orca-cli.md` (#431). - `lib/prep/` — `3d prep` (#431): `pipeline.py` (ordered, cached steps; `cache.py`), `steps.py` (adapters), `cli.py` (flags + spec file), and one library per step: `meshfix.py` (diagnose/repair + backend interface for the public meshfix library), `orient.py`, `overhang.py`, `adapt.py` (cavity fill, 45 degree wedges), `voxel.py` (scan-converted occupancy), `fit.py` (item-table fit envelope), `stability.py`, `regions.py` (landmark boxes -> colour parts/mixes), `layers.py` (adaptive layer-height plan), `project3mf.py` (Orca project 3MF + manifest), `gcode_gates.py` / `gcode_view.py`, and the proof pack (`raster.py`, `panels.py`, `report.py`, `proof.py`). Results follow `contracts.StepResult`. - `prep_cli.py` — entry script `3d prep` runs through `cli.pyrun` with the prep dependencies (`uv sync --extra prep`). @@ -285,7 +286,7 @@ Every current root Python module is listed here so future additions are intentio - `gcode_scan.py` — one streaming pass over a sliced G-code, layer by layer: per-layer Z / height / extrusion runs (type, tool, XY) handed to consumers, plus M600 pauses, tool changes, grams per tool and feature type and footer settings; the whole file is never held; numpy only. - `stripe_contrast.py` — perceptual layer-stripe contrast of a stack of single-colour layers at their real heights: eye-blurred profile vs its period mean, worst CIEDE2000 per layer (the `stripe contrast` check of `3d gcode-mix-gate`). - `gcode_mix_gate.py` — the multi-colour gates of `3d gcode-mix-gate`: colour volumes attributed from the part STLs (pure / mixed per layer on the layer's own outer-wall evidence / accent / thin), pause, heads and prime tower, pocket void, layer heights, footer settings, and the `--mix-teeth` / `--cavity-teeth` controls that must FAIL (a gate that cannot fail is vacuous); numpy/scipy/trimesh. -- `gcode_thumbnails.py` — printer-screen thumbnail blocks in G-code: stdlib-only check against the profile's `thumbnails` spec, Orca-format block writer, z-buffered toolpath preview render (numpy); backs `3d gcode-thumbnails`. +- `gcode_thumbnails.py` — printer-screen thumbnail blocks in G-code: stdlib-only check against the profile's `thumbnails` spec (48x48/96x96/300x300 PNG when it names none), Orca-format block writer, z-buffered toolpath preview render (numpy); backs `3d gcode-thumbnails`. - `frame_check.py` — render framing checks. - `ha_deploy.py` — `3d ha deploy`: manifest/snippet/compose/vendor checks for `integrations/homeassistant/`, plan from a host probe, backup → submodule checkout → symlinks/copies → check_config → reloads → service recreate → post-checks with rollback, HA-repo deploy PR. - `ha_vision.py` — `3d ha vision`: generates the Home Assistant side of camera vision (vision sensors, alert frame grabber, go2rtc stream, camera proxy service, dashboard camera cards, ghost switch) for every fleet printer with a camera, from `fleet/printers.yaml`; checks the committed files for drift and missing entities. diff --git a/docs/commands/gcode-thumbnails.md b/docs/commands/gcode-thumbnails.md index e300030c..811ab900 100644 --- a/docs/commands/gcode-thumbnails.md +++ b/docs/commands/gcode-thumbnails.md @@ -5,16 +5,21 @@ A printer screen (Snapmaker U1, any Klipper/Moonraker host, Creality K1 stock fi ## Usage ``` -3d gcode-thumbnails check ... [--json] -3d gcode-thumbnails add ... [--image PNG] [--size WxH/FMT]... [--out FILE] [--replace] [--preview PNG] [--json] +3d gcode-thumbnails check ... [--json] +3d gcode-thumbnails add ... [--image PNG] [--size WxH/FMT]... [--out FILE] [--replace] [--preview PNG] [--3mf X.3mf [--plate N]] [--json] +3d gcode-thumbnails add ... [--image PNG] [--json] ``` +You rarely need to run this yourself: every path that produces or sends a G-code adds the thumbnails on its own (see [Layers](#layers-a-thumbnail-cannot-be-missed)). + | Subcommand / option | What | |---|---| -| `check` | PASS/FAIL per file. PASS = every size of the profile's `; thumbnails =` is present as a valid block in the first MiB (what Moonraker reads). No spec in the file: PASS when at least one thumbnail is present. Exit 1 on any FAIL. | +| `check` | PASS/FAIL per file. PASS = every size of the profile's `; thumbnails =` is present as a valid block in the first MiB (what Moonraker reads). No spec in the file: 48x48, 96x96 and 300x300 PNG are required. A `.3mf`: every plate has `Metadata/plate_N.png` and `plate_N_small.png`. Exit 1 on any FAIL. | | `add` | Insert the missing sizes. The file is rewritten atomically with its mode and mtime kept (a staleness check "G-code older than its project" must not be fooled). A file that already passes is left byte-identical. | | `--image PNG` | Use this picture (e.g. a plate render; transparent margins trimmed) instead of drawing one from the G-code. Needs Pillow. | -| `--size WxH[/FMT]` | Sizes for a G-code without a `; thumbnails =` spec (repeatable). 1..4096 px per side, otherwise exit 2 (also applies to the profile's own spec). | +| `--size WxH[/FMT]` | Sizes added on top of the required ones (repeatable). 1..4096 px per side, otherwise exit 2 (also applies to the profile's own spec). | +| `--3mf X.3mf [--plate N]` | Also add the missing `Metadata/plate_N.png` (512 px) and `plate_N_small.png` (128 px) to this project, drawn from the same render (written atomically, the project's mtime kept so it does not look newer than the G-code). Plate 1 also gets the package thumbnail relationship. | +| `add X.3mf` | A `.gcode.3mf` / sliced project: the missing plate pictures, drawn from each embedded `Metadata/plate_N.gcode` (or from `--image`). | | `--out FILE` | Write the result to `FILE`, leave the input untouched (one input only). | | `--preview PNG` | Also save the full-size G-code render. | | `--replace` | Redraw every size, dropping the thumbnail blocks already in the head (e.g. after a better `--image`). | @@ -27,6 +32,20 @@ A printer screen (Snapmaker U1, any Klipper/Moonraker host, Creality K1 stock fi 3d gcode-thumbnails add other.gcode --size 300x300/PNG --size 32x32/PNG ``` +## Layers: a thumbnail cannot be missing + +The Orca CLI writes no thumbnails, so nothing may depend on somebody remembering `add`. Five independent layers; each one alone gets a bare G-code to all required sizes, and none changes a G-code that already has them (it stays byte-identical; a block that is there is never duplicated or redrawn). The code is `lib/slicing/thumbnails.py`. + +| Layer | Where | What it does | +|---|---|---| +| (a) ask the slicer | `slice_project` / `slice_stl_with_profiles` (`3d prep`, calibration), `3d slice` of a `.3mf`, `3d retarget --gcode`, PrusaSlicer via `--thumbnails 48x48,96x96,300x300` | The slicer's `thumbnails` setting also asks for 48x48, 96x96 and 300x300 PNG on top of what the preset names (the preset's own sizes and string/list shape are kept; a copy is slicing-ready, the user's file is never rewritten). A slicer that writes thumbnails (Orca GUI, PrusaSlicer, Bambu Studio) does; for every other one the config block names the sizes the checks require. | +| (b) draw after the slice | `3d slice`, `3d prep`, `3d retarget --gcode`, `3d calibrate gen` (all kinds), `3d gcode-resume` (its output copies the head of a G-code that may have had none) | Before the G-code is hashed, moved or recorded, the missing sizes are drawn from the toolpaths and inserted. A `.gcode.3mf` / `sliced.3mf` gets its missing plate pictures from the same render. A G-code that extrudes nothing visible (console macros) gets a neutral grey tile. A failure is a failed slice (`3d slice` exits 1, `3d prep` reports `thumbnails`) and names the repair command. `--dry-run` keeps no G-code and renders nothing. | +| (c) before upload / start | `3d printer upload`, `3d printer start` | The missing sizes are added to FILE **in place** (atomic, mode and mtime kept, a current slice-warnings record follows the new SHA-256) before the snapshot is hashed; the old and new SHA-256 are printed. The remote name is content-addressed, so the fixed bytes have a name of their own: a `start` after such a fix finds no stored copy and asks you to upload again. | +| (d) preflight | `3d print-preflight` | FAIL on a missing size, naming the exact command (`3d gcode-thumbnails add 'FILE'`). `--fix` runs it before the checks (the one option that writes). | +| (e) the gate | `tests/test_thumbnail_guard.py` (part of `dev run test` and the pre-commit fast gate) | Fails when a module of `lib/` runs a slicer (`--slice`, `--outputdir`, `-g --output`) without calling a layer-(b) function, unless it is listed as exempt with a reason (`slice-check` discards its G-code; the mix tester only reads a probe project back). | + +Not covered by (b): the U1 calibration plate generator (`calibration_plates/u1_underlay.py`, [#724](https://github.com/alex-mextner/3d-cli/issues/724): the module is over the repo's lint file-length gate and cannot be committed until it is split; its G-code gets thumbnails from layers (c) and (d)), and a G-code that some other program writes straight to disk, outside `lib/`. Layers (c) and (d) catch it before it reaches a printer. A `3d mix tester` project is sliced in the Orca GUI, which writes its own thumbnails. + ## Format written Exactly Orca's own block (GCode/Thumbnails.hpp), right after `; HEADER_BLOCK_END` and its blank line (after existing thumbnail blocks, if any; at the file start when there is no header) — always before the first move: @@ -52,4 +71,4 @@ Without `--image`, every visible extrusion move (walls, surfaces, bridges, brim, ## Printer preflights -`lib/gcode_thumbnails.py` is importable: `check(path) -> ThumbCheck(ok, required, present, missing, reason)` (stdlib only, raises `InputNotFound` for a missing file) and `ensure(path, image=None, sizes=None, out=None) -> ThumbCheck` (numpy). +`lib/gcode_thumbnails.py` is importable: `check(path) -> ThumbCheck(ok, required, present, missing, reason)` (stdlib only, raises `InputNotFound` for a missing file) and `ensure(path, image=None, sizes=None, out=None) -> ThumbCheck` (numpy). `lib/slicing/thumbnails.py` has the layer entry points: `finalize_slice(gcode, three_mf=None)` / `finalize_gcode` / `finalize_workdir` (b), `fix_in_place` (c), `spec_for` / `with_project_spec` / `with_machine_spec` (a). diff --git a/docs/printers/u1-orca-cli.md b/docs/printers/u1-orca-cli.md index 2f8e1908..e9aae89b 100644 --- a/docs/printers/u1-orca-cli.md +++ b/docs/printers/u1-orca-cli.md @@ -76,7 +76,7 @@ Each row: what you see, why, what to do, where it was observed. | `arrange-moves-objects` | objects overlap, slice fails with -101 | without `--arrange 0` the CLI arranges the plate itself | pass `--arrange 0` | babushkin-dom `gen/verify_slices.py` (P1S roofs, error -101) | | `inherits-one-level` | settings of grandparent presets are lost | `--load-settings` resolves one `inherits` level | flatten the chain (`SystemPresets.flat`) | babushkin-dom `gen/build_orca_3mf.py` | | `application-tag-newer` | the CLI refuses a project we wrote: `result.json` `return_code` -24, "Unsupported 3MF version" | an `Application` tag newer than the CLI's build number is rejected (even `Snapmaker_Orca-2.4.0`, the tag the CLI wrote itself; reproduced by `test_real_cli_refuses_its_own_unstamped_project`) | stamp the build number (`01.10.01.50`) | babushkin-dom `gen/build_orca_3mf.py` `orca_version()` | -| `no-thumbnails` | sliced G-code has no preview image | `export_gcode` gets a null thumbnail callback | `3d gcode-thumbnails add FILE` | babushkin-dom `gen/verify_slices.py` | +| `no-thumbnails` | sliced G-code has no preview image | `export_gcode` gets a null thumbnail callback | nothing: `slicing.orca_cli` asks for 48x48/96x96/300x300 and draws what is missing after every slice (by hand: `3d gcode-thumbnails add FILE`) | babushkin-dom `gen/verify_slices.py` | | `host-noise-warnings` | a clean slice lists warnings (version banner, OpenGL / GLFW / Wayland) | headless thumbnail renderer and banner logged as warnings / errors | ignore; `SliceOutcome.warnings` omits them (`slice_warnings.HOST_NOISE`) | `lib/slicing/slice_warnings.py` | ## Related diff --git a/docs/workflows/print-pipeline.md b/docs/workflows/print-pipeline.md index d8824a2d..f53c69dd 100644 --- a/docs/workflows/print-pipeline.md +++ b/docs/workflows/print-pipeline.md @@ -32,7 +32,7 @@ it keeps the orientation, supports and tuned settings and fails loudly when the Every slice records the slicer's own warnings next to the G-code, bound to its SHA-256: `3d slice` does it itself; a project script that runs the slicer CLI keeps its stdout+stderr, `result.json` and (with `--export-3mf`) the sliced 3MF, and after the last edit of the G-code -(`3d gcode-thumbnails add` moves a current record along) calls +(`3d gcode-thumbnails add` moves a current record along; `3d prep`, `3d slice` and `3d retarget` add the thumbnails themselves) calls [`3d slice-warnings record`](../commands/slice-warnings.md): ```bash @@ -57,7 +57,7 @@ All of these run inside `3d print-preflight FILE.gcode`; each is also a command | 4 | head sequence | [`3d slice-check PLATE.3mf --gcode FILE.gcode --check head-sequence`](../commands/slice-check.md) | an object printed on the wrong head on some layer (mixed-filament patterns); needs the project 3MF | | 4a | mix face | [`3d mix check PLATE.3mf`](../commands/mix.md) (the same check, on the project 3MF next to the G-code) | a part in a mixed filament whose top outweighs half its walls; by the project 3MF (FAIL), else by the G-code alone for objects printed by several heads (WARN) | | 4b | mix layers | [`3d mix gcode FILE.gcode --project PLATE.3mf`](../commands/mix.md) (the same check) | the additional colour printed on consecutive layers, a colour band or a mixed layer thicker than 0.12 mm, measured on the real per-layer heads and Z; works without the project for objects that alternate one head per layer | -| 5 | thumbnails | [`3d gcode-thumbnails check FILE.gcode`](../commands/gcode-thumbnails.md) | missing printer-screen previews | +| 5 | thumbnails | [`3d gcode-thumbnails check FILE.gcode`](../commands/gcode-thumbnails.md) | missing printer-screen previews (added automatically by every slicing wrapper, `upload`/`start` and `print-preflight --fix`) | | 6 | visible faces | [`3d visible-faces --gcode FILE.gcode --visible 'WALL*:y<104.8'`](../commands/visible-faces.md) | support or sagging islands on a visible side, in the real toolpaths | ## 4. `print-preflight`, then `printer start` diff --git a/lib/commands/gcode_resume.py b/lib/commands/gcode_resume.py index 5c3fd547..8cd32a1e 100644 --- a/lib/commands/gcode_resume.py +++ b/lib/commands/gcode_resume.py @@ -16,9 +16,15 @@ """ from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import InputNotFound, UsageError +if TYPE_CHECKING: + from printer_ops.devices import ResumeProfile + USAGE = """3d gcode-resume FILE.gcode --printer NAME (--top-z Z | --layer N) [options] 3d gcode-resume FILE.gcode --where BYTE 3d gcode-resume FILE.gcode --table FIRST:LAST @@ -76,15 +82,8 @@ def _number(opts: dict[str, str], key: str, kind: type[int] | type[float]) -> in command="gcode-resume") from exc -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - from pathlib import Path - +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str]]: + """Split argv into positionals and an option dict (`-o` is stored as `--out`).""" pos: list[str] = [] opts: dict[str, str] = {} i = 0 @@ -104,6 +103,11 @@ def run(argv: list[str]) -> int: else: pos.append(a) i += 1 + return pos, opts + + +def _input_gcode(pos: list[str], opts: dict[str, str]) -> Path: + """Validate the single input file and that exactly one mode was chosen.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="gcode-resume") gcode = Path(pos[0]) @@ -113,16 +117,11 @@ def run(argv: list[str]) -> int: if len(modes) != 1: raise UsageError(f"give exactly one of --where, --table, --top-z, --layer (got {', '.join(modes) or 'none'})", command="gcode-resume", remediation=["See `3d gcode-resume --help`."]) + return gcode - import gcode_resume as gr - where = _number(opts, "--where", int) - if where is not None: - print(gr.where(gr.Walk.read(gcode), int(where))) - return 0 - if "--table" in opts: - print(gr.table(gr.Walk.read(gcode), opts["--table"])) - return 0 +def _resume_device(opts: dict[str, str]) -> ResumeProfile: + """The --printer device's `resume:` block (required to build).""" if "--printer" not in opts: raise UsageError("building a resume needs --printer NAME (its `resume:` block in printer-devices.yaml)", command="gcode-resume", remediation=["Run `3d printer list` to see configured printers."]) @@ -132,10 +131,18 @@ def run(argv: list[str]) -> int: if device.resume is None: raise UsageError(f"printer {device.name!r} has no `resume:` block in {device.source}", command="gcode-resume", remediation=["Add one; the keys are listed in `3d gcode-resume --help`."]) + return device.resume + + +def _build(gcode: Path, opts: dict[str, str]) -> int: + """Build the resume G-code, write it beside the input (never over it) and print the summary.""" + import gcode_resume as gr + + profile = _resume_device(opts) top_z, layer = _number(opts, "--top-z", float), _number(opts, "--layer", int) paused = _number(opts, "--paused-at", int) tower_top = _number(opts, "--tower-top", float) - res = gr.build(gr.Walk.read(gcode), device.resume, name=gcode.name, top_z=top_z, + res = gr.build(gr.Walk.read(gcode), profile, name=gcode.name, top_z=top_z, layer=None if layer is None else int(layer), paused_at=None if paused is None else int(paused), tower_top=None if tower_top is None else float(tower_top), no_tower="--no-tower" in opts, home_xy="--home-xy" in opts) @@ -144,10 +151,35 @@ def run(argv: list[str]) -> int: raise UsageError(f"--out {dest} is the input G-code; the original must stay", command="gcode-resume", remediation=["Give another -o path, or omit it for _resume_L.gcode."]) dest.write_bytes(b"\n".join(res.lines)) + from slicing.thumbnails import finalize_gcode + + finalize_gcode(dest) # the resume copies the head of a G-code that may have had none print(res.summary(dest)) return 0 +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + pos, opts = _parse_args(argv) + gcode = _input_gcode(pos, opts) + + import gcode_resume as gr + + where = _number(opts, "--where", int) + if where is not None: + print(gr.where(gr.Walk.read(gcode), int(where))) + return 0 + if "--table" in opts: + print(gr.table(gr.Walk.read(gcode), opts["--table"])) + return 0 + return _build(gcode, opts) + + COMMAND = Command( name="gcode-resume", group="SLICING", diff --git a/lib/commands/gcode_thumbnails.py b/lib/commands/gcode_thumbnails.py index 7c04e9eb..0d77b720 100644 --- a/lib/commands/gcode_thumbnails.py +++ b/lib/commands/gcode_thumbnails.py @@ -9,6 +9,8 @@ WHY: the Orca-family CLI (`--slice`) exports G-code without thumbnails — it passes a null thumbnail callback to export_gcode — so every headless slice shows a blank preview on the Snapmaker U1 / Klipper / Creality K1 screen even though the profile sets `thumbnails`. + Nobody has to remember this command: the slicing wrappers, `3d printer upload|start` and + `3d print-preflight --fix` run it themselves (docs/commands/gcode-thumbnails.md, "Layers"). ACCESSED VIA: `3d gcode-thumbnails check|add`; printer preflights import lib/gcode_thumbnails.py `check()` directly. `check` runs in-process (stdlib only); `add` @@ -25,14 +27,19 @@ for a job comes from base64 blocks in the G-code header. Orca's CLI never writes them. check FILE... PASS/FAIL per file: every size in the profile's `; thumbnails =` - is present (no spec: at least one thumbnail). Exit 1 on FAIL. + is present (no spec: 48x48, 96x96 and 300x300 PNG). A `.3mf`: + every plate has Metadata/plate_N.png and plate_N_small.png. + Exit 1 on FAIL. add FILE... insert the missing sizes after `; HEADER_BLOCK_END` (before the first move), file rewritten atomically, mtime kept. A file that already has them is left untouched. --image PNG picture to use (e.g. a plate render) instead of drawing the preview from the G-code's extrusion moves - --size WxH[/FMT] sizes for a G-code without a `; thumbnails =` spec (repeatable); + --size WxH[/FMT] sizes added on top of the required ones (repeatable); 1..4096 px per side (the G-code render is capped at 2048 px) + --3mf X.3mf [--plate N] also add the missing Metadata/plate_N.png/_small.png to this project + (drawn from the same render); `add X.3mf` alone draws them from + the plate G-codes embedded in a `.gcode.3mf` --out FILE write the result there instead of in place (one input only) --preview PNG also save the full-size G-code render --replace redraw every size, dropping the thumbnails already there @@ -42,6 +49,7 @@ 3d gcode-thumbnails add print/*.gcode 3d gcode-thumbnails add plate.gcode --image plate.png 3d gcode-thumbnails add other.gcode --size 300x300/PNG --size 32x32/PNG + 3d gcode-thumbnails add sliced.gcode --3mf sliced.3mf """ diff --git a/lib/commands/retarget.py b/lib/commands/retarget.py index 5e838d67..f6526b2e 100644 --- a/lib/commands/retarget.py +++ b/lib/commands/retarget.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from slicing.orca_presets import PresetRef - from slicing.retarget_3mf import Report + from slicing.retarget_3mf import PresetSource, Report USAGE = """3d retarget --to PRINTER [options] Retarget a slicer project .3mf (Orca / Snapmaker Orca / Bambu Studio) to another printer: @@ -121,6 +121,55 @@ def _margin(value: str) -> float: return margin +def _store_value(opts: _Options, arg: str, value: str) -> None: + """Apply one `--flag VALUE` pair to the options.""" + if arg == "--margin": + opts.margin = _margin(value) + elif arg in _VALUED: + setattr(opts, _VALUED[arg], value) + else: + getattr(opts, _REPEATED[arg]).append(value) + + +def _store_bare(opts: _Options, arg: str) -> None: + """Apply a value-less argument: a boolean flag, or the single positional project path.""" + if arg == "--include-non-printable": + opts.include_non_printable = True + elif arg == "--dry-run": + opts.dry_run = True + elif arg in ("-v", "--verbose"): + opts.verbose = True + elif arg.startswith("-"): + raise UsageError( + f"unknown option {arg}", + command="retarget", + remediation=["Run '3d retarget --help'."], + ) + elif opts.src: + raise UsageError( + f"unexpected extra argument {arg!r}", + command="retarget", + remediation=["One project .3mf per run."], + ) + else: + opts.src = arg + + +def _check_required(opts: _Options) -> None: + if not opts.src: + raise UsageError( + "missing project .3mf", + command="retarget", + remediation=["3d retarget --to PRINTER"], + ) + if not opts.to: + raise UsageError( + "missing --to PRINTER", + command="retarget", + remediation=["Name the target machine preset, e.g. --to k1se"], + ) + + def _parse(argv: list[str]) -> _Options: opts = _Options() i = 0 @@ -133,48 +182,12 @@ def _parse(argv: list[str]) -> _Options: command="retarget", remediation=["Run '3d retarget --help'."], ) - value = argv[i + 1] + _store_value(opts, arg, argv[i + 1]) i += 2 - if arg == "--margin": - opts.margin = _margin(value) - elif arg in _VALUED: - setattr(opts, _VALUED[arg], value) - else: - getattr(opts, _REPEATED[arg]).append(value) continue - if arg == "--include-non-printable": - opts.include_non_printable = True - elif arg == "--dry-run": - opts.dry_run = True - elif arg in ("-v", "--verbose"): - opts.verbose = True - elif arg.startswith("-"): - raise UsageError( - f"unknown option {arg}", - command="retarget", - remediation=["Run '3d retarget --help'."], - ) - elif opts.src: - raise UsageError( - f"unexpected extra argument {arg!r}", - command="retarget", - remediation=["One project .3mf per run."], - ) - else: - opts.src = arg + _store_bare(opts, arg) i += 1 - if not opts.src: - raise UsageError( - "missing project .3mf", - command="retarget", - remediation=["3d retarget --to PRINTER"], - ) - if not opts.to: - raise UsageError( - "missing --to PRINTER", - command="retarget", - remediation=["Name the target machine preset, e.g. --to k1se"], - ) + _check_required(opts) return opts @@ -182,20 +195,8 @@ def _slug(text: str) -> str: return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "target" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - opts = _parse(argv) - - # Imported lazily so `3d help` and command discovery stay light. - from slicing import orca_presets, retarget_3mf as rt - - started = time.perf_counter() - src = Path(opts.src) +def _output_path(opts: _Options, src: Path) -> Path: + """Resolve the output project path and refuse to overwrite the source or the G-code target.""" out = ( Path(opts.out) if opts.out @@ -219,12 +220,14 @@ def run(argv: list[str]) -> int: command="retarget", extra="The G-code would overwrite a project file; pick another name.", ) - for pname in opts.setting_sets: - if pname not in rt.PRESETS: - raise InvalidArgument( - "--preset", pname, sorted(rt.PRESETS), command="retarget" - ) - overrides = [rt.parse_assignment(s) for s in opts.sets] + return out + + +def _resolve_sources(opts: _Options) -> tuple[ + dict[str, PresetRef], dict[str, list[str]], dict[str, PresetSource] +]: + """Look up the machine/process/filament presets: (refs, other matches, flattened sources).""" + from slicing import orca_presets, retarget_3mf as rt catalog = orca_presets.PresetCatalog([Path(p) for p in opts.preset_dirs]) queries = {"machine": opts.to, "process": opts.process, "filament": opts.filament} @@ -244,6 +247,54 @@ def run(argv: list[str]) -> int: kind: rt.PresetSource(ref.name, catalog.flatten(ref)) for kind, ref in refs.items() } + return refs, others, sources + + +def _finish( + problems: list[str], opts: _Options, out: Path, elapsed: float +) -> None: + """Print the validation verdict; a failed validation raises.""" + if problems: + print(f"VALIDATION: FAIL ({len(problems)} problem(s))") + for p in problems: + print(f" - {p}") + raise GateFailure( + "retargeted 3MF failed validation; no file kept", + command="retarget", + silent=True, + ) + size = "" if opts.dry_run else f" ({out.stat().st_size / 1024:.0f} KiB)" + print( + "VALIDATION: PASS (XML/JSON well-formed, required settings present, extruder/filament count, footprint inside bed)" + ) + print( + f"RESULT: PASS {'dry run, nothing written' if opts.dry_run else out}{size} in {elapsed:.1f} s" + ) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + opts = _parse(argv) + + # Imported lazily so `3d help` and command discovery stay light. + from slicing import retarget_3mf as rt + + started = time.perf_counter() + src = Path(opts.src) + out = _output_path(opts, src) + for pname in opts.setting_sets: + if pname not in rt.PRESETS: + raise InvalidArgument( + "--preset", pname, sorted(rt.PRESETS), command="retarget" + ) + overrides = [rt.parse_assignment(s) for s in opts.sets] + + refs, others, sources = _resolve_sources(opts) machine = sources["machine"] parts, report = rt.retarget( @@ -267,23 +318,7 @@ def run(argv: list[str]) -> int: report.problems = problems _print_report(report, refs, opts, others) - elapsed = time.perf_counter() - started - if problems: - print(f"VALIDATION: FAIL ({len(problems)} problem(s))") - for p in problems: - print(f" - {p}") - raise GateFailure( - "retargeted 3MF failed validation; no file kept", - command="retarget", - silent=True, - ) - size = "" if opts.dry_run else f" ({out.stat().st_size / 1024:.0f} KiB)" - print( - "VALIDATION: PASS (XML/JSON well-formed, required settings present, extruder/filament count, footprint inside bed)" - ) - print( - f"RESULT: PASS {'dry run, nothing written' if opts.dry_run else out}{size} in {elapsed:.1f} s" - ) + _finish(problems, opts, out, time.perf_counter() - started) if opts.gcode and not opts.dry_run: return _slice_and_check( @@ -347,6 +382,7 @@ def _slice_and_check( from commands import gcode_bounds from commands.slice import _record_warnings from slicing import retarget_3mf as rt + from slicing.thumbnails import spec_for as thumbnail_spec_for binary = _find_orca_cli() presets = rt.split_project_settings( @@ -358,6 +394,8 @@ def _slice_and_check( work = Path(tmp) paths = {kind: work / f"{kind}.json" for kind in presets} for kind, body in presets.items(): + if kind == "machine": # layer (a): the machine preset also asks for the thumbnail sizes + body = {**body, "thumbnails": thumbnail_spec_for(body)} paths[kind].write_text(json.dumps(body, indent=1), encoding="utf-8") stl = work / "part.stl" stl.write_bytes(rt.export_stl(parts)) @@ -380,6 +418,9 @@ def _slice_and_check( command="retarget", remediation=[tail or "no slicer output"], ) + from slicing.thumbnails import finalize_slice + + finalize_slice(produced[0]) # the CLI writes no thumbnails; before the move, so the sidecar hashes the final bytes shutil.move(produced[0], gcode) _record_warnings(str(gcode), proc.stdout + proc.stderr, str(work)) print(f"sliced OK -> {gcode} ({gcode.stat().st_size} bytes)") @@ -395,14 +436,9 @@ def _fmt(value: object, limit: int = 40) -> str: return text if text else "''" -def _print_report( - report: Report, - refs: dict[str, PresetRef], - opts: _Options, - others: dict[str, list[str]], +def _print_presets( + refs: dict[str, PresetRef], others: dict[str, list[str]] ) -> None: - print("=" * 72) - print(f"retarget: {report.source} -> {report.target}") for kind, ref in refs.items(): print(f" {kind:<8} preset: {ref.name} [{ref.origin}]") if kind in others: @@ -410,9 +446,9 @@ def _print_report( ", ..." if len(others[kind]) > 4 else "" ) print(f" also matched: {also} (pass the exact name to choose)") - for label in report.objects: - state = "KEPT" if int(label.split(":")[0]) in report.kept else "dropped" - print(f" object {label} -> {state}") + + +def _print_fit(report: Report) -> None: p = report.placement w, d = report.footprint turn = ( @@ -424,27 +460,44 @@ def _print_report( f" fit footprint {w:.1f} x {d:.1f} mm in bed {report.bed.width:g} x {report.bed.depth:g} mm " f"(margin {report.margin:g}); {turn}moved by ({p.dx:+.1f}, {p.dy:+.1f}) mm; top z {report.zmax:.1f} mm" ) + + +def _print_group(origin: str, rows: list[tuple[str, object, object]], verbose: bool) -> None: + print(f" {origin}: {len(rows)}") + shown = rows if verbose or len(rows) <= _SHOWN_PER_GROUP else rows[:_SHOWN_PER_GROUP] + for key, old, new in shown: + print(f" {key}: {_fmt(old)} -> {_fmt(new)}") + if len(shown) < len(rows): + print( + f" ... {len(rows) - len(shown)} more (-v lists all): " + + ", ".join(k for k, _, _ in rows[_SHOWN_PER_GROUP : _SHOWN_PER_GROUP + 8]) + + (", ..." if len(rows) - len(shown) > 8 else "") + ) + + +def _print_changes(report: Report, verbose: bool) -> None: groups: dict[str, list[tuple[str, object, object]]] = {} for c in report.changes: groups.setdefault(c.origin, []).append((c.key, c.old, c.new)) print(f" changed {len(report.changes)} setting key(s)") for origin, rows in groups.items(): - print(f" {origin}: {len(rows)}") - shown = ( - rows - if opts.verbose or len(rows) <= _SHOWN_PER_GROUP - else rows[:_SHOWN_PER_GROUP] - ) - for key, old, new in shown: - print(f" {key}: {_fmt(old)} -> {_fmt(new)}") - if len(shown) < len(rows): - print( - f" ... {len(rows) - len(shown)} more (-v lists all): " - + ", ".join( - k for k, _, _ in rows[_SHOWN_PER_GROUP : _SHOWN_PER_GROUP + 8] - ) - + (", ..." if len(rows) - len(shown) > 8 else "") - ) + _print_group(origin, rows, verbose) + + +def _print_report( + report: Report, + refs: dict[str, PresetRef], + opts: _Options, + others: dict[str, list[str]], +) -> None: + print("=" * 72) + print(f"retarget: {report.source} -> {report.target}") + _print_presets(refs, others) + for label in report.objects: + state = "KEPT" if int(label.split(":")[0]) in report.kept else "dropped" + print(f" object {label} -> {state}") + _print_fit(report) + _print_changes(report, opts.verbose) for note in report.notes: print(f" note {note}") print("-" * 72) diff --git a/lib/commands/slice.py b/lib/commands/slice.py index 9d10d8c0..d24631a9 100644 --- a/lib/commands/slice.py +++ b/lib/commands/slice.py @@ -29,6 +29,7 @@ import re import shutil import subprocess +import zipfile import sys import tempfile from dataclasses import dataclass @@ -120,11 +121,51 @@ def _slice_prusa(binary: str, work_input: str, out: str, profile: str, printer: args += ["--load", p] if printer: args += ["--load", printer] # best-effort; slicer-specific preset naming varies. + args += _prusa_thumbnail_args() # layer (a) args.append(work_input) with open(log, "w") as lf: return subprocess.run(args, stdout=lf, stderr=subprocess.STDOUT).returncode +def _prusa_thumbnail_args() -> list[str]: + from slicing.thumbnails import PRUSA_ARGS + + return list(PRUSA_ARGS) + + +def _with_thumbnail_spec(work_input: str, tmpdir: str, slicer_kind: str) -> str: + """Layer (a) for a 3MF project given to an Orca-family slicer: a copy of it that also asks for the + thumbnail sizes (the user's file stays as it is). Other inputs are sliced as given.""" + if slicer_kind == "prusa" or not work_input.lower().endswith(".3mf") or not zipfile.is_zipfile(work_input): + return work_input # (a file that is no zip is the slicer's to reject, with its own message) + from pathlib import Path + + from slicing.thumbnails import with_project_spec + + copy = Path(tmpdir) / os.path.basename(work_input) + with_project_spec(Path(work_input), copy) + return str(copy) + + +def _finalize_thumbnails(produced: str) -> None: + """Layer (b): the slicer's output carries every required thumbnail before it is kept. A `.gcode.3mf` + gets its plate images instead. A failure is a failed slice: the message names the repair command.""" + from pathlib import Path + + from slicing import thumbnails + + out = Path(produced) + try: + if out.name.lower().endswith(".3mf"): + thumbnails.finalize_embedded(out) + else: + thumbnails.finalize_slice(out) + except GateFailure as exc: + print(f"STATUS: FAIL - sliced, but the thumbnails could not be added: {exc.message}") + raise + print(f" thumbnails: present ({os.path.basename(produced)})") + + def _slice_orca(binary: str, work_input: str, outdir: str, profile: str, printer: str, log: str) -> int: args = [binary, "--slice", "0", "--outputdir", outdir] loads = profile @@ -137,7 +178,8 @@ def _slice_orca(binary: str, work_input: str, outdir: str, profile: str, printer return subprocess.run(args, stdout=lf, stderr=subprocess.STDOUT).returncode -def run(argv: list[str]) -> int: +def _early_exit(argv: list[str]) -> int | None: + """Exit code for the argv shapes that never slice (no args, help, profile listing); None otherwise.""" if not argv: print(USAGE) return 1 @@ -147,12 +189,11 @@ def run(argv: list[str]) -> int: if argv == ["--list-profiles"]: _print_profiles() return 0 + return None - opts = _parse(argv) - _validate_input(opts.inp) - if opts.profile: - _validate_profiles(opts.profile) +def _require_slicer() -> tuple[str, str]: + """(kind, binary) of the slicer to use, or MissingDependency when none is installed.""" sl = _find_slicer() if sl is None: raise MissingDependency( @@ -161,56 +202,82 @@ def run(argv: list[str]) -> int: degrades="3d slice cannot run, including --dry-run sliceability verification", command="slice", ) - slicer_kind, slicer_bin = sl + return sl + + +def _print_banner(opts: _Options, profile: str, slicer_kind: str, slicer_bin: str, final_out: str) -> None: + print("================================================================") + print(f"slice: {os.path.basename(opts.inp)} via {slicer_kind} ({slicer_bin})") + note = " (--dry-run: verify only, G-code discarded)" if opts.dry_run else "" + extra = (f" printer={opts.printer}" if opts.printer else "") + ( + f" material={opts.material}" if opts.material else "" + ) + ( + f" profile={profile}" if profile else "" + ) + print(f" -> {final_out}{note}{extra}") + print("================================================================") + + +def _report_success(produced: str, final_out: str, dry_run: bool, log_text: str, tmpdir: str) -> None: + """Keep (or, for --dry-run, discard) the produced G-code and print the PASS block.""" + size = os.path.getsize(produced) + if dry_run: + print(f"STATUS: PASS - sliceable ({size} bytes produced, discarded; --dry-run gate)") + else: + _finalize_thumbnails(produced) + os.makedirs(os.path.dirname(final_out) or ".", exist_ok=True) + shutil.move(produced, final_out) + print(f"STATUS: PASS - sliced OK -> {final_out} ({size} bytes)") + _record_warnings(final_out, log_text, tmpdir) + est_time = _grep1(log_text, r"estimated printing time[^0-9]*[0-9hms: ]+") + est_fil = _grep1(log_text, r"filament used[^0-9]*[0-9.]+ ?[gm]+") + if est_time: + print(f" {est_time}") + if est_fil: + print(f" {est_fil}") + print("================================================================") + + +def _report_failure(rc: int) -> None: + print(f"STATUS: FAIL - slicer did not produce G-code (rc={rc})") + print(" Check --profile/--printer. Many slicers require machine/process/filament profiles.") + print(f" {PROFILE_EXPORT_STEPS}") + print("================================================================") + + +def run(argv: list[str]) -> int: + early = _early_exit(argv) + if early is not None: + return early + + opts = _parse(argv) + _validate_input(opts.inp) + if opts.profile: + _validate_profiles(opts.profile) + + slicer_kind, slicer_bin = _require_slicer() profile = opts.profile or _resolve_profile(opts) _validate_profiles(profile) tmpdir = tempfile.mkdtemp(prefix="3dslice.") log = tempfile.mktemp(prefix="3dslicelog.") try: - work_input = _prepare_input(opts.inp, opts.defs, tmpdir) + work_input = _with_thumbnail_spec(_prepare_input(opts.inp, opts.defs, tmpdir), tmpdir, slicer_kind) final_out = opts.out or (opts.inp.rsplit(".", 1)[0] + ".gcode") prusa_out = os.path.join(tmpdir, "out.gcode") - print("================================================================") - print(f"slice: {os.path.basename(opts.inp)} via {slicer_kind} ({slicer_bin})") - note = " (--dry-run: verify only, G-code discarded)" if opts.dry_run else "" - extra = (f" printer={opts.printer}" if opts.printer else "") + ( - f" material={opts.material}" if opts.material else "" - ) + ( - f" profile={profile}" if profile else "" - ) - print(f" -> {final_out}{note}{extra}") - print("================================================================") + _print_banner(opts, profile, slicer_kind, slicer_bin, final_out) rc = _run_slicer(slicer_kind, slicer_bin, work_input, tmpdir, prusa_out, opts, profile, log) produced = _find_produced_gcode(tmpdir, prusa_out) if rc == 0 else "" log_text = _read_log(log) _print_log_tail(log_text) - est_time = _grep1(log_text, r"estimated printing time[^0-9]*[0-9hms: ]+") - est_fil = _grep1(log_text, r"filament used[^0-9]*[0-9.]+ ?[gm]+") - if rc == 0 and produced and os.path.getsize(produced) > 0: - size = os.path.getsize(produced) - if opts.dry_run: - print(f"STATUS: PASS - sliceable ({size} bytes produced, discarded; --dry-run gate)") - else: - os.makedirs(os.path.dirname(final_out) or ".", exist_ok=True) - shutil.move(produced, final_out) - print(f"STATUS: PASS - sliced OK -> {final_out} ({size} bytes)") - _record_warnings(final_out, log_text, tmpdir) - if est_time: - print(f" {est_time}") - if est_fil: - print(f" {est_fil}") - print("================================================================") + _report_success(produced, final_out, opts.dry_run, log_text, tmpdir) return 0 - print(f"STATUS: FAIL - slicer did not produce G-code (rc={rc})") - print(" Check --profile/--printer. Many slicers require machine/process/filament profiles.") - print(f" {PROFILE_EXPORT_STEPS}") - print("================================================================") + _report_failure(rc) return 1 finally: with contextlib.suppress(OSError): @@ -412,39 +479,51 @@ def _profile_score( material_terms: tuple[str, ...], ) -> int: haystack = _profile_haystack(path) - if any(term in haystack for term in excluded_terms): + if _has_any(haystack, excluded_terms): return 0 - score = 0 if role == "machine": - if not all(term in haystack for term in printer_terms): - return 0 - if any(term in haystack for term in ("filament", "material", "process")): - return 0 - score += 30 - score += 10 if any(term in haystack for term in ("machine", "printer", "nozzle")) else 0 - elif role == "process": - if not _matches_process_printer(haystack, printer_terms): - return 0 - if any(term not in material_terms and term in haystack for term in AUTO_PROFILE_ALL_MATERIAL_TERMS): - return 0 - if any(term in haystack for term in ("filament", "material", "machine", "printer", "nozzle")): - return 0 - score += 30 - score += 15 if any(term in haystack for term in ("process", "print", "quality", "standard", "layer")) else 0 - score += 8 if any(term in haystack for term in material_terms) else 0 - score += 5 if "0-2" in haystack or "0-20" in haystack or "0-20mm" in haystack else 0 - elif role == "filament": - if not any(term in haystack for term in material_terms): - return 0 - if any(term in haystack for term in ("machine", "printer", "process")): - return 0 - score += 25 - score += 10 if any(term in haystack for term in ("filament", "material")) else 0 - else: + return _machine_score(haystack, printer_terms) + if role == "process": + return _process_score(haystack, printer_terms, material_terms) + if role == "filament": + return _filament_score(haystack, material_terms) + return 0 + + +def _has_any(haystack: str, terms: tuple[str, ...]) -> bool: + return any(term in haystack for term in terms) + + +def _machine_score(haystack: str, printer_terms: tuple[str, ...]) -> int: + if not all(term in haystack for term in printer_terms): + return 0 + if _has_any(haystack, ("filament", "material", "process")): + return 0 + return 30 + (10 if _has_any(haystack, ("machine", "printer", "nozzle")) else 0) + + +def _process_score(haystack: str, printer_terms: tuple[str, ...], material_terms: tuple[str, ...]) -> int: + if not _matches_process_printer(haystack, printer_terms): + return 0 + if any(term not in material_terms and term in haystack for term in AUTO_PROFILE_ALL_MATERIAL_TERMS): return 0 + if _has_any(haystack, ("filament", "material", "machine", "printer", "nozzle")): + return 0 + score = 30 + score += 15 if _has_any(haystack, ("process", "print", "quality", "standard", "layer")) else 0 + score += 8 if _has_any(haystack, material_terms) else 0 + score += 5 if _has_any(haystack, ("0-2", "0-20", "0-20mm")) else 0 return score +def _filament_score(haystack: str, material_terms: tuple[str, ...]) -> int: + if not _has_any(haystack, material_terms): + return 0 + if _has_any(haystack, ("machine", "printer", "process")): + return 0 + return 25 + (10 if _has_any(haystack, ("filament", "material")) else 0) + + def _profile_haystack(path: str) -> str: parts = _profile_haystack_parts(path) if parts: @@ -638,20 +717,24 @@ def _profile_roots() -> list[str]: return roots +def _prune_profile_dirs(cur: str, dirs: list[str], root: str) -> None: + """In-place os.walk pruning: stop below depth 4 and skip hidden/cache/log directories.""" + rel = os.path.relpath(cur, root) + depth = 0 if rel == "." else rel.count(os.sep) + 1 + if depth >= 4: + dirs[:] = [] + dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("cache", "logs", "__pycache__")] + + def _walk_profile_files(root: str) -> list[str]: out: list[str] = [] - max_depth = 4 for cur, dirs, files in os.walk(root): - rel = os.path.relpath(cur, root) - depth = 0 if rel == "." else rel.count(os.sep) + 1 - if depth >= max_depth: - dirs[:] = [] - dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("cache", "logs", "__pycache__")] - for fn in files: - if os.path.splitext(fn)[1].lower() in (".ini", ".json"): - out.append(os.path.join(cur, fn)) - if len(out) >= 200: - return out + _prune_profile_dirs(cur, dirs, root) + profile_files = [fn for fn in files if os.path.splitext(fn)[1].lower() in (".ini", ".json")] + for fn in profile_files: + out.append(os.path.join(cur, fn)) + if len(out) >= 200: + return out return out diff --git a/lib/gcode_thumbnails.py b/lib/gcode_thumbnails.py index c0b9f563..01c7e42a 100644 --- a/lib/gcode_thumbnails.py +++ b/lib/gcode_thumbnails.py @@ -12,7 +12,7 @@ `check()` compares the blocks present in the first MiB (Moonraker only reads that much of the head) with the sizes the slicer profile asks for (`; thumbnails = 48x48/PNG, - 300x300/PNG` in the config block). `ensure()` adds the missing blocks right after + 300x300/PNG` in the config block; DEFAULT_SIZES when it names none). `ensure()` adds the missing blocks right after `; HEADER_BLOCK_END` — where Orca's GUI writes them, before the first move — byte for byte in Orca's own block format. @@ -47,7 +47,7 @@ from typing import TYPE_CHECKING, BinaryIO, NamedTuple from collections.abc import Callable -from errors import InputNotFound +from errors import InputNotFound, ThreeDError if TYPE_CHECKING: import numpy as np @@ -100,6 +100,23 @@ class ThumbnailError(ValueError): """A G-code or option this module cannot handle (unsupported format, no size spec).""" +# What every G-code must carry when its slicer profile names no `; thumbnails =` of its own: the +# 48x48 icon, the 96x96 list view (Snapmaker U1 file list) and the 300x300 job picture +# (Klipper/Moonraker, Creality). The slicing wrappers also write this set into the slicer's +# `thumbnails` setting (`merge_spec`), so the config block of a freshly sliced G-code names all three. +DEFAULT_SIZES = (Thumb(48, 48, "PNG"), Thumb(96, 96, "PNG"), Thumb(300, 300, "PNG")) + + +def merge_spec(existing: str | list[str] | None, default_fmt: str = "PNG") -> str: + """The slicer `thumbnails` value that asks for everything `existing` asks for plus DEFAULT_SIZES + (Orca's `48x48/PNG, 300x300/PNG` form; existing sizes keep their order and format, a bare `WxH` + takes `default_fmt` — the preset's `thumbnails_format`). `existing` is the preset's string or list.""" + text = ", ".join(existing) if isinstance(existing, list) else existing or "" + sizes = parse_spec(text, default_fmt) + sizes += [t for t in DEFAULT_SIZES if t not in sizes] + return ", ".join(str(t) for t in sizes) + + # ---------------------------------------------------------------- reading @@ -144,7 +161,7 @@ def parse_spec(spec: str, default_fmt: str = "PNG") -> list[Thumb]: def required(path: str) -> list[Thumb]: - """Thumbnail sizes the slicer profile asks for (`; thumbnails = …`); [] when the G-code has no spec.""" + """Thumbnail sizes the G-code must carry: its profile's `; thumbnails = …`, else DEFAULT_SIZES.""" head, tail = _head_tail(path) return _required(head, tail) @@ -152,7 +169,7 @@ def required(path: str) -> list[Thumb]: def _required(head: str, tail: str) -> list[Thumb]: spec = _config_value("thumbnails", head, tail) if not spec: - return [] + return list(DEFAULT_SIZES) return parse_spec(spec, (_config_value("thumbnails_format", head, tail) or "PNG").upper()) @@ -188,19 +205,15 @@ def present(path: str) -> list[Thumb]: def check(path: str) -> ThumbCheck: - """ok = every profile-required size is present; without a spec, ok = at least one thumbnail.""" + """ok = every required size is present (the profile's `; thumbnails =`, else DEFAULT_SIZES).""" if not os.path.isfile(path): raise InputNotFound(path, command="gcode-thumbnails") head, tail = _head_tail(path) req, pres = _required(head, tail), _present(head) have = set(pres) missing = [t for t in req if t not in have] - if req: - ok = not missing - reason = "all required thumbnails present" if ok else "missing " + ", ".join(map(str, missing)) - else: - ok = bool(pres) - reason = "no thumbnails spec; thumbnail present" if ok else "no thumbnails (and no `; thumbnails =` spec)" + ok = not missing + reason = "all required thumbnails present" if ok else "missing " + ", ".join(map(str, missing)) return ThumbCheck(path, ok, req, pres, missing, reason) @@ -399,141 +412,232 @@ def _hex_rgb(c: str) -> tuple[int, int, int]: _NUM = re.compile(rb"([XYZEIJF])(-?\d*\.?\d+)") +_TOOL = re.compile(rb"T(\d+)") +_FEATURE_COMMENTS = (b";TYPE:", b"; FEATURE:") + + +def _words(body: bytes) -> dict[str, float]: + """`X10.5 E-0.2` → {"X": 10.5, "E": -0.2} (only the letters the preview needs).""" + return {k.decode(): float(v) for k, v in _NUM.findall(body)} + + +def _relative(p: dict[str, float], x: float, y: float, z: float) -> tuple[float, float, float]: + """The target of a G91 move: each axis word is an offset, a missing one stays put.""" + return (x + p["X"] if "X" in p else x, y + p["Y"] if "Y" in p else y, z + p["Z"] if "Z" in p else z) + + +def _arc_segments(g: bytes, p: dict[str, float], start: tuple[float, float], end: tuple[float, float], + z: float, tool: int) -> list[float]: + """G2 (clockwise) / G3 (counter-clockwise) arc about the centre start+(I, J), flattened to ≤0.5 mm chords.""" + (x, y), (nx, ny) = start, end + cx, cy = x + p.get("I", 0.0), y + p.get("J", 0.0) + a0, a1 = math.atan2(y - cy, x - cx), math.atan2(ny - cy, nx - cx) + sweep = a1 - a0 + if g == b"2" and sweep >= 0: + sweep -= 2 * math.pi + if g == b"3" and sweep <= 0: + sweep += 2 * math.pi + r = math.hypot(x - cx, y - cy) + n = max(2, min(64, int(abs(sweep) * r / 0.5) + 1)) + out: list[float] = [] + px_, py_ = x, y + for k in range(1, n + 1): + a = a0 + sweep * k / n + qx, qy = cx + r * math.cos(a), cy + r * math.sin(a) + out.extend((px_, py_, qx, qy, z, tool)) + px_, py_ = qx, qy + return out + + +class _Cursor: + """Interpreter state of toolpaths(): position, extrusion/positioning modes, tool, feature skip. + A class with __slots__ (not a dict of locals) so the per-line helpers share it without + allocating anything per line.""" + + __slots__ = ("seg", "x", "y", "z", "e", "abs_xyz", "abs_e", "tool", "skip") + + def __init__(self) -> None: + self.seg = array("d") + self.x = self.y = self.z = self.e = 0.0 + self.abs_xyz, self.abs_e, self.tool, self.skip = True, False, 0, False + + def set_feature(self, line: bytes) -> None: + """`;TYPE:Outer wall` / `; FEATURE: Outer wall` → whether the following extrusion is drawn.""" + feat = line.split(b":", 1)[1].strip().decode("ascii", "ignore").lower() + self.skip = feat in SKIP_FEATURES + + def gcode(self, line: bytes) -> None: + """G0–G3 moves, G90/G91 positioning and G92 extruder reset; any other G-word is ignored.""" + code = line[1:3] + if code[:1] in b"0123" and (len(code) == 1 or not code[1:2].isdigit()): + self.move(line, code[:1]) + elif line.startswith(b"G90"): + self.abs_xyz = True + elif line.startswith(b"G91"): + self.abs_xyz = False + elif line.startswith(b"G92"): + self.e = _words(line.split(b";", 1)[0][3:]).get("E", self.e) + + def mcode(self, line: bytes) -> None: + """M82/M83: absolute / relative extruder.""" + if line.startswith(b"M82"): + self.abs_e = True + elif line.startswith(b"M83"): + self.abs_e = False + + def move(self, line: bytes, g: bytes) -> None: + """One G0–G3 line: advance the position and record the segment when it extrudes visibly.""" + p = _words(line.split(b";", 1)[0][2:]) + x, y, z = self.x, self.y, self.z + nx, ny, nz = (p.get("X", x), p.get("Y", y), p.get("Z", z)) if self.abs_xyz else _relative(p, x, y, z) + pe = p.get("E") + de = 0.0 + if pe is not None: + de = pe - self.e if self.abs_e else pe + self.e = pe if self.abs_e else self.e + pe + if de > 0 and not self.skip and (nx != x or ny != y): + if g in b"23" and ("I" in p or "J" in p): + self.seg.extend(_arc_segments(g, p, (x, y), (nx, ny), nz, self.tool)) + else: + self.seg.extend((x, y, nx, ny, nz, self.tool)) + self.x, self.y, self.z = nx, ny, nz + + def toolpaths(f: BinaryIO) -> array[float]: """Visible extrusion segments as a flat array [x0, y0, x1, y1, z, tool, …] (6 doubles per segment). - Tracks G90/G91, M82/M83, G92, T, ;TYPE:/; FEATURE:, G2/G3 arcs (I/J).""" - seg = array("d") - x = y = z = e = 0.0 - abs_xyz, abs_e, tool, skip = True, False, 0, False + Tracks G90/G91, M82/M83, G92, T, ;TYPE:/; FEATURE:, G2/G3 arcs (I/J). + Reads 100+ MB G-codes line by line: the loop only dispatches on the first byte.""" + cur = _Cursor() + gcode, mcode = cur.gcode, cur.mcode for line in f: c = line[:1] if c == b";": - if line.startswith(b";TYPE:") or line.startswith(b"; FEATURE:"): - feat = line.split(b":", 1)[1].strip().decode("ascii", "ignore").lower() - skip = feat in SKIP_FEATURES - continue - if c == b"G": - code = line[1:3] - if code[:1] in b"0123" and (len(code) == 1 or not code[1:2].isdigit()): - g = code[:1] - body = line.split(b";", 1)[0] - p = {k.decode(): float(v) for k, v in _NUM.findall(body[2:])} - nx = (p["X"] if abs_xyz else x + p["X"]) if "X" in p else x - ny = (p["Y"] if abs_xyz else y + p["Y"]) if "Y" in p else y - nz = (p["Z"] if abs_xyz else z + p["Z"]) if "Z" in p else z - de = 0.0 - if "E" in p: - de = p["E"] - e if abs_e else p["E"] - e = p["E"] if abs_e else e + p["E"] - if de > 0 and not skip and (nx != x or ny != y): - if g in b"23" and ("I" in p or "J" in p): - cx, cy = x + p.get("I", 0.0), y + p.get("J", 0.0) - a0, a1 = math.atan2(y - cy, x - cx), math.atan2(ny - cy, nx - cx) - sweep = a1 - a0 - if g == b"2" and sweep >= 0: - sweep -= 2 * math.pi - if g == b"3" and sweep <= 0: - sweep += 2 * math.pi - r = math.hypot(x - cx, y - cy) - n = max(2, min(64, int(abs(sweep) * r / 0.5) + 1)) - px_, py_ = x, y - for k in range(1, n + 1): - a = a0 + sweep * k / n - qx, qy = cx + r * math.cos(a), cy + r * math.sin(a) - seg.extend((px_, py_, qx, qy, nz, tool)) - px_, py_ = qx, qy - else: - seg.extend((x, y, nx, ny, nz, tool)) - x, y, z = nx, ny, nz - elif line.startswith(b"G90"): - abs_xyz = True - elif line.startswith(b"G91"): - abs_xyz = False - elif line.startswith(b"G92"): - p = {k.decode(): float(v) for k, v in _NUM.findall(line.split(b";", 1)[0][3:])} - e = p.get("E", e) - continue - if c == b"M": - if line.startswith(b"M82"): - abs_e = True - elif line.startswith(b"M83"): - abs_e = False - continue - if c == b"T" and line[1:2].isdigit(): - tool = int(re.match(rb"T(\d+)", line).group(1)) # type: ignore[union-attr] - return seg + if line.startswith(_FEATURE_COMMENTS): + cur.set_feature(line) + elif c == b"G": + gcode(line) + elif c == b"M": + mcode(line) + elif c == b"T" and line[1:2].isdigit(): + cur.tool = int(_TOOL.match(line).group(1)) # type: ignore[union-attr] + return cur.seg -def render_gcode(path: str, size: int = 600) -> Image: - """Isometric z-buffered preview (front-right, from above) of the G-code's visible extrusion, - coloured by each tool's filament_colour and lit from screen-space normals. RGBA, transparent bg.""" +_AZIMUTH, _ELEVATION = math.radians(-45.0), math.radians(35.264) # camera at front-right, true isometric elevation +_CA, _SA, _CE, _SE = math.cos(_AZIMUTH), math.sin(_AZIMUTH), math.cos(_ELEVATION), math.sin(_ELEVATION) + + +def _project(px: np.ndarray, py: np.ndarray, pz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rx = px * _CA - py * _SA + ry = px * _SA + py * _CA # +ry = away from the camera on the bed + return rx, -(pz * _CE + ry * _SE), ry * _CE - pz * _SE # screen u, screen v (down), depth (far = big) + + +def _palette(head: str, tail: str) -> np.ndarray: + """Per-tool RGB from the G-code config's filament/extruder colours (grey when it names none).""" import numpy as np - head, tail = _head_tail(path) colours = (_config_value("filament_colour", head, tail) or _config_value("extruder_colour", head, tail) or "") palette = np.array([_hex_rgb(c) for c in re.split(r"[;,]", colours) if c.strip()] or [(200, 200, 200)], np.float64) - palette = 45.0 + palette * (210.0 / 255.0) # lift near-black filament so shading still shows the form - width = float(_config_value("outer_wall_line_width", head, tail) or _config_value("line_width", head, tail) or 0.42) - with open(path, "rb") as f: - seg = toolpaths(f) - n = len(seg) // 6 - if n == 0: - raise ThumbnailError(f"{path}: no visible extrusion moves to draw a preview from") - s = np.frombuffer(seg, np.float64).reshape(n, 6) - x0, y0, x1, y1, z, tool = s.T + return 45.0 + palette * (210.0 / 255.0) # lift near-black filament so shading still shows the form - az, el = math.radians(-45.0), math.radians(35.264) # camera at front-right, true isometric elevation - ca, sa, ce, se = math.cos(az), math.sin(az), math.cos(el), math.sin(el) - def project(px: np.ndarray, py: np.ndarray, pz: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - rx = px * ca - py * sa - ry = px * sa + py * ca # +ry = away from the camera on the bed - return rx, -(pz * ce + ry * se), ry * ce - pz * se # screen u, screen v (down), depth (far = big) +class _View(NamedTuple): + """Screen-space framing of the toolpaths: bounds (u/v), the square `extent` they fit in, its + `margin`, the pixel size `res` (mm per pixel) and the picture's `side` in pixels.""" + umin: float + umax: float + vmin: float + vmax: float + extent: float + margin: float + res: float + side: int - u0, v0, _ = project(np.concatenate([x0, x1]), np.concatenate([y0, y1]), np.concatenate([z, z])) + +def _frame(s: np.ndarray, size: int, width: float) -> _View: + """Fit the projected toolpaths (n×6 rows from toolpaths()) into about size×size pixels.""" + import numpy as np + + x0, y0, x1, y1, z, _ = s.T + u0, v0, _ = _project(np.concatenate([x0, x1]), np.concatenate([y0, y1]), np.concatenate([z, z])) umin, umax, vmin, vmax = u0.min(), u0.max(), v0.min(), v0.max() extent = max(umax - umin, vmax - vmin, 1.0) margin = 0.03 * extent # a pixel no finer than ~0.4 line widths: neighbouring lines then touch and surfaces close up res = max((extent + 2 * margin) / size, 0.4 * width) side = int(math.ceil((extent + 2 * margin) / res)) + return _View(umin, umax, vmin, vmax, extent, margin, res, side) + - # sample every segment at half-pixel steps; float32 / int32 per point keeps a 115 MB G-code under ~1 GB +def _sample_pixels(s: np.ndarray, view: _View, n_tools: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Sample every segment at half-pixel steps → per-sample pixel column, pixel row, depth, tool index. + float32 / int32 per point keeps a 115 MB G-code under ~1 GB.""" + import numpy as np + + x0, y0, x1, y1, z, tool = s.T seg_len = np.hypot(x1 - x0, y1 - y0) - steps = np.maximum(1, np.ceil(seg_len / (0.5 * res)).astype(np.int64)) - idx = np.repeat(np.arange(n, dtype=np.int32), steps + 1) + steps = np.maximum(1, np.ceil(seg_len / (0.5 * view.res)).astype(np.int64)) + idx = np.repeat(np.arange(len(s), dtype=np.int32), steps + 1) t = (np.arange(len(idx)) - np.repeat(np.cumsum(steps + 1) - (steps + 1), steps + 1)).astype(np.float32) t /= steps[idx].astype(np.float32) px = (x0[idx] + (x1 - x0)[idx] * t).astype(np.float32) py = (y0[idx] + (y1 - y0)[idx] * t).astype(np.float32) del t - u, v, d = project(px, py, z[idx].astype(np.float32)) + u, v, d = _project(px, py, z[idx].astype(np.float32)) del px, py - ti = np.minimum(tool[idx], len(palette) - 1).astype(np.int16) + ti = np.minimum(tool[idx], n_tools - 1).astype(np.int16) del idx - iu0 = np.floor((u - umin + margin + (extent - (umax - umin)) / 2) / res).astype(np.int32) - iv0 = np.floor((v - vmin + margin + (extent - (vmax - vmin)) / 2) / res).astype(np.int32) + iu0 = np.floor((u - view.umin + view.margin + (view.extent - (view.umax - view.umin)) / 2) / view.res).astype(np.int32) + iv0 = np.floor((v - view.vmin + view.margin + (view.extent - (view.vmax - view.vmin)) / 2) / view.res).astype(np.int32) del u, v - d = d.astype(np.float32) + return iu0, iv0, d.astype(np.float32), ti + + +def _window_pixels(iu0: np.ndarray, iv0: np.ndarray, dx: int, dy: int, side: int) -> tuple[np.ndarray, np.ndarray]: + """Flat pixel index of every sample shifted by (dx, dy), and the mask of those that land inside the picture.""" + iu, iv = iu0 + dx, iv0 + dy + ok = (iu >= 0) & (iu < side) & (iv >= 0) & (iv < side) + return iv[ok] * side + iu[ok], ok + + +def _depth_buffers(samples: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray], view: _View, + width: float) -> tuple[np.ndarray, np.ndarray]: + """Z-buffer (side², nearest depth, inf where empty) and tool index per pixel; each sample is + stamped over a disc as wide as the extrusion line.""" + import numpy as np + iu0, iv0, d, ti = samples + side = view.side zbuf = np.full(side * side, np.inf, np.float32) tbuf = np.zeros(side * side, np.int16) - r_px = max(0.0, 0.5 * width / res - 0.5) + r_px = max(0.0, 0.5 * width / view.res - 0.5) offs = [(dx, dy) for dx in range(-2, 3) for dy in range(-2, 3) if dx * dx + dy * dy <= r_px * r_px + 0.25] - - def pixels(dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]: - iu, iv = iu0 + dx, iv0 + dy - ok = (iu >= 0) & (iu < side) & (iv >= 0) & (iv < side) - return iv[ok] * side + iu[ok], ok - for dx, dy in offs: - pix, ok = pixels(dx, dy) + pix, ok = _window_pixels(iu0, iv0, dx, dy, side) np.minimum.at(zbuf, pix, d[ok]) for dx, dy in offs: # the nearest sample of each pixel gives its tool colour - pix, ok = pixels(dx, dy) + pix, ok = _window_pixels(iu0, iv0, dx, dy, side) win = d[ok] <= zbuf[pix] tbuf[pix[win]] = ti[ok][win] - rgb = palette[tbuf] + return zbuf, tbuf + + +def _depth_edges(zf: np.ndarray, filled: np.ndarray, res: float) -> np.ndarray: + """Silhouette / step edges: a filled pixel whose neighbour is empty or a depth jump away.""" + import numpy as np + + edge = np.zeros_like(filled) + for ax in (0, 1): + for sh in (1, -1): + nb = np.roll(zf, sh, axis=ax) + edge |= filled & (~np.isfinite(nb) | (np.abs(nb - zf) > 3 * res)) + return edge + + +def _shade(zbuf: np.ndarray, rgb: np.ndarray, side: int, res: float) -> Image: + """RGBA picture: tool colours lit by the screen-space normals of the depth buffer, edges darkened.""" + import numpy as np zb = zbuf.reshape(side, side) filled = np.isfinite(zb) @@ -545,19 +649,32 @@ def pixels(dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]: light /= np.linalg.norm(light) lambert = np.clip((nrm * light).sum(axis=2), 0.0, 1.0) shade = 0.45 + 0.55 * lambert - # silhouette / step edges: a depth jump to a neighbour darkens the pixel - edge = np.zeros_like(filled) - for ax in (0, 1): - for sh in (1, -1): - nb = np.roll(zf, sh, axis=ax) - edge |= filled & (~np.isfinite(nb) | (np.abs(nb - zf) > 3 * res)) - shade = np.where(edge, shade * 0.7, shade) + shade = np.where(_depth_edges(zf, filled, res), shade * 0.7, shade) img = np.zeros((side, side, 4), np.uint8) img[..., :3] = np.clip(rgb.reshape(side, side, 3) * shade[..., None], 0, 255).round().astype(np.uint8) img[..., 3] = np.where(filled, 255, 0).astype(np.uint8) return img +def render_gcode(path: str, size: int = 600) -> Image: + """Isometric z-buffered preview (front-right, from above) of the G-code's visible extrusion, + coloured by each tool's filament_colour and lit from screen-space normals. RGBA, transparent bg.""" + import numpy as np + + head, tail = _head_tail(path) + palette = _palette(head, tail) + width = float(_config_value("outer_wall_line_width", head, tail) or _config_value("line_width", head, tail) or 0.42) + with open(path, "rb") as f: + seg = toolpaths(f) + n = len(seg) // 6 + if n == 0: + raise ThumbnailError(f"{path}: no visible extrusion moves to draw a preview from") + s = np.frombuffer(seg, np.float64).reshape(n, 6) + view = _frame(s, size, width) + zbuf, tbuf = _depth_buffers(_sample_pixels(s, view, len(palette)), view, width) + return _shade(zbuf, palette[tbuf], view.side, view.res) + + # ---------------------------------------------------------------- ensure @@ -566,28 +683,38 @@ def render_side(sizes: list[Thumb]) -> int: return min(2 * max([max(t.width, t.height) for t in sizes] or [300]), RENDER_MAX_SIDE) +def _missing_sizes(before: ThumbCheck, sizes: list[Thumb] | None, replace: bool) -> list[Thumb]: + """The sizes ensure() must write: required + requested ones not yet present (all of them with `replace`, + which also redraws the sizes the file has but nobody asked for).""" + want = before.required + [t for t in sizes or [] if t not in before.required] + if replace: + want += [t for t in before.present if t not in want] + have = set() if replace else set(before.present) + return [t for t in want if t not in have] + + +def _require_writable(todo: list[Thumb]) -> None: + bad = [t for t in todo if t.fmt not in WRITABLE] + if bad: + raise ThumbnailError(f"cannot write {', '.join(map(str, bad))}: supported formats are {', '.join(WRITABLE)}") + + def ensure(path: str, image: str | None = None, sizes: list[Thumb] | None = None, out: str | None = None, source: Callable[[str], Image] | None = None, replace: bool = False) -> ThumbCheck: - """Add every missing required thumbnail (or `sizes`, when the G-code declares none). - No-op (file untouched) when check() already passes. `replace` redraws every size, + """Add every missing required thumbnail (the profile's sizes, else DEFAULT_SIZES) and every + missing one of `sizes`. No-op (file untouched) when check() already passes. `replace` redraws every size, dropping the existing blocks. Returns the check after writing, with `.added` listing what was inserted.""" before = check(path) seen = os.stat(path) - want = before.required or list(sizes or []) or (list(before.present) if replace else []) - if not want and not before.present: - raise ThumbnailError(f"{path}: no `; thumbnails =` spec in the G-code; pass sizes (e.g. --size 300x300/PNG)") - have = set() if replace else set(before.present) - todo = [t for t in want if t not in have] + todo = _missing_sizes(before, sizes, replace) if not todo: if out and out != path: import shutil shutil.copy2(path, out) # keep mode + mtime like splice() does return before - bad = [t for t in todo if t.fmt not in WRITABLE] - if bad: - raise ThumbnailError(f"cannot write {', '.join(map(str, bad))}: supported formats are {', '.join(WRITABLE)}") + _require_writable(todo) pic = load_image(image) if image else (source or (lambda p: render_gcode(p, size=render_side(todo))))(path) text = "".join(block(t, encode(fit(pic, t.width, t.height), t.fmt)) for t in todo) splice(path, text, out, expect=seen, replace=replace) @@ -599,6 +726,48 @@ def ensure(path: str, image: str | None = None, sizes: list[Thumb] | None = None # ---------------------------------------------------------------- CLI (lib tool entry: `3d gcode-thumbnails`) +def _check_any(path: str) -> ThumbCheck: + if path.lower().endswith(".3mf"): + from pathlib import Path + + from slicing.thumbnails import check_plate_images + + if not os.path.isfile(path): + raise InputNotFound(path, command="gcode-thumbnails") + return check_plate_images(Path(path)) + return check(path) + + +def _image_source(image: str | None, gcode: str) -> Callable[[str], Image]: + from pathlib import Path + + from slicing.thumbnails import Picture + + return (lambda _p: load_image(image)) if image else Picture(Path(gcode)) + + +def _add_3mf(path: str, image: str | None) -> ThumbCheck: + """`add X.3mf`: the plate images drawn from the embedded plate G-codes (or from --image).""" + from pathlib import Path + + from slicing.thumbnails import finalize_embedded + + if not os.path.isfile(path): + raise InputNotFound(path, command="gcode-thumbnails") + return finalize_embedded(Path(path), (lambda _p: load_image(image)) if image else None) + + +def _add_plate_images(three_mf: str, plate: int, gcode: str, image: str | None) -> None: + from pathlib import Path + + from slicing.thumbnails import finalize_plate_images + + if not os.path.isfile(three_mf): + raise InputNotFound(three_mf, command="gcode-thumbnails") + for member in finalize_plate_images(Path(three_mf), plate, _image_source(image, gcode)): + print(f"added {member} to {three_mf}", file=sys.stderr) + + def _describe(c: ThumbCheck) -> str: pres = ", ".join(map(str, c.present)) or "none" req = ", ".join(map(str, c.required)) or "no spec" @@ -610,7 +779,7 @@ def _describe(c: ThumbCheck) -> str: return line -def main(argv: list[str] | None = None) -> int: +def _build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="3d gcode-thumbnails", description="check/add G-code thumbnails") sub = ap.add_subparsers(dest="cmd", required=True) pc = sub.add_parser("check") @@ -623,41 +792,69 @@ def main(argv: list[str] | None = None) -> int: pa.add_argument("--out") pa.add_argument("--replace", action="store_true", help="redraw all sizes, dropping existing thumbnails") pa.add_argument("--preview", help="also save the source picture (PNG) here") + pa.add_argument("--3mf", dest="three_mf", help="also add the missing Metadata/plate_N.png to this 3MF project") + pa.add_argument("--plate", type=int, default=1, help="plate number for --3mf (default 1)") pa.add_argument("--json", action="store_true") - a = ap.parse_args(argv) - results: list[ThumbCheck] = [] + return ap + + +def _preview_saver(keep: str, sizes: list[Thumb]) -> Callable[[str], Image]: + """Image source for `--preview`: render the G-code as ensure() would, and save that picture to `keep`.""" + def src(gp: str) -> Image: + want = check(gp).required + sizes + pic = render_gcode(gp, size=render_side(want)) + with open(keep, "wb") as fh: + fh.write(encode_png(pic)) + return pic + return src + + +def _add_gcode(path: str, a: argparse.Namespace, sizes: list[Thumb]) -> ThumbCheck: + """`add X.gcode`: ensure() the thumbnails, then the plate images of `--3mf` when asked.""" + src = _preview_saver(a.preview, sizes) if a.preview else None + result = ensure(path, image=a.image, sizes=sizes, out=a.out, source=src, replace=a.replace) + if a.three_mf: + _add_plate_images(a.three_mf, a.plate, a.out or path, a.image) + return result + + +def _run_check(a: argparse.Namespace) -> list[ThumbCheck]: + return [_check_any(p) for p in a.files] + + +def _run_add(a: argparse.Namespace) -> list[ThumbCheck]: + if a.out and len(a.files) != 1: + raise ThumbnailError("--out takes exactly one input file") + sizes = [t for s in a.size for t in parse_spec(s)] + return [_add_3mf(p, a.image) if p.lower().endswith(".3mf") else _add_gcode(p, a, sizes) for p in a.files] + + +def _print_results(results: list[ThumbCheck], as_json: bool) -> None: + if not as_json: + for c in results: + print(_describe(c)) + return + import json + + print(json.dumps([{"path": c.path, "ok": c.ok, "reason": c.reason, "required": [str(t) for t in c.required], + "present": [str(t) for t in c.present], "missing": [str(t) for t in c.missing], + "added": [str(t) for t in c.added]} for c in results], indent=2)) + + +def main(argv: list[str] | None = None) -> int: + a = _build_parser().parse_args(argv) try: - if a.cmd == "check": - results = [check(p) for p in a.files] - else: - if a.out and len(a.files) != 1: - raise ThumbnailError("--out takes exactly one input file") - sizes = [t for s in a.size for t in parse_spec(s)] - for p in a.files: - src: Callable[[str], Image] | None = None - if a.preview: - def src(gp: str, keep: str = a.preview) -> Image: - want = check(gp).required or sizes - pic = render_gcode(gp, size=render_side(want)) - with open(keep, "wb") as fh: - fh.write(encode_png(pic)) - return pic - results.append(ensure(p, image=a.image, sizes=sizes, out=a.out, source=src, replace=a.replace)) + results = _run_check(a) if a.cmd == "check" else _run_add(a) except (ThumbnailError, InputNotFound) as e: print(f"gcode-thumbnails: {e}", file=sys.stderr) return 2 + except ThreeDError as e: # a slicing.thumbnails GateFailure: the message names the fix + print(e.render(), file=sys.stderr) + return e.exit_code except ImportError as e: # Pillow for --image / JPG output, numpy for rendering print(f"gcode-thumbnails: {e}; install it (e.g. `uv sync --extra preprocess` for Pillow)", file=sys.stderr) return 127 - if a.json: - import json - - print(json.dumps([{"path": c.path, "ok": c.ok, "reason": c.reason, "required": [str(t) for t in c.required], - "present": [str(t) for t in c.present], "missing": [str(t) for t in c.missing], - "added": [str(t) for t in c.added]} for c in results], indent=2)) - else: - for c in results: - print(_describe(c)) + _print_results(results, a.json) return 0 if all(c.ok for c in results) else 1 diff --git a/lib/slicing/orca_cli.py b/lib/slicing/orca_cli.py index f85095bc..4b4a89f5 100644 --- a/lib/slicing/orca_cli.py +++ b/lib/slicing/orca_cli.py @@ -40,11 +40,11 @@ from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, NoReturn -from errors import MissingDependency, ThreeDError +from errors import GateFailure, MissingDependency, ThreeDError from prep.contracts import Verdict -from slicing import slice_warnings +from slicing import slice_warnings, thumbnails CLI_FALLBACK_VERSION = "01.10.01.50" INSTALL_HINT = ( @@ -182,7 +182,9 @@ class Quirk: "no-thumbnails", "The sliced G-code has no thumbnail, so the printer screen shows no preview.", "The CLI's export_gcode gets a null thumbnail callback.", - "Add thumbnails afterwards with `3d gcode-thumbnails add FILE`.", + "Nothing to do here: every slice through this module asks for 48x48/96x96/300x300 in the settings and " + "draws the missing ones from the toolpaths afterwards (`slicing.thumbnails`); by hand: " + "`3d gcode-thumbnails add FILE`.", "babushkin-dom gen/verify_slices.py (comment before add_thumbnails)", ), Quirk( @@ -346,7 +348,13 @@ def slicing_copy( # --------------------------------------------------------------------------- failures FailureClass = Literal[ - "segfault", "timeout", "lock_busy", "exclude_triangles", "return_code", "no_gcode" + "segfault", + "timeout", + "lock_busy", + "exclude_triangles", + "return_code", + "no_gcode", + "thumbnails", ] @@ -358,6 +366,36 @@ class Failure: quirk_ids: tuple[str, ...] = () +def _lock_was_busy( + heavy: bool, lock_busy_code: int | None, returncode: int, res: Mapping[str, Any] +) -> bool: + """True when the heavy lock wrapper gave up (its busy exit code, and the CLI never ran to write a result).""" + return ( + heavy + and lock_busy_code is not None + and returncode == lock_busy_code + and not res + ) + + +def _return_code_failure(code: Any, log: str, res: Mapping[str, Any]) -> Failure: + """The `return_code` failure: `code` is result.json's non-zero return_code, else the process exit code.""" + detail = str(res.get("error_string") or "").strip() + tail = "\n".join(log.strip().splitlines()[-5:]) + return Failure( + "return_code", + f"the slicer reported failure (return code {code})" + + (f": {detail}" if detail else (f"; log tail: {tail}" if tail else "")), + ( + "Read slice.log in the work dir; the last [error] line names the cause.", + "Error -101 is the plate arrangement: keep `--arrange 0`.", + "Error -24 'Unsupported 3MF version': the project's Application tag is newer than the CLI's " + "build number; stamp it with `orca_version`.", + ), + ("arrange-moves-objects", "application-tag-newer"), + ) + + def classify_failure( returncode: int, log: str, @@ -394,12 +432,7 @@ def classify_failure( "Raise timeout_s, or slice a smaller model; check the slice.log tail for the stage it stuck in.", ), ) - if ( - heavy - and lock_busy_code is not None - and returncode == lock_busy_code - and not res - ): + if _lock_was_busy(heavy, lock_busy_code, returncode, res): return Failure( "lock_busy", "the machine-wide heavy lock stayed busy for the whole bounded wait", @@ -417,21 +450,7 @@ def classify_failure( ) bad_rc = res_rc not in (0, None) if bad_rc or returncode != 0: - code = res_rc if bad_rc else returncode - detail = str(res.get("error_string") or "").strip() - tail = "\n".join(log.strip().splitlines()[-5:]) - return Failure( - "return_code", - f"the slicer reported failure (return code {code})" - + (f": {detail}" if detail else (f"; log tail: {tail}" if tail else "")), - ( - "Read slice.log in the work dir; the last [error] line names the cause.", - "Error -101 is the plate arrangement: keep `--arrange 0`.", - "Error -24 'Unsupported 3MF version': the project's Application tag is newer than the CLI's " - "build number; stamp it with `orca_version`.", - ), - ("arrange-moves-objects", "application-tag-newer"), - ) + return _return_code_failure(res_rc if bad_rc else returncode, log, res) if not has_gcode: return Failure( "no_gcode", @@ -632,21 +651,32 @@ def _prepare_workdir(work: Path) -> Path: return work +def _heavy_guard(heavy: bool) -> tuple[list[str], int | None, int]: + """(command prefix, the lock wrapper's busy exit code, seconds the lock wait may add) for `heavy`.""" + if not heavy: + return [], None, 0 + from cli.heavy import DEFAULT_WAIT_S, EXIT_BUSY, heavy_prefix + + prefix = heavy_prefix() + return prefix, EXIT_BUSY, DEFAULT_WAIT_S if prefix else 0 + + +def _finalize_thumbnails(work: Path, sliced: Path) -> Failure | None: + """Layer (b): the CLI never writes thumbnails; draw them before anyone hashes or copies the G-code.""" + try: + thumbnails.finalize_workdir(work, sliced) + except GateFailure as exc: + return Failure("thumbnails", exc.message, tuple(exc.remediation)) + return None + + def _run_slicer( cli: OrcaCli, args: list[str], work: Path, *, heavy: bool, timeout_s: int ) -> SliceOutcome: """Run `CLI --debug 2 --datadir WORK/dd ARGS` in `work` and collect the outcome. `heavy=True` puts the call under the machine-wide heavy lock/cap (`cli.heavy.heavy_prefix`, `[]` when the caller already holds it). Writes WORK/slice.log.""" - prefix: list[str] = [] - lock_busy_code: int | None = None - lock_wait_s = 0 - if heavy: - from cli.heavy import DEFAULT_WAIT_S, EXIT_BUSY, heavy_prefix - - prefix = heavy_prefix() - lock_busy_code = EXIT_BUSY - lock_wait_s = DEFAULT_WAIT_S if prefix else 0 + prefix, lock_busy_code, lock_wait_s = _heavy_guard(heavy) timeout_bin = _gnu_timeout() # --foreground: no setpgid, so `timeout` and the slicer stay in the group `_run` kills. Without it # (behind `flock`, which is no session leader) they move to a group of their own and escape the kill. @@ -694,9 +724,11 @@ def _run_slicer( lock_busy_code=lock_busy_code, timed_out=timed_out, ) + sliced = work / "sliced.3mf" + if failure is None and gcode is not None: + failure = _finalize_thumbnails(work, sliced) warnings = collect_warnings(work, log) verdict: Verdict = "FAIL" if failure else ("WARN" if warnings else "PASS") - sliced = work / "sliced.3mf" return SliceOutcome( verdict=verdict, workdir=work, @@ -736,7 +768,12 @@ def slice_project( ) work = _prepare_workdir(Path(workdir)) copy = work / "in.3mf" - slicing_copy(project, copy, set_cfg=set_cfg, drop_layer_profile=drop_layer_profile) + slicing_copy( + project, + copy, + set_cfg={**thumbnails.thumbnail_cfg(Path(project)), **(set_cfg or {})}, # layer (a) + drop_layer_profile=drop_layer_profile, + ) args = [ "--arrange", "0", @@ -771,9 +808,11 @@ def slice_stl_with_profiles( work = _prepare_workdir(Path(workdir)) model = work / Path(stl).name shutil.copyfile(stl, model) + machine_copy = work / "machine.json" # layer (a): a copy of the machine preset that also asks for thumbnails + thumbnails.with_machine_spec(Path(machine), machine_copy) args = [ "--load-settings", - f"{Path(machine).resolve()};{Path(process).resolve()}", + f"{machine_copy};{Path(process).resolve()}", "--load-filaments", str(Path(filament).resolve()), "--arrange", @@ -928,6 +967,48 @@ def _write_probe_stl(path: Path, cx: float, cy: float) -> None: path.write_text("\n".join(rows) + "\n", encoding="ascii") +def _write_flat_presets( + presets: SystemPresets, wanted: Sequence[tuple[str, str]], tmp: Path +) -> dict[tuple[str, str], Path]: + """Each distinct (kind, name) in `wanted` flattened into a JSON file under `tmp`. + Raises `MissingDependency` when a named preset is not in `presets`.""" + files: dict[tuple[str, str], Path] = {} + for kind, name in wanted: + if (kind, name) in files: + continue + try: + flat = presets.flat(kind, name) + except KeyError as exc: + raise MissingDependency( + f"system {kind} preset {name!r} (missing: {exc.args[0]!r})", + install=f"install Snapmaker Orca and expose its presets at {_RESOURCES_HINT}", + degrades="project_settings.config cannot be generated", + command="prep", + ) from exc + flat.update({"name": name, "from": "system", "instantiation": "true"}) + f = tmp / f"{kind}_{len(files)}.json" # by index: names hold dots, `;` and `/` + f.write_text(json.dumps(flat, indent=1), encoding="utf-8") + files[kind, name] = f + return files + + +def _raise_no_reference_project(returncode: int, log: str, timed_out: bool) -> NoReturn: + """The probe export left no project: name the missing OpenGL runtime if that is why, else the log tail.""" + why = "timed out" if timed_out else f"exit {returncode}" + if "libOpenGL" in log or "libGLU" in log: + raise MissingDependency( + "host OpenGL runtime libraries (libOpenGL.so.0, libGLU)", + install="sudo apt-get install libopengl0 libglu1-mesa; on a box without sudo point ORCA at a wrapper that sets LD_LIBRARY_PATH (e.g. ORCA=~/.local/bin/snapmaker-orca)", + degrades="headless slicing and project_settings generation", + command="prep", + ) + raise ThreeDError( + f"the Orca CLI wrote no reference project ({why})", + command="prep", + remediation=[log[-1500:].strip() or "no output"], + ) + + def reference_project_settings( cli: OrcaCli, presets: SystemPresets, @@ -953,25 +1034,7 @@ def reference_project_settings( ] with tempfile.TemporaryDirectory(prefix="orca-ref-") as tmp_s: tmp = Path(tmp_s) - files: dict[tuple[str, str], Path] = {} - for kind, name in wanted: - if (kind, name) in files: - continue - try: - flat = presets.flat(kind, name) - except KeyError as exc: - raise MissingDependency( - f"system {kind} preset {name!r} (missing: {exc.args[0]!r})", - install=f"install Snapmaker Orca and expose its presets at {_RESOURCES_HINT}", - degrades="project_settings.config cannot be generated", - command="prep", - ) from exc - flat.update({"name": name, "from": "system", "instantiation": "true"}) - f = ( - tmp / f"{kind}_{len(files)}.json" - ) # by index: names hold dots, `;` and `/` - f.write_text(json.dumps(flat, indent=1), encoding="utf-8") - files[kind, name] = f + files = _write_flat_presets(presets, wanted, tmp) probe = tmp / "probe.stl" _write_probe_stl(probe, *bed_centre) prefix: list[str] = [] @@ -999,19 +1062,7 @@ def reference_project_settings( returncode, log, timed_out = _run(command, timeout_s) out = tmp / "probe.3mf" if not out.exists(): - why = "timed out" if timed_out else f"exit {returncode}" - if "libOpenGL" in log or "libGLU" in log: - raise MissingDependency( - "host OpenGL runtime libraries (libOpenGL.so.0, libGLU)", - install="sudo apt-get install libopengl0 libglu1-mesa; on a box without sudo point ORCA at a wrapper that sets LD_LIBRARY_PATH (e.g. ORCA=~/.local/bin/snapmaker-orca)", - degrades="headless slicing and project_settings generation", - command="prep", - ) - raise ThreeDError( - f"the Orca CLI wrote no reference project ({why})", - command="prep", - remediation=[log[-1500:].strip() or "no output"], - ) + _raise_no_reference_project(returncode, log, timed_out) with zipfile.ZipFile(out) as z: cfg = json.loads(z.read("Metadata/project_settings.config")) if not isinstance(cfg, dict): diff --git a/lib/slicing/thumbnails.py b/lib/slicing/thumbnails.py new file mode 100644 index 00000000..8cebff94 --- /dev/null +++ b/lib/slicing/thumbnails.py @@ -0,0 +1,341 @@ +"""slicing/thumbnails.py — the layers that make a thumbnail impossible to miss in a sliced G-code. + +WHAT: the shared glue between the slicing wrappers / the printer commands and lib/gcode_thumbnails.py. + Layer (a) `spec_for` / `with_project_spec` / `with_machine_spec` / `PRUSA_ARGS`: ask the slicer for + 48x48, 96x96 and 300x300 PNG in its own settings, so a slicer that writes thumbnails (Orca's GUI, + PrusaSlicer, Bambu Studio) does, and the config block of every other G-code names the sizes the + checks require. + Layer (b) `finalize_slice` / `finalize_gcode` / `finalize_plate_images`: after the slice, draw the + missing sizes from the toolpaths into the G-code (and the missing Metadata/plate_N.png into a 3MF + project). Idempotent: complete output stays byte-identical. + Layer (c) `fix_in_place`: what `3d printer upload|start` call before sending a G-code. + +WHY: the Orca-family CLI exports G-code with a null thumbnail callback, so a headless slice never has + a preview (docs/commands/gcode-thumbnails.md). Every path that produces a G-code goes through + one of these functions; tests/test_thumbnail_guard.py fails when a module that runs a slicer + does not. + +INVARIANTS: stdlib at import (numpy/Pillow come in with gcode_thumbnails, lazily). A failure raises + GateFailure naming the exact `3d gcode-thumbnails add` command: a slice whose preview cannot be + written is not reported as done. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import tempfile +import zipfile +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING + +from errors import GateFailure + +if TYPE_CHECKING: + from gcode_thumbnails import Image, ThumbCheck + +PLATE_SIDE = 512 # Metadata/plate_N.png, as the Orca GUI writes it +PLATE_SMALL_SIDE = 128 # Metadata/plate_N_small.png +RENDER_SIDE = ( + 1024 # one toolpath render feeds every size (2x supersampled up to 512 px) +) +SETTINGS_MEMBER = "Metadata/project_settings.config" +# PrusaSlicer takes every config option as a CLI flag; it writes the blocks itself when given sizes. +PRUSA_ARGS = ["--thumbnails", "48x48,96x96,300x300", "--thumbnails-format", "PNG"] +_THUMB_REL = ( + "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" +) +_PLATE_GCODE = re.compile(r"plate_(\d+)\.gcode$", re.I) + + +def fix_command(path: str | Path) -> str: + """The command that repairs a G-code without its thumbnails (what every message prints).""" + return f"3d gcode-thumbnails add {shlex.quote(str(path))}" + + +# ------------------------------------------------------------ (a) ask the slicer for them + + +def spec_for(cfg: Mapping[str, object]) -> str | list[str]: + """The `thumbnails` value of settings `cfg` (a project's config or a machine preset) extended by DEFAULT_SIZES, in the + shape the preset uses: Orca presets hold either `"48x48/PNG, 300x300/PNG"` or a list of sizes.""" + from gcode_thumbnails import ThumbnailError, merge_spec + + value = cfg.get("thumbnails") + fmt = cfg.get("thumbnails_format") + existing = value if isinstance(value, (str, list)) else None + try: + merged = merge_spec(existing, fmt.upper() if isinstance(fmt, str) and fmt else "PNG") + except ThumbnailError: # a size the slicer itself would reject: ask for the defaults only + merged = merge_spec(None) + return merged.split(", ") if isinstance(value, list) else merged + + +def _rewrite_zip( + src: Path, + dst: Path, + edit: Callable[[str, bytes], bytes | None], + extra: Mapping[str, bytes] | None = None, +) -> None: + """Copy zip `src` to `dst` (atomically; `dst` may be `src`) member by member with the zip metadata kept; + `edit(name, data)` returns the new bytes of a member or None to keep it. `extra` members are appended. + Mode and mtime of `src` carry over: a project rewritten for its thumbnails must not look newer than the + G-code sliced from it (the staleness gate).""" + st = src.stat() + with tempfile.NamedTemporaryFile( + dir=dst.parent, prefix=f".{dst.name}.", suffix=".partial", delete=False + ) as h: + tmp = Path(h.name) + try: + with ( + zipfile.ZipFile(src) as zi, + zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zo, + ): + for item in zi.infolist(): + data = zi.read(item.filename) + new = edit(item.filename, data) + zo.writestr(item, data if new is None else new) + for name, data in (extra or {}).items(): + zo.writestr(name, data) + os.chmod(tmp, st.st_mode & 0o7777) + os.utime(tmp, ns=(st.st_atime_ns, st.st_mtime_ns)) + tmp.replace(dst) + finally: + tmp.unlink(missing_ok=True) + + +def with_project_spec(src: Path, dst: Path) -> None: + """Copy of 3MF project `src` whose `thumbnails` setting also asks for DEFAULT_SIZES. Everything else + keeps its bytes. A zip without Metadata/project_settings.config is copied as it is.""" + + def edit(name: str, data: bytes) -> bytes | None: + if name != SETTINGS_MEMBER: + return None + cfg = json.loads(data) + cfg["thumbnails"] = spec_for(cfg) + return json.dumps(cfg, indent=4, ensure_ascii=False).encode() + + _rewrite_zip(src, dst, edit) + + +def thumbnail_cfg(project: Path) -> dict[str, str | list[str]]: + """`set_cfg` entry that makes 3MF `project` ask for DEFAULT_SIZES on top of its own `thumbnails`; + {} for a zip without Metadata/project_settings.config (nothing to override).""" + with zipfile.ZipFile(project) as z: + if SETTINGS_MEMBER not in z.namelist(): + return {} + cfg = json.loads(z.read(SETTINGS_MEMBER)) + return {"thumbnails": spec_for(cfg)} + + +def with_machine_spec(src: Path, dst: Path) -> None: + """Copy of a flat Orca machine preset (JSON) that also asks for DEFAULT_SIZES.""" + cfg = json.loads(src.read_text(encoding="utf-8")) + cfg["thumbnails"] = spec_for(cfg) + dst.write_text(json.dumps(cfg, indent=1), encoding="utf-8") + + +# ------------------------------------------------------------ (b) draw what the slicer did not + + +def _fail(path: Path | str, why: str) -> GateFailure: + return GateFailure( + f"{path}: thumbnails could not be added ({why})", + command="gcode-thumbnails", + remediation=[ + f"Add them by hand: {fix_command(path)}", + "numpy and Pillow are core dependencies: `uv sync` repairs a broken install.", + ], + ) + + +def placeholder(side: int) -> Image: + """A flat grey tile with a darker rim: the picture of a G-code that extrudes nothing visible, so that + even a macro-only file carries every thumbnail its checks require.""" + import numpy as np + + img = np.zeros((side, side, 4), np.uint8) + margin, rim = side // 8, max(side // 32, 1) + img[margin : side - margin, margin : side - margin] = (70, 70, 70, 255) + img[margin + rim : side - margin - rim, margin + rim : side - margin - rim] = (130, 130, 130, 255) + return img + + +class Picture: + """One lazy toolpath render shared by the G-code blocks and the plate images of the same slice.""" + + def __init__(self, gcode: Path) -> None: + self.gcode = gcode + self._image: Image | None = None + + def __call__(self, _path: str = "") -> Image: + if self._image is None: + from gcode_thumbnails import ThumbnailError, render_gcode + + try: + self._image = render_gcode(str(self.gcode), size=RENDER_SIDE) + except ThumbnailError: # nothing visible to draw (console macros, travel-only): a neutral tile + self._image = placeholder(RENDER_SIDE) + return self._image + + +def finalize_gcode( + gcode: Path, picture: Callable[[str], Image] | None = None +) -> ThumbCheck: + """Layer (b): every required size is in `gcode` afterwards (added in place when missing), else GateFailure. + A complete G-code is not touched.""" + import gcode_thumbnails as gt + + try: + result = gt.ensure(str(gcode), source=picture or Picture(gcode)) + except (gt.ThumbnailError, OSError, ImportError) as exc: + raise _fail(gcode, str(exc)) from exc + if not result.ok: + raise _fail(gcode, result.reason) + return result + + +def plate_members(plate: int) -> tuple[str, str]: + return f"Metadata/plate_{plate}.png", f"Metadata/plate_{plate}_small.png" + + +def missing_plate_images(three_mf: Path, plate: int = 1) -> list[str]: + """The Metadata/plate_N PNGs `three_mf` lacks (a member that is not a PNG counts as missing).""" + with zipfile.ZipFile(three_mf) as z: + names = set(z.namelist()) + return [ + m + for m in plate_members(plate) + if m not in names or not z.read(m).startswith(b"\x89PNG\r\n\x1a\n") + ] + + +def finalize_plate_images( + three_mf: Path, plate: int, picture: Callable[[str], Image] +) -> list[str]: + """Add the missing Metadata/plate_N.png / plate_N_small.png (drawn from `picture`) to `three_mf` in place and + return the members written; a 3MF that has both is left byte-identical. Plate 1 also gets the package + thumbnail relationship when the package names none.""" + missing = missing_plate_images(three_mf, plate) + if not missing: + return [] + from gcode_thumbnails import encode_png, fit + + pic = picture("") + side = { + plate_members(plate)[0]: PLATE_SIDE, + plate_members(plate)[1]: PLATE_SMALL_SIDE, + } + images = {m: encode_png(fit(pic, side[m], side[m])) for m in missing} + + def edit(name: str, data: bytes) -> bytes | None: + if name in images: # a corrupt member is replaced, not duplicated + return images.pop(name) + if name == "[Content_Types].xml" and b'Extension="png"' not in data: + return data.replace( + b"", + b' \n', + ) + if name == "_rels/.rels" and plate == 1 and _THUMB_REL.encode() not in data: + rel = f'' + return data.replace(b"", rel.encode() + b"") + return None + + with zipfile.ZipFile(three_mf) as z: + present = set(z.namelist()) + new_members = {m: images[m] for m in missing if m not in present} + _rewrite_zip(three_mf, three_mf, edit, extra=new_members) + return missing + + +def plates_of(three_mf: Path) -> list[int]: + """Plate numbers a 3MF carries a G-code for (`Metadata/plate_N.gcode`); [1] when it has none.""" + with zipfile.ZipFile(three_mf) as z: + found = sorted({int(m.group(1)) for n in z.namelist() if (m := _PLATE_GCODE.search(n))}) + return found or [1] + + +def check_plate_images(three_mf: Path) -> ThumbCheck: + """`3d gcode-thumbnails check X.3mf`: every plate has plate_N.png and plate_N_small.png (a 3MF is not a + printer input, so a missing one is reported like a missing thumbnail but never blocks a print).""" + from gcode_thumbnails import Thumb, ThumbCheck + + big, small = Thumb(PLATE_SIDE, PLATE_SIDE, "PNG"), Thumb(PLATE_SMALL_SIDE, PLATE_SMALL_SIDE, "PNG") + required: list[Thumb] = [] + missing: list[Thumb] = [] + for plate in plates_of(three_mf): + lacking = missing_plate_images(three_mf, plate) + for member, size in zip(plate_members(plate), (big, small), strict=True): + required.append(size) + if member in lacking: + missing.append(size) + ok = not missing + reason = "every plate has its images" if ok else "missing " + ", ".join( + m for p in plates_of(three_mf) for m in missing_plate_images(three_mf, p)) + return ThumbCheck(str(three_mf), ok, required, [t for t in required if t not in missing], missing, reason) + + +def finalize_embedded(three_mf: Path, picture: Callable[[str], Image] | None = None) -> ThumbCheck: + """Add the missing plate images to a `.gcode.3mf`/sliced project, drawn from `picture` or from each plate's + embedded `Metadata/plate_N.gcode`. A 3MF without an embedded G-code needs `picture`.""" + try: + for plate in plates_of(three_mf): + if not missing_plate_images(three_mf, plate): + continue + if picture is not None: + finalize_plate_images(three_mf, plate, picture) + continue + with tempfile.TemporaryDirectory(prefix="3d-plate-") as tmp, zipfile.ZipFile(three_mf) as z: + member = f"Metadata/plate_{plate}.gcode" + if member not in z.namelist(): + raise ValueError(f"{three_mf} has no {member} to draw the image from; pass --image PNG") + gcode = Path(tmp) / f"plate_{plate}.gcode" + gcode.write_bytes(z.read(member)) + finalize_plate_images(three_mf, plate, Picture(gcode)) + except (OSError, ImportError, zipfile.BadZipFile, ValueError) as exc: + raise _fail(three_mf, str(exc)) from exc + return check_plate_images(three_mf) + + +def finalize_slice( + gcode: Path, three_mf: Path | None = None, plate: int | None = None +) -> ThumbCheck: + """Layer (b) for one slice: the G-code, and the project/gcode.3mf next to it when given, from one render. + `plate` defaults to the number in `plate_N.gcode`, else 1.""" + picture = Picture(gcode) + result = finalize_gcode(gcode, picture) + if three_mf is not None and three_mf.is_file(): + match = _PLATE_GCODE.search(gcode.name) + number = plate if plate is not None else int(match.group(1)) if match else 1 + try: + finalize_plate_images(three_mf, number, picture) + except (OSError, ImportError, zipfile.BadZipFile, ValueError) as exc: + raise _fail(three_mf, str(exc)) from exc + return result + + +def finalize_workdir(workdir: Path, three_mf: Path | None = None) -> list[Path]: + """Layer (b) over a slicer output directory: every non-empty `*.gcode` in it. Returns the G-codes.""" + gcodes = [g for g in sorted(workdir.glob("*.gcode")) if g.stat().st_size > 0] + for g in gcodes: + finalize_slice( + g, three_mf if len(gcodes) == 1 or _PLATE_GCODE.search(g.name) else None + ) + return gcodes + + +# ------------------------------------------------------------ (c) before a printer gets the file + + +def fix_in_place(gcode: Path) -> ThumbCheck | None: + """`3d printer upload|start`: add what `gcode` lacks, in place (atomically, mode/mtime kept, the slice-warnings + record follows the new hash). None when the G-code was already complete. The remote name is + content-addressed, so the fixed file gets its own name and the next upload/start refer to the same bytes.""" + from gcode_thumbnails import check + + if check(str(gcode)).ok: + return None + return finalize_gcode(gcode) diff --git a/tests/test_gcode_thumbnails.py b/tests/test_gcode_thumbnails.py index 4136dff4..521f3ccf 100644 --- a/tests/test_gcode_thumbnails.py +++ b/tests/test_gcode_thumbnails.py @@ -145,13 +145,12 @@ def test_ensure_adds_only_the_missing_size_after_existing_blocks(tmp_path: Path) assert [(w, h) for w, h, _ in _moonraker_blocks(g.read_text(encoding="ascii"))] == [(48, 48), (96, 96)] -def test_no_spec_needs_sizes_and_passes_with_any_thumbnail(tmp_path: Path) -> None: +def test_no_spec_requires_the_default_sizes_and_extra_sizes_are_added_on_top(tmp_path: Path) -> None: g = make_gcode(tmp_path / "a.gcode", spec=None) c = gt.check(str(g)) - assert not c.ok and c.required == [] - with pytest.raises(gt.ThumbnailError): - gt.ensure(str(g)) - gt.ensure(str(g), sizes=[gt.Thumb(32, 32, "PNG")]) + assert not c.ok and c.required == list(gt.DEFAULT_SIZES) + c = gt.ensure(str(g), sizes=[gt.Thumb(32, 32, "PNG")]) + assert c.ok and c.added == [*gt.DEFAULT_SIZES, gt.Thumb(32, 32, "PNG")] assert gt.check(str(g)).ok diff --git a/tests/test_thumbnail_guard.py b/tests/test_thumbnail_guard.py new file mode 100644 index 00000000..422cf154 --- /dev/null +++ b/tests/test_thumbnail_guard.py @@ -0,0 +1,74 @@ +"""Gate: no module of lib/ may run a slicer that writes a G-code without handing that G-code to a thumbnail layer. + +The Orca CLI writes no thumbnails (docs/commands/gcode-thumbnails.md). A new slicing entry point that forgets the +layer would ship G-code that `3d print-preflight` refuses; this test fails the commit that adds it. A module +that runs a slicer for something other than a kept G-code must say so in EXEMPT, with the reason.""" + +from __future__ import annotations + +import re +from pathlib import Path + +LIB = Path(__file__).resolve().parents[1] / "lib" + +# a slicer invocation that writes files: the Orca-family `--slice` / `--outputdir` run, or PrusaSlicer's `-g --output` +RUNS_SLICER = re.compile(r'"--slice"|"--outputdir"|"-g",\s*"--output"') +# the layer: slicing.thumbnails finalize_* (or gcode_thumbnails.ensure) applied to what the slicer wrote +HAS_LAYER = re.compile(r"\b(?:finalize_(?:gcode|slice|workdir)|ensure)\(") + +EXEMPT = { + "commands/slice_check.py": "a verification gate: the G-code it slices is counted and deleted, never kept", + "slicing/mix_tester.py": "slices a 10 mm probe only to read the project settings back; no G-code is kept or printed", + "slicing/calibration_plates/u1_underlay.py": ( + "KNOWN GAP, issue #724: the module is over the 2400-line lint gate and cannot be committed until it is split; " + "its plate G-code still gets thumbnails from `3d printer upload|start` and `print-preflight --fix`" + ), +} + + +def _slicer_modules() -> list[str]: + return [ + path.relative_to(LIB).as_posix() + for path in sorted(LIB.rglob("*.py")) + if RUNS_SLICER.search(path.read_text(encoding="utf-8")) + ] + + +def test_every_module_that_slices_to_gcode_applies_a_thumbnail_layer() -> None: + uncovered = [ + rel + for rel in _slicer_modules() + if rel not in EXEMPT + and not HAS_LAYER.search((LIB / rel).read_text(encoding="utf-8")) + ] + assert not uncovered, ( + f"{uncovered} run a slicer but never call slicing.thumbnails.finalize_gcode/finalize_slice/finalize_workdir " + "(or gcode_thumbnails.ensure) on its output: the Orca CLI writes no thumbnails, so the G-code would fail " + "`3d print-preflight`. Call the layer before the G-code is hashed or moved, or add the module to EXEMPT " + "with the reason." + ) + + +def test_exemptions_are_still_slicer_runners() -> None: + """A stale exemption hides a module that was rewritten into a real slicing path.""" + runners = set(_slicer_modules()) + assert set(EXEMPT) <= runners, ( + f"EXEMPT lists modules that no longer run a slicer: {set(EXEMPT) - runners}" + ) + + +def test_the_guard_sees_the_known_entry_points() -> None: + """If the patterns stop matching, the gate above would pass for the wrong reason.""" + assert { + "commands/slice.py", + "commands/retarget.py", + "slicing/orca_cli.py", + "slicing/calibration_gen.py", + "slicing/calibration_plates/u1_underlay.py", + } <= set(_slicer_modules()) + + +def test_gcode_resume_gives_its_derived_gcode_the_thumbnails_too() -> None: + """`3d gcode-resume` writes a new G-code from an old one's head: when that had no thumbnails the resume must get them.""" + source = (LIB / "commands" / "gcode_resume.py").read_text(encoding="utf-8") + assert "finalize_gcode(dest)" in source diff --git a/tests/test_thumbnail_layers.py b/tests/test_thumbnail_layers.py new file mode 100644 index 00000000..658e8e52 --- /dev/null +++ b/tests/test_thumbnail_layers.py @@ -0,0 +1,320 @@ +"""The thumbnail layers one by one (lib/slicing/thumbnails.py): each one alone gets a G-code from +"no thumbnails" / "some" to "all", and none of them touches a G-code that already has them.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import zipfile +from pathlib import Path + +import pytest +from thumbnail_fixtures import ( + ALL_SIZES, + MACROS_ONLY, + bare, + block_sizes, + complete, + partial, + plate_project, +) + +import gcode_thumbnails as gt +from errors import GateFailure +from slicing import orca_cli, slice_warnings, thumbnails + +FIXTURES = {"bare": bare, "partial": partial, "complete": complete} + + +# ------------------------------------------------------------------ (a) ask the slicer + + +def test_spec_for_adds_the_default_sizes_in_the_shape_the_preset_uses() -> None: + assert thumbnails.spec_for({}) == "48x48/PNG, 96x96/PNG, 300x300/PNG" + assert ( + thumbnails.spec_for({"thumbnails": "300x300/PNG"}) + == "300x300/PNG, 48x48/PNG, 96x96/PNG" + ) + assert thumbnails.spec_for( + {"thumbnails": ["230x110"], "thumbnails_format": "JPG"} + ) == ["230x110/JPG", "48x48/PNG", "96x96/PNG", "300x300/PNG"] + assert thumbnails.spec_for({"thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG"}) == ( + "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + assert ( + thumbnails.spec_for({"thumbnails": "nonsense"}) + == "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + + +def test_project_copy_changes_only_the_thumbnails_setting(tmp_path: Path) -> None: + src = plate_project(tmp_path / "in.3mf") + dst = tmp_path / "out.3mf" + thumbnails.with_project_spec(src, dst) + with zipfile.ZipFile(src) as a, zipfile.ZipFile(dst) as b: + assert a.namelist() == b.namelist() + for name in a.namelist(): + if name != "Metadata/project_settings.config": + assert a.read(name) == b.read(name) + assert json.loads(b.read("Metadata/project_settings.config")) == { + "layer_height": "0.2", + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG", + } + assert thumbnails.thumbnail_cfg(src) == { + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG" + } + + +def test_project_without_settings_gets_no_override(tmp_path: Path) -> None: + path = tmp_path / "bare.3mf" + with zipfile.ZipFile(path, "w") as z: + z.writestr("3D/3dmodel.model", b"") + assert thumbnails.thumbnail_cfg(path) == {} + + +def test_machine_preset_copy_asks_for_the_default_sizes(tmp_path: Path) -> None: + src = tmp_path / "machine.json" + src.write_text(json.dumps({"name": "M", "thumbnails": "48x48/PNG"})) + dst = tmp_path / "machine.thumbnails.json" + thumbnails.with_machine_spec(src, dst) + assert json.loads(dst.read_text()) == { + "name": "M", + "thumbnails": "48x48/PNG, 96x96/PNG, 300x300/PNG", + } + assert ( + json.loads(src.read_text())["thumbnails"] == "48x48/PNG" + ) # the user's preset stays as it is + + +def test_prusa_gets_the_sizes_as_cli_flags() -> None: + assert thumbnails.PRUSA_ARGS == [ + "--thumbnails", + "48x48,96x96,300x300", + "--thumbnails-format", + "PNG", + ] + + +# ------------------------------------------------------------------ (b) draw what is missing + + +@pytest.mark.parametrize("kind", FIXTURES) +def test_finalize_gcode_gives_every_kind_of_input_all_the_sizes_once( + tmp_path: Path, kind: str +) -> None: + path = FIXTURES[kind](tmp_path / f"{kind}.gcode") + before = path.read_bytes() + pre_existing = block_sizes(path) + + result = thumbnails.finalize_gcode(path) + + assert result.ok and gt.check(str(path)).ok + assert sorted(block_sizes(path)) == sorted(ALL_SIZES) # all three, none twice + after = path.read_bytes() + assert after.endswith( + before[before.index(b"G90") :] + ) # the toolpath and config are untouched + if kind == "complete": + assert after == before and result.added == [] + if kind == "partial": + assert pre_existing == [(48, 48)] + first = before[ + before.index(b"; THUMBNAIL_BLOCK_START") : before.index( + b"; THUMBNAIL_BLOCK_END" + ) + ] + assert first in after # the 48x48 block that was there is byte-identical + assert [(t.width, t.height) for t in result.added] == [(96, 96), (300, 300)] + + +def test_finalize_gcode_twice_changes_nothing_the_second_time(tmp_path: Path) -> None: + path = bare(tmp_path / "a.gcode") + thumbnails.finalize_gcode(path) + once, mtime = path.read_bytes(), path.stat().st_mtime_ns + thumbnails.finalize_gcode(path) + assert path.read_bytes() == once and path.stat().st_mtime_ns == mtime + + +def test_finalize_gcode_without_a_spec_adds_the_default_sizes(tmp_path: Path) -> None: + path = bare(tmp_path / "a.gcode", spec=None) + thumbnails.finalize_gcode(path) + assert sorted(block_sizes(path)) == sorted(ALL_SIZES) + + +def test_a_macro_only_gcode_still_gets_its_thumbnails(tmp_path: Path) -> None: + """Nothing is extruded, so there is nothing to draw: a neutral tile stands in, the checks still pass.""" + path = bare(tmp_path / "shaper.gcode", body=MACROS_ONLY) + thumbnails.finalize_gcode(path) + assert gt.check(str(path)).ok and sorted(block_sizes(path)) == sorted(ALL_SIZES) + + +def test_finalize_gcode_failure_names_the_repair_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = bare(tmp_path / "a b.gcode") + + def boom(*_a: object, **_k: object) -> None: + raise gt.ThumbnailError("disk full") + + monkeypatch.setattr(gt, "ensure", boom) + with pytest.raises(GateFailure) as err: + thumbnails.finalize_gcode(path) + assert "disk full" in err.value.message + assert f"3d gcode-thumbnails add '{path}'" in err.value.remediation[0] + + +def test_plate_images_are_added_to_a_project_without_them_once(tmp_path: Path) -> None: + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project(tmp_path / "sliced.3mf") + stamp = (1_700_000_000, 1_700_000_000) + os.utime(project, stamp) + + thumbnails.finalize_slice(gcode, project) + + with zipfile.ZipFile(project) as z: + names = z.namelist() + assert ( + names.count("Metadata/plate_1.png") == 1 + and names.count("Metadata/plate_1_small.png") == 1 + ) + assert z.read("Metadata/plate_1.png")[:8] == b"\x89PNG\r\n\x1a\n" + assert b'Extension="png"' in z.read("[Content_Types].xml") + assert b"/Metadata/plate_1.png" in z.read("_rels/.rels") + assert z.read("3D/3dmodel.model") == b"" + assert ( + project.stat().st_mtime == stamp[1] + ) # the project must not look newer than the G-code sliced from it + assert thumbnails.missing_plate_images(project) == [] + + snapshot = project.read_bytes() + thumbnails.finalize_slice(gcode, project) + assert project.read_bytes() == snapshot # idempotent + + +def test_a_project_that_already_has_its_plate_pictures_is_not_rewritten( + tmp_path: Path, +) -> None: + gcode = complete(tmp_path / "plate_2.gcode") + png = b"\x89PNG\r\n\x1a\nORIGINAL" + project = plate_project( + tmp_path / "p.3mf", + members={"Metadata/plate_2.png": png, "Metadata/plate_2_small.png": png}, + ) + before = project.read_bytes() + thumbnails.finalize_slice(gcode, project) # plate number comes from the file name + assert project.read_bytes() == before + + +def test_a_corrupt_plate_picture_is_replaced_not_duplicated(tmp_path: Path) -> None: + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project( + tmp_path / "p.3mf", members={"Metadata/plate_1.png": b"not a png"} + ) + thumbnails.finalize_slice(gcode, project) + with zipfile.ZipFile(project) as z: + assert z.namelist().count("Metadata/plate_1.png") == 1 + assert z.read("Metadata/plate_1.png")[:4] == b"\x89PNG" + + +def test_embedded_plate_gcode_feeds_the_pictures_of_a_gcode_3mf(tmp_path: Path) -> None: + gcode = bare(tmp_path / "g.gcode") + project = plate_project( + tmp_path / "x.gcode.3mf", members={"Metadata/plate_1.gcode": gcode.read_bytes()} + ) + assert not thumbnails.check_plate_images(project).ok + assert thumbnails.finalize_embedded(project).ok + assert thumbnails.check_plate_images(project).ok + + +# ------------------------------------------------------------------ the slicer wrapper (orca_cli) + +FAKE_ORCA = """#!__PYTHON__ +import json, os, sys, zipfile +a = sys.argv[1:] +out = a[a.index("--outputdir") + 1] +with zipfile.ZipFile(a[-1]) as z: + cfg = json.loads(z.read("Metadata/project_settings.config")) +open(os.path.join(out, "seen-thumbnails.json"), "w").write(json.dumps(cfg.get("thumbnails"))) +open(os.path.join(out, "plate_1.gcode"), "w").write(__GCODE__) +with zipfile.ZipFile(os.path.join(out, "sliced.3mf"), "w") as z: + z.writestr("Metadata/slice_info.config", "") + z.writestr("[Content_Types].xml", "") + z.writestr("_rels/.rels", "") +print("[warning] cli mode") +""" + + +@pytest.fixture +def fake_orca(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> orca_cli.OrcaCli: + monkeypatch.setenv( + "THREED_HEAVY_HELD", "1" + ) # as inside the machine lock: no flock prefix + text = bare(tmp_path / "template.gcode").read_text(encoding="ascii") + exe = tmp_path / "fake-orca" + exe.write_text(FAKE_ORCA.replace("__PYTHON__", sys.executable).replace("__GCODE__", repr(text))) + exe.chmod(exe.stat().st_mode | stat.S_IXUSR) + return orca_cli.OrcaCli(exe, "test") + + +def test_slice_project_asks_for_the_sizes_and_returns_a_gcode_that_has_them( + tmp_path: Path, fake_orca: orca_cli.OrcaCli +) -> None: + project = plate_project(tmp_path / "p.3mf") + out = orca_cli.slice_project(project, tmp_path / "work", orca=fake_orca) + + assert out.ok and out.gcode is not None + assert ( + json.loads((tmp_path / "work" / "seen-thumbnails.json").read_text()) + == "48x48/PNG, 96x96/PNG, 300x300/PNG" + ) + assert gt.check(str(out.gcode)).ok and sorted(block_sizes(out.gcode)) == sorted( + ALL_SIZES + ) + assert ( + out.sliced_3mf is not None + and thumbnails.missing_plate_images(out.sliced_3mf) == [] + ) + + +def test_slice_project_fails_loudly_when_the_thumbnails_cannot_be_added( + tmp_path: Path, fake_orca: orca_cli.OrcaCli, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + gt, + "ensure", + lambda *_a, **_k: (_ for _ in ()).throw(gt.ThumbnailError("no space")), + ) + out = orca_cli.slice_project( + plate_project(tmp_path / "p.3mf"), tmp_path / "work", orca=fake_orca + ) + assert not out.ok and out.failure is not None and out.failure.kind == "thumbnails" + assert "3d gcode-thumbnails add" in out.failure.remediation[0] + + +# ------------------------------------------------------------------ (c) before a printer gets the file + + +def test_fix_in_place_adds_the_sizes_and_the_slice_warnings_record_follows( + tmp_path: Path, +) -> None: + path = bare(tmp_path / "part.gcode") + slice_warnings.record(path, logs=[("slicer log", "[warning] thin wall")]) + old_sha = slice_warnings.load(slice_warnings.sidecar_path(path))["gcode_sha256"] + + fixed = thumbnails.fix_in_place(path) + + assert fixed is not None and len(fixed.added) == 3 + record = slice_warnings.load(slice_warnings.sidecar_path(path)) + assert record["gcode_sha256"] != old_sha + steps = record["post_processed"] + assert isinstance(steps, list) and steps[-1]["from_sha256"] == old_sha + assert thumbnails.fix_in_place(path) is None # nothing left to do + + +def test_fix_in_place_leaves_a_complete_gcode_alone(tmp_path: Path) -> None: + path = complete(tmp_path / "part.gcode") + before = path.read_bytes() + assert thumbnails.fix_in_place(path) is None + assert path.read_bytes() == before diff --git a/tests/thumbnail_fixtures.py b/tests/thumbnail_fixtures.py new file mode 100644 index 00000000..e9cb4a65 --- /dev/null +++ b/tests/thumbnail_fixtures.py @@ -0,0 +1,75 @@ +"""Fixtures for the thumbnail layers: a G-code with no thumbnails, with some, and with all of them. + +The G-code is what the Orca CLI writes: a header, a few extruding layers, a config block that names +`; thumbnails = 48x48/PNG, 96x96/PNG, 300x300/PNG`. `bare` has no block, `partial` only the 48x48 one, +`complete` all three.""" + +from __future__ import annotations + +import re +import zipfile +from pathlib import Path + +import numpy as np + +import gcode_thumbnails as gt + +SPEC = "48x48/PNG, 96x96/PNG, 300x300/PNG" +ALL_SIZES = [(48, 48), (96, 96), (300, 300)] +HEADER = ( + "; HEADER_BLOCK_START\n; generated by Snapmaker Orca 2.4.0\n; HEADER_BLOCK_END\n\n" +) +BODY = "G90\nM83\nT0\n;TYPE:Outer wall\n" + "".join( + f"G1 Z{z}\nG1 X10 Y10\nG1 X40 Y10 E1\nG1 X40 Y25 E1\nG1 X10 Y25 E1\nG1 X10 Y10 E1\n" + for z in (0.2, 0.4, 0.6) +) +CONFIG = "; CONFIG_BLOCK_START\n; filament_colour = #C12E1F\n; thumbnails = {spec}\n; CONFIG_BLOCK_END\n" +MACROS_ONLY = "G28\nM104 S0\nTEMPERATURE_WAIT SENSOR=extruder MAXIMUM=38\n" + + +def bare(path: Path, spec: str | None = SPEC, body: str = BODY) -> Path: + config = ( + CONFIG.format(spec=spec) + if spec + else CONFIG.replace("; thumbnails = {spec}\n", "") + ) + path.write_text(HEADER + body + config, encoding="ascii") + return path + + +def partial(path: Path) -> Path: + """Only the 48x48 block, drawn from a flat colour so its bytes differ from what ensure() would draw.""" + bare(path) + tile = np.full((48, 48, 4), 200, np.uint8) + gt.splice(str(path), gt.block(gt.Thumb(48, 48, "PNG"), gt.encode_png(tile))) + return path + + +def complete(path: Path) -> Path: + bare(path) + gt.ensure(str(path)) + return path + + +def block_sizes(path: Path) -> list[tuple[int, int]]: + """(w, h) of every thumbnail block in the file, in order (duplicates included).""" + return [ + (int(w), int(h)) + for w, h in re.findall( + r"; thumbnail begin (\d+)x(\d+) \d+", path.read_text(encoding="ascii") + ) + ] + + +def plate_project(path: Path, *, members: dict[str, bytes] | None = None) -> Path: + """A minimal Orca project 3MF with settings but no plate pictures (what the headless CLI exports).""" + base = { + "[Content_Types].xml": b'', + "_rels/.rels": b'', + "3D/3dmodel.model": b"", + "Metadata/project_settings.config": b'{"layer_height": "0.2"}', + } + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in {**base, **(members or {})}.items(): + z.writestr(name, data) + return path From 9e543ad71b712fc835f9873d0a04a3fcfac55927 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:25:45 +0100 Subject: [PATCH 02/30] feat(printer): upload and start add missing thumbnails; print-preflight 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). --- docs/commands/print-preflight.md | 6 +- docs/commands/printer.md | 4 +- lib/commands/print_preflight.py | 219 +++++++--- lib/commands/printer.py | 340 ++++++++++----- lib/printer_ops/preflight.py | 419 ++++++++++--------- tests/e2e/test_printer_workflows.py | 3 +- tests/e2e/test_thumbnail_layers_workflows.py | 233 +++++++++++ tests/test_printer_ops.py | 11 +- tests/test_thumbnail_preflight.py | 35 ++ 9 files changed, 899 insertions(+), 371 deletions(-) create mode 100644 tests/e2e/test_thumbnail_layers_workflows.py create mode 100644 tests/test_thumbnail_preflight.py diff --git a/docs/commands/print-preflight.md b/docs/commands/print-preflight.md index 85ab46d9..ce9fb648 100644 --- a/docs/commands/print-preflight.md +++ b/docs/commands/print-preflight.md @@ -10,12 +10,14 @@ input is missing is SKIP and its line says what to pass — it never passes sile ``` 3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] - [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] + [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] [--fix] ``` +`--fix` adds the thumbnails the G-code lacks, in place (header only; mtime kept, a current slice-warnings record follows the new SHA-256), then runs the checks. It is the only option that writes. + | Check | When | FAIL means | |---|---|---| -| `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (fix: `3d gcode-thumbnails add FILE`). | +| `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (48x48, 96x96 and 300x300 PNG when it names none). The line names the exact fix, `3d gcode-thumbnails add 'FILE'`; `--fix` runs it. [Layers](gcode-thumbnails.md#layers-a-thumbnail-cannot-be-missing) add thumbnails earlier, so this FAIL means the G-code came from outside them. | | `printer` | `--printer` | The printer is printing, paused, busy, or its state cannot be read. | | `bed` | `--printer` with `watch.bed.enabled` (SKIP otherwise) | The camera sees objects on the bed or the build plate missing against the empty-bed reference; WARN when it cannot tell (no reference at this Z, plate out of view, camera down). See [Bed state](printer.md#bed-state). On an idle printer with `watch.bed.auto_view` (the U1: its camera sees 15 % of the plate at the Z 200 rest) the bed is moved in Z only to where the whole plate is in view, judged there and put back, and the line names the Z used; never while printing/paused, never homing; `--no-bed-move` judges at the current Z. | | `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, or its colour/material differs from `; filament_colour` / `; filament_type`. | diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..fca03efd 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -45,8 +45,8 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). |---|---| | `list` | Every fleet and configured printer: owner, access, model, address, heads (an unusable head with its reason), and where the device settings came from when a user/project file overrides the fleet. | | `status NAME` | State, current file and progress, temperatures, and (U1) what each head holds. | -| `upload NAME FILE` | Store the file on the printer and verify its size there. Never starts a print. | -| `start NAME FILE --yes` | Start a file that was uploaded before. Runs [`3d print-preflight`](print-preflight.md) first. | +| `upload NAME FILE` | Store the file on the printer and verify its size there. Never starts a print. A FILE without its preview thumbnails gets them first, in place (old and new SHA-256 printed; see [`3d gcode-thumbnails`](gcode-thumbnails.md#layers-a-thumbnail-cannot-be-missing)). | +| `start NAME FILE --yes` | Start a file that was uploaded before. Runs [`3d print-preflight`](print-preflight.md) first. Adds missing thumbnails to FILE in place like `upload`; the fixed bytes have another content-addressed name, so upload again. | | `cancel NAME --yes` | Cancel the running or paused job. | | `evidence NAME --out DIR` | After EVERY print (done, cancelled, failed): save the Moonraker history job and the klippy.log lines around its pauses and errors as `DIR/-.md` + `.json`, with the layer of each stop when `--gcode` is given; GET only. See [printer-evidence.md](printer-evidence.md). | | `guard NAME --job-id ID --yes` | Authorize one automatic native PAUSE of this exact live Moonraker job on confirmed camera failure; [policy and readiness](printer-vision.md#one-job-automatic-pause). | diff --git a/lib/commands/print_preflight.py b/lib/commands/print_preflight.py index c96d6c50..6fc92aad 100644 --- a/lib/commands/print_preflight.py +++ b/lib/commands/print_preflight.py @@ -19,21 +19,36 @@ https://github.com/alex-mextner/3d-cli/issues/96); running it alone answers "is this file ready to go on that printer?" without touching anything. It only READS from the printer. -INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside run(). +INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside the helpers and run(). """ from __future__ import annotations +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import GateFailure, InputNotFound, UsageError +if TYPE_CHECKING: # typing-only: printer_ops stays a lazy import inside the helpers + from printer_ops.base import PrinterStatus + from printer_ops.devices import Device + from printer_ops.move_bounds import Limits + from printer_ops.preflight import Check + from printer_ops.printops_checks import PrintopsContext + USAGE = """3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] - [--no-bed-move] - Read-only checks before a print. `3d printer start` runs the same checks and refuses on FAIL. + [--no-bed-move] [--fix] + Read-only checks before a print (the one write is opt-in: --fix). `3d printer start` runs the same checks and refuses on FAIL. Every G-code gate runs; one whose input is missing is SKIP and says what to pass. - thumbnails preview images required by the G-code's `; thumbnails =` spec are present + thumbnails preview images required by the G-code's `; thumbnails =` spec (48x48, 96x96 and + 300x300 PNG when it names none) are present; a FAIL names the exact + `3d gcode-thumbnails add FILE` command, `--fix` runs it printer (--printer) the printer is not printing / paused / busy / unreadable bed (--printer with watch.bed.enabled) the camera sees the bed clear and the build plate on: objects or no plate FAIL, cannot tell WARN (`3d printer bed --help`). On @@ -118,6 +133,9 @@ carries --reason; only those two; needs --reason TEXT --reason TEXT why the accepted mismatch is printed --no-bed-move the bed line judges at the current Z: the printer's bed is not moved + --fix add the thumbnails the G-code lacks, in place (header only: the toolpath is + unchanged, mtime kept, a current slice-warnings record follows the new + SHA-256), then run the checks. The only option that writes. --json JSON report on stdout --secs S Creality: seconds to listen for pushed state (default 3) @@ -137,40 +155,57 @@ 3d print-preflight out/plate.gcode --printer k1se --accept preset_match --reason 'stock flow on purpose'""" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - import json - from pathlib import Path +_VALUE_OPTIONS = ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", + "--printops-api", "--printops-snapshot", "--accept", "--reason") +_FLAG_OPTIONS = ("--json", "--offline", "--no-bed-move", "--fix") + + +@dataclass +class _Target: + """What the checks run against: the printer's device/status/limits, or an offline stand-in.""" + + device: Device | None + status: PrinterStatus | None + limits: Limits | None + limits_source: str + trusted: frozenset[str] + + +def _record_flag_or_positional(a: str, pos: list[str], opts: dict[str, str]) -> None: + """One argv token that takes no value: a flag, the G-code path, or an unknown option.""" + if a in _FLAG_OPTIONS: + opts[a] = "1" + elif a.startswith("--"): + raise UsageError(f"unknown option {a}", command="print-preflight", + remediation=["See `3d print-preflight --help`."]) + else: + pos.append(a) + +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str], list[str]]: + """argv -> (positionals, options by flag, repeatable --visible values).""" pos: list[str] = [] opts: dict[str, str] = {} visible: list[str] = [] i = 0 while i < len(argv): a = argv[i] - if a in ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", - "--printops-api", "--printops-snapshot", "--accept", "--reason"): - if i + 1 >= len(argv): - raise UsageError(f"{a} needs a value", command="print-preflight") - if a == "--visible": - visible.append(argv[i + 1]) - else: - opts[a] = argv[i + 1] - i += 2 + if a not in _VALUE_OPTIONS: + _record_flag_or_positional(a, pos, opts) + i += 1 continue - if a in ("--json", "--offline", "--no-bed-move"): - opts[a] = "1" - elif a.startswith("--"): - raise UsageError(f"unknown option {a}", command="print-preflight", - remediation=["See `3d print-preflight --help`."]) + if i + 1 >= len(argv): + raise UsageError(f"{a} needs a value", command="print-preflight") + if a == "--visible": + visible.append(argv[i + 1]) else: - pos.append(a) - i += 1 + opts[a] = argv[i + 1] + i += 2 + return pos, opts, visible + + +def _resolve_inputs(pos: list[str], opts: dict[str, str]) -> tuple[Path, Path | None]: + """The G-code and the optional source 3MF, both required to exist.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="print-preflight") gcode = Path(pos[0]) @@ -179,52 +214,108 @@ def run(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="print-preflight") + return gcode, source - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - from printer_ops import adapter_by_name, bridge_max_for, device_for, limits_from_option, trusted_macros_for +def _apply_fix(gcode: Path) -> None: + """--fix: add the missing thumbnails in place before the checks run.""" + from slicing.thumbnails import fix_in_place + + fixed = fix_in_place(gcode) + if fixed is not None: + print(f"--fix: added {', '.join(map(str, fixed.added))} to {gcode}", file=sys.stderr) + + +def _offline_profile(opts: dict[str, str], printer: str | None, offline: bool) -> Device | None: + """The static profile standing in for --printer when it is offline or --offline is given. + + Such a printer is never contacted: its profile replaces --printer, and an explicit + --axis-limits replaces only its limits. Mutates `opts` accordingly.""" from printer_ops.devices import get_device + profile = get_device(printer, offline_ok=True) if printer is not None else None + if profile is None or not (offline or profile.access == "offline"): + return None + del opts["--printer"] + opts.setdefault("--axis-limits", profile.name) + return profile + + +def _static_device(opts: dict[str, str], profile: Device | None) -> tuple[Device | None, frozenset[str]]: + """Device and trusted macros known without contacting a printer.""" + from printer_ops import device_for, trusted_macros_for + + if profile is not None: + return profile, profile.trusted_macros + if "--printer" in opts: + return None, frozenset() + spec = opts.get("--axis-limits") + return device_for(spec), trusted_macros_for(spec) + + +def _connect(opts: dict[str, str], profile: Device | None) -> _Target: + """Device, live status, axis limits and trusted macros for the run; reads the printer if --printer.""" + from printer_ops import adapter_by_name, limits_from_option + + device, trusted = _static_device(opts, profile) + limits: Limits | None = None + limits_source = "no --printer or --axis-limits given" + if "--axis-limits" in opts: + limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") + if "--printer" not in opts: + return _Target(device, None, limits, limits_source, trusted) + adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") + status = adapter.status() + if "--axis-limits" not in opts: + limits, limits_source = adapter.axis_limits() + # this printer's macros only, whatever --axis-limits names + return _Target(adapter.device, status, limits, limits_source, adapter.trusted_macros) + + +def _setup_printer(opts: dict[str, str]) -> tuple[PrintopsContext, _Target]: + """PrintOps gate context plus the printer target, from --printer / --axis-limits / --offline.""" + from printer_ops.printops_checks import context_from_options + printer = opts.get("--printer") offline = "--offline" in opts - profile = get_device(printer, offline_ok=True) if printer is not None else None - if profile is not None and (offline or profile.access == "offline"): - # an offline fleet printer, or --offline: never contacted, its static profile stands in for - # --printer, an explicit --axis-limits replaces only its limits - del opts["--printer"] - opts.setdefault("--axis-limits", profile.name) - else: - profile = None + profile = _offline_profile(opts, printer, offline) printops = context_from_options(printer, opts.get("--axis-limits"), api=opts.get("--printops-api"), offline=offline, snapshot=opts.get("--printops-snapshot"), command="print-preflight", accept=opts.get("--accept"), reason=opts.get("--reason")) + return printops, _connect(opts, profile) - status = None - device = profile or (None if "--printer" in opts else device_for(opts.get("--axis-limits"))) - limits, limits_source = None, "no --printer or --axis-limits given" - trusted = profile.trusted_macros if profile else \ - frozenset() if "--printer" in opts else trusted_macros_for(opts.get("--axis-limits")) - if "--axis-limits" in opts: - limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") - if "--printer" in opts: - adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") - trusted = adapter.trusted_macros # this printer's macros only, whatever --axis-limits names - device = adapter.device - status = adapter.status() - if "--axis-limits" not in opts: - limits, limits_source = adapter.axis_limits() - - bed = None - if "--printer" in opts and device is not None: - from printer_ops.preflight import check_bed - - bed = check_bed(device, move="--no-bed-move" not in opts) - bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), device, command="print-preflight") - report = run_preflight(gcode, status=status, source_3mf=source, axis_limits=limits, limits_source=limits_source, - trusted_macros=trusted, visible=visible, bridge_max=bridge, bridge_source=bridge_source, - bed=bed, printops=printops) + +def _bed_line(opts: dict[str, str], device: Device | None) -> Check | None: + """The camera bed line, only for a real --printer whose device is known.""" + if "--printer" not in opts or device is None: + return None + from printer_ops.preflight import check_bed + + return check_bed(device, move="--no-bed-move" not in opts) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + pos, opts, visible = _parse_args(argv) + gcode, source = _resolve_inputs(pos, opts) + + from printer_ops import bridge_max_for + from printer_ops.preflight import FAIL, run_preflight + + if "--fix" in opts: + _apply_fix(gcode) + printops, target = _setup_printer(opts) + bed = _bed_line(opts, target.device) + bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), target.device, command="print-preflight") + report = run_preflight(gcode, status=target.status, source_3mf=source, axis_limits=target.limits, + limits_source=target.limits_source, trusted_macros=target.trusted, visible=visible, + bridge_max=bridge, bridge_source=bridge_source, bed=bed, printops=printops) print(json.dumps(report.to_json(), indent=1) if "--json" in opts else report.render()) if report.overall == FAIL: raise GateFailure("preflight FAIL", command="print-preflight", silent=True) diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..570aa93a 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -15,6 +15,9 @@ re-reads state immediately before the write, and runs `3d print-preflight` plus the camera `bed` check (printer_ops/bed.py; a FAIL blocks; --force needs the owner's Telegram hatch approval, printer_ops/force_hatch.py); + - upload and start first add the thumbnails FILE lacks (48x48/96x96/300x300 PNG per its + `; thumbnails =` spec) in place (lib/slicing/thumbnails.py `fix_in_place`): a G-code + never reaches a printer screen without its preview; - a failed/timed-out start is reported as UNKNOWN and never resent automatically. INVARIANTS: stdlib-only at import time (registry contract); printer_ops is imported lazily. @@ -27,6 +30,7 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus @@ -45,10 +49,13 @@ status NAME [--json] [--secs S] state, job, progress, temperatures, loaded heads (everything about one printer — web UIs, queue, job history, settings: `3d printers show NAME`) - upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print + upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print. + A FILE without its preview thumbnails gets them first (in + place, sha256 printed: `3d gcode-thumbnails check FILE`) start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] [--no-bed-move] - start a file that was uploaded before; runs + start a file that was uploaded before (also adds missing + thumbnails to FILE in place: then upload FILE again); runs `3d print-preflight` first, plus the camera bed check (objects on the bed / plate missing FAIL) when the printer has watch.bed.enabled (an idle printer with @@ -196,32 +203,81 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: + """The fleet-declared columns of a `printer list` row (empty facts when the printer is not in the fleet).""" from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _device_facts(device: Device | None) -> dict[str, Any]: + """The device-derived columns of a `printer list` row (the device that would be contacted).""" + if device is None: + return {"adapter": None, "address": None, "settings": None} + return {"adapter": device.adapter, "address": f"{device.host}:{device.port}", "settings": str(device.source)} + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) + dev = _device_facts(device) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, - "adapter": device.adapter if device else None, - "address": f"{device.host}:{device.port}" if device else None, - "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "adapter": dev["adapter"], + "address": dev["address"], + "settings": dev["settings"], + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + """The `printer list` hint when nothing is configured: where we looked and an example.""" + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + """One human-readable `printer list` line plus its unusable-head and settings notes.""" + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -232,22 +288,11 @@ def _cmd_list(argv: list[str]) -> int: print(json.dumps(rows, indent=1, ensure_ascii=False)) return 0 if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) + _print_no_printers() return 0 print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") + _print_list_row(r) if fleet is not None: print(f"\nfleet: {fleet.path}") return 0 @@ -277,6 +322,20 @@ def _local(path: str) -> Path: return p +def _ensure_thumbnails(original: Path) -> None: + """A G-code reaches a printer with its preview or not at all: add the missing thumbnails to FILE in place + (atomic, mtime kept, the slice-warnings record follows) BEFORE the snapshot is hashed. The remote name is + content-addressed, so the fixed bytes get a name of their own and `start` of the same FILE refers to them.""" + from printer_ops.base import file_sha256 + from slicing.thumbnails import fix_in_place + + old = file_sha256(original) + res = fix_in_place(original) + if res is not None: + print(f"thumbnails: {original} lacked {', '.join(map(str, res.added))}; added in place " + f"(sha256 {old[:12]}… -> {file_sha256(original)[:12]}…)") + + def _cmd_upload(argv: list[str]) -> int: import shutil import tempfile @@ -284,6 +343,7 @@ def _cmd_upload(argv: list[str]) -> int: (name, file), opts = _parse(argv, {"--as", "--secs"}, 2, "upload") original = _local(file) + _ensure_thumbnails(original) # The bytes named by the hash are the bytes sent: hash and upload one snapshot, so a # slicer rewriting FILE meanwhile cannot store other content under this name. with tempfile.TemporaryDirectory(prefix="3d-upload-") as tmp: @@ -343,6 +403,7 @@ def _cmd_start(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="printer") + _ensure_thumbnails(original) # One immutable snapshot: the name hash, the read-back comparison and the preflight all see # the same bytes even if a slicer rewrites FILE meanwhile. with tempfile.TemporaryDirectory(prefix="3d-start-") as tmp: @@ -351,20 +412,11 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _require_stored_file(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + """Refuse unless the printer holds `remote` with exactly the local size.""" + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -373,6 +425,12 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local: Path) -> str: + """Check the stored file is the local one; returns the local sha256.""" + from printer_ops.base import PrinterRefused, file_sha256 + # Identity of what will be printed with what is checked below: the read-back hash where # the firmware can return the file, else the hash in the name plus the size. remote_hash = adapter.remote_sha256(remote) @@ -385,11 +443,19 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P ) identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + return local_hash + + +def _preflight_report(name: str, adapter: Adapter, st: PrinterStatus, original: Path, local: Path, + source: Path | None, opts: dict[str, str]) -> Any: + """Run and print the preflight on the snapshot; the report is relabelled with the original path.""" + from printer_ops import bridge_max_for + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) @@ -402,18 +468,34 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P else None) report.file = f"{original} (snapshot)" print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + return report + + +def _require_preflight_pass(report: Any, name: str, original: Path, local_hash: str, + opts: dict[str, str]) -> None: + """Refuse on a preflight FAIL unless --force is given and the owner approves it.""" + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _start_and_report(adapter: Adapter, name: str, remote: str) -> None: + """Send the start and print the state after it; any unreadable outcome is reported as UNKNOWN.""" + from printer_ops.base import UNKNOWN, PrinterCommError + from printer_ops.render import render_status _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] @@ -440,6 +522,23 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=dont_resend, ) print(render_status(name, adapter, after)) + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import safe_remote_name + + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + _require_stored_file(adapter, name, file, remote, size, opts) + local_hash = _verify_remote_identity(adapter, name, file, remote, local) + report = _preflight_report(name, adapter, st, original, local, source, opts) + _require_preflight_pass(report, name, original, local_hash, opts) + _start_and_report(adapter, name, remote) return 0 @@ -487,6 +586,80 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _run_evidence(rest: list[str], name: str | None) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _run_watch(rest: list[str], name: str | None) -> int: + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _run_supervise(rest: list[str], name: str | None) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _run_guard(rest: list[str], name: str | None) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _run_agent(rest: list[str], name: str | None) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _run_doctor(rest: list[str], name: str | None) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _run_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + +def _run_subscriptions(sub: str) -> Callable[[list[str], str | None], int]: + """Handler for subscribe/unsubscribe/subscriptions: the module takes the subcommand word too.""" + def handler(rest: list[str], name: str | None) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + return handler + + +# subcommand -> handler(rest, -n/--name value). Add new subcommands here. +_SUBCOMMANDS: dict[str, Callable[[list[str], str | None], int]] = { + "list": lambda rest, name: _cmd_list(rest), + "status": lambda rest, name: _cmd_status(rest), + "upload": lambda rest, name: _cmd_upload(rest), + "start": lambda rest, name: _cmd_start(rest), + "cancel": lambda rest, name: _cmd_cancel(rest), + "evidence": _run_evidence, + "watch": _run_watch, + "supervise": _run_supervise, + "vision": lambda rest, name: _cmd_vision(rest), + "guard": _run_guard, + "bed": lambda rest, name: _cmd_bed(rest), + "subscribe": _run_subscriptions("subscribe"), + "unsubscribe": _run_subscriptions("unsubscribe"), + "subscriptions": _run_subscriptions("subscriptions"), + "agent": _run_agent, + "doctor": _run_doctor, + "todo": _run_todo, + "issues": _run_todo, +} + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -502,57 +675,12 @@ def run(argv: list[str]) -> int: if name is not None and sub not in ("todo", "issues"): raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) - if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) - raise UsageError(f"unknown subcommand '{sub}'", command="printer", - remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + raise UsageError(f"unknown subcommand '{sub}'", command="printer", + remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " + "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + return handler(rest, name) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: diff --git a/lib/printer_ops/preflight.py b/lib/printer_ops/preflight.py index 3e5eef47..94556737 100644 --- a/lib/printer_ops/preflight.py +++ b/lib/printer_ops/preflight.py @@ -57,7 +57,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from printer_ops.base import PrinterStatus +from printer_ops.base import PrinterStatus, Slot from printer_ops.move_bounds import ( BoundsReport, Limits, @@ -151,45 +151,57 @@ def _hex(value: str | None) -> str | None: ) +def _scan_gcode_line(line: str, config: dict[str, str], tool_lines: set[int]) -> None: + """Fold one G-code line into the slicer config comments / the `Tn` tool selections seen.""" + if line.startswith(";"): + m = _CONFIG_LINE.match(line.rstrip("\n")) + if m: + config.setdefault(m.group(1).strip(), m.group(2).strip()) + elif line[:1] == "T": + t = _TOOL_LINE.match(line.strip()) + if t: + tool_lines.add(int(t.group(1))) + + +def _filament_amount(value: str) -> float: + """One `filament used [mm]` entry; an unparsable amount counts as unused, keep reading.""" + try: + return float(value) + except ValueError: + return 0.0 + + +def _used_tools(config: dict[str, str], tool_lines: set[int]) -> list[int]: + """Tools that print: those with filament used, else those selected by `Tn`, else tool 0.""" + used_mm = config.get("filament used [mm]") + if used_mm: + amounts = [_filament_amount(v) for v in used_mm.split(",")] + return [i for i, a in enumerate(amounts) if a > 0] + if tool_lines: + return sorted(tool_lines) + return [0] + + def read_gcode_meta(path: Path) -> GcodeMeta: """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" config: dict[str, str] = {} tool_lines: set[int] = set() with open(path, encoding="utf-8", errors="replace") as fh: for line in fh: - if line.startswith(";"): - m = _CONFIG_LINE.match(line.rstrip("\n")) - if m: - config.setdefault(m.group(1).strip(), m.group(2).strip()) - elif line[:1] == "T": - t = _TOOL_LINE.match(line.strip()) - if t: - tool_lines.add(int(t.group(1))) - used: list[int] - used_mm = config.get("filament used [mm]") - if used_mm: - amounts = [] - for v in used_mm.split(","): - try: - amounts.append(float(v)) - except ValueError: # noqa: PERF203 — an unparsable amount counts as unused, keep reading - amounts.append(0.0) - used = [i for i, a in enumerate(amounts) if a > 0] - elif tool_lines: - used = sorted(tool_lines) - else: - used = [0] + _scan_gcode_line(line, config, tool_lines) return GcodeMeta( colours=[_hex(c) for c in _split(config.get("filament_colour", ""))], types=[(t or None) for t in _split(config.get("filament_type", ""))], - used_tools=used, + used_tools=_used_tools(config, tool_lines), ) -def check_thumbnails(path: Path) -> Check: +def check_thumbnails(path: Path, shown: Path | None = None) -> Check: + """`shown` is the file the fix command names (the original when `path` is a snapshot copy).""" from gcode_thumbnails import ( check as thumb_check, ) # lazy: keeps `3d help` import-light + from slicing.thumbnails import fix_command r = thumb_check(str(path)) if r.ok: @@ -199,7 +211,8 @@ def check_thumbnails(path: Path) -> Check: reason = r.reason or "missing " + ( ", ".join(str(t) for t in r.missing) or "thumbnails" ) - return Check("thumbnails", FAIL, f"{reason}; fix: 3d gcode-thumbnails add FILE") + fix = fix_command(shown or path) + return Check("thumbnails", FAIL, f"{reason}; fix: {fix} (or print-preflight --fix; upload/start add them themselves)") def check_printer_free(status: PrinterStatus) -> Check: @@ -211,58 +224,55 @@ def check_printer_free(status: PrinterStatus) -> Check: return Check("printer", PASS, f"{status.state} [{status.raw_state}]") +def _slot_problems(slot: Slot, want_c: str | None, want_m: str | None) -> list[str]: + """How the loaded head differs from the colour/material the G-code expects.""" + problems = [] + if want_c and slot.color and want_c != slot.color: + problems.append(f"colour: G-code {want_c}, loaded {slot.color}") + if want_m and slot.material and want_m.upper() != slot.material.upper(): + problems.append(f"material: G-code {want_m}, loaded {slot.material}") + return problems + + +def _check_slot(meta: GcodeMeta, status: PrinterStatus, t: int) -> Check: + """The `slot Tn` line: is the head the G-code uses present and loaded as expected.""" + name = f"slot T{t}" + if t >= len(status.slots): + return Check( + name, + FAIL, + f"G-code uses tool {t} but the printer has {len(status.slots)} heads", + ) + slot = status.slots[t] + if not slot.present: + return Check( + name, + FAIL, + f"head {slot.label} is used by the G-code but has no filament / is disabled", + ) + want_c = meta.colours[t] if t < len(meta.colours) else None + want_m = meta.types[t] if t < len(meta.types) else None + problems = _slot_problems(slot, want_c, want_m) + if problems: + return Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems)) + if not want_c: + return Check( + name, + WARN, + f"head {slot.label}: G-code has no filament_colour for tool {t}; " + f"loaded {slot.color} {slot.material}", + ) + return Check( + name, + PASS, + f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), + ) + + def check_slots(meta: GcodeMeta, status: PrinterStatus) -> list[Check]: if not status.slots: return [Check("slots", SKIP, "the printer reports no per-head filament data")] - out = [] - for t in meta.used_tools: - name = f"slot T{t}" - if t >= len(status.slots): - out.append( - Check( - name, - FAIL, - f"G-code uses tool {t} but the printer has {len(status.slots)} heads", - ) - ) - continue - slot = status.slots[t] - if not slot.present: - out.append( - Check( - name, - FAIL, - f"head {slot.label} is used by the G-code but has no filament / is disabled", - ) - ) - continue - want_c = meta.colours[t] if t < len(meta.colours) else None - want_m = meta.types[t] if t < len(meta.types) else None - problems = [] - if want_c and slot.color and want_c != slot.color: - problems.append(f"colour: G-code {want_c}, loaded {slot.color}") - if want_m and slot.material and want_m.upper() != slot.material.upper(): - problems.append(f"material: G-code {want_m}, loaded {slot.material}") - if problems: - out.append(Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems))) - elif not want_c: - out.append( - Check( - name, - WARN, - f"head {slot.label}: G-code has no filament_colour for tool {t}; " - f"loaded {slot.color} {slot.material}", - ) - ) - else: - out.append( - Check( - name, - PASS, - f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), - ) - ) - return out + return [_check_slot(meta, status, t) for t in meta.used_tools] def run_halves_check(source: Path) -> tuple[int, str, str]: @@ -361,22 +371,8 @@ def find_project_3mf(gcode: Path, source: Path | None) -> tuple[Path | None, str return None, "" -def check_head_sequence( - gcode: Path, project: Path | None, project_source: str -) -> Check: - """Issue #103: every object prints each layer on the heads its filaments call for.""" - if project is None: - return Check( - "head seq", - SKIP, - "no project 3MF: pass --source-3mf FILE.3mf or keep NAME.3mf next to NAME.gcode", - ) - from slicing.head_sequence import ProjectError, preflight_check - - try: - result = preflight_check(str(gcode), str(project)) - except ProjectError as exc: - return Check("head seq", SKIP, f"{project_source}: {exc}") +def _head_sequence_verdict(result: dict[str, Any], project_source: str) -> Check: + """The `head seq` line for a successful `preflight_check` result.""" objects = result.get("objects") objs = ( [o for o in objects if isinstance(o, dict)] if isinstance(objects, list) else [] @@ -409,6 +405,25 @@ def check_head_sequence( ) +def check_head_sequence( + gcode: Path, project: Path | None, project_source: str +) -> Check: + """Issue #103: every object prints each layer on the heads its filaments call for.""" + if project is None: + return Check( + "head seq", + SKIP, + "no project 3MF: pass --source-3mf FILE.3mf or keep NAME.3mf next to NAME.gcode", + ) + from slicing.head_sequence import ProjectError, preflight_check + + try: + result = preflight_check(str(gcode), str(project)) + except ProjectError as exc: + return Check("head seq", SKIP, f"{project_source}: {exc}") + return _head_sequence_verdict(result, project_source) + + def check_mix_faces(project: Path | None, project_source: str, gcode: Path) -> Check: """Issues #411 / #419: a part in a mixed filament shows its colour on walls, not on a flat top. By the project 3MF when there is one (exact: it knows which parts are mixed, FAIL); else by the @@ -531,101 +546,108 @@ def fmt(names: list[str]) -> str: ] -def check_moves( - rep: BoundsReport, - axis_limits: Limits | None, - limits_source: str, - trusted_macros: frozenset[str] = frozenset(), -) -> list[Check]: - out: list[Check] = [] +def _axis_travel_check( + rep: BoundsReport, axis_limits: Limits | None, limits_source: str +) -> Check: + """The `axis travel` line: every move inside the printer's axis limits.""" if axis_limits is None: why = f"{limits_source}; " if limits_source else "" - out.append( - Check( - "axis travel", - SKIP, - f"printer axis limits unknown ({why}pass --axis-limits " - f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", - ) + return Check( + "axis travel", + SKIP, + f"printer axis limits unknown ({why}pass --axis-limits " + f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", ) - elif rep.axis_out_count: - out.append( - Check( - "axis travel", - FAIL, - f"{fmt_limits(axis_limits)} ({limits_source}): " - + _first(rep.axis_out, rep.axis_out_count), - ) + if rep.axis_out_count: + return Check( + "axis travel", + FAIL, + f"{fmt_limits(axis_limits)} ({limits_source}): " + + _first(rep.axis_out, rep.axis_out_count), ) - else: - out.append( - Check( - "axis travel", - PASS, - f"all moves {fmt_extent(rep.all_extent)} within " - f"{fmt_limits(axis_limits)} ({limits_source})", - ) + return Check( + "axis travel", + PASS, + f"all moves {fmt_extent(rep.all_extent)} within " + f"{fmt_limits(axis_limits)} ({limits_source})", + ) + + +def _print_area_violations(rep: BoundsReport) -> list[str]: + """Plate moves outside printable_area or inside bed_exclude_area, one phrase each. + + bed_exclude_area is evaluated on its own: a missing printable_area must not hide it.""" + parts = [] + if rep.area_out_count and rep.printable_area: + parts.append( + f"printable_area {fmt_area(rep.printable_area)}: " + + _first(rep.area_out, rep.area_out_count) + ) + if rep.excluded_in_count and rep.exclude_area: + parts.append( + f"bed_exclude_area {fmt_area(rep.exclude_area)}: " + + _first(rep.excluded_in, rep.excluded_in_count, "inside") ) + return parts + +def _print_area_check(rep: BoundsReport) -> Check: + """The `print area` line: the plate section stays inside the printable area.""" if not rep.plate_found: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " - "the plate section cannot be told from start/end G-code", - ) + return Check( + "print area", + WARN, + "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " + "the plate section cannot be told from start/end G-code", ) - else: - # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it - parts = [] - if rep.area_out_count and rep.printable_area: - parts.append( - f"printable_area {fmt_area(rep.printable_area)}: " - + _first(rep.area_out, rep.area_out_count) - ) - if rep.excluded_in_count and rep.exclude_area: - parts.append( - f"bed_exclude_area {fmt_area(rep.exclude_area)}: " - + _first(rep.excluded_in, rep.excluded_in_count, "inside") - ) - if parts: - out.append( - Check( - "print area", - FAIL, - f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), - ) - ) - elif rep.printable_area is None: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: the G-code has no `; printable_area`", - ) - ) - else: - out.append( - Check( - "print area", - PASS, - f"plate moves {fmt_extent(rep.plate_extent)} within " - f"{fmt_area(rep.printable_area)}", - ) - ) - - if rep.unverified_count: - more = ", ..." if rep.unverified_count > len(rep.unverified) else "" - out.append( - Check( - "unverified", - WARN, - f"{rep.unverified_count} move(s) only partly checked: " - + ", ".join(rep.unverified) - + more, - ) + parts = _print_area_violations(rep) + if parts: + return Check( + "print area", + FAIL, + f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), + ) + if rep.printable_area is None: + return Check( + "print area", + WARN, + "UNVERIFIED: the G-code has no `; printable_area`", ) + return Check( + "print area", + PASS, + f"plate moves {fmt_extent(rep.plate_extent)} within " + f"{fmt_area(rep.printable_area)}", + ) + + +def _unverified_check(rep: BoundsReport) -> Check | None: + """The `unverified` WARN for moves the bounds check could only partly judge, if any.""" + if not rep.unverified_count: + return None + more = ", ..." if rep.unverified_count > len(rep.unverified) else "" + return Check( + "unverified", + WARN, + f"{rep.unverified_count} move(s) only partly checked: " + + ", ".join(rep.unverified) + + more, + ) + + +def check_moves( + rep: BoundsReport, + axis_limits: Limits | None, + limits_source: str, + trusted_macros: frozenset[str] = frozenset(), +) -> list[Check]: + out = [ + _axis_travel_check(rep, axis_limits, limits_source), + _print_area_check(rep), + ] + unverified = _unverified_check(rep) + if unverified is not None: + out.append(unverified) out.extend(check_macros(rep, trusted_macros)) return out @@ -645,31 +667,26 @@ def bounds_report( ) -def check_bed_verdict(v: dict[str, Any]) -> Check: - """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view - moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" - bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") +def _bed_when(v: dict[str, Any], view: dict[str, Any]) -> str: + """Where the verdict came from, how old it is, and the Z it was judged at if the bed was moved.""" age = v.get("age_s") when = ( f"{v.get('source', '?')}, {age:.0f} s ago" if isinstance(age, (int, float)) else str(v.get("source", "?")) ) - raw_view = v.get("view") - view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} if view.get("action") == "moved": when += ( f", judged at Z {view.get('z_used', v.get('z'))} after moving the bed from Z " f"{view.get('moved_from')}" + (" and back" if view.get("restored") else "") ) - frame = [f"frame: {v['frame']}"] if v.get("frame") else [] - why = str(v.get("reason") or "") - if view.get("action") == "moved" and not view.get("restored"): - frame.append( - f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" - ) - if plate != "missing" and bed != "occupied": - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return when + + +def _bed_judgement( + bed: Any, plate: Any, when: str, why: str, frame: list[str] +) -> Check: + """The bed/plate state pair -> the `bed` line.""" if plate == "missing": return Check("bed", FAIL, f"build plate missing ({when}): {why}", frame) if bed == "occupied": @@ -681,6 +698,24 @@ def check_bed_verdict(v: dict[str, Any]) -> Check: return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) +def check_bed_verdict(v: dict[str, Any]) -> Check: + """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view + moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" + bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") + raw_view = v.get("view") + view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} + when = _bed_when(v, view) + frame = [f"frame: {v['frame']}"] if v.get("frame") else [] + why = str(v.get("reason") or "") + if view.get("action") == "moved" and not view.get("restored"): + frame.append( + f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" + ) + if plate != "missing" and bed != "occupied": + return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return _bed_judgement(bed, plate, when, why, frame) + + def check_bed( device: Device, *, @@ -750,7 +785,7 @@ def run_preflight( sibling NAME.3mf and NAME.slice-warnings.json are looked up there. `bed` is the camera bed line (`check_bed` above), computed by the caller that knows the device.""" report = Report(str(gcode)) - report.checks.append(check_thumbnails(gcode)) + report.checks.append(check_thumbnails(gcode, origin)) if status is not None: report.checks.append(check_printer_free(status)) report.checks.extend(check_slots(read_gcode_meta(gcode), status)) diff --git a/tests/e2e/test_printer_workflows.py b/tests/e2e/test_printer_workflows.py index ab5ca326..ec5c3a8b 100644 --- a/tests/e2e/test_printer_workflows.py +++ b/tests/e2e/test_printer_workflows.py @@ -120,7 +120,8 @@ def test_printer_force_approved_request_lists_the_failing_checks(tmp_path: Path) assert isinstance(context, dict) assert request["hook"] == "3d-printer-start-force" and request["critical"] is True assert context["printer"] == "u1" and context["file"].endswith("part.gcode") - assert "- slicer warn:" in context["failing_checks"] and "- thumbnails:" in context["failing_checks"] + # the upload added the thumbnails this G-code lacked: only the slicer-warnings record is left to force + assert "- slicer warn:" in context["failing_checks"] and "- thumbnails:" not in context["failing_checks"] assert request["command"] == f"3d printer start u1 {context['file']} --yes --force" diff --git a/tests/e2e/test_thumbnail_layers_workflows.py b/tests/e2e/test_thumbnail_layers_workflows.py new file mode 100644 index 00000000..b25ffef6 --- /dev/null +++ b/tests/e2e/test_thumbnail_layers_workflows.py @@ -0,0 +1,233 @@ +"""Whatever way a G-code reaches the printer, it carries its thumbnails: slice, preflight, upload, start. + +Real `bin/3d` runs against a fake slicer binary and a fake Moonraker server; no real printer or slicer.""" + +from __future__ import annotations + +import hashlib +import json +import re +import stat +import sys +import zipfile +from pathlib import Path + +from printer_fakes import FakeMoonraker, write_devices +from thumbnail_fixtures import ALL_SIZES, bare, block_sizes, complete, plate_project + +from .workflow_helper import run_cli, run_cli_with_hatch + +FAKE_SLICER = """#!__PYTHON__ +import sys +from pathlib import Path +args = sys.argv[1:] +Path(__file__ + ".argv").write_text(" ".join(args)) +out = Path(args[args.index("--output") + 1]) if "--output" in args else Path(args[args.index("--outputdir") + 1]) / "plate_1.gcode" +out.write_text(__GCODE__) +print("estimated printing time: 1h 02m") +""" + + +def _fake_slicer(tmp_path: Path) -> Path: + exe = tmp_path / "fake-slicer" + exe.write_text( + FAKE_SLICER.replace("__PYTHON__", sys.executable).replace( + "__GCODE__", + repr(bare(tmp_path / "template.gcode").read_text(encoding="ascii")), + ), + encoding="utf-8", + ) + exe.chmod(exe.stat().st_mode | stat.S_IXUSR) + return exe + + +def test_slice_output_has_every_thumbnail_without_a_separate_step( + tmp_path: Path, +) -> None: + """A user slices with the slicer CLI that writes no preview and checks the file afterwards.""" + model = tmp_path / "part.stl" + model.write_text("solid x\nendsolid x\n") + out = tmp_path / "part.gcode" + + sliced = run_cli( + tmp_path, + "slice", + str(model), + "-o", + str(out), + env_extra={"SLICER": str(_fake_slicer(tmp_path))}, + ) + checked = run_cli(tmp_path, "gcode-thumbnails", "check", str(out)) + + assert sliced.returncode == 0, sliced.stdout + sliced.stderr + assert "thumbnails: present" in sliced.stdout + assert ( + "--thumbnails 48x48,96x96,300x300" + in (tmp_path / "fake-slicer.argv").read_text() + ) # layer (a), Prusa form + assert checked.returncode == 0, checked.stdout + assert sorted(block_sizes(out)) == sorted(ALL_SIZES) + + +def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None: + model = tmp_path / "part.stl" + model.write_text("solid x\nendsolid x\n") + dry = run_cli( + tmp_path, + "slice", + str(model), + "--dry-run", + env_extra={"SLICER": str(_fake_slicer(tmp_path))}, + ) + assert dry.returncode == 0 and "thumbnails:" not in dry.stdout + assert not (tmp_path / "part.gcode").exists() + + +def test_slice_of_a_3mf_project_asks_the_slicer_for_the_sizes_and_leaves_the_input_alone( + tmp_path: Path, +) -> None: + project = plate_project(tmp_path / "p.3mf") + before = project.read_bytes() + slicer = tmp_path / "spy-slicer" + slicer.write_text( + f"#!{sys.executable}\nimport sys, zipfile, json\nfrom pathlib import Path\n" + "a = sys.argv[1:]\nz = zipfile.ZipFile(a[-1])\n" + "Path(a[a.index('--outputdir') + 1], 'seen.json').write_text(z.read('Metadata/project_settings.config').decode())\n" + f"Path(a[a.index('--outputdir') + 1], 'plate_1.gcode').write_text({bare(tmp_path / 't.gcode').read_text()!r})\n" + "if '--output' in a: raise SystemExit(2)\n", + encoding="utf-8", + ) + slicer.chmod(slicer.stat().st_mode | stat.S_IXUSR) + out = tmp_path / "p.gcode" + + sliced = run_cli( + tmp_path, + "slice", + str(project), + "-o", + str(out), + env_extra={"SLICER": str(slicer)}, + ) + + assert sliced.returncode == 0, sliced.stdout + sliced.stderr + assert project.read_bytes() == before # the user's project is never rewritten + assert sorted(block_sizes(out)) == sorted(ALL_SIZES) + + +def test_preflight_names_the_fix_and_fix_applies_it(tmp_path: Path) -> None: + """A user runs the preflight on a bare G-code, reads the command it names, then lets --fix do it.""" + gcode = bare(tmp_path / "plate one.gcode") + gate = run_cli(tmp_path, "print-preflight", str(gcode), "--offline", "--json") + lines = {c["name"]: c for c in json.loads(gate.stdout)["checks"]} + assert lines["thumbnails"]["status"] == "FAIL" + assert f"3d gcode-thumbnails add '{gcode}'" in lines["thumbnails"]["detail"] + assert block_sizes(gcode) == [] # the preflight itself never writes + + fixed = run_cli( + tmp_path, "print-preflight", str(gcode), "--offline", "--json", "--fix" + ) + lines = {c["name"]: c for c in json.loads(fixed.stdout)["checks"]} + assert lines["thumbnails"]["status"] == "PASS" + assert "--fix: added" in fixed.stderr + assert sorted(block_sizes(gcode)) == sorted(ALL_SIZES) + + +def test_upload_adds_the_missing_thumbnails_and_the_printer_receives_them( + tmp_path: Path, +) -> None: + """The G-code of a headless slice is uploaded: the bytes on the (fake) printer carry all three sizes.""" + gcode = bare(tmp_path / "part.gcode") + moon = FakeMoonraker() + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + + assert up.returncode == 0, up.stdout + up.stderr + assert ( + "thumbnails:" in up.stdout + and "added in place" in up.stdout + and "sha256" in up.stdout + ) + ((remote, body),) = moon.contents.items() + assert re.fullmatch(r"part-[0-9a-f]{16}\.gcode", remote) + assert ( + body == gcode.read_bytes() + ) # what the printer got is the fixed file, byte for byte + received = tmp_path / "received.gcode" + received.write_bytes(body) + assert sorted(block_sizes(received)) == sorted(ALL_SIZES) + + +def test_upload_of_a_complete_gcode_is_untouched_and_silent(tmp_path: Path) -> None: + gcode = complete(tmp_path / "part.gcode") + before = gcode.read_bytes() + moon = FakeMoonraker() + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + assert up.returncode == 0 and "thumbnails:" not in up.stdout + assert gcode.read_bytes() == before + assert list(moon.contents.values()) == [before] + + +def test_start_adds_thumbnails_too_and_asks_for_the_upload_of_the_new_bytes( + tmp_path: Path, +) -> None: + """A file uploaded before this feature has none: `start` fixes FILE, so the stored copy is stale + (content-addressed name) and the user is told to upload again. Then the whole chain passes.""" + gcode = bare(tmp_path / "part.gcode") + old_remote = f"part-{hashlib.sha256(gcode.read_bytes()).hexdigest()[:16]}.gcode" + moon = FakeMoonraker( + files={old_remote: gcode.stat().st_size}, + contents={old_remote: gcode.read_bytes()}, + ) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + first = run_cli(tmp_path, "printer", "start", "u1", "part.gcode", "--yes") + assert not [r for r in moon.writes() if r.path == "/printer/print/start"] + up = run_cli(tmp_path, "printer", "upload", "u1", "part.gcode") + started = run_cli_with_hatch( + tmp_path, "printer", "start", "u1", "part.gcode", "--yes", "--force" + ) + + assert "added in place" in first.stdout and first.returncode != 0 + assert "Upload it first" in first.stderr + assert ( + up.returncode == 0 and "thumbnails:" not in up.stdout + ) # the file already carries them + assert started.returncode == 0, started.stdout + started.stderr + assert re.search(r"PASS\s+thumbnails", started.stdout) + assert sorted(block_sizes(gcode)) == sorted(ALL_SIZES) + + +def test_gcode_thumbnails_add_updates_the_plate_pictures_of_a_project( + tmp_path: Path, +) -> None: + """`add G --3mf P` draws the G-code thumbnails and the project's plate pictures from one render; + `check P` says which pictures a project lacks.""" + gcode = bare(tmp_path / "plate_1.gcode") + project = plate_project(tmp_path / "sliced.3mf") + + before = run_cli(tmp_path, "gcode-thumbnails", "check", str(project)) + added = run_cli( + tmp_path, "gcode-thumbnails", "add", str(gcode), "--3mf", str(project) + ) + after = run_cli(tmp_path, "gcode-thumbnails", "check", str(gcode), str(project)) + + assert before.returncode == 1 and "missing" in before.stdout + assert added.returncode == 0, added.stdout + added.stderr + assert "added Metadata/plate_1.png" in added.stderr + assert after.returncode == 0 and after.stdout.count("PASS") == 2 + with zipfile.ZipFile(project) as z: + assert {"Metadata/plate_1.png", "Metadata/plate_1_small.png"} <= set( + z.namelist() + ) + + +def test_help_documents_the_layers(tmp_path: Path) -> None: + thumbs = run_cli(tmp_path, "gcode-thumbnails", "--help") + flight = run_cli(tmp_path, "print-preflight", "--help") + printer = run_cli(tmp_path, "printer", "--help") + assert "--3mf" in thumbs.stdout and "96x96" in thumbs.stdout + assert "--fix" in flight.stdout and "gcode-thumbnails add" in flight.stdout + assert "thumbnails" in printer.stdout and "in place" in printer.stdout diff --git a/tests/test_printer_ops.py b/tests/test_printer_ops.py index 6b054d4f..b14efbec 100644 --- a/tests/test_printer_ops.py +++ b/tests/test_printer_ops.py @@ -84,8 +84,11 @@ def hatch(monkeypatch: pytest.MonkeyPatch) -> FakeHatch: @pytest.fixture() def gates_ok(monkeypatch: pytest.MonkeyPatch) -> None: """Command tests check printer logic, not the G-code gates with their own tests - (thumbnails, overhangs, slicer warnings): pin those to PASS.""" - monkeypatch.setattr(preflight, "check_thumbnails", lambda p: preflight.Check("thumbnails", "PASS", "stub")) + (thumbnails, overhangs, slicer warnings): pin those to PASS. `upload`/`start` add missing + thumbnails to the G-code in place (tests/test_thumbnail_preflight.py, the e2e thumbnail tests); + here the fixture's bytes must stay what the fake printer holds.""" + monkeypatch.setattr(preflight, "check_thumbnails", lambda p, shown=None: preflight.Check("thumbnails", "PASS", "stub")) + monkeypatch.setattr("commands.printer._ensure_thumbnails", lambda original: None) monkeypatch.setattr(preflight, "check_overhangs", lambda p, *_: preflight.Check("overhangs", "PASS", "stub")) monkeypatch.setattr( preflight, "check_slice_warnings", lambda o, g: preflight.Check("slicer warn", "PASS", "stub") @@ -644,7 +647,7 @@ def test_cmd_upload_refused_while_creality_busy( def test_cmd_upload_refused_over_the_running_file_on_moonraker( - moon: FakeMoonraker, env: Path, tmp_path: Path + moon: FakeMoonraker, env: Path, tmp_path: Path, gates_ok: None ) -> None: g = gcode(tmp_path) moon.status = u1_status("printing", safe_remote_name(g)) @@ -1838,7 +1841,7 @@ def test_ws_connection_reset_counts_as_closed() -> None: def test_cmd_upload_sends_the_bytes_it_named( - moon: FakeMoonraker, env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + moon: FakeMoonraker, env: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gates_ok: None ) -> None: """Review #132: a rewrite of FILE between naming and sending must not store other bytes under the first content's hash.""" diff --git a/tests/test_thumbnail_preflight.py b/tests/test_thumbnail_preflight.py new file mode 100644 index 00000000..69ef2d49 --- /dev/null +++ b/tests/test_thumbnail_preflight.py @@ -0,0 +1,35 @@ +"""Layers (c) and (d): what `3d print-preflight` says about a missing thumbnail and where `3d printer` +adds it (the end-to-end runs are in tests/e2e/test_thumbnail_layers_workflows.py).""" +from __future__ import annotations + +from pathlib import Path + +from thumbnail_fixtures import bare + +from printer_ops.preflight import FAIL, PASS, check_thumbnails +from slicing import thumbnails + +LIB = Path(__file__).resolve().parents[1] / "lib" + + +def test_preflight_names_the_exact_fix_command_for_the_original_file( + tmp_path: Path, +) -> None: + original = bare(tmp_path / "my part.gcode") + snapshot = tmp_path / "snapshot.gcode" + snapshot.write_bytes(original.read_bytes()) + check = check_thumbnails(snapshot, original) + assert check.status == FAIL + assert ( + f"3d gcode-thumbnails add '{original}'" in check.detail + and "--fix" in check.detail + ) + assert "48x48/PNG" in check.detail + + thumbnails.finalize_gcode(snapshot) + assert check_thumbnails(snapshot, original).status == PASS + + +def test_printer_upload_and_start_call_the_in_place_fix_before_the_snapshot() -> None: + source = (LIB / "commands" / "printer.py").read_text(encoding="utf-8") + assert source.count("_ensure_thumbnails(original)") == 2 # _cmd_upload and _cmd_start From 5a639fc573e79ed91d676ff9d227f0133aae41a5 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:01:24 +0100 Subject: [PATCH 03/30] feat(bed): `3d printer bed check`, `reference --best-view`, and a verdict 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) --- docs/architecture/lib-map.md | 1 + lib/bed_state.py | 652 +++++++++++++++------- lib/bed_view.py | 19 +- lib/printer_ops/bed.py | 37 +- tests/data/bed/u1_z200_clear_ref_1016.jpg | Bin 0 -> 32807 bytes tests/data/bed/u1_z200_light_1703.jpg | Bin 0 -> 32309 bytes tests/data/bed/u1_z200_light_2013.jpg | Bin 0 -> 25651 bytes tests/test_bed_check.py | 215 +++++++ tests/test_bed_robust.py | 213 +++++++ 9 files changed, 912 insertions(+), 225 deletions(-) create mode 100644 tests/data/bed/u1_z200_clear_ref_1016.jpg create mode 100644 tests/data/bed/u1_z200_light_1703.jpg create mode 100644 tests/data/bed/u1_z200_light_2013.jpg create mode 100644 tests/test_bed_check.py create mode 100644 tests/test_bed_robust.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 0b29ff75..85e4cf0a 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -324,6 +324,7 @@ Every current root Python module is listed here so future additions are intentio - `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier, change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); config, events, start gate and clear ack live in `printer_ops/bed.py`. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. +- `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. - `lib/geometry/mesh_metrics.py` — pinned-convention 3D shape metrics (Chamfer, F-score@tau, Hausdorff, normal consistency, volumetric IoU) between two meshes; backs `3d metrics geometry`. - `perceptual_metrics.py` — perceptual/semantic image metrics (PSNR, LPIPS, CLIP-sim) with explicit senses and graceful degradation; backs `3d metrics perceptual`. - `metrics.py` — compatibility wrapper re-exporting `registries.metrics`. diff --git a/lib/bed_state.py b/lib/bed_state.py index 2f3f4fc8..1ca3d5f0 100644 --- a/lib/bed_state.py +++ b/lib/bed_state.py @@ -122,6 +122,10 @@ class Thresholds: settle_frames: int = 3 # unchanged frames after a change before the bed is judged match_px: float = 1.5 # calibrated: a reference stands for a Z where the bed view moved less view_min: float = 0.10 # calibrated: bed footprint share in the frame below which nothing is judged + shift_px: int = 3 # objects: a pixel matching the reference within this many px counts as unchanged + open_px: int = 5 # objects: changed regions are opened with this square kernel (thinner lines go) + low_view: float = 0.5 # calibrated: under this share of the bed in the frame the ROI is mostly the room ... + occupied_blob_low_px: int = 1200 # ... and only a region this big (or occupied_min of the ROI) says occupied TH = Thresholds() @@ -203,12 +207,37 @@ class Diff: mask: np.ndarray # bool, changed pixels +def _best_shift(cur: np.ndarray, ref: np.ndarray, shift: int) -> np.ndarray: + """`cur - ref` where, per pixel, `ref` is taken from the neighbour within `shift` px that matches `cur` + best (largest channel difference smallest): a picture that moved a pixel or two (the bed parked at another + Z, a vibrating camera) leaves a line along every edge of the scene, not a changed region.""" + import cv2 + import numpy as np + + h, w = ref.shape[:2] + padded = cv2.copyMakeBorder(ref, shift, shift, shift, shift, cv2.BORDER_REPLICATE) + best = cur - ref + best_abs = np.abs(best).max(axis=2) + for dy in range(-shift, shift + 1): + for dx in range(-shift, shift + 1): + if dx == 0 and dy == 0: + continue + signed = cur - padded[shift + dy:shift + dy + h, shift + dx:shift + dx + w] + mag = np.abs(signed).max(axis=2) + better = mag < best_abs + best = np.where(better[..., None], signed, best) + best_abs = np.where(better, mag, best_abs) + return best + + def difference(ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, level: float = TH.level, - detrend: int = 0) -> Diff: + detrend: int = 0, shift: int = 0, open_px: int = 3) -> Diff: """Changed pixels of `cur` against `ref` inside `roi` after `align`. `detrend` (odd px > 0) also subtracts the median of the signed difference over that window: a light gradient or a reflection that moved with the room light is smooth and goes, a part narrower than half the - window stays (used for objects; the plate test keeps the plain difference).""" + window stays (used for objects; the plate test keeps the plain difference). `shift` (px) lets a + pixel match anything within that distance in the reference, and changed regions are opened with an + `open_px` square: what is left is a patch of new colour, not the outline of an unchanged scene.""" import cv2 import numpy as np @@ -217,14 +246,15 @@ def difference(ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, level: float = return Diff(0.0, 0.0, np.zeros(roi.shape, bool)) r = cv2.GaussianBlur(ref, (5, 5), 0).astype(np.float32) c = cv2.GaussianBlur(cur, (5, 5), 0).astype(np.float32) - signed = align(c, r, roi) - r + aligned = align(c, r, roi) + signed = _best_shift(aligned, r, shift) if shift > 0 else aligned - r if detrend > 1: signed[~roi] = 0.0 packed = np.clip(signed / 2 + 128, 0, 255).astype(np.uint8) signed -= (cv2.medianBlur(packed, detrend | 1).astype(np.float32) - 128) * 2 d = np.abs(signed).max(axis=2) changed = ((d > level) & roi).astype(np.uint8) - changed = cv2.morphologyEx(changed, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) + changed = cv2.morphologyEx(changed, cv2.MORPH_OPEN, np.ones((open_px, open_px), np.uint8)) n, _, stats, _ = cv2.connectedComponentsWithStats(changed) blob = int(stats[1:, cv2.CC_STAT_AREA].max()) if n > 1 else 0 return Diff(float(changed.sum()) / area, blob / area, changed.astype(bool)) @@ -469,6 +499,53 @@ def _judge_plate(img: np.ndarray, plate_ref: Reference | None, noplate_ref: Refe return MISSING, f"bed surface matches the bare-bed reference ({dn:.0%} off)", {"surface_vs_noplate": dn} +def _objects_verdict(d: Diff, blob_px: float, ref: Reference, plate_info: tuple[str, str, dict[str, float]], + inner: np.ndarray, in_view: float | None, z: float | None, printer: str, + th: Thresholds) -> Verdict: + """clear | occupied | unknown from the changed region against the empty-bed reference (see Thresholds).""" + plate, plate_why, metrics = plate_info + seen = (f"{d.frac:.1%} of the bed view differs from the empty-bed reference " + f"(largest region {blob_px:.0f} px of {PROC_W}-px frame)") + low_view = in_view is not None and in_view < th.low_view + if in_view is not None: + metrics["bed_in_view"] = in_view + if low_view: # e.g. the U1 at Z 200: the front of the bed and what stands tall + seen += f"; the camera sees {in_view:.0%} of the bed at this Z, low parts at the back may not show" + # at a low view most of the ROI is the machine and the room behind the bed (a window, a moved cable): a + # region has to be big to count there, a small one stays unknown instead of failing a start gate + blob_min = th.occupied_blob_low_px if low_view else th.occupied_blob_px + if blob_px >= blob_min or d.frac >= th.occupied_min: + return Verdict(OCCUPIED, plate, f"{seen}; {plate_why}", metrics, d.mask, inner) + if blob_px < th.clear_blob_px and d.frac <= th.clear_max: + return Verdict(CLEAR, plate, f"{seen}; {plate_why}", metrics, d.mask, inner) + seen = f"{seen}: a small part, a shadow or the room behind the bed? look at the frame" + if low_view: + seen += (f"; the bed itself is mostly out of view at Z {_fz(z)}: " + f"`3d printer bed check {printer}` looks from a better Z") + if ref.ts > 0: + seen += (f"; the reference is {(time.time() - ref.ts) / 86400:.0f} days old " + f"(`3d printer bed reference {printer} --capture` takes a new one)") + return Verdict(UNKNOWN, plate, f"{seen}; {plate_why}", metrics, d.mask, inner) + + +def _select_refs(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, + match: ZMatch) -> tuple[Reference | None, Reference | None, Verdict | None]: + plate_ref, noplate_ref = nearest(refs, "plate", z, match), nearest(refs, "noplate", z, match) + if plate_ref is not None and noplate_ref is not None and noplate_ref.image.shape != plate_ref.image.shape: + noplate_ref = None # taken at another resolution: judge against the with-plate one only + if plate_ref is None and noplate_ref is None: + have = ", ".join(f"{r.kind} at Z {_fz(r.z)}" for r in refs) or "none" + return None, None, Verdict(UNKNOWN, UNKNOWN, f"no empty-bed reference at Z {_fz(z)} (have: {have}); with the bed " + f"clear and the plate on: 3d printer bed reference {printer} --capture") + base = plate_ref or noplate_ref + assert base is not None + if img.shape != base.image.shape: + return None, None, Verdict(UNKNOWN, UNKNOWN, f"frame is {img.shape[1]}x{img.shape[0]} but the reference " + f"{base.path.name} is {base.image.shape[1]}x{base.image.shape[0]} (camera changed?): " + f"capture it again: 3d printer bed reference {printer} --capture") + return plate_ref, noplate_ref, None + + def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, th: Thresholds = TH, match: ZMatch | None = None) -> Verdict: import cv2 @@ -479,19 +556,10 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if hidden is not None: # before the references: no reference can show what the camera cannot see return Verdict(UNKNOWN, UNKNOWN, f"{hidden}: the camera cannot tell whether it is clear", {"bed_in_view": match.in_view(z) or 0.0}) - plate_ref, noplate_ref = nearest(refs, "plate", z, match), nearest(refs, "noplate", z, match) - if plate_ref is not None and noplate_ref is not None and noplate_ref.image.shape != plate_ref.image.shape: - noplate_ref = None # taken at another resolution: judge against the with-plate one only - if plate_ref is None and noplate_ref is None: - have = ", ".join(f"{r.kind} at Z {_fz(r.z)}" for r in refs) or "none" - return Verdict(UNKNOWN, UNKNOWN, f"no empty-bed reference at Z {_fz(z)} (have: {have}); with the bed " - f"clear and the plate on: 3d printer bed reference {printer} --capture") - base = plate_ref or noplate_ref - assert base is not None - if img.shape != base.image.shape: - return Verdict(UNKNOWN, UNKNOWN, f"frame is {img.shape[1]}x{img.shape[0]} but the reference " - f"{base.path.name} is {base.image.shape[1]}x{base.image.shape[0]} (camera changed?): " - f"capture it again: 3d printer bed reference {printer} --capture") + plate_ref, noplate_ref, early = _select_refs(img, refs, z, printer, match) + if early is not None: + return early + assert plate_ref is not None or noplate_ref is not None plate, plate_why, metrics = _judge_plate(img, plate_ref, noplate_ref, z, th) ref = noplate_ref if plate == MISSING else plate_ref if ref is None: @@ -504,23 +572,10 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if inner.sum() < 100: # a degenerate ROI inspects nothing: never call that clear return Verdict(UNKNOWN, plate, f"the bed ROI of {ref.path.name} is empty or tiny ({int(inner.sum())} px): " f"fix watch.bed.roi and capture the reference again; {plate_why}", metrics) - d = difference(ref.image, img, inner, th.level, th.detrend_px) + d = difference(ref.image, img, inner, th.level, th.detrend_px, th.shift_px, th.open_px) blob_px = d.blob * int(inner.sum()) metrics.update(changed=d.frac, blob=d.blob, blob_px=blob_px) - seen = (f"{d.frac:.1%} of the bed view differs from the empty-bed reference " - f"(largest region {blob_px:.0f} px of {PROC_W}-px frame)") - in_view = match.in_view(z) - if in_view is not None: - metrics["bed_in_view"] = in_view - if in_view < 0.5: # e.g. the U1 at Z 200: the front of the bed and what stands tall - seen += f"; the camera sees {in_view:.0%} of the bed at this Z, low parts at the back may not show" - if blob_px >= th.occupied_blob_px or d.frac >= th.occupied_min: - bed = OCCUPIED - elif blob_px < th.clear_blob_px and d.frac <= th.clear_max: - bed = CLEAR - else: - bed, seen = UNKNOWN, f"{seen}: a small part or a shadow? look at the frame" - return Verdict(bed, plate, f"{seen}; {plate_why}", metrics, d.mask, inner) + return _objects_verdict(d, blob_px, ref, (plate, plate_why, metrics), inner, match.in_view(z), z, printer, th) # ---- the change gate ------------------------------------------------------------------------ @@ -616,8 +671,16 @@ def grab(camera: Any) -> bytes: # ---- evidence ------------------------------------------------------------------------------- +def raw_frame_path(annotated: str) -> Path: + """Where the untouched camera frame of an evidence image lies (`…-label.jpg` -> `…-label.raw.jpg`).""" + p = Path(annotated) + return p.with_name(p.stem + ".raw.jpg") + + def save_evidence(printer: str, jpeg: bytes, verdict: Verdict, label: str, now: float, keep: int = 100) -> str: - """The full frame with the ROI outlined (cyan) and the changed pixels (red); returns the path.""" + """The full frame with the ROI outlined (cyan) and the changed pixels (red); returns the path. The camera + frame itself is kept next to it (`raw_frame_path`): the 2026-11-04 false "objects on the bed" could not be + re-judged because only the annotated picture survived.""" import cv2 import numpy as np @@ -625,6 +688,7 @@ def save_evidence(printer: str, jpeg: bytes, verdict: Verdict, label: str, now: out_dir.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(now)) path = out_dir / f"{printer}-{stamp}-{label}.jpg" + raw_frame_path(str(path)).write_bytes(jpeg) img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR) if img is None: path.write_bytes(jpeg) @@ -642,8 +706,10 @@ def save_evidence(printer: str, jpeg: bytes, verdict: Verdict, label: str, now: ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) path.write_bytes(buf.tobytes() if ok else jpeg) own = re.compile(rf"{re.escape(printer)}-\d{{8}}T\d{{6}}Z-") # not another printer named "{printer}-…" - for old in sorted(p for p in out_dir.glob(f"{printer}-*.jpg") if own.match(p.name))[:-keep]: + annotated = (p for p in out_dir.glob(f"{printer}-*.jpg") if own.match(p.name) and not p.name.endswith(".raw.jpg")) + for old in sorted(annotated)[:-keep]: old.unlink(missing_ok=True) + raw_frame_path(str(old)).unlink(missing_ok=True) return str(path) @@ -722,112 +788,152 @@ def run_monitor(device: Device, cfg: BedConfig, **kw: Any) -> Monitor: return _run_monitor(device, cfg, **kw) +class _Watch: + """The monitor loop's mutable state: what was last said, the last frame, the last write.""" + + def __init__(self, device: Device, cfg: BedConfig, out: Any, ack_fn: Callable[..., tuple[bool, str]], + store: RefStore, match: ZMatch, mon: Monitor, prev: dict[str, Any], + known_bed: Any, known_plate: Any) -> None: + self.device, self.cfg, self.out, self.ack_fn = device, cfg, out, ack_fn + self.name = device.name + self.store, self.match, self.mon = store, match, mon + self.prev, self.known_bed, self.known_plate = prev, known_bed, known_plate + self.refs_sig = store.signature() + self.last_jpeg = b"" + self.last_img: Any = None + self.last_write = 0.0 + self.last_err = "" + + def _forget_frame(self) -> None: + self.mon.reset() + self.last_jpeg, self.last_img = b"", None + + def _printing(self, state: str, z: float | None, t0: float) -> None: + if self.known_bed != PRINTING: + self.known_bed = PRINTING + self.prev = {**self.prev, "printer": self.name, "bed": PRINTING, "known_bed": PRINTING, "z": z, "ts": t0, + "checked": t0, "reason": f"printer is {state}", "frame": None} + write_state(self.name, self.prev) + _emit(self.out, {"state": {"bed": PRINTING, "plate": self.known_plate, "z": z}}) + self._forget_frame() + + def acquire(self, t0: float, camera: Any, printer_fn: Callable[[Device], tuple[str, float | None]], + busy_interval: float) -> tuple[bytes | None, float | None, float]: + """One frame and the printer's Z, or (None, z, seconds to pause) when there is nothing to judge.""" + from printer_ops.base import PrinterCommError + + z: float | None = None + try: + state, z = printer_fn(self.device) + if state in ACTIVE: + self._printing(state, z, t0) + return None, z, busy_interval + if view_in_progress(self.name, t0): # an auto view check is moving the bed: its frames are not the bed's state + self._forget_frame() + return None, z, max(self.cfg.interval_s, 1.0) + jpeg = grab(camera) + self.last_err = "" + except PrinterCommError as exc: + if exc.message != self.last_err: + _log(exc.message) + self.last_err = exc.message + return None, z, max(self.cfg.interval_s, 5.0) + return jpeg, z, 0.0 + + def _decode(self, jpeg: bytes) -> None: + self.last_jpeg = jpeg + try: + self.last_img = decode_small(jpeg) + except ValueError as exc: + _log(f"{self.name}: {exc}") + self.last_img = None + + def process(self, jpeg: bytes, z: float | None, clock: Callable[[], float]) -> None: + sig = self.store.signature() + if sig != self.refs_sig: + self.refs_sig = sig + self.mon.invalidate() + # identical bytes (a static or frozen snapshot) still count as unchanged frames while a + # verdict is pending, so the monitor settles; the cached decode keeps that cheap + jpeg_new = jpeg != self.last_jpeg + if not (jpeg_new or self.mon.dirty): + return + ref = (nearest(self.store.load(), "plate", z, self.match) + or nearest(self.store.load(), "noplate", z, self.match)) + if jpeg_new: + self._decode(jpeg) + img = self.last_img + verdict = self.mon.feed(img, z, ref.volume if ref is not None else None) if img is not None else None + now = clock() + if verdict is not None: + self._verdict(verdict, jpeg, z, now) + elif not self.mon.dirty and jpeg_new: + self.last_write = _touch_checked(self.name, self.prev, now, self.last_write) + + def _verdict(self, verdict: Verdict, jpeg: bytes, z: float | None, now: float) -> None: + name = self.name + was_occupied = self.known_bed == OCCUPIED + kinds = transitions(self.known_bed, self.known_plate, verdict.bed, verdict.plate) + frame = save_evidence(name, jpeg, verdict, kinds[0] if kinds else verdict.bed, now) if kinds else None + # bed_unknown (right after a job) also leaves "printing": it is said once per job, + # and a later clear/occupied verdict is news again + if verdict.bed in (CLEAR, OCCUPIED) or "bed_unknown" in kinds: + self.known_bed = verdict.bed + if verdict.plate in (PRESENT, MISSING): + self.known_plate = verdict.plate + self.prev = _state_doc(name, verdict, z, now, frame, self.prev) + self.prev.update(known_bed=self.known_bed, known_plate=self.known_plate) + write_state(name, self.prev) + self.last_write = now + _log(f"{name}: bed {verdict.bed}, plate {verdict.plate} at Z {_fz(z)}: {verdict.reason}") + for kind in kinds: + why = verdict.reason + _emit(self.out, {"event": { + "kind": kind, "ts": now, "key": f"{name}:{kind}:{int(now)}", + "text": _TEXT[kind].format(p=name, z=_fz(z), why=why), + "data": {"bed": verdict.bed, "plate": verdict.plate, "z": z, "reason": why, + "metrics": self.prev["metrics"], "frame": frame}}}) + _emit(self.out, {"state": {"bed": verdict.bed, "plate": verdict.plate, "z": z}}) + # the ack says "the parts were taken off": not on a first-ever clear or right after a job + if "bed_clear" in kinds and self.cfg.clear_ack is not None and was_occupied: + self._ack(z, now) + + def _ack(self, z: float | None, now: float) -> None: + try: + sent, what = self.ack_fn(self.device, self.cfg.clear_ack, z=z, last_ack=self.prev.get("last_ack"), now=now) + except Exception as exc: # a failed ack never stops the monitor + sent, what = False, f"failed: {exc}" + _log(f"{self.name}: clear ack {what}") + self.prev["ack"] = what + if sent: + self.prev["last_ack"] = now + write_state(self.name, self.prev) + + def _run_monitor(device: Device, cfg: BedConfig, *, duration: float | None = None, out: Any = sys.stdout, camera: Any = None, printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, ack_fn: Callable[..., tuple[bool, str]] = clear_ack, th: Thresholds | None = None, clock: Callable[[], float] = time.time, sleep: Callable[[float], None] = time.sleep, busy_interval: float = 10.0) -> Monitor: - from printer_ops.base import PrinterCommError - name = device.name th = thresholds_for(cfg) if th is None else th camera = camera if camera is not None else camera_for(device) store = RefStore(name) prev = read_state(name) or {} known_bed, known_plate = prev.get("known_bed", prev.get("bed")), prev.get("known_plate", prev.get("plate")) - refs_sig = store.signature() match = z_match(device, cfg) mon = Monitor(lambda img, z: classify(img, store.load(), z, name, th, match), th, cfg.z_tol_mm) - last_jpeg = b"" - last_img: Any = None - last_write = 0.0 - last_err = "" + w = _Watch(device, cfg, out, ack_fn, store, match, mon, prev, known_bed, known_plate) end = None if duration is None else clock() + duration _log(f"{name}: watching the bed every {cfg.interval_s:g} s while the printer is not printing " f"(references: {store.dir}; last verdict: {prev.get('bed', 'none')})") while end is None or clock() < end: t0 = clock() - try: - state, z = printer_fn(device) - if state in ACTIVE: - if known_bed != PRINTING: - known_bed = PRINTING - prev = {**prev, "printer": name, "bed": PRINTING, "known_bed": PRINTING, "z": z, "ts": t0, - "checked": t0, "reason": f"printer is {state}", "frame": None} - write_state(name, prev) - _emit(out, {"state": {"bed": PRINTING, "plate": known_plate, "z": z}}) - mon.reset() - last_jpeg, last_img = b"", None - sleep(busy_interval) - continue - if view_in_progress(name, t0): # an auto view check is moving the bed: its frames are not the bed's state - mon.reset() - last_jpeg, last_img = b"", None - sleep(max(cfg.interval_s, 1.0)) - continue - jpeg = grab(camera) - last_err = "" - except PrinterCommError as exc: - if exc.message != last_err: - _log(exc.message) - last_err = exc.message - sleep(max(cfg.interval_s, 5.0)) + jpeg, z, pause = w.acquire(t0, camera, printer_fn, busy_interval) + if jpeg is None: + sleep(pause) continue - sig = store.signature() - if sig != refs_sig: - refs_sig = sig - mon.invalidate() - # identical bytes (a static or frozen snapshot) still count as unchanged frames while a - # verdict is pending, so the monitor settles; the cached decode keeps that cheap - jpeg_new = jpeg != last_jpeg - if jpeg_new or mon.dirty: - ref = nearest(store.load(), "plate", z, match) or nearest(store.load(), "noplate", z, match) - if jpeg_new: - last_jpeg = jpeg - try: - last_img = decode_small(jpeg) - except ValueError as exc: - _log(f"{name}: {exc}") - last_img = None - img = last_img - verdict = mon.feed(img, z, ref.volume if ref is not None else None) if img is not None else None - now = clock() - if verdict is not None: - was_occupied = known_bed == OCCUPIED - kinds = transitions(known_bed, known_plate, verdict.bed, verdict.plate) - frame = save_evidence(name, jpeg, verdict, kinds[0] if kinds else verdict.bed, now) if kinds else None - # bed_unknown (right after a job) also leaves "printing": it is said once per job, - # and a later clear/occupied verdict is news again - if verdict.bed in (CLEAR, OCCUPIED) or "bed_unknown" in kinds: - known_bed = verdict.bed - if verdict.plate in (PRESENT, MISSING): - known_plate = verdict.plate - prev = _state_doc(name, verdict, z, now, frame, prev) - prev.update(known_bed=known_bed, known_plate=known_plate) - write_state(name, prev) - last_write = now - _log(f"{name}: bed {verdict.bed}, plate {verdict.plate} at Z {_fz(z)}: {verdict.reason}") - for kind in kinds: - why = verdict.reason - _emit(out, {"event": { - "kind": kind, "ts": now, "key": f"{name}:{kind}:{int(now)}", - "text": _TEXT[kind].format(p=name, z=_fz(z), why=why), - "data": {"bed": verdict.bed, "plate": verdict.plate, "z": z, "reason": why, - "metrics": prev["metrics"], "frame": frame}}}) - _emit(out, {"state": {"bed": verdict.bed, "plate": verdict.plate, "z": z}}) - # the ack says "the parts were taken off": not on a first-ever clear or right after a job - if "bed_clear" in kinds and cfg.clear_ack is not None and was_occupied: - try: - sent, what = ack_fn(device, cfg.clear_ack, z=z, last_ack=prev.get("last_ack"), now=now) - except Exception as exc: # a failed ack never stops the monitor - sent, what = False, f"failed: {exc}" - _log(f"{name}: clear ack {what}") - prev["ack"] = what - if sent: - prev["last_ack"] = now - write_state(name, prev) - elif not mon.dirty and jpeg_new: - last_write = _touch_checked(name, prev, now, last_write) + w.process(jpeg, z, clock) # byte-identical frames never refresh `checked`: a frozen camera must age out of --max-age sleep(max(0.0, cfg.interval_s - (clock() - t0))) return mon @@ -880,7 +986,8 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A v.changed, v.roi) frame = save_evidence(device.name, jpeg, v, f"status-{v.bed}", now) return {"printer": device.name, "bed": v.bed, "plate": v.plate, "z": z, "source": "camera now", "age_s": 0.0, - "reason": v.reason, "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, "frame": frame} + "reason": v.reason, "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, "frame": frame, + "raw_frame": str(raw_frame_path(frame))} def status_view(device: Device, cfg: BedConfig, *, mode: str = "auto", max_age: float | None = None, @@ -915,6 +1022,91 @@ def view(z: float) -> float | None: settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) +def check_result(v: dict[str, Any], expect: str | None) -> tuple[str, bool]: + """(`empty` | `objects` | `unknown` | `out-of-view` | `busy`, or with expect=`part` `part-present` | + `part-gone`; whether it is what was expected) of a bed verdict. `objects` and `part-present` are the same + pixels read with different intent: after a print the parts standing on the bed are expected.""" + bed = v.get("bed") + if bed == PRINTING: + result = "busy" + elif bed == CLEAR: + result = "part-gone" if expect == "part" else "empty" + elif bed == OCCUPIED: + result = "part-present" if expect == "part" else "objects" + elif str(v.get("reason", "")).startswith("the bed is out of the camera's view"): + result = "out-of-view" + else: + result = UNKNOWN + wanted = {None: None, "empty": "empty", "part": "part-present"}[expect] + definite = result in ("empty", "objects", "part-present", "part-gone") + return result, definite and (wanted is None or result == wanted) + + +def check_view(device: Device, cfg: BedConfig, *, mode: str = "force", expect: str | None = None, + part_height: float | None = None, adapter: Any = None, camera: Any = None, + th: Thresholds | None = None, now: float | None = None, + sleep: Callable[[float], None] = time.sleep, settle_s: float | None = None) -> dict[str, Any]: + """`3d printer bed check`: a FRESH frame of the bed judged from the best view (`status_view` with + mode force: the idle bed is moved in Z to where the camera sees the plate and put back; `never`: + --no-move), in the words of the check: result, the changed share of the bed view (`fraction`), the + annotated evidence frame and the camera frame it came from.""" + v = status_view(device, cfg, mode=mode, max_age=None, part_height=part_height, adapter=adapter, camera=camera, + th=th, now=now, sleep=sleep, settle_s=settle_s) + result, ok = check_result(v, expect) + metrics = v.get("metrics") or {} + return {**v, "result": result, "expect": expect, "ok": ok, "fraction": metrics.get("changed"), + "blob_px": metrics.get("blob_px"), "bed_in_view": metrics.get("bed_in_view")} + + +def capture_reference_best_view(device: Device, cfg: BedConfig, *, plate: bool, part_height: float | None = None, + adapter: Any = None, camera: Any = None, now: float | None = None, + sleep: Callable[[float], None] = time.sleep, + settle_s: float | None = None) -> dict[str, Any]: + """`3d printer bed reference --capture --best-view`: the bed is moved in Z (the same guarded Z-only + move as `check`) to where the camera sees the plate, the empty bed is stored as the reference THERE, + and the bed is put back. No reference has to stand at that Z yet. The caller asserts the plate is empty.""" + import bed_view + from printer_ops import make_adapter + + adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) + camera = camera if camera is not None else camera_for(device) + match = z_match(device, cfg) + + def printer_fn(_: Device) -> tuple[str, float | None]: + m = bed_view.read_motion(adapter) + return m.state, m.z + + def take(_: float | None) -> dict[str, Any]: + return capture_reference(device, cfg, plate=plate, camera=camera, printer_fn=printer_fn, now=now) + + def view(z: float) -> float | None: + return 1.0 if match.cam is None else match.in_view(z) + + return bed_view.auto_view(device, cfg, mode="force", adapter=adapter, judge=take, view=view, ref_zs=[], + stands=match, part_height_mm=part_height, sleep=sleep, need_reference=False, + settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) + + +def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], tuple[str, float | None]], + frame: bytes | None, frame_z: float | None) -> tuple[float, bytes]: + from errors import UsageError + + if frame is not None: + if frame_z is None or not math.isfinite(frame_z): + raise UsageError("a reference from a file needs the Z it was taken at (--z, a finite number)", + command="printer bed") + return frame_z, frame + state, z = printer_fn(device) + if state not in QUIET_STATES: + raise UsageError(f"{device.name} is {state}: a reference needs the empty bed of an idle printer", + command="printer bed", + remediation=["Capture when `3d printer status` shows idle, complete or cancelled."]) + if z is None: + raise UsageError(f"{device.name}: toolhead Z unknown (Klipper not ready?)", command="printer bed", + remediation=["Retry when `3d printer status` reads the printer."]) + return z, grab(camera if camera is not None else camera_for(device)) + + def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: Any = None, printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, now: float | None = None, frame: bytes | None = None, @@ -924,23 +1116,7 @@ def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: An from errors import UsageError now = time.time() if now is None else now - if frame is not None: - if frame_z is None or not math.isfinite(frame_z): - raise UsageError("a reference from a file needs the Z it was taken at (--z, a finite number)", - command="printer bed") - z: float | None = frame_z - jpeg = frame - else: - state, z = printer_fn(device) - if state not in QUIET_STATES: - raise UsageError(f"{device.name} is {state}: a reference needs the empty bed of an idle printer", - command="printer bed", - remediation=["Capture when `3d printer status` shows idle, complete or cancelled."]) - if z is None: - raise UsageError(f"{device.name}: toolhead Z unknown (Klipper not ready?)", command="printer bed", - remediation=["Retry when `3d printer status` reads the printer."]) - jpeg = grab(camera if camera is not None else camera_for(device)) - assert z is not None + z, jpeg = _reference_frame(device, camera, printer_fn, frame, frame_z) match = z_match(device, cfg) hidden = out_of_view(match, z, thresholds_for(cfg)) if hidden is not None: # a reference there would read every low part as a clear bed @@ -1000,8 +1176,9 @@ def list_references(printer: str) -> list[dict[str, Any]]: # ---- CLI ------------------------------------------------------------------------------------ -USAGE = """3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] -3d printer bed reference NAME [--capture [--no-plate] [--from FRAME.jpg --z Z]] [--json] +USAGE = """3d printer bed check NAME [--expect empty|part] [--no-move] [--part-height MM] [--json] +3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] +3d printer bed reference NAME [--capture [--best-view] [--no-plate] [--from FRAME.jpg --z Z]] [--json] 3d printer bed monitor NAME [--duration S] Bed state from the printer's camera (issue #303): is the bed clear, is the build plate on. Needs one reference frame of the EMPTY bed WITH the plate per printer and resting Z, taken @@ -1011,6 +1188,17 @@ def list_references(printer: str) -> list[dict[str, Any]]: after a home) the state is unknown and no reference is taken; without one (watch.bed.roi) a reference is used only within watch.bed.z_tol_mm (default 0.5 mm) of the current Z. + check THE tool to look at the bed: a fresh frame judged from where the camera sees the + plate. An idle printer's bed is moved in Z ONLY (the guarded move of "Auto view", + even when watch.bed.auto_view is off), judged, put back; --no-move judges at the + current Z. Result: empty | objects | out-of-view | unknown | busy (printing: the + bed belongs to the job, nothing is moved or asked: `3d printer watch NAME --job` + watches a running print). --expect part reads the same pixels as part-present | + part-gone (after a print: is the part still on the plate?). `fraction` = share of + the bed view that differs from the empty-bed reference; `frame` = the annotated + picture (ROI cyan, changed pixels red), `raw_frame` = the camera frame it came + from. Exit 1 when --expect is given and the result differs from it (and for + unknown / out-of-view / busy), else 0. status the bed now: clear | occupied | unknown, plate present | missing | unknown, with the reason and an evidence frame (~/.cache/3d-cli/vision/bed/). With --max-age S the `3d printer watch` monitor's last verdict is used when it was @@ -1020,7 +1208,8 @@ def list_references(printer: str) -> list[dict[str, Any]]: plate on); --capture --no-plate takes the bare-bed one (plate off), which makes plate detection reliable. --from FRAME.jpg --z Z stores a saved frame of the empty bed taken at gcode Z instead of grabbing one. Stored in - ~/.local/share/3d-cli/bed/NAME/. + ~/.local/share/3d-cli/bed/NAME/. --best-view moves the bed (as `check`) to where + the camera sees the plate and takes the reference THERE, then puts the bed back. monitor the loop `3d printer watch` runs per printer with `watch.bed.enabled: true`: JSON lines on stdout (events bed_clear, bed_occupied, plate_present, plate_missing; the warning bed_unknown when the first verdict after a job @@ -1035,7 +1224,15 @@ def list_references(printer: str) -> list[dict[str, Any]]: thresholds: {clear_max: 0.05, level: 30} per-printer overrides of the tuned pixel thresholds (level, detrend_px, edge_px, clear_blob_px, clear_max, occupied_blob_px, occupied_min, plate_missing_min, plate_tie, surface_min, change_min, - settle_frames, match_px, view_min) + settle_frames, match_px, view_min, shift_px, open_px, low_view, + occupied_blob_low_px) + Objects are judged robustly: a pixel that matches the reference within shift_px (3) counts as + unchanged (a bed parked at another Z, a vibrating camera), changed regions are opened with an + open_px (5) square (a moved cable, an edge line), and where the camera sees less than low_view + (0.5) of the bed (U1 at Z 200: 15 %) most of the ROI is the machine and the room behind it, so + only a region of occupied_blob_low_px (1200) or occupied_min of the ROI says "occupied"; a + smaller one stays "unknown" with the hint to `bed check` from a better Z. Light and exposure + changes are mapped out per channel, then smooth gradients are detrended. The camera is the printer's watch.vision.camera (a printer without one cannot enable the bed check). clear_ack: {enabled: true, command: STEPPER_BUZZ STEPPER=stepper_z, debounce_minutes: 10, min_z_mm: 10} one motor sound after bed_clear (the command must be in @@ -1065,6 +1262,10 @@ def list_references(printer: str) -> list[dict[str, Any]]: The `3d printer watch` monitor never moves the bed and judges nothing while a check has it away. Examples: + 3d printer bed check u1 # move the bed to the camera, look, put it back + 3d printer bed check u1 --expect part --json # after a print: is the part still on the plate? + 3d printer bed check k1se --no-move --expect empty + 3d printer bed reference u1 --capture --best-view # new empty-bed reference from the best view 3d printer bed reference u1 --capture 3d printer bed status u1 3d printer bed status u1 --no-move @@ -1072,37 +1273,84 @@ def list_references(printer: str) -> list[dict[str, Any]]: 3d printer bed status k1se --json | jq .bed""" -def main(argv: list[str]) -> int: - if not argv or argv[0] in ("-h", "--help", "help"): - print(USAGE) - return 0 if argv else 1 - from printer_ops.devices import get_device +def _cmd_reference(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + """`bed reference`: list the references, or --capture one (from the camera, the best view or a saved frame).""" + if not args.capture: + return _list_references_cmd(args, device) + frame = None + if args.from_file: + path = Path(args.from_file).expanduser() + if not path.is_file(): + from errors import InputNotFound - ap = argparse.ArgumentParser(prog="3d printer bed", add_help=False) - ap.add_argument("sub", choices=("status", "reference", "monitor")) - ap.add_argument("name") - ap.add_argument("--json", action="store_true") - ap.add_argument("--max-age", type=float) - ap.add_argument("--capture", action="store_true") - ap.add_argument("--no-plate", action="store_true") - ap.add_argument("--duration", type=float) - ap.add_argument("--from", dest="from_file") - ap.add_argument("--z", type=float) - ap.add_argument("--best-view", action="store_true") - ap.add_argument("--no-move", action="store_true") - ap.add_argument("--part-height", type=float) - ap.add_argument("-h", "--help", action="store_true") - args, rest = ap.parse_known_args(argv) - if args.help: - print(USAGE) - return 0 + raise InputNotFound(str(path), command="printer bed") + frame = path.read_bytes() + if args.best_view and args.from_file: + from errors import UsageError + + raise UsageError("--best-view and --from contradict each other", command="printer bed", + remediation=["--best-view moves the bed and photographs it; --from stores a saved frame."]) + if args.best_view: + ref = capture_reference_best_view(device, cfg, plate=not args.no_plate, part_height=args.part_height) + else: + ref = capture_reference(device, cfg, plate=not args.no_plate, frame=frame, frame_z=args.z) + print(json.dumps(ref, indent=1) if args.json else + f"{ref['kind']} reference of {device.name} at Z {ref['z']:.1f}: {ref['path']}\n" + f" ROI: {ref['roi_source']} ({ref['volume_share']:.1%} of the frame; " + f"bed surface {ref['surface_share']:.1%})") + return 0 + + +def _list_references_cmd(args: argparse.Namespace, device: Device) -> int: + refs = list_references(device.name) + if args.json: + print(json.dumps(refs, indent=1)) + elif not refs: + print(f"no references for {device.name}; with the bed clear and the plate on: " + f"3d printer bed reference {device.name} --capture") + else: + for r in refs: + stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(r.get("ts", 0)))) + print(f"{r['kind']:<8} Z {float(r['z']):6.1f} {stamp} {r['path']} ROI: {r.get('roi_source')}") + return 0 + + +def _cmd_check(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + c = check_view(device, cfg, mode="never" if args.no_move else "force", expect=args.expect, + part_height=args.part_height) + if args.json: + print(json.dumps(c, indent=1, default=str)) + return 0 if c["ok"] else 1 + frac = "" if c.get("fraction") is None else f" ({c['fraction']:.1%} of the bed view differs from the empty bed)" + moved = (c.get("view") or {}).get("action") + print(f"{device.name}: {c['result']}{frac}; Z {_fz(c.get('z'))}" + + (f", bed {moved}" if moved else "") + f"\n {c.get('reason', '')}" + + (f"\n frame: {c['frame']}" if c.get("frame") else "") + + (f"\n camera frame: {c['raw_frame']}" if c.get("raw_frame") else "")) + return 0 if c["ok"] else 1 + + +def _cmd_status(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + v = status_view(device, cfg, mode="never" if args.no_move else "force" if args.best_view else "auto", + max_age=args.max_age, part_height=args.part_height) + if args.json: + print(json.dumps(v, indent=1, default=str)) + else: + print(f"{device.name}: bed {v['bed']}, plate {v['plate']} (Z {_fz(v.get('z'))}; {v['source']}, " + f"{v.get('age_s', 0):.0f} s ago)\n {v.get('reason', '')}" + (f"\n frame: {v['frame']}" if v.get("frame") else "")) + return 0 + + +def _validate_args(args: argparse.Namespace, rest: list[str]) -> None: if rest: from errors import UsageError raise UsageError(f"unknown arguments for `printer bed {args.sub}`: {' '.join(rest)}", command="printer bed", remediation=["See `3d printer bed --help`."]) - allowed = {"status": {"json", "max_age", "best_view", "no_move", "part_height"}, "monitor": {"duration"}, - "reference": {"json", "capture"} | ({"no_plate", "from_file", "z"} if args.capture else set())}[args.sub] + allowed = {"check": {"json", "expect", "no_move", "part_height"}, + "status": {"json", "max_age", "best_view", "no_move", "part_height"}, "monitor": {"duration"}, + "reference": {"json", "capture"} | ({"no_plate", "from_file", "z", "best_view", "part_height"} + if args.capture else set())}[args.sub] stray = [f"--{k.replace('_', '-')}" if k != "from_file" else "--from" for k, v in vars(args).items() if k not in {"sub", "name", "help"} | allowed and v is not None and v is not False] if stray: @@ -1120,48 +1368,46 @@ def main(argv: list[str]) -> int: raise InvalidArgument("--part-height", str(args.part_height), ["a height in mm, 0 or more"], command="printer bed") + + +def main(argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help", "help"): + print(USAGE) + return 0 if argv else 1 + from printer_ops.devices import get_device + + ap = argparse.ArgumentParser(prog="3d printer bed", add_help=False) + ap.add_argument("sub", choices=("check", "status", "reference", "monitor")) + ap.add_argument("name") + ap.add_argument("--json", action="store_true") + ap.add_argument("--max-age", type=float) + ap.add_argument("--capture", action="store_true") + ap.add_argument("--no-plate", action="store_true") + ap.add_argument("--duration", type=float) + ap.add_argument("--from", dest="from_file") + ap.add_argument("--z", type=float) + ap.add_argument("--best-view", action="store_true") + ap.add_argument("--no-move", action="store_true") + ap.add_argument("--part-height", type=float) + ap.add_argument("--expect", choices=("empty", "part")) + ap.add_argument("-h", "--help", action="store_true") + args, rest = ap.parse_known_args(argv) + if args.help: + print(USAGE) + return 0 + _validate_args(args, rest) device = get_device(args.name) cfg = bed_config(device) - if args.sub in ("monitor", "status") or (args.sub == "reference" and args.capture and not args.from_file): + if args.sub in ("monitor", "status", "check") or (args.sub == "reference" and args.capture and not args.from_file): camera_for(device) # a printer without a camera is refused here, before any printer request if args.sub == "monitor": run_monitor(device, cfg, duration=args.duration) return 0 if args.sub == "reference": - if args.capture: - frame = None - if args.from_file: - path = Path(args.from_file).expanduser() - if not path.is_file(): - from errors import InputNotFound - - raise InputNotFound(str(path), command="printer bed") - frame = path.read_bytes() - ref = capture_reference(device, cfg, plate=not args.no_plate, frame=frame, frame_z=args.z) - print(json.dumps(ref, indent=1) if args.json else - f"{ref['kind']} reference of {device.name} at Z {ref['z']:.1f}: {ref['path']}\n" - f" ROI: {ref['roi_source']} ({ref['volume_share']:.1%} of the frame; " - f"bed surface {ref['surface_share']:.1%})") - return 0 - refs = list_references(device.name) - if args.json: - print(json.dumps(refs, indent=1)) - elif not refs: - print(f"no references for {device.name}; with the bed clear and the plate on: " - f"3d printer bed reference {device.name} --capture") - else: - for r in refs: - stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(r.get("ts", 0)))) - print(f"{r['kind']:<8} Z {float(r['z']):6.1f} {stamp} {r['path']} ROI: {r.get('roi_source')}") - return 0 - v = status_view(device, cfg, mode="never" if args.no_move else "force" if args.best_view else "auto", - max_age=args.max_age, part_height=args.part_height) - if args.json: - print(json.dumps(v, indent=1, default=str)) - else: - print(f"{device.name}: bed {v['bed']}, plate {v['plate']} (Z {_fz(v.get('z'))}; {v['source']}, " - f"{v.get('age_s', 0):.0f} s ago)\n {v.get('reason', '')}" + (f"\n frame: {v['frame']}" if v.get("frame") else "")) - return 0 + return _cmd_reference(args, device, cfg) + if args.sub == "check": + return _cmd_check(args, device, cfg) + return _cmd_status(args, device, cfg) if __name__ == "__main__": diff --git a/lib/bed_view.py b/lib/bed_view.py index 46cd63de..76fa831b 100644 --- a/lib/bed_view.py +++ b/lib/bed_view.py @@ -143,14 +143,15 @@ def _nearest_best(good: list[Candidate], current_z: float | None) -> Candidate: def pick_inspection_z(view: Callable[[float], float | None], ref_zs: Sequence[float], stands: Callable[[float | None, float | None], bool], current_z: float | None, *, - z_floor: float, z_ceiling: float, threshold: float) -> ViewPick: + z_floor: float, z_ceiling: float, threshold: float, need_reference: bool = True) -> ViewPick: """The best Z to judge the bed at. `view(z)` = share of the plate in the frame (None: cannot tell), `ref_zs` = Zs of the empty-bed references, `stands(ref_z, z)` = the reference stands for the bed at z - (bed_state.ZMatch). See the module docstring for the rule.""" + (bed_state.ZMatch). `need_reference` False: any Z in the range qualifies (taking a NEW reference: none + stands there yet). See the module docstring for the rule.""" refs = sorted({round(float(z), 1) for z in ref_zs}) def referenced(z: float) -> bool: - return any(stands(r, z) for r in refs) + return not need_reference or any(stands(r, z) for r in refs) top = max([z_ceiling, *refs, current_z or 0.0]) grid = {round(i * GRID_MM, 1) for i in range(int(top // GRID_MM) + 1)} @@ -538,10 +539,10 @@ def _no_move_reason(mode: str, cfg: BedConfig) -> str | None: def _pick_move(view: Callable[[float], float | None], ref_zs: Sequence[float], stands: Callable[[float | None, float | None], bool], m: Motion, limits: Limits, cfg: BedConfig, - base: dict[str, Any]) -> ViewPick: + base: dict[str, Any], need_reference: bool = True) -> ViewPick: """The Z to judge at, recorded in `base` together with the limits it was picked within.""" pick = pick_inspection_z(view, ref_zs, stands, m.z, z_floor=limits.z_floor, z_ceiling=limits.z_ceiling, - threshold=cfg.auto_view_min) + threshold=cfg.auto_view_min, need_reference=need_reference) base.update(pick=pick.to_json(), part_mm=limits.part_mm, part_source=limits.part_source, z_floor=limits.z_floor, z_ceiling=limits.z_ceiling) return pick @@ -552,10 +553,12 @@ def auto_view(device: Any, cfg: BedConfig, *, mode: str, adapter: Any, judge: Ca stands: Callable[[float | None, float | None], bool], max_age: float | None = None, part_height_mm: float | None = None, settle_s: float = AUTO_VIEW_SETTLE_S, sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.time, - read: Callable[[Any], Motion] = read_motion) -> dict[str, Any]: + read: Callable[[Any], Motion] = read_motion, need_reference: bool = True) -> dict[str, Any]: """The bed verdict of `judge`, at a better Z when the current one shows too little of the plate. `mode`: auto (cfg.auto_view decides) | force (--best-view) | never (--no-move). `judge(max_age)` is - `bed_state.one_shot` (max_age None = a fresh frame). The verdict carries `view`: what was done and why.""" + `bed_state.one_shot` (max_age None = a fresh frame). `need_reference` False: the Z needs no empty-bed + reference because `judge` TAKES one (`bed reference --capture --best-view`). The verdict carries `view`: + what was done and why.""" if mode not in ("auto", "force", "never"): raise ValueError(f"mode must be auto, force or never, not {mode!r}") m = read(adapter) @@ -579,7 +582,7 @@ def auto_view(device: Any, cfg: BedConfig, *, mode: str, adapter: Any, judge: Ca limits = _may_move(device, adapter, cfg, m, part_height_mm) if not isinstance(limits, Limits): # 3.-5. the guards that stop a move return _not_moved(judge, max_age, base, note, str(limits), f"{shown}, bed not moved: {limits}") - pick = _pick_move(view, ref_zs, stands, m, limits, cfg, base) + pick = _pick_move(view, ref_zs, stands, m, limits, cfg, base, need_reference) if pick.status != "move" or pick.z is None: return _not_moved(judge, max_age, base, note, pick.reason, f"bed not moved: {pick.reason}") with view_lock(device.name) as held: diff --git a/lib/printer_ops/bed.py b/lib/printer_ops/bed.py index 3e89ccb5..2e5bf586 100644 --- a/lib/printer_ops/bed.py +++ b/lib/printer_ops/bed.py @@ -77,7 +77,8 @@ # The per-printer overrides of bed_state.Thresholds (tuned on the U1 and K1 SE frames of tests/data/bed; # another camera may need other values): every field of that dataclass, a test keeps the two in step. THRESHOLD_KEYS = ("level", "detrend_px", "edge_px", "clear_blob_px", "clear_max", "occupied_blob_px", "occupied_min", - "plate_missing_min", "plate_tie", "surface_min", "change_min", "settle_frames", "match_px", "view_min") + "plate_missing_min", "plate_tie", "surface_min", "change_min", "settle_frames", "match_px", "view_min", + "shift_px", "open_px", "low_view", "occupied_blob_low_px") ACK_KEYS = ("enabled", "command", "debounce_minutes", "min_z_mm") @@ -150,17 +151,7 @@ def _roi(value: Any, what: str) -> tuple[tuple[float, float], ...]: return tuple((float(x), float(y)) for x, y in value) -def _ack(value: Any, device: Device) -> AckConfig | None: - where = f"{device.name}.watch.bed.clear_ack" - if not isinstance(value, dict): - raise InvalidArgument(where, str(value), ["a mapping: {enabled, command, debounce_minutes, min_z_mm}"], - command="printer", extra=f"Fix {device.source}.") - unknown = sorted(set(value) - set(ACK_KEYS)) - if unknown: - raise InvalidArgument(where, ", ".join(unknown), ACK_KEYS, command="printer", extra=f"Fix {device.source}.") - if not _flag(value.get("enabled", False), f"{where}.enabled"): - return None - raw = value.get("command") +def _ack_lines(raw: Any, where: str, device: Device) -> list[str]: lines = [raw] if isinstance(raw, str) else raw if (not isinstance(lines, list) or not lines or not all(isinstance(c, str) and c.strip() and not any(ch in c for ch in "\n\r;") for c in lines)): @@ -177,6 +168,20 @@ def _ack(value: Any, device: Device) -> AckConfig | None: command="printer", extra=f"Add {word} to {device.name}.trusted_macros in {device.source} first " "(it is the list of commands 3d may let the printer run).") + return lines + + +def _ack(value: Any, device: Device) -> AckConfig | None: + where = f"{device.name}.watch.bed.clear_ack" + if not isinstance(value, dict): + raise InvalidArgument(where, str(value), ["a mapping: {enabled, command, debounce_minutes, min_z_mm}"], + command="printer", extra=f"Fix {device.source}.") + unknown = sorted(set(value) - set(ACK_KEYS)) + if unknown: + raise InvalidArgument(where, ", ".join(unknown), ACK_KEYS, command="printer", extra=f"Fix {device.source}.") + if not _flag(value.get("enabled", False), f"{where}.enabled"): + return None + lines = _ack_lines(value.get("command"), where, device) return AckConfig(tuple(c.strip() for c in lines), _num(value.get("debounce_minutes", 10), f"{where}.debounce_minutes", 0) * 60, _num(value.get("min_z_mm", 10), f"{where}.min_z_mm", 0)) @@ -370,6 +375,11 @@ def close(self, timeout: float = 5.0) -> None: # ---- the clear acknowledgement -------------------------------------------------------------- +def _toolhead_z(adapter: Any) -> float | None: + pos = (adapter.query("toolhead").get("toolhead") or {}).get("position") + return pos[2] if isinstance(pos, list) and len(pos) > 2 and isinstance(pos[2], (int, float)) else None + + def clear_ack(device: Device, ack: AckConfig, *, z: float | None, last_ack: float | None, now: float, adapter: Any = None) -> tuple[bool, str]: """Send `ack.command` once (see WRITES above). -> (sent, what happened).""" @@ -389,8 +399,7 @@ def clear_ack(device: Device, ack: AckConfig, *, z: float | None, last_ack: floa st = adapter.status() # re-read right before the write: the bed may have been jogged since the frame if st.state not in QUIET_STATES: return False, f"printer is {st.state} [{st.raw_state}]" - pos = (adapter.query("toolhead").get("toolhead") or {}).get("position") - z_now = pos[2] if isinstance(pos, list) and len(pos) > 2 and isinstance(pos[2], (int, float)) else None + z_now = _toolhead_z(adapter) if z_now is None or not math.isfinite(z_now) or z_now < ack.min_z_mm: return False, f"bed Z now {z_now} is unknown or below min_z_mm {ack.min_z_mm:g}" script = "\n".join(ack.command) diff --git a/tests/data/bed/u1_z200_clear_ref_1016.jpg b/tests/data/bed/u1_z200_clear_ref_1016.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7d69643a5a33f64512d429ef176501cc2721530d GIT binary patch literal 32807 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%0D!T}?&`>c*bP24*A4 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 z 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 literal 0 HcmV?d00001 diff --git a/tests/data/bed/u1_z200_light_1703.jpg b/tests/data/bed/u1_z200_light_1703.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4ea975b3e2ef0fde5e51fb81c28ba61610bcea57 GIT binary patch literal 32309 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 WAy9BZqOZF7dmCY&G&%dbpa0ng3A%d# literal 0 HcmV?d00001 diff --git a/tests/test_bed_check.py b/tests/test_bed_check.py new file mode 100644 index 00000000..6fa5fb7b --- /dev/null +++ b/tests/test_bed_check.py @@ -0,0 +1,215 @@ +"""`3d printer bed check` and `bed reference --capture --best-view` (issue #723): the bed is moved in Z +only (the guarded move of lib/bed_view.py), a fresh frame is judged from where the camera sees the +plate, the bed is put back, and the answer is in the words of the check (empty / objects / +part-present / part-gone / out-of-view / unknown / busy). + +The printer is `FakePrinter` and the camera `ZCamera` of test_bed_view (saved frames by the bed's Z); +nothing here touches a real printer or camera. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from printer_ops import bed as pbed +from test_bed_view import CFG, FRAMES, FakePrinter, ZCamera, capture, device, moves + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_state as B # noqa: E402 (needs numpy + OpenCV) + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def refs() -> None: + capture(200.0, "u1_z200_clear_ref.jpg") + capture(70.0, "u1_z10_clear_ref.jpg") + + +def check( + printer: FakePrinter, *, mode: str = "force", expect: str | None = None +) -> dict[str, Any]: + return B.check_view( + device(), + CFG, + mode=mode, + expect=expect, + part_height=20.0, + adapter=printer, + camera=ZCamera(printer, FRAMES), + sleep=lambda s: None, + settle_s=0.0, + ) + + +def test_check_moves_the_bed_to_the_camera_and_reports_empty_with_both_frames( + refs: None, +) -> None: + """The incident: at Z 200 the sliver of the plate reads 'parts'; from Z 70 the plate is clear.""" + printer = FakePrinter("complete", 200.0) + c = check(printer) + assert (c["result"], c["ok"]) == ("empty", True) + assert ( + c["z"] == 70.0 + and c["view"]["action"] == "moved" + and c["view"]["restored"] is True + ) + assert c["fraction"] is not None and c["fraction"] < 0.03 + assert Path(c["frame"]).is_file() and Path(c["raw_frame"]).is_file() + assert Path(c["raw_frame"]).name == Path(c["frame"]).stem + ".raw.jpg" + assert printer.z == 200.0 # put back + assert moves(printer) == ["G1 Z70.000 F600", "G1 Z200.000 F600"] + + +def test_check_moves_even_when_auto_view_is_off_and_never_with_no_move( + refs: None, +) -> None: + off = pbed.BedConfig(enabled=True, auto_view=False) + printer = FakePrinter("complete", 200.0) + moved = B.check_view( + device(), + off, + part_height=20.0, + adapter=printer, + camera=ZCamera(printer, FRAMES), + sleep=lambda s: None, + settle_s=0.0, + ) + assert moved["view"]["action"] == "moved" + printer = FakePrinter("complete", 200.0) + still = check(printer, mode="never") + assert moves(printer) == [] and still["view"]["action"] == "not moved" + assert still["z"] == 200.0 + + +def test_expect_part_reads_the_same_pixels_as_part_present_or_gone( + refs: None, +) -> None: + printer = FakePrinter("complete", 200.0) + gone = check(printer, expect="part") + assert (gone["result"], gone["ok"]) == ("part-gone", False) + # the frame the camera shows at Z 70 with parts on the bed: occupied + present = B.check_view( + device(), + CFG, + expect="part", + part_height=20.0, + adapter=(p2 := FakePrinter("complete", 200.0)), + camera=ZCamera( + p2, {200.0: "u1_occupied_1400.jpg", 70.0: "u1_occupied_1351.jpg"} + ), + sleep=lambda s: None, + settle_s=0.0, + ) + assert present["result"] in ("part-present", "unknown"), present["reason"] + + +def test_check_while_printing_moves_nothing_and_says_busy(refs: None) -> None: + printer = FakePrinter("printing", 150.0) + cam = ZCamera(printer, FRAMES) + c = B.check_view( + device(), + CFG, + adapter=printer, + camera=cam, + sleep=lambda s: None, + settle_s=0.0, + ) + assert (c["result"], c["ok"]) == ("busy", False) + assert ( + printer.scripts == [] and cam.grabs == 0 + ) # the bed belongs to the job: no move, no camera + assert "3d printer watch" in c["reason"] or "belongs to the job" in c["reason"] + + +def test_check_without_a_view_says_out_of_view_not_empty(refs: None) -> None: + printer = FakePrinter( + "complete", 275.0, homed="" + ) # not homed: cannot move, Z 275 sees nothing + c = check(printer) + assert c["result"] == "out-of-view" and c["ok"] is False + assert printer.scripts == [] + + +# ---- bed reference --capture --best-view ------------------------------------------------------ + + +def test_a_reference_is_taken_from_the_best_view_and_the_bed_goes_back() -> None: + printer = FakePrinter("complete", 200.0) + r = B.capture_reference_best_view( + device(), + CFG, + plate=True, + part_height=20.0, + adapter=printer, + camera=ZCamera(printer, FRAMES), + now=1.0, + sleep=lambda s: None, + settle_s=0.0, + ) + assert r["kind"] == "plate" and r["view"]["action"] == "moved" + assert 35.0 <= r["z"] < 200.0 and r["view"]["pick"]["visible"] >= 0.85 + assert printer.z == 200.0 and len(moves(printer)) == 2 + stored = B.list_references("u1") + assert [round(float(s["z"]), 1) for s in stored] == [round(r["z"], 1)] + + +def test_a_reference_is_never_taken_while_a_job_runs() -> None: + from errors import UsageError + + printer = FakePrinter("printing", 150.0) + with pytest.raises(UsageError, match="printing"): + B.capture_reference_best_view( + device(), + CFG, + plate=True, + adapter=printer, + camera=ZCamera(printer, FRAMES), + sleep=lambda s: None, + settle_s=0.0, + ) + assert printer.scripts == [] and B.list_references("u1") == [] + + +# ---- the words -------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("bed", "reason", "expect", "result", "ok"), + [ + ("clear", "", None, "empty", True), + ("clear", "", "empty", "empty", True), + ("clear", "", "part", "part-gone", False), + ("occupied", "", None, "objects", True), + ("occupied", "", "part", "part-present", True), + ("occupied", "", "empty", "objects", False), + ( + "unknown", + "the bed is out of the camera's view at Z 275.0", + None, + "out-of-view", + False, + ), + ("unknown", "a small part or a shadow?", None, "unknown", False), + ("printing", "printing: bed not moved", None, "busy", False), + ], +) +def test_check_result_words_and_exit_logic( + bed: str, reason: str, expect: str | None, result: str, ok: bool +) -> None: + assert B.check_result({"bed": bed, "reason": reason}, expect) == (result, ok) diff --git a/tests/test_bed_robust.py b/tests/test_bed_robust.py new file mode 100644 index 00000000..2855bded --- /dev/null +++ b/tests/test_bed_robust.py @@ -0,0 +1,213 @@ +"""The bed verdict stays honest under light, exposure and registration drift (issue #723). + +2026-11-04: the idle Snapmaker U1 sat at Z 200, where its camera sees 15 % of the plate, and +`print-preflight` said "objects on the bed 14.3 %" on an empty, clean plate. The owner's evidence +frames showed why: at that Z the bed ROI 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 +reference from October. Only the annotated evidence image was kept, so +`u1_z200_light_1703.jpg` and `u1_z200_light_2013.jpg` are rebuilt from it: the red "changed" +overlay (0.45 * pixel + (0, 0, 140), 4x4 blocks) was inverted, the cyan outline and the caption +were inpainted, and the result was scaled to 480 px like the other fixtures. The old classifier +reads both as occupied (14.1 % / 17.1 % changed, a 823 / 925 px region) against +`u1_z200_clear_ref_1016.jpg`, the reference that was in force. Those frames carry a real change +(a moved room object at the edge of the ROI) that no pixel difference can call "not the bed", so +the honest verdict at a 15 % view is UNKNOWN with the way out, never occupied and never clear. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path +from typing import Any + +import pytest + +from printer_ops import bed as pbed +from test_bed_state import DATA, device, frame, k1_ref, u1_match + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_state as B # noqa: E402 (needs numpy + OpenCV) + +OLD = dataclasses.replace( + B.TH, shift_px=0, open_px=3, occupied_blob_low_px=B.TH.occupied_blob_px +) # the classifier before #723 + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def u1_ref_1016() -> B.Reference: + """The Z 199.67 reference of 2026-10-16, the one the false verdict was judged against.""" + img = frame("u1_z200_clear_ref_1016.jpg") + vol, surf, _ = B.build_masks( + device(), pbed.BedConfig(enabled=True), 199.67, img.shape[:2] + ) + return B.Reference( + "plate", 199.67, 0.0, "calibration", img, vol, surf, DATA / "x.jpg" + ) + + +LIGHT_DRIFT = ["u1_z200_light_1703.jpg", "u1_z200_light_2013.jpg"] + + +@pytest.mark.parametrize("name", LIGHT_DRIFT) +def test_the_empty_plate_under_new_window_light_is_not_called_occupied( + name: str, +) -> None: + v = B.classify(frame(name), [u1_ref_1016()], 200.2, "u1", match=u1_match()) + assert v.bed == "unknown", v.reason + assert v.metrics["changed"] < B.TH.occupied_min + assert v.metrics["blob_px"] < B.TH.occupied_blob_low_px + # the verdict says what to do instead of guessing + assert "`3d printer bed check u1`" in v.reason + assert "the room behind the bed" in v.reason + + +@pytest.mark.parametrize("name", LIGHT_DRIFT) +def test_the_same_frames_failed_the_old_classifier(name: str) -> None: + """Teeth: with the pre-#723 settings the same frames are 'occupied' (the incident).""" + v = B.classify(frame(name), [u1_ref_1016()], 200.2, "u1", OLD, u1_match()) + assert v.bed == "occupied", v.reason + assert 0.12 < v.metrics["changed"] < 0.20 + + +def paste(img: Any, roi: Any, size: int, colour: tuple[int, int, int]) -> Any: + """`size` x `size` px block of `colour` centred in the ROI's middle rows.""" + out = img.copy() + ys, xs = np.nonzero(roi) + cy, cx = int(np.percentile(ys, 40)), int(np.percentile(xs, 60)) + out[cy : cy + size, cx : cx + size] = colour + return out + + +def test_a_real_part_at_a_low_view_is_still_occupied() -> None: + """Hiding real objects is the one thing the tolerance must not do.""" + ref = B.Reference( + "plate", + 200.0, + 0.0, + "calibration", + frame("u1_z200_clear_ref.jpg"), + *B.build_masks(device(), pbed.BedConfig(enabled=True), 200.0, (270, 480))[:2], + DATA / "x.jpg", + ) + base = frame("u1_z200_clear_1403.jpg") + big = paste(base, ref.volume, 56, (30, 90, 30)) + v = B.classify(big, [ref], 200.0, "u1", match=u1_match()) + assert v.bed == "occupied", v.reason + # smaller than the low-view minimum: not clear, and the way out is named + small = paste(base, ref.volume, 16, (30, 90, 30)) + v = B.classify(small, [ref], 200.0, "u1", match=u1_match()) + assert v.bed in ("unknown", "occupied"), v.reason + if v.bed == "unknown": + assert "bed check u1" in v.reason + + +@pytest.mark.parametrize(("size", "bed"), [(24, "occupied"), (16, "occupied")]) +def test_a_small_part_at_a_full_view_is_found_as_before(size: int, bed: str) -> None: + """The K1 SE sees the whole bed: the tolerance for shifts did not cost its sensitivity.""" + img = frame("k1_z125_clear_ref.jpg").copy() + img[180 : 180 + size, 300 : 300 + size] = (20, 20, 20) + assert B.classify(img, [k1_ref()], 125.0, "k1se").bed == bed + + +def test_a_scene_shifted_by_two_pixels_is_not_a_part() -> None: + """The bed parked 0.3 mm off its reference Z, a camera nudge: every edge of the scene moves a + pixel or two. That is a line, not a region.""" + shifted = np.roll(frame("u1_z200_clear_ref.jpg"), (2, 2), axis=(0, 1)) + refs = [ + B.Reference( + "plate", + 200.0, + 0.0, + "calibration", + frame("u1_z200_clear_ref.jpg"), + *B.build_masks(device(), pbed.BedConfig(enabled=True), 200.0, (270, 480))[ + :2 + ], + DATA / "x.jpg", + ) + ] + assert B.classify(shifted, refs, 200.0, "u1", match=u1_match()).bed == "clear" + assert B.classify(shifted, refs, 200.0, "u1", OLD, u1_match()).bed == "occupied" + + +@pytest.mark.parametrize(("gain", "offset"), [(0.7, 0), (1.25, -10), (0.55, 12)]) +def test_a_lighting_change_on_an_empty_bed_is_clear(gain: float, offset: int) -> None: + """Exposure / room light: the whole frame darker or brighter.""" + img = np.clip( + frame("k1_z125_clear_ref.jpg").astype(np.float32) * gain + offset, 0, 255 + ).astype(np.uint8) + v = B.classify(img, [k1_ref()], 125.0, "k1se") + assert v.bed == "clear", v.reason + + +def test_a_lighting_change_on_a_u1_bed_with_parts_still_shows_the_parts() -> None: + ref = B.Reference( + "plate", + 200.0, + 0.0, + "calibration", + frame("u1_z200_clear_ref.jpg"), + *B.build_masks(device(), pbed.BedConfig(enabled=True), 200.0, (270, 480))[:2], + DATA / "x.jpg", + ) + dim = np.clip( + frame("u1_occupied_1400.jpg").astype(np.float32) * 0.7, 0, 255 + ).astype(np.uint8) + assert B.classify(dim, [ref], 200.0, "u1", match=u1_match()).bed == "occupied" + + +# ---- evidence --------------------------------------------------------------------------------- + + +def test_evidence_keeps_the_camera_frame_next_to_the_annotated_one( + tmp_path: Path, +) -> None: + raw = (DATA / "u1_z200_clear_1403.jpg").read_bytes() + v = B.classify(frame("u1_z200_clear_1403.jpg"), [], 200.0, "u1") + path = B.save_evidence("u1", raw, v, "status-unknown", 1_700_000_000.0) + assert Path(path).is_file() and path.endswith("status-unknown.jpg") + assert B.raw_frame_path(path).read_bytes() == raw + assert B.raw_frame_path(path).name == "u1-20231114T221320Z-status-unknown.raw.jpg" + + +def test_pruning_old_evidence_removes_the_raw_frame_with_it() -> None: + raw = (DATA / "u1_z200_clear_1403.jpg").read_bytes() + v = B.classify(frame("u1_z200_clear_1403.jpg"), [], 200.0, "u1") + paths = [ + B.save_evidence("u1", raw, v, "x", 1_700_000_000.0 + i, keep=2) + for i in range(4) + ] + assert [Path(p).is_file() for p in paths] == [False, False, True, True] + assert [B.raw_frame_path(p).is_file() for p in paths] == [False, False, True, True] + + +def test_the_new_thresholds_are_per_printer_overridable() -> None: + cfg = pbed.bed_config( + device( + watch={ + "bed": { + "enabled": True, + "thresholds": {"shift_px": 2, "occupied_blob_low_px": 900}, + } + } + ) + ) + th = B.thresholds_for(cfg) + assert (th.shift_px, th.occupied_blob_low_px) == (2, 900) + assert json.dumps(sorted(pbed.THRESHOLD_KEYS)) == json.dumps( + sorted(f.name for f in dataclasses.fields(B.Thresholds)) + ) From ff6088fbd2dbfe57bc4b1f4aad2fc830115afbba Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:03:02 +0100 Subject: [PATCH 04/30] feat(printer): `3d printer watch NAME --job` aborts a print whose part 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) --- docs/commands/printer.md | 131 ++++- lib/bed_part.py | 337 +++++++++++ lib/commands/printer.py | 357 ++++++++---- lib/printer_ops/jobwatch.py | 421 ++++++++++++++ lib/printer_ops/jobwatch_actions.py | 392 +++++++++++++ lib/printer_ops/jobwatch_launch.py | 91 +++ lib/printer_ops/jobwatch_run.py | 735 ++++++++++++++++++++++++ lib/printer_ops/jobwatch_sources.py | 261 +++++++++ tests/data/jobwatch/empty.jpg | Bin 0 -> 34430 bytes tests/data/jobwatch/empty_dark_room.jpg | Bin 0 -> 29699 bytes tests/data/jobwatch/part.jpg | Bin 0 -> 34530 bytes tests/data/jobwatch/part_dark_room.jpg | Bin 0 -> 29802 bytes tests/data/jobwatch/partial.jpg | Bin 0 -> 34529 bytes tests/data/jobwatch/spaghetti.jpg | Bin 0 -> 37905 bytes tests/test_printer_jobwatch.py | 537 +++++++++++++++++ 15 files changed, 3141 insertions(+), 121 deletions(-) create mode 100644 lib/bed_part.py create mode 100644 lib/printer_ops/jobwatch.py create mode 100644 lib/printer_ops/jobwatch_actions.py create mode 100644 lib/printer_ops/jobwatch_launch.py create mode 100644 lib/printer_ops/jobwatch_run.py create mode 100644 lib/printer_ops/jobwatch_sources.py create mode 100644 tests/data/jobwatch/empty.jpg create mode 100644 tests/data/jobwatch/empty_dark_room.jpg create mode 100644 tests/data/jobwatch/part.jpg create mode 100644 tests/data/jobwatch/part_dark_room.jpg create mode 100644 tests/data/jobwatch/partial.jpg create mode 100644 tests/data/jobwatch/spaghetti.jpg create mode 100644 tests/test_printer_jobwatch.py diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..d5f256c3 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -716,7 +716,21 @@ until someone takes them off. The camera can see that, so nobody has to tell an - **How.** Frames are decoded at 480 px; the current one is mapped onto the reference colours by a robust gain/offset fit (auto-exposure, room light) and a median detrend removes smooth light gradients; a changed region of ≥ 200 px (or ≥ 8 % of the bed view) is `occupied`, under 100 px - and ≤ 3 % is `clear`, in between `unknown`. ROI: `watch.bed.roi` (a polygon of the bed, 0..1 of + and ≤ 3 % is `clear`, in between `unknown`. Two things keep light and registration drift from + reading as objects ([#723](https://github.com/alex-mextner/3d-cli/issues/723): the idle U1 at + Z 200 reported «objects on the bed 14.3 %» on an empty plate — the window light behind the + printer had changed, a cable had moved and the picture sat a pixel or two off the October + reference): a pixel that matches the reference anywhere within `shift_px` (3) counts as + unchanged, and changed regions are opened with an `open_px` (5) square, so an edge outline + or a thin line goes while a patch of new colour stays. Where the camera sees under `low_view` + (0.5) of the bed (U1 at Z 200: 15 %) most of the ROI is the machine and the room behind the + bed, so there only a region of `occupied_blob_low_px` (1200 px) or `occupied_min` of the ROI + says `occupied`; a smaller change is `unknown` with «the room behind the bed? … + `3d printer bed check NAME` looks from a better Z» and the age of the reference. A real part + is not hidden by this (the U1 frames of parts on the bed are 2700–3900 px regions, a 16 px + object on the K1 SE stays `occupied`); a thing smaller than that from a 15 % view is + `unknown`, never `clear`. Every evidence image has the untouched camera frame beside it + (`…-label.raw.jpg`), so a doubtful verdict can be re-judged. ROI: `watch.bed.roi` (a polygon of the bed, 0..1 of the frame), else the camera calibration (bed plus 80 mm above it at the current Z), else the whole frame. On the U1 at Z 200 only parts taller than a few cm show, and only the plate's front edge is in view (about 2 % of the frame), so the plate verdict rests on that strip and the @@ -726,7 +740,7 @@ until someone takes them off. The camera can see that, so nobody has to tell an `watch.bed.thresholds: {clear_max: 0.05, level: 30, ...}` overrides any of them for one printer (`level`, `detrend_px`, `edge_px`, `clear_blob_px`, `clear_max`, `occupied_blob_px`, `occupied_min`, `plate_missing_min`, `plate_tie`, `surface_min`, `change_min`, `settle_frames`, - `match_px`, `view_min`). The camera is the printer's `watch.vision.camera`; enabling the bed check + `match_px`, `view_min`, `shift_px`, `open_px`, `low_view`, `occupied_blob_low_px`). The camera is the printer's `watch.vision.camera`; enabling the bed check on a printer without one is refused when the config is read. - **Monitor.** `3d printer watch` runs `bed monitor NAME` as a child for every Moonraker printer with `watch.bed.enabled: true`. Nothing is grabbed while a job runs; otherwise one snapshot per @@ -772,6 +786,119 @@ k1se: A Home Assistant bed sensor is a follow-up: [#305](https://github.com/alex-mextner/3d-cli/issues/305). +### `3d printer bed check` + +`3d printer bed check NAME [--expect empty|part] [--no-move] [--part-height MM] [--json]` is **the** tool +to look at the bed: a fresh frame judged from where the camera sees the plate. An idle printer's bed is +moved in Z only (the guarded move of auto view, even when `watch.bed.auto_view` is off), judged and put +back; `--no-move` judges at the current Z. + +- **Result:** `empty` | `objects` | `out-of-view` | `unknown` | `busy` (printing: the bed belongs to + the job, nothing is moved; use `3d printer watch NAME --job`). `--expect part` reads the same + pixels as `part-present` | `part-gone` — after a print, is the part still on the plate? + `fraction` is the share of the bed view that differs from the empty-bed reference; `frame` is the + annotated picture (ROI cyan, changed pixels red), `raw_frame` the camera frame it came from. +- **Exit codes:** 1 when `--expect` is given and the result differs (and for `unknown`, + `out-of-view`, `busy`), else 0. +- **Reference at the best Z:** `3d printer bed reference NAME --capture --best-view` moves the bed + to where the camera sees the plate, takes the empty-bed reference there and puts the bed back. + +## Job watch + +Why: on 2026-11-04 a part came off the plate during a print and nobody noticed, the printer went on +extruding into thin air. `3d printer watch NAME --job --yes` guards **one** running print and stops +it when it is lost. It is not the always-on [`3d printer watch`](#watching-printers) (read-only, +alerts an agent): this one *acts*, so it needs `--yes` — you authorise a pause/cancel of this job. + +``` +3d printer watch NAME --job --yes [--on-fail auto|pause|cancel] [--gcode FILE.gcode] [--out DIR] + [--interval S] [--checkpoints L1@25%,L1@50%,L1@100%,25%,50%,100%] [--dry-run | --tg-dry-run] + [--fixture SCENARIO.json] [--duration S] [--wait-start S] +``` + +Start it right after `3d printer start NAME FILE --yes` (or give `start` [`--watch`](#start---watch)); +it follows that job until it ends. + +**What is checked**, every `--interval` (60 s; 15 s while layer 1 prints) and at the checkpoints (a +quarter, half and all of the first layer — the first-layer marks need `--gcode` — and 25/50/100 %): + +- *Camera.* The plate against an empty-plate baseline taken at the start of the job. The part's + footprint is learnt from layer 3 on, then `part_gone` (little of it left), `part_moved`, + `spaghetti` (much new material beyond it) and the `arm` state. A finding needs **2 readings of the + last 4**: a toolhead passing over the part is not a finding. With the plate out of view, no + baseline, or no camera calibration to warp it the camera says `blind` — **never "gone"**. +- *Signals.* The same Moonraker events as the always-on watch (filament runout, heater loss, bed off, + exception, G-code error, homing lost), and the printer's own defect detection (`noodle` verdicts + with p >= 0.8, read from klippy.log). + +**Actions on a critical finding**, in this order, each step runs even if an earlier one failed: + +1. save the frames and `finding.json`; +2. **stop** the job (after re-reading the printer and binding it to the watched file — never another + job, never an idle printer; once per job, verified in the printer state); +3. `tg call me --say ""` rings the owner; +4. the klippy.log lines of the job via `3d printer evidence` (GET only) into the same folder; +5. `tg --photo FRAME ""` with the frame that showed it. + +A warning (part moved, nothing seen, a heater drift) is only a tg message with the frame; nothing is +touched and nobody is called. + +**`--on-fail`.** `auto` (default): CANCEL when the part is gone, or on a camera finding in the first +layers (layer <= 3); PAUSE for everything else (runout, heater, later spaghetti: resumable). +`pause` / `cancel`: always that. Defaults can also be set in `.watch.job` (`on_fail`, +`interval_s`, `min_view`, `confirm`, `window`, `arm_layer`, `out_dir`, …); flags win. + +**It never starts a job and never moves an axis.** The only printer writes are PAUSE and CANCEL; no +G-code, no resume, no start. In particular **the bed is not moved during a print**: the U1 camera sees +98 % of the plate up to Z 30 and 77 % at Z 100, so the watcher looks at the print's own Z. Above about +Z 130 the plate is mostly out of view and the camera is `blind` (`min_view` 0.5): the signals keep +running, the part check does not. + +**Exit codes.** 0 the job ended, nothing lost; 3 a lost print was found and the stop attempted; +1 it could not run. + +**Testing without risk.** `--dry-run`: nothing is sent to the printer or the phone, the stop and both +tg commands are printed. `--tg-dry-run`: the job is really stopped, the tg commands are only printed. +`--fixture SCENARIO.json`: no network at all; the printer and the camera come from a scenario file, +with saved frames (implies `--dry-run` for the phone; the fixture printer records the stop it is +given): + +```json +{"printer": "k1se", "file": "part.gcode", "steps": [ + {"t": 0, "state": "printing", "layer": 1, "progress": 0.01, "z": 0.2, "frame": "empty.jpg"}, + {"t": 600, "state": "printing", "layer": 40, "progress": 0.4, "z": 8.0, "frame": "gone.jpg", + "events": [{"kind": "filament", "severity": "critical", "text": "..."}], "noodles": [["noodle", 0.9]]}]} +``` + +Frame paths are relative to the scenario file; a step holds until the next step's `t` (seconds on +the virtual clock). + +### `start --watch` + +`3d printer start NAME FILE --yes --watch` launches `3d printer watch NAME --job --yes --gcode FILE` +detached once the printer acknowledged the start and its state was printed: it prints the pid, the log +(`~/.local/state/3d-cli/jobwatch/NAME.log`) and how to stop it (`kill PID`). It is **opt-in** because +the watcher has the authority to cancel the job. Without `--watch`, `start` prints one line, +`NOT WATCHED: a lost print runs on unseen. Guard it (it may pause/cancel the job): 3d printer watch +NAME --job --yes --gcode FILE`, with the estimated duration when the G-code header carries +`; estimated printing time (normal mode) = 1h 32m 10s`, and the advice that jobs of 30 min or more +should be watched. + +### Evidence folder + +On a lost print `DIR/-NAME-jobwatch/` (default `./print/runs`, else +`~/.local/state/3d-cli/jobwatch/NAME/runs`) holds the frames, `finding.json`, the klippy.log lines of +the job and `outcome.json` (what was sent, what was verified, which step failed). The watcher's +working files (baseline, footprint) live in `~/.local/state/3d-cli/jobwatch/NAME/`. + +### Open questions + +- Camera latency: how stale a frame can be when a part comes off is not measured. +- Per-model Z positions: the 98 % / 77 % / blind-above-Z-130 numbers are the U1's; other models need + their own calibration. +- Printers without a camera calibration go blind after a few mm of Z (no way to warp the baseline). +- Black-on-black parts (dark filament on a dark plate) give the footprint too little contrast. + ## Safety ([#95](https://github.com/alex-mextner/3d-cli/issues/95)) diff --git a/lib/bed_part.py b/lib/bed_part.py new file mode 100644 index 00000000..e933d05f --- /dev/null +++ b/lib/bed_part.py @@ -0,0 +1,337 @@ +"""bed_part.py — is the printed part still on the plate? The pixel half of `3d printer watch NAME --job`. + +Run through cli.pyrun with numpy + OpenCV (BED_DEPS) by printer_ops/jobwatch.py, once per check: + + bed_part.py check NAME --frame F.jpg --baseline B.jpg --baseline-z Z0 --z Z \ + [--footprint P.png] [--save-footprint P.png] [--annotated OUT.jpg] [--min-view 0.5] [--z-tol 2] + +It measures; it does not decide. The decision (armed, present, gone, partial, growing) is the pure state +machine of jobwatch.py, so the pixel tool stays stateless: everything it needs lives in the files it is given. + +WHAT IS MEASURED: the changed share of the plate region of `frame` against `baseline`, an image of the empty + plate taken at the start of the job (`baseline-z` = the toolhead Z it was taken at). Same light/exposure + robustness as the bed check (bed_state.difference: per-channel gain/offset fit, detrend, shift tolerance, + opening). With a camera calibration the baseline is warped to the current Z (a homography of the bed plane: + the bed moves in Z, the picture of the plate moves with it); without one (a `watch.bed.roi` printer) the + baseline stands only within `--z-tol` mm. With a `--footprint` (the changed mask the part had when the watcher + last saw it healthy) it also says how much of that footprint is still changed (`overlap`) and how much changed + OUTSIDE it (`outside`, relative to the footprint: spaghetti, a part that slid). + +BLIND, NOT WRONG: when the plate is not in the frame at this Z (`view` under `--min-view`), when the baseline + cannot be brought to this Z, or the frame is another size, the answer is `blind` with the reason and no + numbers: a camera that cannot see must never read as "the part is gone". + +INVARIANTS: read-only (files only; no printer, no network); numpy/OpenCV imported lazily; frames decode at + bed_state.PROC_W (480 px). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +LIB = Path(__file__).resolve().parent +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +import bed_state as B # noqa: E402 + +if TYPE_CHECKING: + import numpy as np + + from printer_ops.devices import Device + from vision_geometry import CameraModel + +MIN_VIEW = 0.5 # share of the plate that must be in the frame for the part to be judged +Z_TOL_MM = 2.0 # an uncalibrated baseline stands this far from its Z +ROI_GROW_PX = 9 # the plate region is grown by this many px of the 480-px frame: a part stands above it +FOOTPRINT_GROW_PX = 15 # changes this close to the footprint are the part itself growing, not new things + + +def blind(reason: str, **extra: Any) -> dict[str, Any]: + return {"status": "blind", "reason": reason, **extra} + + +def warp_baseline( + base: np.ndarray, cam: CameraModel, z0: float, z1: float +) -> np.ndarray: + """`base` (the empty plate seen with the bed at Z `z0`) as the camera would see it at Z `z1`: the + homography of the bed plane between the two views, fitted on a grid of plate points. The scene off the + plate (the machine) is not on that plane and does not follow; judge only the plate region.""" + import cv2 + import numpy as np + + scale = base.shape[1] / cam.width + pts = B._bed_points(cam, (0.0,), 9) + a, b = cam.project(pts, z0) * scale, cam.project(pts, z1) * scale + ok = np.isfinite(a).all(axis=1) & np.isfinite(b).all(axis=1) + if int(ok.sum()) < 8: + raise ValueError("the plate is behind the camera at one of the two Zs") + h, _ = cv2.findHomography(a[ok].astype(np.float32), b[ok].astype(np.float32), 0) + if h is None: + raise ValueError("no homography between the two views of the plate") + return cv2.warpPerspective( + base, h, (base.shape[1], base.shape[0]), borderMode=cv2.BORDER_REPLICATE + ) + + +def baseline_at( + base: np.ndarray, base_z: float, z: float, cam: CameraModel | None, z_tol: float +) -> tuple[np.ndarray | None, str | None]: + """(the baseline as seen at Z `z`, None) or (None, why it cannot be).""" + if abs(z - base_z) < 0.05: + return base, None + if cam is None: + if abs(z - base_z) <= z_tol: + return base, None + return None, ( + f"the bed moved {abs(z - base_z):.1f} mm since the baseline (Z {base_z:.1f} -> {z:.1f}) and " + "this printer has no camera calibration to warp it (a watch.bed.roi polygon only)" + ) + try: + return warp_baseline(base, cam, base_z, z), None + except ValueError as exc: + return None, str(exc) + + +def plate_region( + device: Device, cfg: Any, z: float, shape: tuple[int, int] +) -> np.ndarray: + """The plate surface at Z `z`, grown a little (a part stands on it), as the region to compare.""" + import cv2 + import numpy as np + + _, surface, _ = B.build_masks(device, cfg, z, shape) + grown = ( + cv2.dilate( + surface.astype(np.uint8), np.ones((2 * ROI_GROW_PX + 1,) * 2, np.uint8) + ) + > 0 + ) + return grown + + +def footprint_numbers(changed: np.ndarray, footprint: np.ndarray) -> dict[str, float]: + """How much of `footprint` is still changed, and how much changed beyond it (both / footprint area).""" + import cv2 + import numpy as np + + area = max(1, int(footprint.sum())) + near = ( + cv2.dilate( + footprint.astype(np.uint8), + np.ones((2 * FOOTPRINT_GROW_PX + 1,) * 2, np.uint8), + ) + > 0 + ) + return { + "overlap": round(float((changed & footprint).sum()) / area, 4), + "outside": round(float((changed & ~near).sum()) / area, 4), + "footprint_px": int(footprint.sum()), + } + + +def view_share(cam: CameraModel | None, z: float) -> float | None: + return None if cam is None else B.bed_in_view(cam, round(z, 1)) + + +def measure( + jpeg: bytes, + base_jpeg: bytes, + *, + device: Device, + z: float, + base_z: float, + footprint: np.ndarray | None = None, + min_view: float = MIN_VIEW, + z_tol: float = Z_TOL_MM, +) -> tuple[dict[str, Any], np.ndarray | None, np.ndarray | None]: + """(numbers, changed mask, region) for one frame against the job's baseline. Numbers `status` is `ok` + or `blind` (with `reason`).""" + cfg = B.bed_config(device) + cam = B.camera_model(device, cfg) + seen = view_share(cam, z) + if seen is not None and seen < min_view: + return ( + blind( + f"the camera sees {seen:.0%} of the plate at Z {z:.1f}, under {min_view:.0%}", + view=seen, + ), + None, + None, + ) + img, base = B.decode_small(jpeg), B.decode_small(base_jpeg) + if img.shape != base.shape: + return ( + blind( + f"the frame is {img.shape[1]}x{img.shape[0]}, the baseline {base.shape[1]}x{base.shape[0]}" + ), + None, + None, + ) + shifted, why = baseline_at(base, base_z, z, cam, z_tol) + if shifted is None: + return blind(why or "no baseline", view=seen), None, None + region = plate_region(device, cfg, z, img.shape[:2]) + th = B.thresholds_for(cfg) + d = B.difference( + shifted, img, region, th.level, th.detrend_px, th.shift_px, th.open_px + ) + out: dict[str, Any] = { + "status": "ok", + "view": seen, + "changed": round(d.frac, 4), + "blob_px": round(d.blob * int(region.sum())), + "changed_px": int(d.mask.sum()), + "region_px": int(region.sum()), + "z": z, + "baseline_z": base_z, + } + if footprint is not None and footprint.shape == d.mask.shape: + out.update(footprint_numbers(d.mask, footprint)) + return out, d.mask, region + + +def annotate( + jpeg: bytes, + changed: np.ndarray | None, + region: np.ndarray | None, + label: str, + path: Path, +) -> None: + """The camera frame with the compared region (cyan) and the changed pixels (red), as the bed check draws it.""" + import cv2 + import numpy as np + + img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR) + if img is None: + path.write_bytes(jpeg) + return + h, w = img.shape[:2] + if changed is not None: + red = ( + cv2.resize( + changed.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST + ) + > 0 + ) + img[red] = (0.45 * img[red] + np.array([0, 0, 140])).astype(np.uint8) + if region is not None: + roi = cv2.resize( + region.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST + ) + contours, _ = cv2.findContours(roi, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + cv2.drawContours(img, contours, -1, (255, 255, 0), max(1, w // 640)) + cv2.putText( + img, + label, + (12, 36), + cv2.FONT_HERSHEY_SIMPLEX, + max(0.6, w / 1600), + (0, 255, 255), + 2, + ) + ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + path.write_bytes(buf.tobytes() if ok else jpeg) + + +def read_mask(path: str | None) -> np.ndarray | None: + import cv2 + + if not path or not Path(path).is_file(): + return None + m = cv2.imread(path, cv2.IMREAD_GRAYSCALE) + return None if m is None else m > 0 + + +def write_mask(path: str, mask: np.ndarray) -> None: + import cv2 + + Path(path).parent.mkdir(parents=True, exist_ok=True) + cv2.imwrite(path, mask.astype("uint8") * 255) + + +USAGE = """bed_part.py check NAME --frame F.jpg --baseline B.jpg --baseline-z Z0 --z Z [--footprint P.png] + [--save-footprint P.png] [--annotated OUT.jpg] [--label TEXT] [--min-view 0.5] [--z-tol 2] [--json] + The pixel half of `3d printer watch NAME --job` (see lib/printer_ops/jobwatch.py): the changed share of + the plate region of the frame against the job's empty-plate baseline, and with a footprint how much of it + is still changed (overlap) and how much changed beyond it (outside). `status` is ok or blind (why).""" + + +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser(prog="bed_part.py", add_help=False) + ap.add_argument("sub", choices=("check",)) + ap.add_argument("name") + ap.add_argument("--frame", required=True) + ap.add_argument("--baseline", required=True) + ap.add_argument("--baseline-z", type=float, required=True) + ap.add_argument("--z", type=float, required=True) + ap.add_argument("--footprint") + ap.add_argument("--save-footprint") + ap.add_argument("--annotated") + ap.add_argument("--label", default="") + ap.add_argument("--min-view", type=float, default=MIN_VIEW) + ap.add_argument("--z-tol", type=float, default=Z_TOL_MM) + ap.add_argument("--json", action="store_true") + ap.add_argument("-h", "--help", action="store_true") + return ap + + +def run_check(args: argparse.Namespace) -> dict[str, Any]: + from printer_ops.devices import get_device + + device = get_device(args.name) + jpeg, base = Path(args.frame).read_bytes(), Path(args.baseline).read_bytes() + nums, changed, region = measure( + jpeg, + base, + device=device, + z=args.z, + base_z=args.baseline_z, + footprint=read_mask(args.footprint), + min_view=args.min_view, + z_tol=args.z_tol, + ) + if args.annotated: + annotate( + jpeg, + changed, + region, + args.label or f"{nums['status']} {nums.get('changed', '')}", + Path(args.annotated), + ) + nums["annotated"] = args.annotated + if args.save_footprint and changed is not None: + write_mask(args.save_footprint, changed) + nums["footprint_saved"] = args.save_footprint + return nums + + +def main(argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help", "help"): + print(USAGE) + return 0 if argv else 1 + args = build_parser().parse_args(argv) + if args.help: + print(USAGE) + return 0 + out = run_check(args) + print( + json.dumps(out, default=str) + if args.json + else json.dumps(out, indent=1, default=str) + ) + return 0 + + +if __name__ == "__main__": + from errors import ThreeDError + + try: + sys.exit(main(sys.argv[1:])) + except ThreeDError as exc: + print(exc.render(), file=sys.stderr) + sys.exit(exc.exit_code) diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..e6eeffe3 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -27,10 +27,12 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus from printer_ops.devices import Device + from printer_ops.preflight import Report as PreflightReport from printops.fleet import FleetPrinter USAGE = """3d printer [-n NAME] [args] @@ -47,13 +49,16 @@ history, settings: `3d printers show NAME`) upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] - [--no-bed-move] + [--no-bed-move] [--watch] start a file that was uploaded before; runs `3d print-preflight` first, plus the camera bed check (objects on the bed / plate missing FAIL) when the printer has watch.bed.enabled (an idle printer with watch.bed.auto_view has its bed moved in Z to where the - camera sees the whole plate, and back: --no-bed-move) + camera sees the whole plate, and back: --no-bed-move). + --watch: right after the start, begin `watch NAME --job --yes` + in the background (it may CANCEL the job, so it is never on + by default; without it `start` prints the command to run) cancel NAME --yes cancel the running/paused job evidence NAME [--job ID | --last] [--out DIR] [--gcode F] [--note T] after EVERY print (done/cancelled/failed): save the Moonraker @@ -65,6 +70,13 @@ watch NAME...|--all [options] live read-only subscription: anomaly events to stdout JSONL / --exec / --steer SESSION / subscribers (see `3d printer watch --help`) + watch NAME --job --yes [options] guard ONE running print: every 60 s and at the first-layer / + 25/50/100 % checkpoints look at the plate (is the part still + there?) and the printer's signals; on a lost print PAUSE or + CANCEL (--on-fail), save the frames + klippy lines to + print/runs/, `tg call me --say`, tg with the frame. Start it + right after `printer start`. Never moves an axis; --dry-run, + --fixture (see `3d printer watch --job --help`) supervise status|install|uninstall|quiet [NAME...|--all] the ALWAYS-ON supervision: one watch + one vision systemd user unit per owned printer from the fleet registry, running without a login, waiting @@ -77,11 +89,14 @@ `vision fetch-model [--tier a|b|all]`, `vision fit-head ..`, `vision doctor` (read-only: does any service run --dry-run?) (see docs/commands/printer-vision.md) - bed status|reference|monitor NAME bed state from the camera: clear/occupied, build plate - present/missing; `reference NAME --capture` takes the - empty-bed reference; `status` moves an idle printer's bed - in Z only to where the whole plate is in view and back - (watch.bed.auto_view; --no-move, --best-view) + bed check|status|reference|monitor NAME + bed state from the camera: clear/occupied, build plate + present/missing; `check NAME` is THE look at the bed: it moves + an idle bed in Z only to where the camera sees the plate, judges + a fresh frame (empty / objects / part-present, the changed + share, annotated + raw frame) and puts the bed back (--no-move, + --expect empty|part); `reference NAME --capture [--best-view]` + takes the empty-bed reference (see `3d printer bed --help`) subscribe --session ID [..] printer alerts go to that omp session (it looks at the frame, tells the user only if real); `unsubscribe`, @@ -117,6 +132,8 @@ --bridge-max MM preflight overhangs gate: longest bridge; overrides the printer's bridge_max_mm in printer-devices.yaml (default 15) --no-bed-move start: the camera bed check judges at the current Z (the bed is not moved) + --watch start: launch `watch NAME --job --yes --gcode FILE` in the background once the printer + accepted the start (log in ~/.local/state/3d-cli/jobwatch/NAME.log) Safety: start refuses while the printer is printing, paused, busy or unreadable, and when the content-addressed remote file is missing, differs in size, or (Moonraker, read back) @@ -139,6 +156,9 @@ 3d printer cancel k1se --yes 3d printer evidence u1 --out projects/chekhov/print/runs --gcode print/chekhov.gcode 3d printer watch u1 k1se --steer 01a12cd6 + 3d printer start u1 print/part.gcode --yes --watch # and the job is guarded from the first layer + 3d printer watch u1 --job --yes --gcode print/part.gcode --out print/runs + 3d printer bed check u1 --json 3d printer vision u1 --once --save /tmp/u1.jpg 3d printer vision u1 3d printer subscribe --session 01a12cd6 --printer u1,k1se @@ -151,7 +171,7 @@ 3d printer -n k1se issues add --title "X belt squeaks" --type inspect""" _VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max"} -_FLAGS = {"--json", "--yes", "--force", "--no-bed-move"} +_FLAGS = {"--json", "--yes", "--force", "--no-bed-move", "--watch"} def _parse(argv: list[str], allowed: set[str], n_pos: int, sub: str) -> tuple[list[str], dict[str, str]]: @@ -196,32 +216,70 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, "adapter": device.adapter if device else None, "address": f"{device.host}:{device.port}" if device else None, "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -230,26 +288,14 @@ def _cmd_list(argv: list[str]) -> int: rows = [_list_row(n, printers.get(n), devices.get(n)) for n in sorted(set(printers) | set(devices))] if "--json" in opts: print(json.dumps(rows, indent=1, ensure_ascii=False)) - return 0 - if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) - return 0 - print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") - for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") - if fleet is not None: - print(f"\nfleet: {fleet.path}") + elif not rows: + _print_no_printers() + else: + print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") + for r in rows: + _print_list_row(r) + if fleet is not None: + print(f"\nfleet: {fleet.path}") return 0 @@ -331,8 +377,8 @@ def _cmd_start(argv: list[str]) -> int: import tempfile from pathlib import Path - (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", "--no-bed-move"}, 2, - "start") + (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", + "--no-bed-move", "--watch"}, 2, "start") if "--yes" not in opts: raise UsageError( "start is a physical action and needs --yes", @@ -351,20 +397,10 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _refuse_unless_stored(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -373,10 +409,14 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) - # Identity of what will be printed with what is checked below: the read-back hash where - # the firmware can return the file, else the hash in the name plus the size. + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local_hash: str) -> None: + """Identity of what will be printed with what is checked: the read-back hash where the + firmware can return the file, else the hash in the name plus the size.""" + from printer_ops.base import PrinterRefused + remote_hash = adapter.remote_sha256(remote) - local_hash = file_sha256(local) if remote_hash is not None and remote_hash != local_hash: raise PrinterRefused( f"{remote} on {name} has different content than {file} " @@ -386,11 +426,16 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + +def _preflight_report(adapter: Adapter, name: str, original: Path, local: Path, source: Path | None, + st: PrinterStatus, opts: dict[str, str]) -> PreflightReport: + from printer_ops import bridge_max_for + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options + limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed - # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) printops = context_from_options(name, axis_limits=None, api=None, offline=False, snapshot=None, @@ -401,21 +446,35 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P bed=check_bed(adapter.device, move="--no-bed-move" not in opts) if adapter.device is not None else None) report.file = f"{original} (snapshot)" + return report + + +def _gate_on_preflight(report: PreflightReport, name: str, original: Path, local_hash: str, + opts: dict[str, str]) -> None: + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _send_start(adapter: Adapter, name: str, remote: str) -> PrinterStatus: + """Start `remote`; the state read back after the acknowledgement. Every failure on the way + is an UNKNOWN outcome that must not be resent blindly.""" + from printer_ops.base import UNKNOWN, PrinterCommError - _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] try: adapter.start(remote) @@ -439,7 +498,36 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P f"[{after.raw_state}]: outcome UNKNOWN", command="printer", remediation=dont_resend, ) + return after + + +def _after_start(name: str, original: Path, opts: dict[str, str]) -> None: + from printer_ops.jobwatch_launch import after_start + + after_start(name, original, watch="--watch" in opts) + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import file_sha256, safe_remote_name + from printer_ops.render import render_status + + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + _refuse_unless_stored(adapter, name, file, remote, size, opts) + local_hash = file_sha256(local) + _verify_remote_identity(adapter, name, file, remote, local_hash) + report = _preflight_report(adapter, name, original, local, source, st, opts) + _gate_on_preflight(report, name, original, local_hash, opts) + + _refuse_if_busy(name, adapter.status(), "state changed during preflight") + after = _send_start(adapter, name, remote) print(render_status(name, adapter, after)) + _after_start(name, original, opts) return 0 @@ -487,6 +575,81 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _cmd_evidence(rest: list[str]) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _cmd_watch(rest: list[str]) -> int: + if "--job" in rest: + from printer_ops.jobwatch_run import run as jobwatch + + return jobwatch(rest) + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _cmd_supervise(rest: list[str]) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _cmd_guard(rest: list[str]) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _cmd_agent(rest: list[str]) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _cmd_doctor(rest: list[str]) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _subscription_cmd(sub: str) -> Callable[[list[str]], int]: + def handler(rest: list[str]) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + + return handler + + +_SUBCOMMANDS: dict[str, Callable[[list[str]], int]] = { + "list": _cmd_list, + "status": _cmd_status, + "upload": _cmd_upload, + "start": _cmd_start, + "cancel": _cmd_cancel, + "evidence": _cmd_evidence, + "watch": _cmd_watch, + "supervise": _cmd_supervise, + "vision": _cmd_vision, + "guard": _cmd_guard, + "bed": _cmd_bed, + "subscribe": _subscription_cmd("subscribe"), + "unsubscribe": _subscription_cmd("unsubscribe"), + "subscriptions": _subscription_cmd("subscriptions"), + "agent": _cmd_agent, + "doctor": _cmd_doctor, +} + + +def _cmd_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -502,54 +665,10 @@ def run(argv: list[str]) -> int: if name is not None and sub not in ("todo", "issues"): raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) + if sub in _SUBCOMMANDS: + return _SUBCOMMANDS[sub](rest) if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) + return _cmd_todo(rest, name) raise UsageError(f"unknown subcommand '{sub}'", command="printer", remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) diff --git a/lib/printer_ops/jobwatch.py b/lib/printer_ops/jobwatch.py new file mode 100644 index 00000000..f573328f --- /dev/null +++ b/lib/printer_ops/jobwatch.py @@ -0,0 +1,421 @@ +"""jobwatch.py — the decision half of `3d printer watch NAME --job`: is the running print still alive? + +WHY (2026-11-04): a part came off the plate without a trace and the print went on; every later look at the + bed was a look at nothing. The always-on watchers (`printer watch`, `printer vision`) are read-only by + design and alert an agent; this guard is the one an agent STARTS for ONE job (right after `3d printer start`) + and that acts on it: pause or cancel, save the evidence, ring the owner. + +WHAT (pure, stdlib, no clock, no I/O: feed `Observation`s, get `Finding`s and a `decide`d action): + `JobConfig` everything tunable, from CLI flags and `.watch.job` in printer-devices.yaml; + `Checkpoint` "L1@25%" (a quarter through the first layer), "50%" (half of the job): extra checks on top of + the periodic one; + `JobJudge` the state machine over the readings. CAMERA: the part is ARMED when its footprint (what + differs from the empty plate) showed on two readings from `arm_layer` on; from then on the + part is `gone` when little of the footprint is still changed, `partial` when part of it is, + `growing` (spaghetti) when much changed beyond it. A finding needs `confirm` such readings + within the last `window` (a toolhead over the part, a hand, one bad frame is not a finding). + SIGNALS: Moonraker events from the same MoonrakerDetector the always-on watch uses + (filament runout, heater loss, bed off, exception, G-code error, homing lost) and the + printer's own defect detection (`noodle` verdicts in klippy.log). + `decide` pause | cancel | alert from the findings, the layer and `--on-fail`. + +BLIND IS NOT GONE: with the plate out of view at this Z, no baseline, or no calibration to warp it, the camera + says `blind` and contributes nothing; the signals keep running. A job that was never seen on the camera + (`no_part_seen`) is a WARNING, never a cancel: black PLA on a black plate is invisible to a camera, a part that + came off during the first layers is not, and the two cannot be told apart from one still frame. + +INVARIANTS: never starts a print, never moves an axis (the only writes are PAUSE and CANCEL of the bound job, in + jobwatch_actions.py). The camera sees the plate at the print's own Z (U1: 98 % up to Z 30, 77 % at Z 100), so + nothing is ever moved to look: moving a bed under a running print would ruin it. +""" + +from __future__ import annotations + +import re +from collections import deque +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from errors import InvalidArgument +from printer_ops.watch import CRITICAL, WARNING, Event + +CMD = "printer watch --job" +ON_FAIL = ("auto", "pause", "cancel") + +# camera labels of one reading +BLIND, EMPTY, SEEN, PRESENT, GONE, PARTIAL, GROWING = ( + "blind", + "empty", + "seen", + "present", + "gone", + "partial", + "growing", +) + +# finding kinds +PART_GONE, PART_MOVED, SPAGHETTI, NO_PART = ( + "part_gone", + "part_moved", + "spaghetti", + "no_part_seen", +) +NOODLE = "printer_noodle" +HOPELESS = frozenset( + {PART_GONE} +) # printing in the air: nothing to resume onto, cancel at any layer +CAMERA_KINDS = frozenset({PART_GONE, PART_MOVED, SPAGHETTI, NO_PART, NOODLE}) +NOODLE_CRITICAL_PROB = ( + 0.80 # the confidence the firmware itself thresholds (vision.NpuVerdict) +) + + +@dataclass(frozen=True) +class Checkpoint: + """An extra check: `layer1` = a share of the first layer (needs the G-code), `progress` = of the job.""" + + kind: str + fraction: float + + def __str__(self) -> str: + pct = f"{self.fraction * 100:g}%" + return f"L1@{pct}" if self.kind == "layer1" else pct + + +_CHECKPOINT = re.compile(r"(?:(?PL1@))?(?P\d{1,3}(?:\.\d+)?)%") + + +def parse_checkpoints(text: str) -> tuple[Checkpoint, ...]: + """`L1@25%,L1@50%,L1@100%,25%,50%,100%` -> Checkpoints; ValueError-free: InvalidArgument names the bad item.""" + out: list[Checkpoint] = [] + for item in (x.strip() for x in text.split(",") if x.strip()): + m = _CHECKPOINT.fullmatch(item) + if m is None or not 0 < float(m["pct"]) <= 100: + raise InvalidArgument( + "--checkpoints", + item, + [ + "L1@25% (share of the first layer), 50% (share of the job): 0 < N <= 100" + ], + command=CMD, + ) + out.append( + Checkpoint("layer1" if m["l1"] else "progress", float(m["pct"]) / 100) + ) + return tuple(out) + + +DEFAULT_CHECKPOINTS = "L1@25%,L1@50%,L1@100%,25%,50%,100%" + + +@dataclass(frozen=True) +class JobConfig: + interval_s: float = 60.0 # camera + signal check period + first_layer_interval_s: float = ( + 15.0 # ... while layer 1 prints (adhesion failures happen there) + ) + poll_s: float = ( + 5.0 # cheap Moonraker poll between checks (checkpoints fire from it) + ) + recheck_s: float = 10.0 # a suspicious reading is checked again this soon + confirm: int = 2 # suspicious readings within `window` that make a finding + window: int = 4 + arm_layer: int = 3 # the part's footprint is learnt from this layer on + arm_readings: int = 2 # ... after this many readings that show it + first_layer_max: int = ( + 3 # layers up to this are "first layers": a failure there cancels under `auto` + ) + on_fail: str = "auto" + checkpoints: tuple[Checkpoint, ...] = () + min_view: float = 0.5 # share of the plate in the frame to judge the part + z_tol_mm: float = 2.0 # an uncalibrated baseline stands this far + present_min: float = ( + 0.004 # changed share of the plate region that says "something stands there" + ) + present_blob_px: int = 150 + gone_overlap: float = ( + 0.25 # share of the footprint still changed: under this the part is gone + ) + partial_overlap: float = 0.6 # ... under this only part of it is left + heal_overlap: float = ( + 0.8 # healthy enough to refresh the footprint (the part grows) + ) + grow_outside: float = 1.0 # changed beyond the footprint, relative to it: spaghetti + no_part_s: float = 180.0 # armed never, this long past arm_layer: no_part_seen + wait_start_s: float = 120.0 # how long to wait for the job to start printing + ended_polls: int = 2 # polls of a finished printer before the watch ends + verify_s: float = 20.0 # how long a pause/cancel has to show in the printer state + out_dir: Path = field(default_factory=lambda: Path("print") / "runs") + + +@dataclass +class PartReading: + """One answer of bed_part.py (`status` ok | blind) and the files it came from.""" + + status: str + numbers: dict[str, Any] + frame: str | None = None + annotated: str | None = None + + @property + def changed(self) -> float: + return float(self.numbers.get("changed") or 0.0) + + @property + def blob_px(self) -> float: + return float(self.numbers.get("blob_px") or 0.0) + + +@dataclass +class Observation: + now: float + state: str + filename: str | None = None + progress: float | None = None + layer: int | None = None + total_layer: int | None = None + z: float | None = None + file_position: int | None = None + events: list[Event] = field(default_factory=list) + noodles: list[tuple[str, float]] = field( + default_factory=list + ) # (label, probability) of defect detection + part: PartReading | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Finding: + kind: str + critical: bool + text: str + data: dict[str, Any] = field(default_factory=dict) + + @property + def camera(self) -> bool: + return self.kind in CAMERA_KINDS + + +# ---- one camera reading ----------------------------------------------------------------------- + + +def label_reading(part: PartReading | None, armed: bool, cfg: JobConfig) -> str: + """`blind` | `empty` | `seen` (unarmed, something stands there) | `present` | `partial` | `gone` | `growing`.""" + if part is None or part.status != "ok": + return BLIND + if not armed: + shows = part.changed >= cfg.present_min and part.blob_px >= cfg.present_blob_px + return SEEN if shows else EMPTY + overlap, outside = part.numbers.get("overlap"), part.numbers.get("outside") + if overlap is None: + return BLIND + if overlap < cfg.gone_overlap and (outside or 0.0) < cfg.grow_outside: + return GONE + if (outside or 0.0) >= cfg.grow_outside: + return GROWING + return PARTIAL if overlap < cfg.partial_overlap else PRESENT + + +def healthy(part: PartReading | None, cfg: JobConfig) -> bool: + """The footprint may be refreshed from this reading (the part is whole and has not spread).""" + if part is None or part.status != "ok": + return False + overlap, outside = part.numbers.get("overlap"), part.numbers.get("outside") + return ( + overlap is not None and overlap >= cfg.heal_overlap and (outside or 0.0) < 0.3 + ) + + +# ---- the state machine ------------------------------------------------------------------------ + + +@dataclass +class Verdict: + """What one `JobJudge.step` says: findings, whether to learn/refresh the footprint, notes for the log.""" + + label: str = BLIND + findings: list[Finding] = field(default_factory=list) + arm: bool = False # keep the candidate footprint of this reading as THE footprint + refresh: bool = ( + False # the part is healthy: the candidate footprint replaces the old one + ) + suspicious: bool = False # check again soon (cfg.recheck_s) + notes: list[str] = field(default_factory=list) + + +class JobJudge: + def __init__(self, cfg: JobConfig) -> None: + self.cfg = cfg + self.armed = False + self.labels: deque[str] = deque(maxlen=cfg.window) + self._seen_run = 0 # consecutive `seen` readings while unarmed + self._reported: set[str] = set() + self._first_arm_layer_at: float | None = None + self.camera_blind_note: str | None = None + + # -- camera + + def _in_arm_window(self, o: Observation) -> bool: + if o.layer is not None: + return o.layer >= self.cfg.arm_layer + return (o.progress or 0.0) >= 0.03 + + def _count(self, label: str) -> int: + return sum(1 for x in self.labels if x == label) + + def _camera(self, o: Observation, v: Verdict) -> None: + label = label_reading(o.part, self.armed, self.cfg) + if not self.armed and label in (SEEN, EMPTY) and not self._in_arm_window(o): + label = BLIND # the first layers are learnt about, not judged + v.label = label + if label == BLIND: + self._note_blind(o, v) + return + self.labels.append(label) + if not self.armed: + self._unarmed(o, label, v) + else: + self._armed(o, label, v) + + def _note_blind(self, o: Observation, v: Verdict) -> None: + why = ( + o.part.numbers.get("reason") if o.part is not None else None + ) or "no camera reading" + if why != self.camera_blind_note: + self.camera_blind_note = why + v.notes.append(f"camera blind: {why}") + + def _unarmed(self, o: Observation, label: str, v: Verdict) -> None: + self._seen_run = self._seen_run + 1 if label == SEEN else 0 + if self._seen_run >= self.cfg.arm_readings: + self.armed, v.arm = True, True + v.notes.append("armed: the part's footprint is on the camera") + return + if label == SEEN: + v.arm = False + return + self._first_arm_layer_at = self._first_arm_layer_at or o.now + if ( + o.now - self._first_arm_layer_at >= self.cfg.no_part_s + and NO_PART not in self._reported + ): + self._reported.add(NO_PART) + v.findings.append( + Finding( + NO_PART, + False, + f"nothing stands on the plate at layer {o.layer} (progress {self._pct(o)}): the first layers did " + "not stick, or the part is invisible to the camera (dark on dark)", + {"layer": o.layer}, + ) + ) + + @staticmethod + def _pct(o: Observation) -> str: + return "?" if o.progress is None else f"{o.progress:.0%}" + + def _armed(self, o: Observation, label: str, v: Verdict) -> None: + v.suspicious = label in (GONE, PARTIAL, GROWING) + v.refresh = label == PRESENT and healthy(o.part, self.cfg) + if label == GONE and self._confirmed(GONE, 0): + v.findings.append( + self._finding(PART_GONE, True, o, "the part is no longer on the plate") + ) + elif label == GROWING and self._confirmed(GROWING, 0): + v.findings.append( + self._finding( + SPAGHETTI, + True, + o, + "a mass of new material around/off the part: spaghetti", + ) + ) + elif label == PARTIAL and self._count(PARTIAL) + self._count(GONE) >= max( + 3, self.cfg.confirm + 1 + ): + v.findings.append( + self._finding( + PART_MOVED, + False, + o, + "only part of the footprint is where it was: the part moved", + ) + ) + + def _confirmed(self, label: str, _slack: int) -> bool: + return self._count(label) >= self.cfg.confirm + + def _finding(self, kind: str, critical: bool, o: Observation, what: str) -> Finding: + n = o.part.numbers if o.part is not None else {} + text = ( + f"{what} (layer {o.layer}, progress {self._pct(o)}; footprint still changed " + f"{_pc(n.get('overlap'))}, new change beyond it {_pc(n.get('outside'))})" + ) + return Finding(kind, critical, text, {"layer": o.layer, "numbers": n}) + + # -- signals + + def _signals(self, o: Observation, v: Verdict) -> None: + for e in o.events: + if e.severity == CRITICAL and e.kind not in ( + "snapshot", + "connection", + "klippy", + ): + v.findings.append( + Finding( + f"signal_{e.kind}", True, e.text, {"event": e.kind, **e.data} + ) + ) + elif e.severity == WARNING and e.kind in ("bed_off", "temperature"): + v.findings.append( + Finding( + f"signal_{e.kind}", False, e.text, {"event": e.kind, **e.data} + ) + ) + for label, prob in o.noodles: + if label == "noodle" and prob >= NOODLE_CRITICAL_PROB: + v.findings.append( + Finding( + NOODLE, + True, + f"the printer's own defect detection says noodle (p {prob:.2f})", + {"prob": prob}, + ) + ) + + def step(self, o: Observation) -> Verdict: + """A full check: the camera reading and the signals.""" + v = Verdict() + self._camera(o, v) + self._signals(o, v) + return v + + def signals_only(self, o: Observation) -> Verdict: + """A cheap poll between two camera checks: only what the printer itself reported.""" + v = Verdict(label=self.labels[-1] if self.labels else BLIND) + self._signals(o, v) + return v + + +def _pc(x: Any) -> str: + return "?" if x is None else f"{float(x):.0%}" + + +# ---- the decision ----------------------------------------------------------------------------- + + +def decide(findings: Iterable[Finding], layer: int | None, cfg: JobConfig) -> str: + """`cancel` | `pause` | `alert` (nothing critical: tell, touch nothing).""" + critical = [f for f in findings if f.critical] + if not critical: + return "alert" + if cfg.on_fail in ("pause", "cancel"): + return cfg.on_fail + first_layers = layer is not None and layer <= cfg.first_layer_max + for f in critical: + if f.kind in HOPELESS or (f.camera and first_layers): + return "cancel" + return "pause" diff --git a/lib/printer_ops/jobwatch_actions.py b/lib/printer_ops/jobwatch_actions.py new file mode 100644 index 00000000..577155c9 --- /dev/null +++ b/lib/printer_ops/jobwatch_actions.py @@ -0,0 +1,392 @@ +"""jobwatch_actions.py — what `3d printer watch NAME --job` DOES when the print is lost. + +Order, because every second of a lost print costs filament and a failed print can hurt the hotend: + 1. save the frames and the findings (local files, instant); + 2. STOP the job: `adapter.pause()` or `adapter.cancel()`, after re-reading the printer and binding it to the + job that was watched (printing/paused AND the same file; never another job, never an idle printer), + once per job, verified in the printer state; + 3. RING the owner: `tg call me --say ""`; + 4. the klippy.log lines of the job (`3d printer evidence`, GET only) into the same folder; + 5. TELL the owner: `tg --tag problem --photo FRAME ""` (the frame that showed it). +Every step runs even when an earlier one failed: a failed cancel still calls, a failed call still messages. +The outcome (what was sent, what was verified, which step failed) is printed as one JSON line and saved as +`outcome.json` next to the evidence. + +SAFETY: the only printer writes are PAUSE and CANCEL (Moonraker `printer/print/pause|cancel`); no G-code, no +axis, no resume, no start. `dry_run` sends NOTHING (printer or phone) and prints what it would; `tg_dry_run` +prints the two tg command lines instead of running them but still stops the job. Nothing here runs the real `tg` +under pytest (watch_sinks.run_real_tg refuses). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import time +from collections.abc import Callable +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from printer_ops.base import ( + ACTIVE_JOB_STATES, + CANCELLED, + COMPLETE, + IDLE, + PAUSED, + Adapter, + PrinterCommError, + PrinterRefused, +) +from printer_ops.jobwatch import Finding + +Runner = Callable[..., "subprocess.CompletedProcess[str]"] +STOPPED = {"pause": (PAUSED,), "cancel": (CANCELLED, IDLE, COMPLETE)} + + +@dataclass(frozen=True) +class Bound: + """The job being watched: the printer asks for nothing else to be stopped.""" + + printer: str + filename: str + job_id: str | None = None + + +@dataclass +class Step: + name: str + ok: bool + detail: str = "" + + +@dataclass +class Outcome: + printer: str + job: str + action: str # pause | cancel | alert + dry_run: bool + findings: list[dict[str, Any]] + evidence_dir: str + steps: list[Step] = field(default_factory=list) + + def add(self, name: str, ok: bool, detail: str = "") -> None: + self.steps.append(Step(name, ok, detail)) + + def to_json(self) -> dict[str, Any]: + return asdict(self) + + +def bind(adapter: Adapter) -> Bound: + """Bind to the job printing now (file, and the history job id when the printer keeps one).""" + st = adapter.status() + if st.state not in ACTIVE_JOB_STATES or not st.filename: + raise PrinterRefused( + f"{adapter.name} is {st.state} [{st.raw_state}]: there is no job to watch", + command="printer watch --job", + remediation=[ + f"Start the job first: 3d printer start {adapter.name} FILE.gcode --yes" + ], + ) + job_id = None + try: + records = adapter.history(1) + if ( + records + and records[0].status == "in_progress" + and records[0].filename == st.filename + ): + job_id = records[0].job_id + except Exception: # noqa: BLE001 — a printer without history still gets watched + job_id = None + return Bound(adapter.name, st.filename, job_id) + + +class Responder: + def __init__( + self, + adapter: Adapter, + bound: Bound, + *, + out_dir: Path, + dry_run: bool = False, + tg_dry_run: bool = False, + runner: Runner | None = None, + tg_binary: str | None = None, + clock: Callable[[], float] = time.time, + sleep: Callable[[float], None] = time.sleep, + verify_s: float = 20.0, + evidence_fn: Callable[[str, str | None, Path], str] | None = None, + echo: Callable[[str], None] = print, + ) -> None: + self.adapter, self.bound = adapter, bound + self.out_dir, self.dry_run, self.tg_dry_run = ( + out_dir, + dry_run, + tg_dry_run or dry_run, + ) + self.runner, self.tg_binary = runner, tg_binary + self.clock, self.sleep, self.verify_s = clock, sleep, verify_s + self.evidence_fn = evidence_fn or klippy_evidence + self.echo = echo + self.acted = False # PAUSE/CANCEL goes out once per watch + + # -- 1. files + + def evidence_dir(self, now: float) -> Path: + stamp = datetime.fromtimestamp(now, timezone.utc).strftime("%Y%m%dT%H%M%SZ") + d = self.out_dir / f"{stamp}-{self.bound.printer}-jobwatch" + d.mkdir(parents=True, exist_ok=True) + return d + + def save_files( + self, + d: Path, + files: dict[str, str | None], + findings: list[Finding], + extra: dict[str, Any], + ) -> str | None: + """Copy the frames in, write finding.json; -> the path of the frame to send (annotated first).""" + sent: str | None = None + for name, src in files.items(): + if src and Path(src).is_file(): + shutil.copyfile(src, d / name) + if sent is None and name.startswith("frame"): + sent = str(d / name) + doc = { + "printer": self.bound.printer, + "job": self.bound.filename, + "job_id": self.bound.job_id, + "findings": [asdict(f) for f in findings], + **extra, + } + (d / "finding.json").write_text( + json.dumps(doc, indent=1, default=str) + "\n", encoding="utf-8" + ) + return sent + + # -- 2. stop + + def stop(self, action: str, out: Outcome) -> None: + if self.acted: + out.add(action, False, "already acted on this job") + return + try: + st = self.adapter.status() + except PrinterCommError as exc: + out.add(action, False, f"printer unreadable, nothing sent: {exc.message}") + return + why = self.refusal(action, st.state, st.filename) + if why: + out.add(action, False, why) + return + if self.dry_run: + out.add( + action, True, f"dry run: would send {action} to {self.bound.printer}" + ) + return + self.acted = True + self.send(action, out) + + def refusal(self, action: str, state: str, filename: str | None) -> str | None: + if state not in ACTIVE_JOB_STATES: + return f"the printer is {state}: no job to {action}" + if filename != self.bound.filename: + return f"the printer runs {filename}, not the watched {self.bound.filename}: refused" + if action == "pause" and state == PAUSED: + return "already paused (by the printer)" + return None + + def send(self, action: str, out: Outcome) -> None: + try: + (self.adapter.pause if action == "pause" else self.adapter.cancel)() + except PrinterCommError as exc: + out.add(action, False, f"the printer did not accept it: {exc.message}") + return + state = self.wait_for(STOPPED[action]) + out.add( + action, + state is not None, + f"sent; the printer is now {state}" + if state + else f"sent, but the printer never showed {action}", + ) + + def wait_for(self, states: tuple[str, ...]) -> str | None: + deadline = self.clock() + self.verify_s + while True: + try: + state = self.adapter.status().state + except PrinterCommError: + state = "" + if state in states: + return state + if self.clock() >= deadline: + return None + self.sleep(1.0) + + # -- 3. and 5. the phone + + def tg(self, argv: list[str], out: Outcome, name: str) -> bool: + line = " ".join(_quote(a) for a in argv) + if self.tg_dry_run: + self.echo(json.dumps({"would_run": argv}, ensure_ascii=False)) + out.add(name, True, f"dry run: {line}") + return True + runner = self.runner or _real_runner() + try: + r = runner(argv, text=True, capture_output=True, timeout=180) + except (OSError, subprocess.SubprocessError, RuntimeError) as exc: + out.add(name, False, f"{line}: {exc}") + return False + ok = r.returncode == 0 + out.add( + name, + ok, + line + if ok + else f"{line}: exit {r.returncode}: {(r.stderr or r.stdout or '').strip()[:200]}", + ) + return ok + + def call(self, text: str, out: Outcome) -> None: + self.tg([self.tg_binary or "tg", "call", "me", "--say", text], out, "call") + + def message(self, text: str, frame: str | None, out: Outcome) -> None: + binary = self.tg_binary or "tg" + rich = [ + binary, + "--tag", + "problem", + *(["--photo", frame] if frame else []), + text, + ] + if not self.tg(rich, out, "message") and not self.tg_dry_run: + self.tg([binary, text], out, "message_plain") + + # -- 4. klippy lines + + def job_evidence(self, d: Path, out: Outcome) -> None: + if self.dry_run: + out.add("klippy_evidence", True, "dry run: not fetched") + return + try: + out.add( + "klippy_evidence", + True, + self.evidence_fn(self.bound.printer, self.bound.job_id, d), + ) + except Exception as exc: # noqa: BLE001 — partial evidence is kept, the alerts still go out + out.add("klippy_evidence", False, f"{exc.__class__.__name__}: {exc}") + + # -- all of it + + def respond( + self, + action: str, + findings: list[Finding], + *, + layer: int | None, + files: dict[str, str | None], + extra: dict[str, Any], + ) -> Outcome: + now = self.clock() + d = self.evidence_dir(now) + out = Outcome( + self.bound.printer, + self.bound.filename, + action, + self.dry_run, + [asdict(f) for f in findings], + str(d), + ) + frame = self.save_files(d, files, findings, extra) + out.add("evidence_files", True, str(d)) + if action in ("pause", "cancel"): + self.stop(action, out) + text = owner_text(self.bound, action, findings, layer, out) + if action != "alert": + self.call(text, out) + if action in ("pause", "cancel"): + self.job_evidence(d, out) + self.message(text + f"\nEvidence: {d}", frame, out) + (d / "outcome.json").write_text( + json.dumps(out.to_json(), indent=1, default=str) + "\n", encoding="utf-8" + ) + self.echo( + json.dumps({"jobwatch": out.to_json()}, ensure_ascii=False, default=str) + ) + return out + + +def owner_text( + bound: Bound, action: str, findings: list[Finding], layer: int | None, out: Outcome +) -> str: + """The call/message, in Russian (the owner's language): what is lost, what was done, what to do.""" + stopped = any( + s.name == action and s.ok and not s.detail.startswith("dry run") + for s in out.steps + ) + where = f", слой {layer}" if layer is not None else "" + what = "; ".join(f.text for f in findings[:2]) + if action == "alert": + return ( + f"{bound.printer}: печать {bound.filename}{where}: внимание. {what}. " + "Принтер не трогал, посмотрите на кадр." + ) + did = { + "cancel": "Печать ОТМЕНЕНА" + if stopped + else "Печать нужно ОТМЕНИТЬ (отмена не подтвердилась)", + "pause": "Печать на ПАУЗЕ" + if stopped + else "Печать нужно поставить на паузу (пауза не подтвердилась)", + }[action] + return f"{bound.printer}: печать {bound.filename} провалена{where}. {what}. {did}. Подойдите к принтеру." + + +def klippy_evidence(printer: str, job_id: str | None, d: Path) -> str: + """`3d printer evidence` of the job (GET only) into `d`; -> its one-line summary.""" + import contextlib + import io + + from printer_ops.evidence import run as evidence + + argv = [printer, "--out", str(d)] + (["--job", job_id] if job_id else ["--last"]) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = evidence(argv) + return f"rc {rc}: " + (buf.getvalue().strip().splitlines() or ["evidence saved"])[0] + + +def _real_runner() -> Runner: + from printer_ops.watch_sinks import run_real_tg + + return run_real_tg + + +def _quote(a: str) -> str: + return ( + a + if a and all(c.isalnum() or c in "-_./:=@" for c in a) + else json.dumps(a, ensure_ascii=False) + ) + + +def find_tg() -> str | None: + from printer_ops.watch_sinks import find_tool + + return find_tool("tg") + + +__all__ = [ + "Bound", + "Outcome", + "Responder", + "bind", + "find_tg", + "klippy_evidence", + "owner_text", +] diff --git a/lib/printer_ops/jobwatch_launch.py b/lib/printer_ops/jobwatch_launch.py new file mode 100644 index 00000000..8c20ed67 --- /dev/null +++ b/lib/printer_ops/jobwatch_launch.py @@ -0,0 +1,91 @@ +"""What `3d printer start` does about watching the job it just started (issue #723). + +`start --watch` launches `3d printer watch NAME --job --yes --gcode FILE` detached; without it +`start` prints the command, because an unwatched lost print runs on unseen. The watcher may +pause or CANCEL the job, so launching it is never implicit. + +INVARIANTS: stdlib-only; nothing is launched unless the caller passes watch=True. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +BIN = Path(__file__).resolve().parents[2] / "bin" / "3d" +LONG_JOB_S = 30 * 60 +_HEAD_TAIL = 64 * 1024 +_ESTIMATE = re.compile( + r"^;\s*estimated printing time \(normal mode\)\s*=\s*(.+?)\s*$", re.MULTILINE +) +_PART = re.compile(r"(\d+)\s*([dhms])") +_UNIT_S = {"d": 86400, "h": 3600, "m": 60, "s": 1} + + +def estimated_seconds(gcode: Path) -> int | None: + """Slicer estimate from the G-code header/footer (first and last 64 KB), None when absent.""" + try: + size = gcode.stat().st_size + with gcode.open("rb") as fh: + head = fh.read(_HEAD_TAIL) + fh.seek(max(0, size - _HEAD_TAIL)) + tail = fh.read(_HEAD_TAIL) + except OSError: + return None + text = (head + b"\n" + tail).decode("utf-8", errors="replace") + found = _ESTIMATE.findall(text) + if not found: + return None + parts = _PART.findall(found[-1]) + return sum(int(n) * _UNIT_S[u] for n, u in parts) if parts else None + + +def _human(seconds: int) -> str: + h, rest = divmod(seconds, 3600) + return f"{h}h {rest // 60:02d}m" if h else f"{rest // 60}m" + + +def watch_command(name: str, gcode: Path) -> list[str]: + return [str(BIN), "printer", "watch", name, "--job", "--yes", "--gcode", str(gcode)] + + +def _launch(name: str, gcode: Path) -> None: + from cli.paths import state_dir + + log = state_dir() / "jobwatch" / f"{name}.log" + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("ab") as out: + proc = subprocess.Popen( + watch_command(name, gcode), + stdin=subprocess.DEVNULL, + stdout=out, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + print(f"job watch started in the background: pid {proc.pid}, log {log}") + print( + f" it may pause/cancel the job on a lost print; stop it with: kill {proc.pid}" + ) + + +def _not_watched(name: str) -> None: + print( + "NOT WATCHED: a lost print runs on unseen. Guard it (it may pause/cancel the job): " + f"3d printer watch {name} --job --yes --gcode FILE" + ) + + +def after_start(name: str, gcode: Path, *, watch: bool) -> None: + """Called once the printer acknowledged the start and its status was printed.""" + if watch: + _launch(name, gcode) + return + _not_watched(name) + seconds = estimated_seconds(gcode) + if seconds is None: + return + note = f" estimated print time {_human(seconds)}" + if seconds >= LONG_JOB_S: + note += ": jobs of 30 min or more should be watched" + print(note) diff --git a/lib/printer_ops/jobwatch_run.py b/lib/printer_ops/jobwatch_run.py new file mode 100644 index 00000000..c0649ec3 --- /dev/null +++ b/lib/printer_ops/jobwatch_run.py @@ -0,0 +1,735 @@ +"""jobwatch_run.py — `3d printer watch NAME --job`: the loop, the camera checks and the command line. + +An agent starts it RIGHT AFTER `3d printer start NAME FILE --yes` (or `printer start ... --watch` does): + + 3d printer watch u1 --job --yes --gcode print/part.gcode --out print/runs + +It watches that ONE job until it ends. Every `--interval` (60 s; 15 s while layer 1 prints) and at the +checkpoints (a quarter, half and all of the first layer, 25/50/100 % of the job) it looks at the camera and at +the printer's own signals; on a lost print it stops the job (PAUSE or CANCEL, see `--on-fail`), saves the +frames and the klippy.log lines into `--out`, rings `tg call me --say ...` and sends the frame with `tg`. +Decisions are in jobwatch.py, the stop/alert steps in jobwatch_actions.py, the readings in jobwatch_sources.py. + +NEVER: starts a print, moves an axis, resumes, sends G-code. The camera sees the plate at the print's own Z, so +nothing is moved to look. Exit codes: 0 the job ended and nothing was lost, 3 a lost print was found (the stop +was sent, or only attempted: see the outcome), 1 the watch could not run (no job, printer unreadable). +""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import subprocess +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, fields, replace +from pathlib import Path +from typing import Any + +from errors import InvalidArgument, UsageError +from printer_ops.base import PAUSED, PRINTING, PrinterCommError +from printer_ops.jobwatch import ( + DEFAULT_CHECKPOINTS, + ON_FAIL, + CMD, + Checkpoint, + Finding, + JobConfig, + JobJudge, + Observation, + PartReading, + Verdict, + decide, + parse_checkpoints, +) +from printer_ops.jobwatch_actions import Bound, Responder, bind, find_tg + +USAGE = """3d printer watch NAME --job --yes [--on-fail auto|pause|cancel] [--gcode FILE.gcode] [--out DIR] + [--interval S] [--checkpoints L1@25%,L1@50%,L1@100%,25%,50%,100%] [--dry-run | --tg-dry-run] + [--fixture SCENARIO.json] [--duration S] [--wait-start S] + Watch ONE running print and ABORT IT when it is lost. Start it right after `3d printer start NAME FILE --yes` + (or give `start` --watch): it follows that job until it ends. Unlike the always-on `3d printer watch NAME` + (read-only, alerts an agent) this one ACTS, so it needs --yes: you authorise a pause/cancel of this job. + + Every --interval (default 60 s, 15 s while layer 1 prints) and at the checkpoints (a quarter, half and all of + the first layer — the first-layer marks need --gcode — and 25 / 50 / 100 % of the job) it checks: + camera the plate against an empty-plate baseline taken at the start of the job: the part's footprint is + learnt from layer 3 on, then `part_gone` (little of it left), `part_moved`, `spaghetti` (much new + material beyond it). A finding needs 2 readings of the last 4 (a toolhead over the part is not + a finding). With the plate out of view, no baseline or no camera calibration to warp it, the + camera says `blind` — never "gone". + signals the same Moonraker events as the always-on watch (filament runout, heater loss, bed off, exception, + G-code error, homing lost) and the printer's own defect detection (`noodle` verdicts, p >= 0.8). + On a critical finding: the job is stopped, the frames + finding.json + the klippy.log lines of the job are saved + to DIR/-NAME-jobwatch/ (default ./print/runs), `tg call me --say ""` rings the owner, then + `tg --photo FRAME ""` sends the picture. A warning (part moved, nothing seen, a heater drift) is a tg + message with the frame and nothing is touched. The owner is called only for a lost print. + + --on-fail auto (default) CANCEL when the part is gone, or a camera finding in the first layers (layer <= 3); + PAUSE for everything else (runout, heater, later spaghetti: resumable). `pause` / `cancel`: + always that. + --gcode FILE the sliced G-code: first-layer checkpoints and the layer of a stop + --out DIR evidence folder (default ./print/runs, else ~/.local/state/3d-cli/jobwatch/NAME/runs) + --interval S seconds between checks (60); --first-layer-interval S (15); --poll S between cheap polls (5) + --checkpoints LIST extra checks (see above); `--checkpoints none` for the periodic one only + --dry-run nothing is sent to the printer or the phone: the stop and both tg commands are printed + --tg-dry-run the job is really stopped, the tg commands are only printed + --fixture FILE no network at all: the printer and the camera come from a scenario file with saved frames + (implies --dry-run for the phone; a fixture printer records the stop it is given) + --duration S stop watching after S seconds (default: until the job ends) + --wait-start S wait this long for the job to be printing (120) + --yes required (not with --dry-run / --fixture): authorises pause/cancel of this job + Config: `.watch.job: {interval_s, on_fail, min_view, confirm, window, arm_layer, out_dir, ...}` in + printer-devices.yaml (the JobConfig fields, printer_ops/jobwatch.py); flags win. + + Exit: 0 job ended, nothing lost; 3 a lost print was found and the stop attempted; 1 could not run. + +Examples: + 3d printer start u1 print/part.gcode --yes && 3d printer watch u1 --job --yes --gcode print/part.gcode + 3d printer watch u1 --job --yes --on-fail pause --out projects/chekhov/print/runs + 3d printer watch k1se --job --fixture tests/data/jobwatch/detached.json --dry-run""" + +STATE_FILES = ("baseline.jpg", "baseline.json", "footprint.png", "candidate.png") + + +@dataclass +class Args: + name: str + yes: bool + on_fail: str | None + gcode: str | None + out: str | None + interval: float | None + first_layer_interval: float | None + poll: float | None + checkpoints: str | None + dry_run: bool + tg_dry_run: bool + fixture: str | None + duration: float | None + wait_start: float | None + + +class _Parser(argparse.ArgumentParser): + def error(self, message: str) -> None: # type: ignore[override] + raise UsageError( + message, command=CMD, remediation=["See `3d printer watch --help`."] + ) + + +def parse_args(argv: list[str]) -> Args: + p = _Parser(prog="3d printer watch NAME --job", add_help=False) + p.add_argument("name") + p.add_argument("--job", action="store_true", required=True) + p.add_argument("--yes", action="store_true") + p.add_argument("--on-fail", choices=ON_FAIL) + for flag in ("--gcode", "--out", "--checkpoints", "--fixture"): + p.add_argument(flag) + for flag in ( + "--interval", + "--first-layer-interval", + "--poll", + "--duration", + "--wait-start", + ): + p.add_argument(flag, type=float) + p.add_argument("--dry-run", action="store_true") + p.add_argument("--tg-dry-run", action="store_true") + a = p.parse_args(argv) + return Args( + a.name, + a.yes, + a.on_fail, + a.gcode, + a.out, + a.interval, + a.first_layer_interval, + a.poll, + a.checkpoints, + a.dry_run, + a.tg_dry_run, + a.fixture, + a.duration, + a.wait_start, + ) + + +def job_config(args: Args, device_job: dict[str, Any], default_out: Path) -> JobConfig: + """The tuned defaults, then `.watch.job`, then the flags.""" + known = {f.name for f in fields(JobConfig)} + bad = sorted(set(device_job) - known) + if bad: + raise InvalidArgument("watch.job", ", ".join(bad), sorted(known), command=CMD) + given: dict[str, Any] = { + k: Path(v) if k == "out_dir" else v for k, v in device_job.items() + } + cfg = replace(JobConfig(out_dir=default_out), **given) + over: dict[str, Any] = {} + for flag, field_name in ( + ("interval", "interval_s"), + ("first_layer_interval", "first_layer_interval_s"), + ("poll", "poll_s"), + ("on_fail", "on_fail"), + ("wait_start", "wait_start_s"), + ): + if getattr(args, flag) is not None: + over[field_name] = getattr(args, flag) + if args.out: + over["out_dir"] = Path(args.out) + text = args.checkpoints if args.checkpoints is not None else DEFAULT_CHECKPOINTS + over["checkpoints"] = () if text == "none" else parse_checkpoints(text) + cfg = replace(cfg, **over) + if ( + cfg.on_fail not in ON_FAIL + or cfg.interval_s <= 0 + or cfg.poll_s <= 0 + or cfg.confirm < 1 + ): + raise InvalidArgument( + "watch.job", + str(device_job), + ["on_fail auto|pause|cancel, interval_s > 0, confirm >= 1"], + command=CMD, + ) + return cfg + + +def default_out_dir(name: str) -> Path: + """`./print/runs` of the project when there is a `print/` folder, else the user state dir.""" + if Path("print").is_dir(): + return Path("print") / "runs" + from cli.paths import state_dir + + return state_dir() / "jobwatch" / name / "runs" + + +# ---- checkpoints ------------------------------------------------------------------------------ + + +class CheckpointTracker: + """Which of the configured checkpoints this observation has just crossed (each once).""" + + def __init__( + self, points: tuple[Checkpoint, ...], layer1: tuple[int, int] | None + ) -> None: + self.points, self.layer1 = points, layer1 + self.fired: set[Checkpoint] = set() + + def reached(self, p: Checkpoint, o: Observation) -> bool: + if p.kind == "progress": + return o.progress is not None and o.progress >= p.fraction + if p.fraction >= 1.0: + return (o.layer is not None and o.layer >= 2) or ( + self.layer1 is not None + and o.file_position is not None + and o.file_position >= self.layer1[1] + ) + if self.layer1 is None or o.file_position is None: + return False + start, end = self.layer1 + return o.file_position >= start + p.fraction * (end - start) + + def crossed(self, o: Observation) -> list[str]: + out = [] + for p in self.points: + if p not in self.fired and self.reached(p, o): + self.fired.add(p) + out.append(str(p)) + return out + + +def first_layer_range(gcode: str | None) -> tuple[int, int] | None: + """Byte range of layer 1 in the G-code (`;LAYER_CHANGE` offsets), None without a G-code or a second layer.""" + if not gcode: + return None + from printer_ops.evidence import scan_gcode + + starts = scan_gcode(Path(gcode)).starts + return (starts[0], starts[1]) if len(starts) > 1 else None + + +# ---- the camera ------------------------------------------------------------------------------- + +PartTool = Callable[[list[str]], dict[str, Any]] + + +def run_part_tool(argv: list[str]) -> dict[str, Any]: + """bed_part.py through cli.pyrun (numpy + OpenCV): its JSON answer, or a blind reading saying why not.""" + from cli.pyrun import tool_argv + from printer_ops.bed import BED_DEPS + + try: + r = subprocess.run( + tool_argv(BED_DEPS, "bed_part.py", argv), + capture_output=True, + text=True, + timeout=120, + ) + return ( + dict(json.loads(r.stdout)) + if r.returncode == 0 + else { + "status": "blind", + "reason": f"the pixel tool failed (exit {r.returncode}): {(r.stderr or '')[-200:]}", + } + ) + except (OSError, subprocess.SubprocessError, ValueError) as exc: + return {"status": "blind", "reason": f"the pixel tool failed: {exc}"} + + +class CameraCheck: + """The job's files (baseline, footprint, frames) and the calls of the pixel tool.""" + + def __init__( + self, name: str, run_dir: Path, tool: PartTool, cfg: JobConfig + ) -> None: + self.name, self.dir, self.tool, self.cfg = name, run_dir, tool, cfg + run_dir.mkdir(parents=True, exist_ok=True) + self.n = 0 + + @property + def baseline(self) -> tuple[Path, float] | None: + meta = self.dir / "baseline.json" + if not meta.is_file(): + return None + return self.dir / "baseline.jpg", float( + json.loads(meta.read_text(encoding="utf-8"))["z"] + ) + + def ensure_baseline(self, jpeg: bytes, o: Observation) -> str | None: + """Take the job's empty-plate baseline from the first frames; else the nearest empty-bed reference. -> why not.""" + if self.baseline is not None or o.z is None: + return None if o.z is not None else "the toolhead Z is unknown" + if (o.layer is None or o.layer <= 1) and (o.progress or 0.0) < 0.05: + self.store_baseline(jpeg, o.z) + return None + ref = reference_baseline(self.name, o.z) + if ref is None: + return ( + "no empty-plate baseline: the watch started after the first layer and there is no empty-bed " + f"reference (3d printer bed reference {self.name} --capture --best-view)" + ) + self.store_baseline(ref[0], ref[1]) + return None + + def store_baseline(self, jpeg: bytes, z: float) -> None: + (self.dir / "baseline.jpg").write_bytes(jpeg) + (self.dir / "baseline.json").write_text(json.dumps({"z": z}), encoding="utf-8") + + def reading( + self, jpeg: bytes, o: Observation, why_blind: str | None + ) -> PartReading: + base = self.baseline + if base is None or o.z is None: + return PartReading("blind", {"reason": why_blind or "no baseline"}) + self.n += 1 + frame, annotated = ( + self.dir / f"frame-{self.n % 6}.jpg", + self.dir / f"annotated-{self.n % 6}.jpg", + ) + frame.write_bytes(jpeg) + argv = [ + "check", + self.name, + "--frame", + str(frame), + "--baseline", + str(base[0]), + "--baseline-z", + str(base[1]), + "--z", + str(o.z), + "--save-footprint", + str(self.dir / "candidate.png"), + "--annotated", + str(annotated), + "--label", + f"layer {o.layer} {o.progress or 0:.0%}", + "--min-view", + str(self.cfg.min_view), + "--z-tol", + str(self.cfg.z_tol_mm), + "--json", + ] + if (self.dir / "footprint.png").is_file(): + argv += ["--footprint", str(self.dir / "footprint.png")] + nums = self.tool(argv) + return PartReading( + str(nums.get("status", "blind")), nums, str(frame), str(annotated) + ) + + def learn(self, verdict: Verdict) -> None: + """Keep this reading's changed mask as the part's footprint (armed / refreshed).""" + cand = self.dir / "candidate.png" + if (verdict.arm or verdict.refresh) and cand.is_file(): + os.replace(cand, self.dir / "footprint.png") + + +def reference_baseline(name: str, z: float) -> tuple[bytes, float] | None: + """The empty-bed reference nearest to Z (bed reference NAME --capture): (its JPEG, its Z).""" + from printer_ops.bed import refs_dir + + best: tuple[float, bytes, float] | None = None + for meta in sorted(refs_dir(name).glob("plate-z*.json")): + try: + rz = float(json.loads(meta.read_text(encoding="utf-8"))["z"]) + jpg = meta.with_name(meta.name[: -len(".json")] + ".jpg").read_bytes() + except (OSError, ValueError, KeyError): + continue + if best is None or abs(rz - z) < abs(best[2] - z): + best = (abs(rz - z), jpg, rz) + return None if best is None else (best[1], best[2]) + + +# ---- the loop --------------------------------------------------------------------------------- + + +class JobWatch: + def __init__( + self, + source: Any, + camera: CameraCheck, + responder: Responder, + cfg: JobConfig, + tracker: CheckpointTracker, + *, + clock: Callable[[], float], + sleep: Callable[[float], None], + echo: Callable[[str], None] = print, + ) -> None: + self.source, self.camera, self.responder, self.cfg, self.tracker = ( + source, + camera, + responder, + cfg, + tracker, + ) + self.clock, self.sleep, self.echo = clock, sleep, echo + self.judge = JobJudge(cfg) + self.next_check = 0.0 + self.recheck_at: float | None = None + self.ended = 0 + self.alerted: set[str] = set() + self.unreadable = 0 + self.last_files: dict[str, str | None] = {} + self.history: list[dict[str, Any]] = [] + + def say(self, text: str) -> None: + self.echo(f"[{time.strftime('%H:%M:%S', time.localtime(self.clock()))}] {text}") + + def due(self, o: Observation, now: float) -> bool: + crossed = self.tracker.crossed(o) + for label in crossed: + self.say(f"checkpoint {label}") + return ( + bool(crossed) + or now >= self.next_check + or (self.recheck_at is not None and now >= self.recheck_at) + ) + + def check(self, o: Observation, now: float) -> Verdict: + why = None + try: + jpeg = self.source.grab() + why = self.camera.ensure_baseline(jpeg, o) + o.part = self.camera.reading(jpeg, o, why) + except (PrinterCommError, OSError) as exc: + o.part = PartReading("blind", {"reason": f"camera: {exc}"}) + o.noodles = self.source.noodles() + v = self.judge.step(o) + self.camera.learn(v) + first = o.layer is not None and o.layer <= 1 + self.next_check = now + ( + self.cfg.first_layer_interval_s if first else self.cfg.interval_s + ) + self.recheck_at = now + self.cfg.recheck_s if v.suspicious else None + self.remember(o, v) + return v + + def remember(self, o: Observation, v: Verdict) -> None: + n = o.part.numbers if o.part is not None else {} + self.say( + f"layer {o.layer}/{o.total_layer} {o.progress or 0:.0%} z {o.z}: camera {v.label}" + + ( + f" changed {n.get('changed')} overlap {n.get('overlap')} outside {n.get('outside')}" + if n.get("changed") is not None + else "" + ) + + "".join(f"; {x}" for x in v.notes) + ) + if o.part is not None and o.part.frame: + self.last_files = { + "frame.annotated.jpg": o.part.annotated, + "frame.raw.jpg": o.part.frame, + "baseline.jpg": str(self.camera.dir / "baseline.jpg"), + "footprint.png": str(self.camera.dir / "footprint.png"), + } + self.history = [ + *self.history[-20:], + { + "t": o.now, + "layer": o.layer, + "progress": o.progress, + "label": v.label, + "numbers": o.part.numbers if o.part else None, + }, + ] + + def poll(self, now: float) -> Observation | None: + try: + o: Observation = self.source.poll(now) + except PrinterCommError as exc: + self.unreadable += 1 + self.say(f"printer unreadable ({self.unreadable}): {exc.message}") + return None + self.unreadable = 0 + return o + + def step(self, o: Observation, now: float) -> int | None: + """One poll's work -> an exit code when the watch is over, else None.""" + if o.state not in (PRINTING, PAUSED): + self.ended += 1 + if self.ended >= self.cfg.ended_polls: + self.say(f"the job ended: the printer is {o.state}; nothing was lost") + return 0 + return None + self.ended = 0 + v = self.check(o, now) if self.due(o, now) else self.judge.signals_only(o) + return self.handle(v.findings, o) + + def handle(self, findings: list[Finding], o: Observation) -> int | None: + critical = [f for f in findings if f.critical] + if critical: + action = decide(findings, o.layer, self.cfg) + self.responder.respond( + action, + critical, + layer=o.layer, + files=self.last_files, + extra={"observations": self.history, "action": action}, + ) + return 3 + fresh = [f for f in findings if f.kind not in self.alerted] + self.alerted.update(f.kind for f in fresh) + if fresh: + self.responder.respond( + "alert", + fresh, + layer=o.layer, + files=self.last_files, + extra={"observations": self.history, "action": "alert"}, + ) + return None + + def wait_for_job(self, deadline: float) -> bool: + while self.clock() < deadline: + o = self.poll(self.clock()) + if o is not None and o.state in (PRINTING, PAUSED): + return True + self.sleep(self.cfg.poll_s) + return False + + def run(self, duration: float | None) -> int: + start = self.clock() + if not self.wait_for_job(start + self.cfg.wait_start_s): + self.say("no job is printing: nothing to watch") + return 1 + end = None if duration is None else self.clock() + duration + while end is None or self.clock() < end: + now = self.clock() + o = self.poll(now) + code = self.step(o, now) if o is not None else self.when_unreadable() + if code is not None: + return code + self.sleep(self.cfg.poll_s) + self.say("duration over: the watch stops, the job goes on unwatched") + return 0 + + def when_unreadable(self) -> int | None: + if self.unreadable >= 12 and "unreadable" not in self.alerted: + self.alerted.add("unreadable") + self.responder.respond( + "alert", + [ + Finding( + "printer_unreadable", + False, + "the printer has not answered for a minute", + ) + ], + layer=None, + files=self.last_files, + extra={}, + ) + return None + + +@contextmanager +def one_watch_per_printer(name: str) -> Iterator[None]: + """A second job watch for the same printer would double every alert and every stop: refused.""" + from cli.paths import state_dir + + path = state_dir() / "jobwatch" / f"{name}.lock" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a") as fh: + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise UsageError( + f"a job watch for {name} is already running", + command=CMD, + remediation=["One watch per printer; it ends with the job."], + ) from None + try: + yield + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + + +def require_authority(args: Args) -> None: + if not (args.yes or args.dry_run or args.fixture): + raise UsageError( + "this watch PAUSES or CANCELS a physical job and needs --yes", + command=CMD, + remediation=[ + f"Authorise it: 3d printer watch {args.name} --job --yes", + "Or rehearse: add --dry-run", + ], + ) + + +def build_live(args: Args, cfg: JobConfig) -> tuple[Any, Any, Responder]: + from printer_ops import adapter_by_name + from printer_ops.camera import open_camera, require_camera + from printer_ops.devices import get_device + from printer_ops.jobwatch_sources import LiveSource + from printer_ops.moonraker import MoonrakerAdapter + from printer_ops.vision import KlippyLogTap + + adapter = adapter_by_name(args.name, command="printer") + if not isinstance(adapter, MoonrakerAdapter): + raise UsageError( + f"{args.name} is a {adapter.kind} printer: the job watch needs Moonraker", + command=CMD, + ) + device = get_device(args.name) + if device.access != "full" and not args.dry_run: + from printer_ops.base import PrinterRefused + + raise PrinterRefused( + f"{args.name} is {device.access}: it may not be paused or cancelled", + command=CMD, + remediation=["Watch it dry: add --dry-run"], + ) + camera = open_camera(device, require_camera(device, CMD)) + source = LiveSource(adapter, camera, KlippyLogTap(adapter.host, adapter.port)) + bound = bind(adapter) + responder = Responder( + adapter, + bound, + out_dir=cfg.out_dir, + dry_run=args.dry_run, + tg_dry_run=args.tg_dry_run, + tg_binary=find_tg(), + verify_s=cfg.verify_s, + ) + return adapter, source, responder + + +def build_fixture(args: Args, cfg: JobConfig, clock: Any) -> tuple[Any, Any, Responder]: + from printer_ops.jobwatch_sources import ( + FixtureAdapter, + FixtureSource, + Scenario, + require_fixture_printer, + ) + + scenario = Scenario(Path(str(args.fixture))) + require_fixture_printer(scenario, args.name) + adapter = FixtureAdapter(args.name, scenario, clock) + source = FixtureSource(scenario, clock, adapter) + bound = Bound(args.name, scenario.filename, "FIXTURE") + responder = Responder( + adapter, + bound, + out_dir=cfg.out_dir, + dry_run=args.dry_run, + tg_dry_run=True, + clock=clock, + sleep=lambda s: None, + verify_s=cfg.verify_s, + evidence_fn=lambda p, j, d: "fixture: there is no klippy.log to fetch", + ) + return adapter, source, responder + + +class VirtualClock: + """Fixture time: `sleep` advances it, nothing waits.""" + + def __init__(self) -> None: + self.t = 0.0 + + def __call__(self) -> float: + return self.t + + def sleep(self, s: float) -> None: + self.t += s + + +def run(argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help"): + print(USAGE) + return 0 if argv else 1 + args = parse_args(argv) + require_authority(args) + from printer_ops.devices import get_device + + device = get_device(args.name) + cfg = job_config( + args, dict((device.watch or {}).get("job") or {}), default_out_dir(args.name) + ) + tracker = CheckpointTracker(cfg.checkpoints, first_layer_range(args.gcode)) + with one_watch_per_printer(args.name): + return _run_watch(args, cfg, tracker) + + +def _run_watch(args: Args, cfg: JobConfig, tracker: CheckpointTracker) -> int: + from cli.paths import state_dir + + run_dir = state_dir() / "jobwatch" / args.name + if args.fixture: + clock = VirtualClock() + _, source, responder = build_fixture(args, cfg, clock) + sleep: Callable[[float], None] = clock.sleep + now: Callable[[], float] = clock + else: + _, source, responder = build_live(args, cfg) + sleep, now = time.sleep, time.time + for name in ( + STATE_FILES + ): # a new job starts from nothing: no baseline or footprint of the last one + (run_dir / name).unlink(missing_ok=True) + camera = CameraCheck(args.name, run_dir, run_part_tool, cfg) + watch = JobWatch(source, camera, responder, cfg, tracker, clock=now, sleep=sleep) + print( + f"watching {args.name}: {responder.bound.filename} (job {responder.bound.job_id or '?'}), every " + f"{cfg.interval_s:g} s, on-fail {cfg.on_fail}, evidence -> {cfg.out_dir}" + + (" [DRY RUN: nothing is sent]" if args.dry_run else ""), + flush=True, + ) + return watch.run(args.duration) + + +__all__ = [ + "USAGE", + "run", + "parse_args", + "job_config", + "JobWatch", + "CameraCheck", + "CheckpointTracker", + "VirtualClock", +] diff --git a/lib/printer_ops/jobwatch_sources.py b/lib/printer_ops/jobwatch_sources.py new file mode 100644 index 00000000..b4cfb8f4 --- /dev/null +++ b/lib/printer_ops/jobwatch_sources.py @@ -0,0 +1,261 @@ +"""jobwatch_sources.py — where `3d printer watch NAME --job` gets its readings: a live Moonraker printer and +camera, or a fixture (a scenario file with saved frames; no network at all). + +`LiveSource` one GET per poll of the objects the always-on watch subscribes to (`moonraker_subscription`), + fed to the SAME `MoonrakerDetector` (filament runout, heater loss, bed off, exception, G-code + error, homing lost) plus the layer, progress, file position and toolhead Z; the printer's own + defect detection from klippy.log (`KlippyLogTap`); the camera snapshot (`open_camera`). Read-only: + GETs and the `camera.start_monitor` keepalive only. +`FixtureSource` the same surface from a scenario JSON on a virtual clock, with a `FixtureAdapter` that records + the PAUSE/CANCEL it is given. This is the dry-run/demo mode: `3d printer watch NAME --job + --fixture scenario.json`, nothing is ever sent to a printer or a phone. + +Scenario file: {"printer": "k1se", "file": "part.gcode", "steps": [{"t": 0, "state": "printing", "layer": 1, + "progress": 0.01, "z": 0.2, "frame": "empty.jpg", "events": [{"kind": "filament", "severity": "critical", + "text": "..."}], "noodles": [["noodle", 0.9]]}, ...]}; frame paths are relative to the scenario file; a step + holds until the next step's `t` (seconds on the virtual clock). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from errors import InvalidArgument, UsageError +from printer_ops.base import ( + CANCELLED, + PAUSED, + PRINTING, + Adapter, + JobRecord, + PrinterCommError, + PrinterStatus, +) +from printer_ops.jobwatch import Observation +from printer_ops.watch import Event, MoonrakerDetector, moonraker_subscription + +CMD = "printer watch --job" + + +def query_string(objects: list[str]) -> str: + """`printer.objects.list` -> the GET query of everything the always-on watch subscribes to, plus the toolhead.""" + sub = moonraker_subscription(objects) + sub["toolhead"] = ["position", "homed_axes"] + return "&".join( + quote(name, safe="") + ("=" + ",".join(fields) if fields else "") + for name, fields in sub.items() + ) + + +def toolhead_z(status: dict[str, Any]) -> float | None: + pos = (status.get("toolhead") or {}).get("position") + z = pos[2] if isinstance(pos, list) and len(pos) > 2 else None + return float(z) if isinstance(z, (int, float)) and not isinstance(z, bool) else None + + +def file_position(status: dict[str, Any]) -> int | None: + pos = (status.get("virtual_sdcard") or {}).get("file_position") + return pos if isinstance(pos, int) and not isinstance(pos, bool) else None + + +class LiveSource: + def __init__(self, adapter: Any, camera: Any, tap: Any | None = None) -> None: + self.adapter = adapter + self.camera = camera + self.tap = tap + self.detector = MoonrakerDetector(adapter.name) + self._query: str | None = None + self._layers: tuple[float, float] | None | bool = ( + False # (first layer height, layer height); False = unasked + ) + + def _objects_query(self) -> str: + if self._query is None: + listed = self.adapter._get("/printer/objects/list") + names = listed.get("objects", []) if isinstance(listed, dict) else [] + self._query = query_string([n for n in names if isinstance(n, str)]) + return self._query + + def poll(self, now: float) -> Observation: + status = self.adapter.query(self._objects_query()) + events = self.detector.on_status(status, now, full=True) + f = self.detector.frame + assert f is not None + o = Observation( + now, + f.state, + f.filename, + f.progress, + f.layer, + f.total_layer, + toolhead_z(status), + file_position(status), + list(events), + ) + if o.layer is None: + o.layer = self._layer_from_z(o) + return o + + def _layer_from_z(self, o: Observation) -> int | None: + """No `print_stats.info.current_layer` (a slicer without SET_PRINT_STATS_INFO): the layer from Z and the + file's layer heights (Moonraker file metadata).""" + if self._layers is False: + self._layers = self._read_layer_heights(o.filename) + if not isinstance(self._layers, tuple) or o.z is None: + return None + first, step = self._layers + return max(1, 1 + round((o.z - first) / step)) if o.z >= first * 0.5 else 1 + + def _read_layer_heights(self, filename: str | None) -> tuple[float, float] | None: + try: + meta = self.adapter.file_metadata(filename) if filename else {} + except Exception: # noqa: BLE001 — no metadata: the layer stays unknown + return None + first, step = meta.get("first_layer_height"), meta.get("layer_height") + if ( + isinstance(first, (int, float)) + and isinstance(step, (int, float)) + and first > 0 + and step > 0 + ): + return float(first), float(step) + return None + + def noodles(self) -> list[tuple[str, float]]: + if self.tap is None: + return [] + try: + return [(v.label, v.prob) for v in self.tap.poll()] + except PrinterCommError: + return [] + + def grab(self) -> bytes: + self.camera.keepalive() + return bytes(self.camera.grab().jpeg) + + +# ---- fixtures --------------------------------------------------------------------------------- + + +@dataclass +class Step: + t: float + data: dict[str, Any] + + +class Scenario: + def __init__(self, path: Path) -> None: + self.path = path + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise InvalidArgument( + "--fixture", + str(path), + ["a scenario JSON file"], + command=CMD, + extra=str(exc), + ) from exc + steps = doc.get("steps") if isinstance(doc, dict) else None + if ( + not isinstance(steps, list) + or not steps + or not all(isinstance(s, dict) and "t" in s for s in steps) + ): + raise InvalidArgument( + "--fixture", + str(path), + ['{"printer", "file", "steps": [{"t": 0, ...}]}'], + command=CMD, + ) + self.printer = str(doc.get("printer") or "") + self.filename = str(doc.get("file") or "fixture.gcode") + self.steps = sorted((Step(float(s["t"]), s) for s in steps), key=lambda s: s.t) + + def at(self, t: float) -> Step: + current = self.steps[0] + for s in self.steps: + if s.t <= t: + current = s + return current + + def frame(self, step: Step) -> bytes: + name = step.data.get("frame") + if not name: + raise PrinterCommError("the fixture step has no frame", command=CMD) + return (self.path.parent / str(name)).read_bytes() + + +class FixtureAdapter(Adapter): + """A printer that exists only in a scenario: reports the step's state and RECORDS pause/cancel.""" + + kind = "fixture" + + def __init__(self, name: str, scenario: Scenario, clock: Any) -> None: + super().__init__(name, "fixture", 0) + self.scenario, self.clock = scenario, clock + self.sent: list[str] = [] + self.forced: str | None = None + + def status(self) -> PrinterStatus: + state = self.forced or str( + self.scenario.at(self.clock()).data.get("state", PRINTING) + ) + return PrinterStatus(state, state, self.scenario.filename, None) + + def history(self, limit: int) -> list[JobRecord]: + return [JobRecord(self.scenario.filename, "in_progress", job_id="FIXTURE")] + + def pause(self) -> None: + self.sent.append("pause") + self.forced = PAUSED + + def cancel(self) -> None: + self.sent.append("cancel") + self.forced = CANCELLED + + +class FixtureSource: + def __init__(self, scenario: Scenario, clock: Any, adapter: FixtureAdapter) -> None: + self.scenario, self.clock, self.adapter = scenario, clock, adapter + + def poll(self, now: float) -> Observation: + d = self.scenario.at(now).data + events = [ + Event("fixture", e["kind"], e["severity"], e["text"], e.get("data", {})) + for e in d.get("events", []) + ] + return Observation( + now, + self.adapter.status().state, + self.scenario.filename, + d.get("progress"), + d.get("layer"), + d.get("total_layer"), + d.get("z"), + None, + events, + ) + + def noodles(self) -> list[tuple[str, float]]: + return [ + (str(a), float(b)) + for a, b in self.scenario.at(self.clock()).data.get("noodles", []) + ] + + def grab(self) -> bytes: + return self.scenario.frame(self.scenario.at(self.clock())) + + +def require_fixture_printer(scenario: Scenario, name: str) -> None: + if scenario.printer and scenario.printer != name: + raise UsageError( + f"the fixture is for printer {scenario.printer!r}, not {name!r}", + command=CMD, + remediation=[ + f"3d printer watch {scenario.printer} --job --fixture {scenario.path}" + ], + ) diff --git a/tests/data/jobwatch/empty.jpg b/tests/data/jobwatch/empty.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4ff1545de0c969a5c7b4571799ea9a709e438492 GIT binary patch literal 34430 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 inLeO@jdl7zjN>q=jKi7fB)If<9+i0 literal 0 HcmV?d00001 diff --git a/tests/data/jobwatch/part.jpg b/tests/data/jobwatch/part.jpg new file mode 100644 index 0000000000000000000000000000000000000000..97c91649132d5e1c4a947c056d2ce02ceca2b838 GIT binary patch literal 34530 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 literal 0 HcmV?d00001 diff --git a/tests/test_printer_jobwatch.py b/tests/test_printer_jobwatch.py new file mode 100644 index 00000000..e14b8ecd --- /dev/null +++ b/tests/test_printer_jobwatch.py @@ -0,0 +1,537 @@ +"""`3d printer watch NAME --job` (issue #723): a lost print is stopped, the evidence is saved and the owner +is rung; a print that is fine is left alone. + +The frames of tests/data/jobwatch are the real K1 SE empty bed (daylight and the dark room) with a +pasted part, half a part and a spaghetti pile; the printer is a fixture scenario (virtual clock, a +printer that records the stop it is given) or the loopback FakeMoonraker. Nothing here touches a real +printer, camera or phone: the real `tg` is refused under pytest and the fixture forces its dry run. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from errors import InvalidArgument, UsageError +from printer_fakes import FakeMoonraker, u1_status +from printer_ops import jobwatch as J +from printer_ops import jobwatch_actions as A +from printer_ops import jobwatch_run as R +from printer_ops.base import PAUSED, PRINTING, PrinterRefused +from printer_ops.moonraker import MoonrakerAdapter +from printer_ops.watch import CRITICAL, WARNING, Event + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_part # noqa: E402 (needs numpy + OpenCV) + +DATA = Path(__file__).parent / "data" / "jobwatch" +DEVICES = """k1se: + adapter: moonraker + host: 127.0.0.1 + port: 1 + model: Creality K1 SE + watch: + vision: {snapshot: "http://127.0.0.1:1/?action=snapshot"} + bed: + enabled: true + roi: [[0.31, 0.48], [0.72, 0.27], [0.92, 0.55], [0.92, 0.97], [0.41, 0.97]] +""" + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + cfg = tmp_path / "config" / "3d-cli" + cfg.mkdir(parents=True) + (cfg / "printer-devices.yaml").write_text(DEVICES, encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(R, "run_part_tool", in_process_tool) + return tmp_path + + +def in_process_tool(argv: list[str]) -> dict[str, Any]: + return bed_part.run_check(bed_part.build_parser().parse_args(argv)) + + +def scenario(tmp_path: Path, steps: list[dict[str, Any]]) -> Path: + for name in {s["frame"] for s in steps if "frame" in s}: + (tmp_path / name).write_bytes((DATA / name).read_bytes()) + path = tmp_path / "scenario.json" + path.write_text( + json.dumps({"printer": "k1se", "file": "part.gcode", "steps": steps}), + encoding="utf-8", + ) + return path + + +def at(t: float, frame: str, layer: int, **extra: Any) -> dict[str, Any]: + return { + "t": t, + "state": "printing", + "layer": layer, + "total_layer": 100, + "progress": layer / 100, + "z": 0.2 + 0.02 * layer, + "frame": frame, + **extra, + } + + +def end(t: float) -> dict[str, Any]: + return { + "t": t, + "state": "complete", + "layer": 100, + "progress": 1.0, + "frame": "empty.jpg", + } + + +def watch( + tmp_path: Path, + steps: list[dict[str, Any]], + *flags: str, + capsys: pytest.CaptureFixture[str], +) -> tuple[int, str, list[dict[str, Any]]]: + out_dir = tmp_path / "runs" + code = R.run( + [ + "k1se", + "--job", + "--fixture", + str(scenario(tmp_path, steps)), + "--out", + str(out_dir), + "--duration", + "1500", + *flags, + ] + ) + out = capsys.readouterr().out + outcomes = [ + json.loads(line)["jobwatch"] + for line in out.splitlines() + if line.startswith('{"jobwatch"') + ] + return code, out, outcomes + + +def would_run(out: str) -> list[list[str]]: + return [ + json.loads(line)["would_run"] + for line in out.splitlines() + if line.startswith('{"would_run"') + ] + + +# a part is on the plate from layer 3, the camera learns it, then it comes off at t=200 +HEALTHY = [at(0, "empty.jpg", 1), at(20, "part.jpg", 3), at(300, "part.jpg", 30)] + + +def test_a_detached_part_cancels_the_job_keeps_the_evidence_and_rings_the_owner( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [*HEALTHY[:2], at(200, "empty.jpg", 20)] + code, out, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 3 + (o,) = outcomes + assert o["action"] == "cancel" and o["findings"][0]["kind"] == "part_gone" + steps_ok = {s["name"]: s for s in o["steps"]} + assert ( + steps_ok["cancel"]["ok"] + and "the printer is now cancelled" in steps_ok["cancel"]["detail"] + ) + # the phone: the call FIRST, then the text with the frame that showed it + cmds = would_run(out) + assert ( + cmds[0][:5] == ["tg", "call", "me", "--say", cmds[0][4]] + and "ОТМЕНЕНА" in cmds[0][4] + ) + assert cmds[1][:3] == ["tg", "--tag", "problem"] and "--photo" in cmds[1] + # the evidence folder + d = Path(o["evidence_dir"]) + assert d.parent == tmp_path / "runs" and d.name.endswith("-k1se-jobwatch") + for name in ( + "finding.json", + "frame.raw.jpg", + "frame.annotated.jpg", + "baseline.jpg", + "outcome.json", + ): + assert (d / name).is_file(), name + assert ( + json.loads((d / "finding.json").read_text())["findings"][0]["kind"] + == "part_gone" + ) + + +def test_the_stop_is_sent_only_after_two_readings_not_on_the_first_empty_frame( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """One empty frame (a toolhead over the part, a bad frame) is not a finding.""" + steps = [*HEALTHY[:2], at(200, "empty.jpg", 20), at(205, "part.jpg", 20), end(400)] + code, out, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 0 and outcomes == [] and would_run(out) == [] + assert "the job ended" in out + + +@pytest.mark.parametrize("name", ["part.jpg", "part_dark_room.jpg"]) +def test_a_lighting_change_while_the_part_stands_does_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str +) -> None: + steps = [ + at(0, "empty.jpg", 1), + at(20, "part.jpg", 3), + at(200, name, 20), + at(300, name, 30), + end(400), + ] + code, out, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 0 and outcomes == [] and would_run(out) == [] + + +def test_a_lighting_change_on_an_empty_plate_never_cancels_and_never_calls( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Nothing was ever seen on the plate (black on black, or it never stuck): a WARNING with the frame, no stop.""" + steps = [ + at(0, "empty.jpg", 1), + at(20, "empty_dark_room.jpg", 4), + at(400, "empty_dark_room.jpg", 40), + end(500), + ] + code, out, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 0 + assert [o["action"] for o in outcomes] == ["alert"] and outcomes[0]["findings"][0][ + "kind" + ] == J.NO_PART + cmds = would_run(out) + assert ( + all(c[1] != "call" for c in cmds) and len(cmds) == 1 + ) # one message, nobody is rung + assert all(s["name"] != "cancel" for s in outcomes[0]["steps"]) + + +def test_half_a_part_is_a_warning_not_a_cancel( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [ + *HEALTHY[:2], + at(200, "partial.jpg", 20), + at(300, "partial.jpg", 25), + end(500), + ] + code, _, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 0 + assert [(o["action"], o["findings"][0]["kind"]) for o in outcomes] == [ + ("alert", J.PART_MOVED) + ] + + +def test_spaghetti_after_the_first_layers_pauses_under_auto( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [*HEALTHY[:2], at(200, "spaghetti.jpg", 20), at(300, "spaghetti.jpg", 25)] + code, _, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 3 + assert ( + outcomes[0]["action"] == "pause" + and outcomes[0]["findings"][0]["kind"] == J.SPAGHETTI + ) + + +def test_on_fail_pause_pauses_even_a_gone_part( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [*HEALTHY[:2], at(200, "empty.jpg", 20)] + code, _, outcomes = watch(tmp_path, steps, "--on-fail", "pause", capsys=capsys) + assert code == 3 and outcomes[0]["action"] == "pause" + + +def test_dry_run_sends_nothing_and_still_says_what_it_would_do( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [*HEALTHY[:2], at(200, "empty.jpg", 20)] + code, out, outcomes = watch(tmp_path, steps, "--dry-run", capsys=capsys) + assert code == 3 + (cancel,) = [s for s in outcomes[0]["steps"] if s["name"] == "cancel"] + assert cancel["detail"].startswith("dry run: would send cancel") + assert len(would_run(out)) == 2 + + +def test_a_firmware_runout_pauses_without_a_camera_finding( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + ev = {"kind": "filament", "severity": "critical", "text": "filament runout on left"} + steps = [at(0, "empty.jpg", 1), at(20, "empty.jpg", 2, events=[ev])] + code, _, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 3 and outcomes[0]["action"] == "pause" + assert outcomes[0]["findings"][0]["kind"] == "signal_filament" + + +def test_the_printers_own_noodle_verdict_cancels_in_the_first_layers( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [at(0, "empty.jpg", 1), at(20, "empty.jpg", 2, noodles=[["noodle", 0.91]])] + code, _, outcomes = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 3 and outcomes[0]["action"] == "cancel" + + +def test_without_yes_a_real_watch_is_refused_but_a_fixture_is_not( + tmp_path: Path, +) -> None: + with pytest.raises(UsageError, match="needs --yes"): + R.run(["k1se", "--job"]) + scenario_path = scenario(tmp_path, [end(0)]) + assert ( + R.run(["k1se", "--job", "--fixture", str(scenario_path), "--wait-start", "5"]) + == 1 + ) # not printing + + +# ---- the pure judge -------------------------------------------------------------------------- + + +def reading(**n: Any) -> J.PartReading: + return J.PartReading("ok", n) + + +@pytest.mark.parametrize( + ("armed", "numbers", "label"), + [ + (False, {"changed": 0.001, "blob_px": 10}, J.EMPTY), + (False, {"changed": 0.02, "blob_px": 400}, J.SEEN), + (True, {"overlap": 0.95, "outside": 0.0}, J.PRESENT), + (True, {"overlap": 0.4, "outside": 0.0}, J.PARTIAL), + (True, {"overlap": 0.05, "outside": 0.1}, J.GONE), + (True, {"overlap": 0.9, "outside": 2.5}, J.GROWING), + ], +) +def test_labels(armed: bool, numbers: dict[str, float], label: str) -> None: + assert J.label_reading(reading(**numbers), armed, J.JobConfig()) == label + + +def test_blind_is_never_gone() -> None: + cfg = J.JobConfig() + assert ( + J.label_reading(J.PartReading("blind", {"reason": "out of view"}), True, cfg) + == J.BLIND + ) + assert J.label_reading(None, True, cfg) == J.BLIND + + +def test_decide_policy() -> None: + cfg = J.JobConfig() + gone = J.Finding(J.PART_GONE, True, "x") + spag = J.Finding(J.SPAGHETTI, True, "x") + runout = J.Finding("signal_filament", True, "x") + warn = J.Finding(J.PART_MOVED, False, "x") + assert J.decide([warn], 10, cfg) == "alert" + assert J.decide([gone], 50, cfg) == "cancel" + assert J.decide([spag], 2, cfg) == "cancel" and J.decide([spag], 50, cfg) == "pause" + assert J.decide([runout], 1, cfg) == "pause" + assert J.decide([runout], 1, J.JobConfig(on_fail="cancel")) == "cancel" + assert J.decide([gone], 1, J.JobConfig(on_fail="pause")) == "pause" + + +def test_checkpoints_parse_and_fire_once() -> None: + cps = J.parse_checkpoints(J.DEFAULT_CHECKPOINTS) + assert [str(c) for c in cps] == [ + "L1@25%", + "L1@50%", + "L1@100%", + "25%", + "50%", + "100%", + ] + with pytest.raises(InvalidArgument): + J.parse_checkpoints("L1@150%") + t = R.CheckpointTracker(cps, (1000, 2000)) + o = J.Observation(0.0, PRINTING, layer=1, progress=0.01, file_position=1300) + assert t.crossed(o) == ["L1@25%"] and t.crossed(o) == [] + o2 = J.Observation(1.0, PRINTING, layer=2, progress=0.30, file_position=2100) + assert t.crossed(o2) == ["L1@50%", "L1@100%", "25%"] + without_gcode = R.CheckpointTracker(cps, None) + assert without_gcode.crossed( + J.Observation(0.0, PRINTING, layer=2, progress=0.0) + ) == ["L1@100%"] + + +def test_a_warning_event_and_a_critical_event_are_told_apart() -> None: + judge = J.JobJudge(J.JobConfig()) + ev = [ + Event("p", "bed_off", WARNING, "bed off"), + Event("p", "exception", CRITICAL, "boom"), + ] + v = judge.signals_only(J.Observation(0.0, PRINTING, events=ev)) + assert [(f.kind, f.critical) for f in v.findings] == [ + ("signal_bed_off", False), + ("signal_exception", True), + ] + + +# ---- the stop, against the loopback fake Moonraker ------------------------------------------- + + +def moon(state: str = "printing", name: str = "part.gcode") -> FakeMoonraker: + m = FakeMoonraker(status=u1_status(state, name, 0.3)) + m.history = [ + { + "job_id": "000042", + "filename": name, + "status": "in_progress", + "start_time": 1.0, + } + ] + return m + + +def responder(adapter: MoonrakerAdapter, tmp_path: Path, **kw: Any) -> A.Responder: + return A.Responder( + adapter, + A.bind(adapter), + out_dir=tmp_path / "runs", + tg_dry_run=True, + sleep=lambda s: None, + verify_s=0.0, + echo=lambda s: None, + evidence_fn=lambda p, j, d: "fake", + **kw, + ) + + +def test_cancel_goes_to_the_fake_moonraker_once_and_is_verified(tmp_path: Path) -> None: + m = moon() + with m.serve(): + adapter = MoonrakerAdapter("u1", "127.0.0.1", m.port) + r = responder(adapter, tmp_path) + assert r.bound == A.Bound("u1", "part.gcode", "000042") + o = r.respond( + "cancel", + [J.Finding(J.PART_GONE, True, "gone")], + layer=20, + files={}, + extra={}, + ) + again = r.respond( + "cancel", + [J.Finding(J.PART_GONE, True, "gone")], + layer=20, + files={}, + extra={}, + ) + assert [w.path for w in m.writes()] == [ + "/printer/print/cancel" + ] # PAUSE/CANCEL only, and once + assert {s.name: s.ok for s in o.steps}["cancel"] is True + assert [s for s in again.steps if s.name == "cancel"][ + 0 + ].detail == "already acted on this job" + + +def test_another_job_or_an_idle_printer_is_never_stopped(tmp_path: Path) -> None: + m = moon() + with m.serve(): + adapter = MoonrakerAdapter("u1", "127.0.0.1", m.port) + r = responder(adapter, tmp_path) + m.status["print_stats"]["filename"] = ( + "other.gcode" # someone started another job meanwhile + ) + o = r.respond( + "cancel", + [J.Finding(J.PART_GONE, True, "gone")], + layer=2, + files={}, + extra={}, + ) + m.status["print_stats"]["state"] = "standby" + o2 = A.Responder( + adapter, + r.bound, + out_dir=tmp_path / "r2", + tg_dry_run=True, + sleep=lambda s: None, + verify_s=0.0, + echo=lambda s: None, + evidence_fn=lambda p, j, d: "fake", + ).respond( + "cancel", + [J.Finding(J.PART_GONE, True, "gone")], + layer=2, + files={}, + extra={}, + ) + assert m.writes() == [] + assert ( + "not the watched part.gcode" + in [s for s in o.steps if s.name == "cancel"][0].detail + ) + assert "no job to cancel" in [s for s in o2.steps if s.name == "cancel"][0].detail + + +def test_pause_of_an_already_paused_job_sends_nothing(tmp_path: Path) -> None: + m = moon(PAUSED) + with m.serve(): + adapter = MoonrakerAdapter("u1", "127.0.0.1", m.port) + r = responder(adapter, tmp_path) + o = r.respond( + "pause", + [J.Finding("signal_filament", True, "runout")], + layer=5, + files={}, + extra={}, + ) + assert ( + m.writes() == [] + and "already paused" in [s for s in o.steps if s.name == "pause"][0].detail + ) + + +def test_there_is_no_job_to_bind_to_on_an_idle_printer() -> None: + m = moon("standby") + with m.serve(), pytest.raises(PrinterRefused, match="no job to watch"): + A.bind(MoonrakerAdapter("u1", "127.0.0.1", m.port)) + + +def test_the_real_tg_runs_only_through_the_injected_runner(tmp_path: Path) -> None: + m = moon() + sent: list[list[str]] = [] + + class Done: + returncode, stdout, stderr = 0, "", "" + + def runner(argv: list[str], **kw: Any) -> Any: + sent.append(argv) + return Done() + + with m.serve(): + adapter = MoonrakerAdapter("u1", "127.0.0.1", m.port) + r = A.Responder( + adapter, + A.bind(adapter), + out_dir=tmp_path / "runs", + runner=runner, + sleep=lambda s: None, + verify_s=0.0, + echo=lambda s: None, + evidence_fn=lambda p, j, d: "fake", + ) + r.respond( + "cancel", + [J.Finding(J.PART_GONE, True, "gone")], + layer=2, + files={}, + extra={}, + ) + assert [a[:3] for a in sent] == [["tg", "call", "me"], ["tg", "--tag", "problem"]] + assert PRINTING # the fake was printing when the job was bound From bf0da29df1652b2fab5e467dc704d4397cbc2a7d Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:41:44 +0100 Subject: [PATCH 05/30] feat(jobwatch): follow the plate on a printer without a camera calibration (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) --- docs/architecture/lib-map.md | 2 +- docs/commands/printer.md | 39 ++++- lib/bed_part.py | 293 ++++++++++++++++++++++++++++---- lib/printer_ops/jobwatch.py | 7 +- lib/printer_ops/jobwatch_run.py | 30 +++- tests/test_printer_jobwatch.py | 178 ++++++++++++++++++- 6 files changed, 504 insertions(+), 45 deletions(-) diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 85e4cf0a..89326093 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -324,7 +324,7 @@ Every current root Python module is listed here so future additions are intentio - `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier, change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); config, events, start gate and clear ack live in `printer_ops/bed.py`. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. -- `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. +- `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration; without one the plate is FOLLOWED on the picture — a chained ECC affine transform saved with every healthy footprint, `--track`/`--save-track`, so the baseline survives the bed's drift), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view, cannot be followed, or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. - `lib/geometry/mesh_metrics.py` — pinned-convention 3D shape metrics (Chamfer, F-score@tau, Hausdorff, normal consistency, volumetric IoU) between two meshes; backs `3d metrics geometry`. - `perceptual_metrics.py` — perceptual/semantic image metrics (PSNR, LPIPS, CLIP-sim) with explicit senses and graceful degradation; backs `3d metrics perceptual`. - `metrics.py` — compatibility wrapper re-exporting `registries.metrics`. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index d5f256c3..95a492f0 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -826,7 +826,8 @@ quarter, half and all of the first layer — the first-layer marks need `--gcode footprint is learnt from layer 3 on, then `part_gone` (little of it left), `part_moved`, `spaghetti` (much new material beyond it) and the `arm` state. A finding needs **2 readings of the last 4**: a toolhead passing over the part is not a finding. With the plate out of view, no - baseline, or no camera calibration to warp it the camera says `blind` — **never "gone"**. + baseline, or a plate that cannot be followed (no camera calibration: see + [below](#job-watch-no-calibration)) the camera says `blind` — **never "gone"**. - *Signals.* The same Moonraker events as the always-on watch (filament runout, heater loss, bed off, exception, G-code error, homing lost), and the printer's own defect detection (`noodle` verdicts with p >= 0.8, read from klippy.log). @@ -854,6 +855,37 @@ G-code, no resume, no start. In particular **the bed is not moved during a print Z 130 the plate is mostly out of view and the camera is `blind` (`min_view` 0.5): the signals keep running, the part check does not. + +**A printer without a camera calibration (the K1 SE, a `watch.bed.roi` polygon) follows the plate.** +Nothing says how the bed's move in Z shows in its picture, and the old rule (judge the part only within +`z_tol_mm` = 2 mm of the baseline's Z) blinded the camera after ~10 layers. The job watch now *chains* +the baseline: + +- Every healthy reading (label `present`, overlap >= `heal_overlap`, outside < 0.3) saves, next to the + part's footprint, the affine transform that carries the empty baseline's plate onto that frame + (`track.json` in the watcher's working folder). +- The next reading starts from that transform and refines it by ECC (OpenCV `findTransformECC`: + correlation based, so a global light change does not move it) on the plate pixels only, **without the + part's footprint neighbourhood**: the part is excluded from the measurement of the drift. Layer to + layer the bed moves 0.2 mm (about a pixel), so the chain never has to jump, and every step is anchored + to the true empty first-layer baseline, so errors do not accumulate. The footprint is carried along by + the change of transform, the baseline is warped onto the frame, the compared region is the plate as it + moved. +- **Gone** is then the footprint of the current frame matching the empty plate again whatever the Z: + a vanished part reads as overlap ~0, a part that stands as overlap ~1, a lighting change as neither. +- Within `z_tol_mm` of the baseline's Z a failed tracking falls back to the baseline as it stands (the + old behaviour); beyond it the camera is `blind` with the reason (`... the plate cannot be followed on + the picture: ...`), never `gone`. + +*Limits.* The plate must show texture (a blank plate cannot be followed). The transform may not jump +more than `TRACK_STEP_PX` (12 px of the 480-px frame, a few mm of bed) between two healthy readings (the +camera was bumped, the watch was blind for a long time while the bed went far, a pause with a big Z +hop): `blind`. An ECC correlation under 0.6 is `blind`. The model is affine, the real bed move is a +perspective one: approximate over a big drift, absorbed by the comparison's shift tolerance. A part so +tall that its top leaves the plate polygon, or spaghetti over most of the plate, may make the plate +untrackable: `blind`, the signals keep running. A toolhead or a hand over the plate for one reading +only lowers the correlation of that reading. + **Exit codes.** 0 the job ended, nothing lost; 3 a lost print was found and the stop attempted; 1 it could not run. @@ -896,7 +928,10 @@ working files (baseline, footprint) live in `~/.local/state/3d-cli/jobwatch/NAME - Camera latency: how stale a frame can be when a part comes off is not measured. - Per-model Z positions: the 98 % / 77 % / blind-above-Z-130 numbers are the U1's; other models need their own calibration. -- Printers without a camera calibration go blind after a few mm of Z (no way to warp the baseline). +- Printers without a camera calibration (K1 SE): the chained baseline ([above](#job-watch-no-calibration)) + was validated on frames of the real K1 SE bed moved by a synthetic affine drift, not on a real print of + many layers; the real picture's correlation (noise, the toolhead, shadows) may sit lower than the + fixtures' and `TRACK_MIN_CORR` (0.6) / `TRACK_STEP_PX` (12) may need tuning from the first real run. - Black-on-black parts (dark filament on a dark plate) give the footprint too little contrast. ## Safety diff --git a/lib/bed_part.py b/lib/bed_part.py index e933d05f..c1def3fe 100644 --- a/lib/bed_part.py +++ b/lib/bed_part.py @@ -12,10 +12,25 @@ plate taken at the start of the job (`baseline-z` = the toolhead Z it was taken at). Same light/exposure robustness as the bed check (bed_state.difference: per-channel gain/offset fit, detrend, shift tolerance, opening). With a camera calibration the baseline is warped to the current Z (a homography of the bed plane: - the bed moves in Z, the picture of the plate moves with it); without one (a `watch.bed.roi` printer) the - baseline stands only within `--z-tol` mm. With a `--footprint` (the changed mask the part had when the watcher - last saw it healthy) it also says how much of that footprint is still changed (`overlap`) and how much changed - OUTSIDE it (`outside`, relative to the footprint: spaghetti, a part that slid). + the bed moves in Z, the picture of the plate moves with it). With a `--footprint` (the changed mask the part + had when the watcher last saw it healthy) it also says how much of that footprint is still changed + (`overlap`) and how much changed OUTSIDE it (`outside`, relative to the footprint: spaghetti, a part that slid). + +CHAINED BASELINE (a printer without a calibration, a `watch.bed.roi` polygon): nothing says how the bed's move + shows in the picture, so the plate is FOLLOWED instead. Every healthy reading saves, next to the footprint, + the affine transform (`--save-track`) that carries the baseline's plate onto that frame. The next reading + starts from it (`--track`) and refines it with ECC (cv2.findTransformECC, correlation based: a global + light change does not move it) on the plate pixels only, WITHOUT the part's footprint neighbourhood. Layer + to layer the bed moves 0.2 mm (a px or less), so the chain never has to jump: 30 layers (6 mm) or 100 are + the same small steps, and every step is anchored to the true empty baseline, so errors do not add up (the + footprint is carried along by the change of transform). A part that vanishes then reads as `overlap` ~0: + its footprint matches the empty plate again whatever Z it is at. Within `--z-tol` mm of the baseline a + failed tracking falls back to the baseline as it is; beyond it the answer is `blind`. + LIMITS: the plate must show texture (a blank plate cannot be followed: `blind`); the transform may not + jump more than TRACK_STEP_PX between two readings (the camera was off, the bed went far while nothing was + healthy: `blind`); a correlation under TRACK_MIN_CORR is `blind`. The model is affine, so the perspective + of a big move is followed only approximately (the shift tolerance of the comparison absorbs a pixel or + two). Spaghetti covering most of the plate may make the plate untrackable too: `blind`, never gone. BLIND, NOT WRONG: when the plate is not in the frame at this Z (`view` under `--min-view`), when the baseline cannot be brought to this Z, or the frame is another size, the answer is `blind` with the reason and no @@ -30,6 +45,7 @@ import argparse import json import sys +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -49,6 +65,10 @@ Z_TOL_MM = 2.0 # an uncalibrated baseline stands this far from its Z ROI_GROW_PX = 9 # the plate region is grown by this many px of the 480-px frame: a part stands above it FOOTPRINT_GROW_PX = 15 # changes this close to the footprint are the part itself growing, not new things +TRACK_STEP_PX = 12.0 # the plate may move this far (px of the 480-px frame) between two healthy readings +TRACK_MIN_CORR = 0.6 # ECC correlation of the baseline's plate with the frame's under which it is not followed +TRACK_MIN_PX = 4000 # plate pixels (outside the footprint) needed to follow it +TRACK_ERODE_PX = 7 # the plate edge is not trusted: the bed's border is where the static machine begins def blind(reason: str, **extra: Any) -> dict[str, Any]: @@ -114,6 +134,160 @@ def plate_region( return grown +def plate_surface( + device: Device, cfg: Any, z: float, shape: tuple[int, int] +) -> np.ndarray: + """The plate surface at Z `z` as it is (not grown).""" + _, surface, _ = B.build_masks(device, cfg, z, shape) + return surface + + +# ---- following the plate (no camera calibration) ---------------------------------------------- + + +def identity() -> np.ndarray: + import numpy as np + + return np.eye(2, 3, dtype=np.float32) + + +def gray(img: np.ndarray) -> np.ndarray: + import cv2 + import numpy as np + + return np.asarray(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), dtype=np.float32) + + +def move_mask(mask: np.ndarray, warp: np.ndarray, shape: tuple[int, int]) -> np.ndarray: + """`mask` carried by the affine `warp` (src -> dst) onto an image of `shape`.""" + import cv2 + import numpy as np + + moved = cv2.warpAffine( + mask.astype(np.uint8), + warp, + (shape[1], shape[0]), + flags=cv2.INTER_NEAREST, + ) + return np.asarray(moved > 0) + + +def carry_footprint( + footprint: np.ndarray, old: np.ndarray | None, new: np.ndarray | None +) -> np.ndarray: + """The footprint seen under transform `old` (baseline -> the frame it was learnt on) as it is under `new`.""" + import cv2 + import numpy as np + + if new is None: + return footprint + inv = cv2.invertAffineTransform(identity() if old is None else old) + step = (np.vstack([new, [0, 0, 1]]) @ np.vstack([inv, [0, 0, 1]]))[:2] + return move_mask(footprint, step.astype(np.float32), footprint.shape) + + +def step_px(surface: np.ndarray, a: np.ndarray, b: np.ndarray) -> float: + """How far transforms `a` and `b` put the corners of the plate's bounding box from each other (px).""" + import cv2 + import numpy as np + + x, y, w, h = cv2.boundingRect(surface.astype(np.uint8)) + pts = np.array([[x, y, 1], [x + w, y, 1], [x, y + h, 1], [x + w, y + h, 1]]).T + return float(np.abs(a @ pts - b @ pts).max()) + + +def tracking_mask( + surface: np.ndarray, start: np.ndarray, footprint: np.ndarray | None +) -> np.ndarray: + """Where the plate is expected on the frame (under the transform it started from), minus its borders + and the part: the pixels that say how the bed moved.""" + import cv2 + import numpy as np + + moved = move_mask(surface, start, surface.shape).astype(np.uint8) + plate = cv2.erode(moved, np.ones((TRACK_ERODE_PX,) * 2, np.uint8)) + if footprint is not None and footprint.shape == surface.shape: + grow = np.ones((2 * FOOTPRINT_GROW_PX + 1,) * 2, np.uint8) + plate[cv2.dilate(footprint.astype(np.uint8), grow) > 0] = 0 + return plate + + +def ecc_fit( + base: np.ndarray, img: np.ndarray, mask: np.ndarray, start: np.ndarray +) -> tuple[np.ndarray, float] | str: + """The affine transform that carries `base` onto `img` on the masked pixels, refined from `start`.""" + import cv2 + + stop = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 100, 1e-6) + try: + corr, warp = cv2.findTransformECC( + gray(base), gray(img), start.copy(), cv2.MOTION_AFFINE, stop, mask, 5 + ) + except cv2.error: + return "the plate's picture does not converge onto the baseline's (no texture, or it moved too far)" + if corr < TRACK_MIN_CORR: + return f"the plate matches the baseline's picture only to {corr:.2f} (under {TRACK_MIN_CORR})" + return warp, float(corr) + + +def follow_plate( + base: np.ndarray, + img: np.ndarray, + plate: tuple[np.ndarray, np.ndarray], + footprint: np.ndarray | None, + track: np.ndarray | None, +) -> Aligned | str: + """The baseline carried onto the frame by following the plate, starting from the last healthy transform + (`track`, else none), or why the plate cannot be followed.""" + _, surface = plate + start = identity() if track is None else track + mask = tracking_mask(surface, start, footprint) + if int(mask.sum()) < TRACK_MIN_PX: + return "too little of the plate is left in view to follow it" + fit = ecc_fit(base, img, mask, start) + if isinstance(fit, str): + return fit + warp, corr = fit + moved = step_px(surface, start, warp) + if moved > TRACK_STEP_PX: + return f"it moved {moved:.0f} px since the last healthy reading (more than {TRACK_STEP_PX:.0f})" + import cv2 + + size = (base.shape[1], base.shape[0]) + shifted = cv2.warpAffine(base, warp, size, borderMode=cv2.BORDER_REPLICATE) + # the plate as it moved, NOT grown: the grown band holds the static machine, which the baseline's move + # would drag over and report as new material once the drift is more than a few px + return Aligned(shifted, move_mask(surface, warp, surface.shape), warp, corr) + + +def tracking_numbers(al: Aligned, surface: np.ndarray) -> dict[str, Any]: + """`warp` (to be saved) and, when the plate was followed, its correlation and drift since the baseline.""" + out: dict[str, Any] = {} + if al.warp is None: + return out + out["warp"] = [[round(float(v), 5) for v in row] for row in al.warp] + if al.corr is not None: + out["track_corr"] = round(al.corr, 3) + out["drift_px"] = round(step_px(surface, identity(), al.warp), 1) + return out + + +def read_track(path: str | None) -> np.ndarray | None: + """The transform saved with the footprint (`--save-track`), None without.""" + import numpy as np + + if not path or not Path(path).is_file(): + return None + return np.array( + json.loads(Path(path).read_text(encoding="utf-8"))["warp"], np.float32 + ) + + +def write_track(path: str, warp: list[list[float]]) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps({"warp": warp}), encoding="utf-8") + + def footprint_numbers(changed: np.ndarray, footprint: np.ndarray) -> dict[str, float]: """How much of `footprint` is still changed, and how much changed beyond it (both / footprint area).""" import cv2 @@ -138,6 +312,48 @@ def view_share(cam: CameraModel | None, z: float) -> float | None: return None if cam is None else B.bed_in_view(cam, round(z, 1)) +@dataclass +class Aligned: + """The baseline and the plate region brought onto the frame, and the transform that did it (None: none).""" + + base: np.ndarray + region: np.ndarray + warp: np.ndarray | None = None + corr: float | None = None + + +def align_baseline( + base: np.ndarray, + img: np.ndarray, + plate: tuple[np.ndarray, np.ndarray], + *, + cam: CameraModel | None, + z: float, + base_z: float, + z_tol: float, + footprint: np.ndarray | None, + track: np.ndarray | None, +) -> Aligned | str: + """The baseline as the frame sees it (an `Aligned`), or why it cannot be (a string). `plate` = (region, + surface) in baseline coordinates. A calibrated camera warps the baseline to Z; without one the plate is + followed on the picture (`follow_plate`), and the baseline as it stands is kept only within `z_tol`.""" + region, _ = plate + if cam is not None or abs(z - base_z) < 0.05: + shifted, why = baseline_at(base, base_z, z, cam, z_tol) + if shifted is None: + return why or "no baseline" + return Aligned(shifted, region, None if cam is not None else identity()) + followed = follow_plate(base, img, plate, footprint, track) + if not isinstance(followed, str): + return followed + if abs(z - base_z) <= z_tol: + return Aligned(base, region, identity()) + return ( + f"the bed moved {abs(z - base_z):.1f} mm since the baseline (Z {base_z:.1f} -> {z:.1f}) and " + f"the plate cannot be followed on the picture: {followed}" + ) + + def measure( jpeg: bytes, base_jpeg: bytes, @@ -146,53 +362,59 @@ def measure( z: float, base_z: float, footprint: np.ndarray | None = None, + track: np.ndarray | None = None, min_view: float = MIN_VIEW, z_tol: float = Z_TOL_MM, ) -> tuple[dict[str, Any], np.ndarray | None, np.ndarray | None]: """(numbers, changed mask, region) for one frame against the job's baseline. Numbers `status` is `ok` - or `blind` (with `reason`).""" + or `blind` (with `reason`); `warp` (ok, no calibration) is the transform to save with the footprint.""" cfg = B.bed_config(device) cam = B.camera_model(device, cfg) seen = view_share(cam, z) if seen is not None and seen < min_view: - return ( - blind( - f"the camera sees {seen:.0%} of the plate at Z {z:.1f}, under {min_view:.0%}", - view=seen, - ), - None, - None, - ) + why = f"the camera sees {seen:.0%} of the plate at Z {z:.1f}, under {min_view:.0%}" + return blind(why, view=seen), None, None img, base = B.decode_small(jpeg), B.decode_small(base_jpeg) if img.shape != base.shape: - return ( - blind( - f"the frame is {img.shape[1]}x{img.shape[0]}, the baseline {base.shape[1]}x{base.shape[0]}" - ), - None, - None, - ) - shifted, why = baseline_at(base, base_z, z, cam, z_tol) - if shifted is None: - return blind(why or "no baseline", view=seen), None, None - region = plate_region(device, cfg, z, img.shape[:2]) + why = f"the frame is {img.shape[1]}x{img.shape[0]}, the baseline {base.shape[1]}x{base.shape[0]}" + return blind(why), None, None + plate = ( + plate_region(device, cfg, z, img.shape[:2]), + plate_surface(device, cfg, z, img.shape[:2]), + ) + al = align_baseline( + base, + img, + plate, + cam=cam, + z=z, + base_z=base_z, + z_tol=z_tol, + footprint=footprint, + track=track, + ) + if isinstance(al, str): + return blind(al, view=seen), None, None th = B.thresholds_for(cfg) d = B.difference( - shifted, img, region, th.level, th.detrend_px, th.shift_px, th.open_px + al.base, img, al.region, th.level, th.detrend_px, th.shift_px, th.open_px ) out: dict[str, Any] = { "status": "ok", "view": seen, "changed": round(d.frac, 4), - "blob_px": round(d.blob * int(region.sum())), + "blob_px": round(d.blob * int(al.region.sum())), "changed_px": int(d.mask.sum()), - "region_px": int(region.sum()), + "region_px": int(al.region.sum()), "z": z, "baseline_z": base_z, } + out.update(tracking_numbers(al, plate[1])) if footprint is not None and footprint.shape == d.mask.shape: - out.update(footprint_numbers(d.mask, footprint)) - return out, d.mask, region + out.update( + footprint_numbers(d.mask, carry_footprint(footprint, track, al.warp)) + ) + return out, d.mask, al.region def annotate( @@ -255,10 +477,13 @@ def write_mask(path: str, mask: np.ndarray) -> None: USAGE = """bed_part.py check NAME --frame F.jpg --baseline B.jpg --baseline-z Z0 --z Z [--footprint P.png] - [--save-footprint P.png] [--annotated OUT.jpg] [--label TEXT] [--min-view 0.5] [--z-tol 2] [--json] + [--track T.json] [--save-footprint P.png] [--save-track T.json] [--annotated OUT.jpg] [--label TEXT] + [--min-view 0.5] [--z-tol 2] [--json] The pixel half of `3d printer watch NAME --job` (see lib/printer_ops/jobwatch.py): the changed share of the plate region of the frame against the job's empty-plate baseline, and with a footprint how much of it - is still changed (overlap) and how much changed beyond it (outside). `status` is ok or blind (why).""" + is still changed (overlap) and how much changed beyond it (outside). `status` is ok or blind (why). + Without a camera calibration the plate is followed on the picture from the transform of --track (what + --save-track wrote with the last healthy footprint), so the baseline survives the bed's move in Z.""" def build_parser() -> argparse.ArgumentParser: @@ -270,7 +495,9 @@ def build_parser() -> argparse.ArgumentParser: ap.add_argument("--baseline-z", type=float, required=True) ap.add_argument("--z", type=float, required=True) ap.add_argument("--footprint") + ap.add_argument("--track") ap.add_argument("--save-footprint") + ap.add_argument("--save-track") ap.add_argument("--annotated") ap.add_argument("--label", default="") ap.add_argument("--min-view", type=float, default=MIN_VIEW) @@ -292,9 +519,11 @@ def run_check(args: argparse.Namespace) -> dict[str, Any]: z=args.z, base_z=args.baseline_z, footprint=read_mask(args.footprint), + track=read_track(args.track), min_view=args.min_view, z_tol=args.z_tol, ) + warp = nums.pop("warp", None) if args.annotated: annotate( jpeg, @@ -307,6 +536,8 @@ def run_check(args: argparse.Namespace) -> dict[str, Any]: if args.save_footprint and changed is not None: write_mask(args.save_footprint, changed) nums["footprint_saved"] = args.save_footprint + if args.save_track and warp is not None: + write_track(args.save_track, warp) return nums diff --git a/lib/printer_ops/jobwatch.py b/lib/printer_ops/jobwatch.py index f573328f..8ed8dc30 100644 --- a/lib/printer_ops/jobwatch.py +++ b/lib/printer_ops/jobwatch.py @@ -19,8 +19,9 @@ printer's own defect detection (`noodle` verdicts in klippy.log). `decide` pause | cancel | alert from the findings, the layer and `--on-fail`. -BLIND IS NOT GONE: with the plate out of view at this Z, no baseline, or no calibration to warp it, the camera - says `blind` and contributes nothing; the signals keep running. A job that was never seen on the camera +BLIND IS NOT GONE: with the plate out of view at this Z, no baseline, or (no camera calibration) a plate that + cannot be followed on the picture (bed_part.py: the chained baseline), the camera says `blind` and + contributes nothing; the signals keep running. A job that was never seen on the camera (`no_part_seen`) is a WARNING, never a cancel: black PLA on a black plate is invisible to a camera, a part that came off during the first layers is not, and the two cannot be told apart from one still frame. @@ -130,7 +131,7 @@ class JobConfig: on_fail: str = "auto" checkpoints: tuple[Checkpoint, ...] = () min_view: float = 0.5 # share of the plate in the frame to judge the part - z_tol_mm: float = 2.0 # an uncalibrated baseline stands this far + z_tol_mm: float = 2.0 # without tracking an uncalibrated baseline stands this far present_min: float = ( 0.004 # changed share of the plate region that says "something stands there" ) diff --git a/lib/printer_ops/jobwatch_run.py b/lib/printer_ops/jobwatch_run.py index c0649ec3..63a85a10 100644 --- a/lib/printer_ops/jobwatch_run.py +++ b/lib/printer_ops/jobwatch_run.py @@ -92,7 +92,14 @@ 3d printer watch u1 --job --yes --on-fail pause --out projects/chekhov/print/runs 3d printer watch k1se --job --fixture tests/data/jobwatch/detached.json --dry-run""" -STATE_FILES = ("baseline.jpg", "baseline.json", "footprint.png", "candidate.png") +STATE_FILES = ( + "baseline.jpg", + "baseline.json", + "footprint.png", + "candidate.png", + "track.json", + "candidate-track.json", +) @dataclass @@ -344,6 +351,8 @@ def reading( str(o.z), "--save-footprint", str(self.dir / "candidate.png"), + "--save-track", + str(self.dir / "candidate-track.json"), "--annotated", str(annotated), "--label", @@ -354,18 +363,25 @@ def reading( str(self.cfg.z_tol_mm), "--json", ] - if (self.dir / "footprint.png").is_file(): - argv += ["--footprint", str(self.dir / "footprint.png")] + for flag, name in (("--footprint", "footprint.png"), ("--track", "track.json")): + if (self.dir / name).is_file(): + argv += [flag, str(self.dir / name)] nums = self.tool(argv) return PartReading( str(nums.get("status", "blind")), nums, str(frame), str(annotated) ) def learn(self, verdict: Verdict) -> None: - """Keep this reading's changed mask as the part's footprint (armed / refreshed).""" - cand = self.dir / "candidate.png" - if (verdict.arm or verdict.refresh) and cand.is_file(): - os.replace(cand, self.dir / "footprint.png") + """Keep this reading's changed mask (and the transform that carried the baseline onto it) as the + part's footprint: armed / refreshed. The two move together or the footprint would not be carried.""" + if not (verdict.arm or verdict.refresh): + return + for cand, final in ( + ("candidate.png", "footprint.png"), + ("candidate-track.json", "track.json"), + ): + if (self.dir / cand).is_file(): + os.replace(self.dir / cand, self.dir / final) def reference_baseline(name: str, z: float) -> tuple[bytes, float] | None: diff --git a/tests/test_printer_jobwatch.py b/tests/test_printer_jobwatch.py index e14b8ecd..0385eb70 100644 --- a/tests/test_printer_jobwatch.py +++ b/tests/test_printer_jobwatch.py @@ -10,6 +10,7 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Any @@ -65,7 +66,8 @@ def in_process_tool(argv: list[str]) -> dict[str, Any]: def scenario(tmp_path: Path, steps: list[dict[str, Any]]) -> Path: for name in {s["frame"] for s in steps if "frame" in s}: - (tmp_path / name).write_bytes((DATA / name).read_bytes()) + if (DATA / name).is_file(): # the rest was generated into tmp_path (drifted()) + (tmp_path / name).write_bytes((DATA / name).read_bytes()) path = tmp_path / "scenario.json" path.write_text( json.dumps({"printer": "k1se", "file": "part.gcode", "steps": steps}), @@ -299,6 +301,180 @@ def test_without_yes_a_real_watch_is_refused_but_a_fixture_is_not( ) # not printing +# ---- the bed goes down (a printer without a camera calibration): the baseline is chained -------- + +ROI = ( + (0.31, 0.48), + (0.72, 0.27), + (0.92, 0.55), + (0.92, 0.97), + (0.41, 0.97), +) # DEVICES' plate +DRIFT_PX_PER_MM = ( + 0.8, + 2.2, +) # what the K1 SE picture of the plate does per mm the bed goes down +DRIFT_SCALE_PER_MM = ( + -0.003 +) # ... and the plate shrinks a little (perspective) about its middle + + +def layer_z(layer: int) -> float: + return round(0.2 * layer, 2) + + +def drifted(tmp_path: Path, name: str, mm: float) -> str: + """DATA/name as the camera sees it with the bed `mm` lower: the content of the plate polygon moves + (shift + shrink), the machine around it stands still. Written into tmp_path; returns its name.""" + img = cv2.imread(str(DATA / name)) + h, w = img.shape[:2] + plate = bed_part.B.polygon_mask(ROI, (h, w)).astype(np.uint8) + s = 1 + DRIFT_SCALE_PER_MM * mm + cx, cy = 0.62 * w, 0.7 * h + m = np.array( + [ + [s, 0, (1 - s) * cx + DRIFT_PX_PER_MM[0] * mm], + [0, s, (1 - s) * cy + DRIFT_PX_PER_MM[1] * mm], + ], + np.float32, + ) + moved = cv2.warpAffine(img, m, (w, h), borderMode=cv2.BORDER_REPLICATE) + inside = cv2.warpAffine(plate, m, (w, h), flags=cv2.INTER_NEAREST) > 0 + img[inside] = moved[inside] + out = f"{Path(name).stem}@{mm:.1f}.jpg" + cv2.imwrite(str(tmp_path / out), img) + return out + + +def layers( + tmp_path: Path, name: str, first: int, last: int, **extra: Any +) -> list[dict[str, Any]]: + """One reading a minute, one layer each: layer n shows `name` with the bed 0.2 mm lower than at n-1.""" + return [ + at( + 60 * n, + drifted(tmp_path, name, layer_z(n) - layer_z(1)), + n, + z=layer_z(n), + **extra, + ) + for n in range(first, last + 1) + ] + + +def camera_labels(out: str) -> dict[int, str]: + """layer -> the camera label of its reading, from the watch log.""" + found = re.findall(r"layer (\d+)/\d+ .*?camera (\w+)", out) + return {int(n): label for n, label in found} + + +def drift_watch( + tmp_path: Path, steps: list[dict[str, Any]], capsys: pytest.CaptureFixture[str] +) -> tuple[int, str, list[dict[str, Any]]]: + return watch( + tmp_path, + [at(0, "empty.jpg", 1, z=layer_z(1)), *steps], + "--tg-dry-run", + "--duration", + "5000", + capsys=capsys, + ) + + +def test_a_healthy_part_stays_present_over_30_layers_of_drift( + tmp_path: Path, capsys: pytest.CaptureFixture[str], xdg: Path +) -> None: + """6 mm of bed travel is far beyond z_tol (2 mm): the plate is followed, the camera never goes blind.""" + steps = [*layers(tmp_path, "part.jpg", 3, 30), end(60 * 31)] + code, out, outcomes = drift_watch(tmp_path, steps, capsys) + assert code == 0 and outcomes == [] and would_run(out) == [] + labels = camera_labels(out) + assert set(range(5, 31)) <= set(labels) + assert {labels[n] for n in range(5, 31)} == {"present"}, out + assert all(label != "blind" for n, label in labels.items() if n >= 3) + run_dir = xdg / "state" / "3d-cli" / "jobwatch" / "k1se" + assert (run_dir / "track.json").is_file() # the chain: the last healthy transform + + +def test_a_detached_part_at_layer_30_is_gone_and_cancels_through_the_drift( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + steps = [ + *layers(tmp_path, "part.jpg", 3, 29), + *layers(tmp_path, "empty.jpg", 30, 32), + ] + code, _, outcomes = drift_watch(tmp_path, steps, capsys) + assert code == 3 + (o,) = outcomes + assert o["action"] == "cancel" and o["findings"][0]["kind"] == J.PART_GONE + assert o["findings"][0]["data"]["layer"] == 30 # after two readings, never one + + +@pytest.mark.parametrize("name", ["part.jpg", "part_dark_room.jpg"]) +def test_a_lighting_change_at_layer_30_with_the_part_standing_is_no_finding( + tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str +) -> None: + steps = [ + *layers(tmp_path, "part.jpg", 3, 29), + *layers(tmp_path, name, 30, 34), + end(60 * 35), + ] + code, out, outcomes = drift_watch(tmp_path, steps, capsys) + assert code == 0 and outcomes == [] and would_run(out) == [] + assert {camera_labels(out)[n] for n in range(30, 35)} == {"present"}, out + + +@pytest.mark.parametrize("name", ["empty.jpg", "part.jpg"]) +def test_a_camera_that_jumps_beyond_what_can_be_followed_is_blind_never_gone( + tmp_path: Path, capsys: pytest.CaptureFixture[str], name: str +) -> None: + """The picture moves 90 px at once (a bumped camera): the chain cannot follow, it must not say gone.""" + jump = [ + at(60 * n, drifted(tmp_path, name, 40.0), n, z=layer_z(n)) for n in (30, 31, 32) + ] + steps = [*layers(tmp_path, "part.jpg", 3, 29), *jump, end(60 * 33)] + code, out, outcomes = drift_watch(tmp_path, steps, capsys) + assert code == 0 and outcomes == [] and would_run(out) == [] + assert {camera_labels(out)[n] for n in (30, 31, 32)} == {"blind"}, out + assert "the plate cannot be followed on the picture" in out + + +def pixel_check(tmp_path: Path, frame: str, z: float, chained: bool = False) -> dict[str, Any]: + """bed_part.py on one frame against DATA/empty.jpg (Z 0.2); `chained`: start from the files of the last call.""" + argv = [ + *("check", "k1se", "--json", "--baseline", str(DATA / "empty.jpg")), + *("--baseline-z", "0.2", "--frame", str(tmp_path / frame), "--z", str(z)), + *("--save-footprint", str(tmp_path / "fp.png")), + *("--save-track", str(tmp_path / "t.json")), + ] + if chained: + argv += [ + "--footprint", + str(tmp_path / "fp.png"), + "--track", + str(tmp_path / "t.json"), + ] + return bed_part.run_check(bed_part.build_parser().parse_args(argv)) + + +def test_the_pixel_tool_chains_small_steps_to_a_drift_no_single_step_could_follow( + tmp_path: Path, +) -> None: + """Each call starts from the transform the last healthy one saved (--track) and carries the footprint + along: 8 mm (18+ px) of bed travel in 5 steps of ~1.6 mm; the same 8 mm in ONE step is blind.""" + zs = [0.2 + 1.6 * k for k in range(1, 6)] + names = [drifted(tmp_path, "part.jpg", z - 0.2) for z in zs] + first = pixel_check(tmp_path, names[0], zs[0]) + assert first["status"] == "ok" + for name, z in zip(names[1:], zs[1:], strict=True): + nums = pixel_check(tmp_path, name, z, chained=True) + assert nums["status"] == "ok" and nums["overlap"] >= 0.8, nums + assert nums["outside"] < 0.3 and nums["track_corr"] >= 0.9, nums + assert nums["drift_px"] > bed_part.TRACK_STEP_PX # beyond what one step may jump + cold = pixel_check(tmp_path, names[-1], zs[-1]) # no --track: the chain is lost + assert cold["status"] == "blind" and "cannot be followed" in cold["reason"] + + # ---- the pure judge -------------------------------------------------------------------------- From 630ce51f7110455248980bdf24be57d7ca0bf5ee Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:47:03 +0100 Subject: [PATCH 06/30] feat(bed): the printer's light for camera frames: detection, session, `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) --- lib/printer_ops/bed.py | 54 ++++- lib/printer_ops/bed_light.py | 375 +++++++++++++++++++++++++++++++++ tests/test_bed_light.py | 396 +++++++++++++++++++++++++++++++++++ 3 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 lib/printer_ops/bed_light.py create mode 100644 tests/test_bed_light.py diff --git a/lib/printer_ops/bed.py b/lib/printer_ops/bed.py index 2e5bf586..c923161a 100644 --- a/lib/printer_ops/bed.py +++ b/lib/printer_ops/bed.py @@ -18,13 +18,17 @@ * `clear_ack` optional `watch.bed.clear_ack`: one short motor sound after bed_clear so the person at the printer hears that the camera saw the clear bed. -WRITES: two, both guarded by the printer reading idle/complete/cancelled just before the write. +WRITES: three, all guarded by the printer reading idle/complete/cancelled just before the write (the light, + while a job prints, only for the job watch's frame). `clear_ack` is the first: one G-code line that the printer's `trusted_macros` lists, at most once per bed_clear event and per debounce window, never below `min_z_mm` (a Z buzz must not reach the nozzle). The second is the auto view (lib/bed_view.py, `watch.bed.auto_view`): a Z-only move of the bed to where the camera sees the whole plate and back, only from `3d printer bed status` and the checks built on it (preflight `bed`, the one-shot `bed-free` subscription); the watch - monitor never moves anything. Everything else only reads (camera snapshot, print_stats, toolhead). + monitor never moves anything. The third is the printer's light (printer_ops/bed_light.py, + `watch.bed.light`): switched on before a camera frame of those checks, a reference capture and the + job watch's frame, and back off after it; the watch monitor only reads idle frames and never + touches it. Everything else only reads (camera snapshot, print_stats, toolhead). INVARIANTS: stdlib only at import time; numpy/OpenCV live in bed_state.py. """ @@ -44,6 +48,7 @@ from typing import TYPE_CHECKING, Any from errors import InvalidArgument +from printer_ops.bed_light import BUILTIN_LIGHT_COMMANDS, LIGHT_KINDS, LightConfig if TYPE_CHECKING: from printer_ops.devices import Device @@ -72,7 +77,7 @@ BED_VIEW_EVENT = "bed_free" # the event of the one-shot `3d printer subscribe --once --on bed-free` BED_KEYS = ("enabled", "interval_s", "roi", "volume_height_mm", "z_tol_mm", "stale_s", "clear_ack", "thresholds", - "auto_view", "auto_view_min", "auto_view_part_mm") + "auto_view", "auto_view_min", "auto_view_part_mm", "light") # The per-printer overrides of bed_state.Thresholds (tuned on the U1 and K1 SE frames of tests/data/bed; # another camera may need other values): every field of that dataclass, a test keeps the two in step. @@ -80,6 +85,7 @@ "plate_missing_min", "plate_tie", "surface_min", "change_min", "settle_frames", "match_px", "view_min", "shift_px", "open_px", "low_view", "occupied_blob_low_px") ACK_KEYS = ("enabled", "command", "debounce_minutes", "min_z_mm") +LIGHT_KEYS = ("enabled", "object", "on_gcode", "off_gcode", "settle_s") def _log(msg: str) -> None: @@ -108,6 +114,7 @@ class BedConfig: auto_view: bool = False # move the bed in Z to where the camera sees the whole plate (bed_view.py) auto_view_min: float = AUTO_VIEW_MIN # share of the plate in the frame below which the bed is moved auto_view_part_mm: float | None = None # declared height of the tallest thing that may stand on the bed + light: LightConfig = LightConfig() # the printer's light for every camera frame (bed_light.py) def _num(value: Any, what: str, minimum: float) -> float: @@ -187,6 +194,46 @@ def _ack(value: Any, device: Device) -> AckConfig | None: _num(value.get("min_z_mm", 10), f"{where}.min_z_mm", 0)) +def _light_object(value: Any, where: str, device: Device) -> str: + parts = value.split() if isinstance(value, str) else [] + if len(parts) != 2 or parts[0] not in LIGHT_KINDS: + raise InvalidArgument(f"{where}.object", str(value), [f"{kind} NAME" for kind in LIGHT_KINDS], + command="printer", extra=f"Name the light as Moonraker lists it. Fix {device.source}.") + return " ".join(parts) + + +def _light_lines(raw: Any, where: str, device: Device) -> tuple[str, ...]: + lines = [raw] if isinstance(raw, str) else raw + if (not isinstance(lines, list) or not lines + or not all(isinstance(c, str) and c.strip() and not any(ch in c for ch in "\n\r;") for c in lines)): + raise InvalidArgument(where, str(raw), ["one G-code line (LIGHT_ON) or a list of lines, no comments"], + command="printer", extra=f"Fix {device.source}.") + for line in lines: + word = line.split()[0].upper() + if word not in BUILTIN_LIGHT_COMMANDS and word not in device.trusted_macros: + raise InvalidArgument(where, line, [f"SET_LED, SET_PIN or a command listed in {device.name}.trusted_macros"], + command="printer", + extra=f"Add {word} to {device.name}.trusted_macros in {device.source} first " + "(it is the list of commands 3d may let the printer run).") + return tuple(c.strip() for c in lines) + + +def _light(value: Any, device: Device) -> LightConfig: + where = f"{device.name}.watch.bed.light" + if not isinstance(value, dict): + raise InvalidArgument(where, str(value), ["a mapping: {object, on_gcode, off_gcode, settle_s, enabled}"], + command="printer", extra=f"Fix {device.source}.") + unknown = sorted(set(value) - set(LIGHT_KEYS)) + if unknown: + raise InvalidArgument(where, ", ".join(unknown), LIGHT_KEYS, command="printer", extra=f"Fix {device.source}.") + return LightConfig( + enabled=_flag(value.get("enabled", True), f"{where}.enabled"), + object=_light_object(value["object"], where, device) if "object" in value else None, + on_gcode=_light_lines(value["on_gcode"], f"{where}.on_gcode", device) if "on_gcode" in value else (), + off_gcode=_light_lines(value["off_gcode"], f"{where}.off_gcode", device) if "off_gcode" in value else (), + settle_s=_num(value.get("settle_s", LightConfig.settle_s), f"{where}.settle_s", 0)) + + def bed_config(device: Device) -> BedConfig: """`watch.bed` of a device, validated (absent = monitor off).""" raw = (device.watch or {}).get("bed") @@ -212,6 +259,7 @@ def bed_config(device: Device) -> BedConfig: auto_view_min=_fraction(raw.get("auto_view_min", AUTO_VIEW_MIN), f"{where}.auto_view_min"), auto_view_part_mm=_num(raw["auto_view_part_mm"], f"{where}.auto_view_part_mm", 0) if "auto_view_part_mm" in raw else None, + light=_light(raw["light"], device) if "light" in raw else LightConfig(), ) if cfg.enabled: from printer_ops.camera import camera_spec diff --git a/lib/printer_ops/bed_light.py b/lib/printer_ops/bed_light.py new file mode 100644 index 00000000..49148188 --- /dev/null +++ b/lib/printer_ops/bed_light.py @@ -0,0 +1,375 @@ +"""bed_light.py — the printer's light for a camera frame of the bed (issue #723). + +WHY: a camera frame of the bed is only comparable to the reference when the bed is lit the same way. + A dark chamber reads as "objects" (or as nothing), so every frame the bed check, the preflight + `bed` line, a reference capture and the job watch take is taken with the printer's own light on + and the light is put back as it was found. + +WHAT: + * `detect_light` the light among Moonraker's `printer.objects.list`: `led NAME` / `neopixel NAME` / + `dotstar NAME` (SET_LED) or `output_pin NAME` (SET_PIN) whose name says light, + lamp, led or chamber; `watch.bed.light.object` names it when the guess is wrong. + * `LightConfig` `watch.bed.light` (validated in printer_ops/bed.py): object, on_gcode / off_gcode + (a macro such as LIGHT_ON / LIGHT_OFF), settle_s, enabled. + * `LightSession` reads the light's current state (read-only `objects/query`), switches it on when it + is off (`on()`, once, lazily), puts it back in `restore()` / on leaving the `with`. + `lit()` is the same as a context manager that switches on at once. + * `LightReport` what happened, in one line (`.text`) and as a dict for the verdict (`.to_dict()`). + +WRITES: at most two scripts per frame, both through the adapter's `run_gcode`: the light on and the same + light off again. Built-in SET_LED / SET_PIN for an autodetected light; the configured on_gcode / + off_gcode otherwise, each first word listed in the printer's `trusted_macros` (checked when the config + is read, like `clear_ack`). Never while a job prints or is paused, except for the job watch + (`printing_ok`: its frame is taken during the print) and then only when the light was off. A light + that is already on is not touched. A printer that is not full-access is never written to. No light, an + unreadable printer or a failed switch only say so in the report: the frame is taken anyway. + +INVARIANTS: stdlib only; never raises for a light problem (only KeyboardInterrupt and friends pass + through, after the `finally` restored the light); a light that could not be read is switched on but + not restored (its previous state is unknown, the report says "left on"). +""" + +from __future__ import annotations + +import re +import time +from collections.abc import Callable, Iterator, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +SETTLE_S = 1.5 # after the light is on: the camera's auto exposure and its next frame +LED_KINDS = ("led", "neopixel", "dotstar") # SET_LED LED=name +PIN_KIND = "output_pin" # SET_PIN PIN=name +LIGHT_KINDS = (*LED_KINDS, PIN_KIND) +# Klipper built-ins that only drive LEDs / pins: allowed in on_gcode / off_gcode without a trusted_macros entry. +BUILTIN_LIGHT_COMMANDS = ("SET_LED", "SET_PIN") +ACTIVE_JOB_STATES = ( + "printing", + "paused", +) # print_stats.state: the bed and the machine belong to the job +_OBJECT = re.compile(r"^(" + "|".join(LIGHT_KINDS) + r")\s+(\S+)$") +_STRONG_HINTS = ( + "light", + "lamp", +) # a substring of the name is enough: caselight, chamber_lamp +_WORD_HINTS = ("chamber", "led", "leds") # whole words only: not "enabled" + + +@dataclass(frozen=True) +class LightConfig: + """`watch.bed.light` of a printer (validated by printer_ops.bed.bed_config).""" + + enabled: bool = True + object: str | None = ( + None # "output_pin chamber_light": which Moonraker object is the light + ) + on_gcode: tuple[ + str, ... + ] = () # a macro (LIGHT_ON); default: SET_LED / SET_PIN on `object` + off_gcode: tuple[str, ...] = () + settle_s: float = SETTLE_S + + +@dataclass(frozen=True) +class LightState: + object: str + on: ( + bool | None + ) # None: the printer reports the object but not a value that says on or off + + +@dataclass +class LightReport: + """What the light did for one frame; `to_dict()` goes into the bed verdict as `light`.""" + + note: str = "" # what the frame is for ("the bed check") + found: bool = False + object: str | None = None + was_on: bool | None = None + switched: bool = False + restored: bool | None = None # None: nothing to restore + error: str | None = None + detail: str = "" # why the light was not touched / not found, with the way to fix it + skipped: str | None = None # off by config, or the printer may not be written to + + @property + def on_for_frame(self) -> bool | None: + """Was the bed lit when the frame was taken? None: unknown (no light, or an unreadable state).""" + return True if self.switched else self.was_on + + @property + def text(self) -> str: + if self.skipped: + return f"light not used: {self.skipped}" + if not self.found: + why = ( + self.error + or self.detail + or "none of the printer's objects looks like a light" + ) + return f"no light found ({why}): the frame is taken with the room as it is" + if self.detail: + return f"light {self.object}: {self.detail}" + if self.was_on: + return f"light {self.object} was already on" + if not self.switched: + return f"light {self.object} could not be switched on ({self.error}): the frame is taken without it" + for_what = f" for {self.note}" if self.note else "" + if self.restored is None: + return f"light {self.object} switched on{for_what}; its previous state is unknown, left on" + if self.restored: + return f"light {self.object} switched on{for_what} and restored (off)" + return f"light {self.object} switched on{for_what}, NOT restored: {self.error}" + + def to_dict(self) -> dict[str, Any]: + return { + "found": self.found, + "object": self.object, + "was_on": self.was_on, + "switched": self.switched, + "restored": self.restored, + "on_for_frame": self.on_for_frame, + "error": self.error, + "skipped": self.skipped, + "text": self.text, + } + + +# ---- finding the light ------------------------------------------------------------------------ + + +def _hint_rank(name: str) -> int | None: + """0 for a name that says light/lamp, 1 for chamber/led as a word, None for anything else.""" + low = name.lower() + if any(h in low for h in _STRONG_HINTS): + return 0 + words = re.split(r"[^a-z0-9]+", low) + return 1 if any(w in _WORD_HINTS for w in words) else None + + +def detect_light(objects: Sequence[str], configured: str | None = None) -> str | None: + """The Moonraker object that is the light: `configured` when the printer has it, else the best guess + among the led / neopixel / dotstar / output_pin objects by name (light, lamp first, then chamber, led; + LEDs before pins, then alphabetical so the answer never depends on the order Klipper listed them).""" + if configured is not None: + return configured if configured in objects else None + ranked = [] + for obj in objects: + m = _OBJECT.match(obj) + rank = _hint_rank(m.group(2)) if m else None + if m and rank is not None: + ranked.append((rank, 1 if m.group(1) == PIN_KIND else 0, obj)) + return min(ranked)[2] if ranked else None + + +def list_objects(adapter: Any) -> list[str]: + """`printer.objects.list` of a Moonraker adapter; [] for an adapter that cannot list objects.""" + get = getattr(adapter, "_get", None) + if get is None: + return [] + listed = get("/printer/objects/list") + names = listed.get("objects", []) if isinstance(listed, dict) else [] + return [n for n in names if isinstance(n, str)] + + +def _number(x: Any) -> float | None: + return float(x) if isinstance(x, (int, float)) and not isinstance(x, bool) else None + + +def parse_state(obj: str, status: dict[str, Any]) -> LightState: + """The state of `obj` in an `objects/query` status: an output_pin has `value`, a LED chain `color_data` + (one [r, g, b, w] per LED); on = any channel above 0.""" + entry = status.get(obj) + if not isinstance(entry, dict): + return LightState(obj, None) + value = _number(entry.get("value")) + if value is not None: + return LightState(obj, value > 0) + data = entry.get("color_data") + if isinstance(data, list) and data and all(isinstance(px, list) for px in data): + levels = [_number(c) for px in data for c in px] + return LightState(obj, any(c is not None and c > 0 for c in levels)) + return LightState(obj, None) + + +def read_state(adapter: Any, obj: str) -> tuple[LightState, str | None]: + """(the light's state, print_stats.state or None) in one read-only `objects/query`.""" + status = adapter.query("print_stats&" + quote(obj)) + raw = (status.get("print_stats") or {}).get("state") + return parse_state(obj, status), raw if isinstance(raw, str) else None + + +def _builtin(obj: str, level: int) -> list[str]: + kind, name = obj.split(None, 1) + if kind == PIN_KIND: + return [f"SET_PIN PIN={name} VALUE={level}"] + return [ + f"SET_LED LED={name} RED={level} GREEN={level} BLUE={level} WHITE={level} TRANSMIT=1" + ] + + +def on_script(obj: str, cfg: LightConfig) -> str: + return "\n".join(cfg.on_gcode or _builtin(obj, 1)) + + +def off_script(obj: str, cfg: LightConfig) -> str: + return "\n".join(cfg.off_gcode or _builtin(obj, 0)) + + +def write_block(device: Any) -> str | None: + """Why the printer may not be sent anything (not a full-access device), or None.""" + access = getattr(device, "access", "full") + return None if access == "full" else f"{device.name} is {access}: no writes" + + +# ---- the session ------------------------------------------------------------------------------ + + +class LightSession: + """One light for one or more frames: `on()` when a frame is about to be taken (once; later calls do + nothing), `restore()` when the frames are done; leaving the `with` restores, also after an error. + + `printing_ok`: the caller is the job watch, whose frame is taken while the job prints; the light is + then switched only when it was off. Without it nothing is touched while a job prints or is paused, + or when the printer's state cannot be read. `blocked`: why the printer may not be written to.""" + + def __init__( + self, + adapter: Any, + cfg: LightConfig | None = None, + *, + printing_ok: bool = False, + note: str = "", + blocked: str | None = None, + objects: Sequence[str] | None = None, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self.adapter = adapter + self.cfg = cfg or LightConfig() + self.printing_ok = printing_ok + self.blocked = blocked + self.objects = objects + self.sleep = sleep + self.report = LightReport(note=note) + self._started = False + self._dirty = ( + False # the light may be on because of us: restore() sends the off script + ) + + def __enter__(self) -> LightSession: + return self + + def __exit__(self, *exc: object) -> None: + self.restore() + + def on(self) -> LightReport: + if not self._started: + self._started = True + try: + self._switch_on() + except Exception as exc: # noqa: BLE001 — a light problem never stops the frame + self.report.error = f"{type(exc).__name__}: {exc}" + return self.report + + def _skip_reason(self) -> str | None: + if not self.cfg.enabled: + return "off by config (watch.bed.light.enabled: false)" + return self.blocked + + def _switch_on(self) -> None: + rep = self.report + why = self._skip_reason() + if why is not None: + rep.skipped = why + return + names = self.objects if self.objects is not None else list_objects(self.adapter) + obj = detect_light(names, self.cfg.object) + if obj is None: + rep.detail = ( + f"the printer has no object {self.cfg.object!r}" + if self.cfg.object + else "no led / output_pin named light, lamp, led or chamber; name it in watch.bed.light.object" + ) + return + state, job = read_state(self.adapter, obj) + rep.found, rep.object, rep.was_on = True, obj, state.on + if job in ACTIVE_JOB_STATES and not self.printing_ok: + rep.detail = f"left alone: the printer is {job}" + elif job is None and not self.printing_ok: + rep.detail = "left alone: the printer's state could not be read" + elif state.on is not True: + self._send_on(obj, state) + + def _send_on(self, obj: str, state: LightState) -> None: + self._dirty = state.on is False # an unreadable previous state is not restored + try: + self.adapter.run_gcode(on_script(obj, self.cfg)) + except Exception as exc: # noqa: BLE001 — a half-sent script may have lit it: restore() still sends off + self.report.error = f"{type(exc).__name__}: {exc}" + return + self.report.switched = True + self.sleep(self.cfg.settle_s) + + def restore(self) -> None: + """Put the light back (off). Idempotent; a failure is in the report, never raised.""" + if not self._dirty or self.report.object is None: + return + self._dirty = False + try: + self.adapter.run_gcode(off_script(self.report.object, self.cfg)) + except Exception as exc: # noqa: BLE001 + self.report.restored, self.report.error = ( + False, + f"{type(exc).__name__}: {exc}", + ) + return + self.report.restored = True + + +@contextmanager +def lit( + adapter: Any, + cfg: LightConfig | None = None, + *, + printing_ok: bool = False, + note: str = "", + blocked: str | None = None, + objects: Sequence[str] | None = None, + sleep: Callable[[float], None] = time.sleep, +) -> Iterator[LightReport]: + """The light on for the body, put back after it (also when the body raises). Yields the report.""" + session = LightSession( + adapter, + cfg, + printing_ok=printing_ok, + note=note, + blocked=blocked, + objects=objects, + sleep=sleep, + ) + try: + yield session.on() + finally: + session.restore() + + +__all__ = [ + "ACTIVE_JOB_STATES", + "BUILTIN_LIGHT_COMMANDS", + "LIGHT_KINDS", + "SETTLE_S", + "LightConfig", + "LightReport", + "LightSession", + "LightState", + "detect_light", + "lit", + "list_objects", + "off_script", + "on_script", + "parse_state", + "read_state", + "write_block", +] diff --git a/tests/test_bed_light.py b/tests/test_bed_light.py new file mode 100644 index 00000000..283ebcbc --- /dev/null +++ b/tests/test_bed_light.py @@ -0,0 +1,396 @@ +"""The printer's light for every camera frame of the bed (issue #723): `printer_ops/bed_light.py`. + +It finds the light among Moonraker's objects, reads its state, switches it on when it is off and puts it +back; `watch.bed.light` is validated in printer_ops/bed.py. The printer is `FakePrinter` of test_bed_view +plus a light object (`LightPrinter`, also used by test_bed_light_frames and test_bed_preflight); nothing +here touches a real printer or camera. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +import pytest + +from errors import InvalidArgument +from printer_ops import bed as pbed +from printer_ops import bed_light as BL +from printer_ops.devices import Device +from test_bed_view import FRAMES, FakePrinter, ZCamera, device + +PIN = "output_pin chamber_light" +NOW = lambda s: None # noqa: E731 — the settle wait of every test + + +class LightPrinter(FakePrinter): + """A FakePrinter that lists and drives a light, and logs what happens in order (`log`).""" + + def __init__( + self, + state: str = "complete", + z: float = 200.0, + *, + light: str | None = PIN, + lit: bool = False, + others: tuple[str, ...] = (), + **kw: Any, + ) -> None: + super().__init__(state, z, **kw) + self.light = light + self.lit = lit + self.others = others + self.log: list[str] = [] + self.fail_scripts: set[str] = set() + self.hidden: set[str] = set() # status keys the printer does not report + + def _get(self, path: str) -> dict[str, Any]: + assert path == "/printer/objects/list" + names = ["print_stats", "toolhead", "fan", *self.others] + return {"objects": names + ([self.light] if self.light else [])} + + def query(self, objects: str) -> dict[str, Any]: + status = super().query(objects) + if self.light and self.light.split()[0] == "output_pin": + status[self.light] = {"value": 1.0 if self.lit else 0.0} + elif self.light: + status[self.light] = { + "color_data": [[1.0, 1.0, 1.0, 1.0] if self.lit else [0.0] * 4] + } + return {k: v for k, v in status.items() if k not in self.hidden} + + def run_gcode(self, script: str, *, timeout: float = 120.0) -> None: + self.log.append(script.replace("\n", " | ")) + if script in self.fail_scripts: + raise OSError(f"{script}: connection reset") + super().run_gcode(script) + first = script.splitlines()[0] + if first.startswith(("SET_PIN", "SET_LED")) or first in ( + "LIGHT_ON", + "LIGHT_OFF", + ): + self.lit = "VALUE=1" in first or "RED=1" in first or first == "LIGHT_ON" + + def camera(self, frames: dict[float, str] | None = None) -> ZCamera: + cam = ZCamera(self, frames or FRAMES) + cam.on_grab = lambda: self.log.append(f"capture lit={self.lit}") + return cam + + +def collapsed(log: list[str]) -> list[str]: + """The log with repeated captures (the check waits for a fresh frame) folded into one.""" + out: list[str] = [] + for entry in log: + if not (entry.startswith("capture") and out and out[-1] == entry): + out.append(entry) + return out + + +# ---- finding the light -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("objects", "found"), + [ + (["extruder", "output_pin chamber_light"], "output_pin chamber_light"), + (["led cavity_led", "output_pin beeper"], "led cavity_led"), + (["neopixel chamber"], "neopixel chamber"), + (["dotstar status_leds"], "dotstar status_leds"), + (["output_pin caselight"], "output_pin caselight"), + (["output_pin motor_enabled"], None), # "led" inside "enabled" is not a light + (["fan_generic light_fan", "heater_bed"], None), # not an led/output_pin object + (["output_pin beeper", "extruder"], None), + ( + ["output_pin chamber_fan", "output_pin lamp"], + "output_pin lamp", + ), # light/lamp beat chamber + (["output_pin zz_light", "led aa_light"], "led aa_light"), # LEDs before pins + ( + ["output_pin b_light", "output_pin a_light"], + "output_pin a_light", + ), # not the listing order + ([], None), + ], +) +def test_the_light_is_found_by_object_kind_and_name( + objects: list[str], found: str | None +) -> None: + assert BL.detect_light(objects) == found + assert BL.detect_light(list(reversed(objects))) == found + + +def test_a_configured_light_wins_and_must_exist() -> None: + objects = ["output_pin chamber_light", "led strip"] + assert BL.detect_light(objects, "led strip") == "led strip" + assert BL.detect_light(objects, "led nothere") is None + + +@pytest.mark.parametrize( + ("status", "on"), + [ + ({PIN: {"value": 1.0}}, True), + ({PIN: {"value": 0.0}}, False), + ( + {"led strip": {"color_data": [[0.0, 0.0, 0.0, 0.0], [0.0, 0.2, 0.0, 0.0]]}}, + True, + ), + ({"led strip": {"color_data": [[0.0, 0.0, 0.0, 0.0]]}}, False), + ({"led strip": {"color_data": []}}, None), + ({}, None), + ({PIN: {"value": True}}, None), # a bool is not a level + ], +) +def test_the_state_comes_from_value_or_color_data( + status: dict[str, Any], on: bool | None +) -> None: + obj = next(iter(status), PIN) + assert BL.parse_state(obj, status) == BL.LightState(obj, on) + + +# ---- the session -------------------------------------------------------------------------------- + + +def test_an_off_light_is_switched_on_for_the_body_and_put_back() -> None: + p = LightPrinter() + with BL.lit(p, note="the bed check", sleep=NOW) as report: + assert p.lit is True + assert p.log == ["SET_PIN PIN=chamber_light VALUE=1"] + assert p.lit is False and p.log[-1] == "SET_PIN PIN=chamber_light VALUE=0" + assert ( + report.found, + report.object, + report.was_on, + report.switched, + report.restored, + ) == ( + True, + PIN, + False, + True, + True, + ) + assert "switched on for the bed check and restored" in report.text + assert report.to_dict()["on_for_frame"] is True + + +def test_an_led_is_driven_with_set_led_and_comes_back_off() -> None: + p = LightPrinter(light="led cavity_led") + with BL.lit(p, sleep=NOW): + assert p.log == [ + "SET_LED LED=cavity_led RED=1 GREEN=1 BLUE=1 WHITE=1 TRANSMIT=1" + ] + assert ( + p.log[-1] == "SET_LED LED=cavity_led RED=0 GREEN=0 BLUE=0 WHITE=0 TRANSMIT=1" + and p.lit is False + ) + + +def test_a_light_that_is_already_on_is_not_touched() -> None: + p = LightPrinter(lit=True) + with BL.lit(p, sleep=NOW) as report: + pass + assert p.log == [] and p.lit is True + assert ( + report.was_on is True and report.switched is False and report.restored is None + ) + assert report.text == f"light {PIN} was already on" and report.on_for_frame is True + + +def test_the_light_is_restored_when_the_body_raises() -> None: + p = LightPrinter() + with pytest.raises(RuntimeError, match="camera"), BL.lit(p, sleep=NOW): + raise RuntimeError("camera down") + assert p.lit is False and p.log[-1].endswith("VALUE=0") + + +def test_the_light_is_restored_on_keyboard_interrupt() -> None: + p = LightPrinter() + with pytest.raises(KeyboardInterrupt), BL.lit(p, sleep=NOW): + raise KeyboardInterrupt + assert p.lit is False + + +def test_no_light_says_so_and_the_body_still_runs() -> None: + p = LightPrinter(light=None) + ran = [] + with BL.lit(p, sleep=NOW) as report: + ran.append(1) + assert ran == [1] and p.log == [] + assert report.found is False and report.text.startswith("no light found") + assert "watch.bed.light.object" in report.text + + +def test_an_adapter_that_cannot_list_objects_means_no_light_found() -> None: + p = FakePrinter("complete", 200.0) # no `_get` + with BL.lit(p, sleep=NOW) as report: + pass + assert report.found is False and p.scripts == [] + + +def test_an_unreadable_object_list_is_reported_not_raised() -> None: + p = LightPrinter() + + def boom(path: str) -> dict[str, Any]: + raise OSError("moonraker down") + + p._get = boom # type: ignore[method-assign] + with BL.lit(p, sleep=NOW) as report: + pass + assert report.found is False and "moonraker down" in (report.error or "") + assert report.text.startswith("no light found (") + + +@pytest.mark.parametrize("raw", ["printing", "paused"]) +def test_a_light_is_left_alone_while_a_job_runs(raw: str) -> None: + p = LightPrinter(raw) + with BL.lit(p, sleep=NOW) as report: + pass + assert p.log == [] and report.found is True and report.switched is False + assert f"left alone: the printer is {raw}" in report.text + + +def test_an_unreadable_printer_state_is_not_a_licence_to_touch_the_light() -> None: + p = LightPrinter() + p.hidden = {"print_stats"} + with BL.lit(p, sleep=NOW) as report: + pass + assert p.log == [] and "could not be read" in report.text + + +def test_the_job_watch_frame_lights_an_off_light_during_the_print_and_restores_it() -> ( + None +): + p = LightPrinter("printing") + with BL.lit(p, printing_ok=True, note="the job watch frame", sleep=NOW) as report: + assert p.lit is True + assert p.lit is False and report.restored is True + on = LightPrinter("printing", lit=True) + with BL.lit(on, printing_ok=True, sleep=NOW): + pass + assert on.log == [] and on.lit is True # already on: not touched, not turned off + + +def test_a_read_only_printer_is_never_written_to() -> None: + p = LightPrinter() + with BL.lit(p, blocked="u1 is read-only: no writes", sleep=NOW) as report: + pass + assert p.log == [] and report.found is False + assert report.text == "light not used: u1 is read-only: no writes" + + +def test_enabled_false_opts_out() -> None: + p = LightPrinter() + with BL.lit(p, BL.LightConfig(enabled=False), sleep=NOW) as report: + pass + assert p.log == [] and "watch.bed.light.enabled: false" in report.text + + +def test_a_failed_switch_is_reported_and_the_frame_is_taken_unlit() -> None: + p = LightPrinter() + p.fail_scripts = {"SET_PIN PIN=chamber_light VALUE=1"} + with BL.lit(p, sleep=NOW) as report: + pass + assert report.switched is False and "connection reset" in (report.error or "") + assert "could not be switched on" in report.text and report.on_for_frame is False + # the half-sent script may have lit it: the light is still sent back off + assert p.log[-1] == "SET_PIN PIN=chamber_light VALUE=0" + + +def test_a_failed_restore_is_reported_never_swallowed_silently() -> None: + p = LightPrinter() + p.fail_scripts = {"SET_PIN PIN=chamber_light VALUE=0"} + with BL.lit(p, sleep=NOW) as report: + pass + assert ( + report.restored is False + and "NOT restored" in report.text + and "connection reset" in report.text + ) + + +def test_a_macro_replaces_the_builtin_command_and_the_settle_is_waited() -> None: + p = LightPrinter() + waits: list[float] = [] + cfg = BL.LightConfig(on_gcode=("LIGHT_ON",), off_gcode=("LIGHT_OFF",), settle_s=2.5) + with BL.lit(p, cfg, sleep=waits.append): + assert p.lit is True + assert p.log == ["LIGHT_ON", "LIGHT_OFF"] and waits == [2.5] + + +def test_a_light_whose_state_is_unreadable_is_switched_on_and_left_on() -> None: + p = LightPrinter() + p.hidden = {PIN} + with BL.lit(p, sleep=NOW) as report: + pass + assert p.log == ["SET_PIN PIN=chamber_light VALUE=1"] and report.restored is None + assert "previous state is unknown, left on" in report.text + + +def test_the_session_switches_once_and_restores_once() -> None: + p = LightPrinter() + with BL.LightSession(p, sleep=NOW) as session: + session.on() + session.on() + session.restore() + assert p.log == [ + "SET_PIN PIN=chamber_light VALUE=1", + "SET_PIN PIN=chamber_light VALUE=0", + ] + + +def test_a_session_that_never_needed_a_frame_never_touches_the_light() -> None: + p = LightPrinter() + with BL.LightSession(p, sleep=NOW): + pass + assert p.log == [] + + +# ---- config ------------------------------------------------------------------------------------- + + +def dev(light: Any, trusted: frozenset[str] = frozenset()) -> Device: + return replace(device(watch={"bed": {"light": light}}), trusted_macros=trusted) + + +def test_the_light_config_is_validated_and_defaults_to_autodetect() -> None: + assert pbed.bed_config(device(watch={"bed": {}})).light == BL.LightConfig() + cfg = pbed.bed_config( + dev({"object": "output_pin chamber_light", "settle_s": 3}) + ).light + assert cfg.object == PIN and cfg.settle_s == 3.0 and cfg.enabled is True + assert pbed.bed_config(dev({"enabled": False})).light.enabled is False + assert "light" in pbed.BED_KEYS + + +def test_a_macro_in_the_light_config_must_be_trusted_like_clear_ack() -> None: + with pytest.raises(InvalidArgument, match="trusted_macros"): + pbed.bed_config(dev({"on_gcode": "LIGHT_ON"})) + cfg = pbed.bed_config( + dev( + {"on_gcode": "LIGHT_ON", "off_gcode": ["LIGHT_OFF"]}, + frozenset({"LIGHT_ON", "LIGHT_OFF"}), + ) + ).light + assert cfg.on_gcode == ("LIGHT_ON",) and cfg.off_gcode == ("LIGHT_OFF",) + # the two Klipper built-ins that only drive LEDs and pins need no entry + builtin = pbed.bed_config(dev({"on_gcode": "SET_PIN PIN=lamp VALUE=1"})).light + assert builtin.on_gcode == ("SET_PIN PIN=lamp VALUE=1",) + + +@pytest.mark.parametrize( + "bad", + [ + {"nope": 1}, + {"object": "fan chamber"}, + {"object": 5}, + {"settle_s": -1}, + {"enabled": "yes"}, + {"on_gcode": "LIGHT_ON; M112"}, + {"on_gcode": ["SET_PIN PIN=a VALUE=1\nM112"]}, + {"on_gcode": []}, + "light", + ], +) +def test_a_bad_light_block_is_refused_when_the_config_is_read(bad: Any) -> None: + with pytest.raises(InvalidArgument): + pbed.bed_config(dev(bad, frozenset({"LIGHT_ON"}))) From 9d42cd3d3ba7a5a297d5fe22994242a3f0caef77 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:49:24 +0100 Subject: [PATCH 07/30] feat(bed): every camera frame of the bed check, reference capture and 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) --- lib/bed_state.py | 122 ++++++++-- lib/printer_ops/jobwatch_sources.py | 50 +++- tests/test_bed_light_frames.py | 352 ++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 tests/test_bed_light_frames.py diff --git a/lib/bed_state.py b/lib/bed_state.py index 1ca3d5f0..c8d423a9 100644 --- a/lib/bed_state.py +++ b/lib/bed_state.py @@ -40,8 +40,10 @@ after each verdict; logs on stderr. Evidence frames (ROI outlined, changed pixels red) go to ~/.cache/3d-cli/vision/bed/. -READ-ONLY except `watch.bed.clear_ack` (printer_ops/bed.py WRITES) and the auto view of `status` -(lib/bed_view.py: a Z-only move of the bed to where the camera sees the whole plate, and back). +READ-ONLY except `watch.bed.clear_ack` (printer_ops/bed.py WRITES), the auto view of `status` +(lib/bed_view.py: a Z-only move of the bed to where the camera sees the whole plate, and back) and the +printer's light (printer_ops/bed_light.py: on before a frame of `status`/`check`/`reference --capture`, +back as found after; the `monitor` only reads idle frames and never touches it). """ from __future__ import annotations @@ -82,6 +84,7 @@ transitions, write_state, ) +from printer_ops.bed_light import LightSession, write_block # noqa: E402 from bed_view import view_in_progress # noqa: E402 @@ -578,6 +581,16 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s return _objects_verdict(d, blob_px, ref, (plate, plate_why, metrics), inner, match.in_view(z), z, printer, th) +def occupied_confident(v: Verdict, th: Thresholds, view_min: float) -> bool: + """Is this `occupied` one a start gate may FAIL on? A changed region of the normal occupied size (blob + `occupied_blob_px` or `occupied_min` of the ROI: not the lenient low-view size) seen with at least + `view_min` (watch.bed.auto_view_min) of the plate in the frame. At a poor view most of the ROI is the + machine and the room behind the bed, so the same pixels are a suspicion, not a finding.""" + m = v.metrics + big = m.get("blob_px", 0.0) >= th.occupied_blob_px or m.get("changed", 0.0) >= th.occupied_min + return v.bed == OCCUPIED and big and m.get("bed_in_view", 1.0) >= view_min # no calibration: the ROI is the view + + # ---- the change gate ------------------------------------------------------------------------ class Monitor: @@ -947,9 +960,11 @@ def _run_monitor(device: Device, cfg: BedConfig, *, duration: float | None = Non def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: Any = None, printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, th: Thresholds | None = None, now: float | None = None, - sleep: Callable[[float], None] = time.sleep) -> dict[str, Any]: + sleep: Callable[[float], None] = time.sleep, + before_frame: Callable[[], object] | None = None) -> dict[str, Any]: """The bed now: the monitor's last verdict when it is fresher than `max_age` at this Z, - else one fresh camera frame classified here (never written to the monitor's state).""" + else one fresh camera frame classified here (never written to the monitor's state). + `before_frame` runs right before the first camera grab of a fresh judgement (the light, status_view).""" th = thresholds_for(cfg) if th is None else th now = time.time() if now is None else now state, z = printer_fn(device) @@ -963,6 +978,8 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A and now - float(last["checked"]) <= max_age and last.get("bed") not in (None, PRINTING) and isinstance(last.get("z"), (int, float)) and match(float(last["z"]), z)): return {**last, "source": "watch", "age_s": round(now - float(last["checked"]), 1)} + if before_frame is not None: + before_frame() camera = camera if camera is not None else camera_for(device) first = jpeg = grab(camera) waited = 0.0 @@ -987,7 +1004,7 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A frame = save_evidence(device.name, jpeg, v, f"status-{v.bed}", now) return {"printer": device.name, "bed": v.bed, "plate": v.plate, "z": z, "source": "camera now", "age_s": 0.0, "reason": v.reason, "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, "frame": frame, - "raw_frame": str(raw_frame_path(frame))} + "raw_frame": str(raw_frame_path(frame)), "occupied_confident": occupied_confident(v, th, cfg.auto_view_min)} def status_view(device: Device, cfg: BedConfig, *, mode: str = "auto", max_age: float | None = None, @@ -1010,16 +1027,24 @@ def printer_fn(_: Device) -> tuple[str, float | None]: m = bed_view.read_motion(adapter) return m.state, m.z + light = LightSession(adapter, cfg.light, note="the bed check", blocked=write_block(device), sleep=sleep) + def judge(age: float | None) -> dict[str, Any]: - return one_shot(device, cfg, max_age=age, camera=camera, printer_fn=printer_fn, th=th, now=now, sleep=sleep) + return one_shot(device, cfg, max_age=age, camera=camera, printer_fn=printer_fn, th=th, now=now, sleep=sleep, + before_frame=light.on) def view(z: float) -> float | None: return 1.0 if match.cam is None else match.in_view(z) # a roi polygon is in the frame by definition - return bed_view.auto_view(device, cfg, mode=mode, adapter=adapter, judge=judge, view=view, - ref_zs=[r.z for r in refs if r.z is not None], stands=match, max_age=max_age, - part_height_mm=part_height, sleep=sleep, - settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) + with light: # the light is back as it was found after the bed is back (restore order: Z, then light) + if max_age is None: # a fresh frame is certain: the settle overlaps the Z move + light.on() + verdict = bed_view.auto_view(device, cfg, mode=mode, adapter=adapter, judge=judge, view=view, + ref_zs=[r.z for r in refs if r.z is not None], stands=match, max_age=max_age, + part_height_mm=part_height, sleep=sleep, + settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) + verdict["light"] = light.report.to_dict() + return verdict def check_result(v: dict[str, Any], expect: str | None) -> tuple[str, bool]: @@ -1063,7 +1088,8 @@ def capture_reference_best_view(device: Device, cfg: BedConfig, *, plate: bool, sleep: Callable[[float], None] = time.sleep, settle_s: float | None = None) -> dict[str, Any]: """`3d printer bed reference --capture --best-view`: the bed is moved in Z (the same guarded Z-only - move as `check`) to where the camera sees the plate, the empty bed is stored as the reference THERE, + move as `check`) to where the camera sees the plate, the empty bed is stored as the reference THERE + with the printer's light on (restored after the bed is back; `light` in the result says what happened), and the bed is put back. No reference has to stand at that Z yet. The caller asserts the plate is empty.""" import bed_view from printer_ops import make_adapter @@ -1071,24 +1097,31 @@ def capture_reference_best_view(device: Device, cfg: BedConfig, *, plate: bool, adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) camera = camera if camera is not None else camera_for(device) match = z_match(device, cfg) + light = LightSession(adapter, cfg.light, note="the reference frame", blocked=write_block(device), sleep=sleep) def printer_fn(_: Device) -> tuple[str, float | None]: m = bed_view.read_motion(adapter) return m.state, m.z def take(_: float | None) -> dict[str, Any]: - return capture_reference(device, cfg, plate=plate, camera=camera, printer_fn=printer_fn, now=now) + return capture_reference(device, cfg, plate=plate, camera=camera, printer_fn=printer_fn, now=now, + before_frame=light.on) def view(z: float) -> float | None: return 1.0 if match.cam is None else match.in_view(z) - return bed_view.auto_view(device, cfg, mode="force", adapter=adapter, judge=take, view=view, ref_zs=[], - stands=match, part_height_mm=part_height, sleep=sleep, need_reference=False, - settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) + with light: + light.on() # before the move: the settle overlaps the Z travel + ref = bed_view.auto_view(device, cfg, mode="force", adapter=adapter, judge=take, view=view, ref_zs=[], + stands=match, part_height_mm=part_height, sleep=sleep, need_reference=False, + settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) + ref["light"] = light.report.to_dict() + return ref def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], tuple[str, float | None]], - frame: bytes | None, frame_z: float | None) -> tuple[float, bytes]: + frame: bytes | None, frame_z: float | None, + before_frame: Callable[[], object] | None = None) -> tuple[float, bytes]: from errors import UsageError if frame is not None: @@ -1104,19 +1137,22 @@ def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], if z is None: raise UsageError(f"{device.name}: toolhead Z unknown (Klipper not ready?)", command="printer bed", remediation=["Retry when `3d printer status` reads the printer."]) + if before_frame is not None: + before_frame() return z, grab(camera if camera is not None else camera_for(device)) def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: Any = None, printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, now: float | None = None, frame: bytes | None = None, - frame_z: float | None = None) -> dict[str, Any]: + frame_z: float | None = None, before_frame: Callable[[], object] | None = None) -> dict[str, Any]: """Store the current camera frame (or `frame`, a saved JPEG of the empty bed taken at gcode - Z `frame_z`, e.g. an earlier snapshot) as the printer's reference at that Z.""" + Z `frame_z`, e.g. an earlier snapshot) as the printer's reference at that Z. `before_frame` runs right + before the camera is asked (the light), never for a saved frame.""" from errors import UsageError now = time.time() if now is None else now - z, jpeg = _reference_frame(device, camera, printer_fn, frame, frame_z) + z, jpeg = _reference_frame(device, camera, printer_fn, frame, frame_z, before_frame) match = z_match(device, cfg) hidden = out_of_view(match, z, thresholds_for(cfg)) if hidden is not None: # a reference there would read every low part as a clear bed @@ -1241,6 +1277,8 @@ def list_references(printer: str) -> list[dict[str, Any]]: view"); off unless set (fleet/printers.yaml: true for the U1) auto_view_min: 0.85 share of the plate that must be in the frame; below it the bed is moved auto_view_part_mm: 60 declared height of the tallest thing that may stand on the bed (mm) + light: {object: "output_pin chamber_light", on_gcode: LIGHT_ON, off_gcode: LIGHT_OFF, settle_s: 1.5, + enabled: false} the printer's light for every camera frame (see "Light"); all keys optional Auto view (status; also the `bed` line of `3d print-preflight --printer` and `3d printer start`): The U1 rests at Z 200 after a job, where its camera sees ~15 % of the plate: window light there @@ -1261,6 +1299,20 @@ def list_references(printer: str) -> list[dict[str, Any]]: --part-height MM the tallest thing that may stand on the bed, for this call The `3d printer watch` monitor never moves the bed and judges nothing while a check has it away. +Light (check, status, reference --capture, the `bed` line of the preflight, `printer watch --job`): + A frame of a dark chamber is not comparable to the reference, so the printer's own light is switched + on before every frame and put back as it was found (also after a failure). The light is the Moonraker + object `led NAME` / `neopixel NAME` / `dotstar NAME` (SET_LED) or `output_pin NAME` (SET_PIN) whose + name contains light, lamp, led or chamber; `watch.bed.light.object` names it when the guess is wrong + and `on_gcode` / `off_gcode` run a macro instead (each command must be SET_LED, SET_PIN or in + trusted_macros). Its state is read first (read-only objects/query): a light that is already on is + left alone; settle_s (1.5 s) is the wait after switching on. No light found: the verdict says + "no light found" and the frame is taken as it is. While a job prints nothing is touched, except + that the job watch takes its frame with the light on when it was off and restores it right after. + The `light` key of the verdict (`--json`) and a `light:` line of the text say what happened. The + `3d printer watch` monitor only reads idle frames and never touches the light; `enabled: false` + turns the whole thing off for a printer. + Examples: 3d printer bed check u1 # move the bed to the camera, look, put it back 3d printer bed check u1 --expect part --json # after a print: is the part still on the plate? @@ -1293,14 +1345,35 @@ def _cmd_reference(args: argparse.Namespace, device: Device, cfg: BedConfig) -> if args.best_view: ref = capture_reference_best_view(device, cfg, plate=not args.no_plate, part_height=args.part_height) else: - ref = capture_reference(device, cfg, plate=not args.no_plate, frame=frame, frame_z=args.z) - print(json.dumps(ref, indent=1) if args.json else + ref = _capture_lit(device, cfg, plate=not args.no_plate, frame=frame, frame_z=args.z) + print(json.dumps(ref, indent=1, default=str) if args.json else f"{ref['kind']} reference of {device.name} at Z {ref['z']:.1f}: {ref['path']}\n" f" ROI: {ref['roi_source']} ({ref['volume_share']:.1%} of the frame; " - f"bed surface {ref['surface_share']:.1%})") + f"bed surface {ref['surface_share']:.1%})" + _light_line(ref)) return 0 +def _capture_lit(device: Device, cfg: BedConfig, *, plate: bool, frame: bytes | None, + frame_z: float | None) -> dict[str, Any]: + """`capture_reference`; a camera frame (not a saved one) is taken with the printer's light on.""" + if frame is not None: + return capture_reference(device, cfg, plate=plate, frame=frame, frame_z=frame_z) + from printer_ops import make_adapter + + light = LightSession(make_adapter(device, timeout=10.0), cfg.light, note="the reference frame", + blocked=write_block(device)) + with light: + ref = capture_reference(device, cfg, plate=plate, frame_z=frame_z, before_frame=light.on) + ref["light"] = light.report.to_dict() + return ref + + +def _light_line(v: dict[str, Any]) -> str: + """The `light:` line of a check / status / reference text ('' when the verdict has no light report).""" + light = v.get("light") + return f"\n light: {light['text']}" if isinstance(light, dict) and light.get("text") else "" + + def _list_references_cmd(args: argparse.Namespace, device: Device) -> int: refs = list_references(device.name) if args.json: @@ -1326,7 +1399,7 @@ def _cmd_check(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: print(f"{device.name}: {c['result']}{frac}; Z {_fz(c.get('z'))}" + (f", bed {moved}" if moved else "") + f"\n {c.get('reason', '')}" + (f"\n frame: {c['frame']}" if c.get("frame") else "") - + (f"\n camera frame: {c['raw_frame']}" if c.get("raw_frame") else "")) + + (f"\n camera frame: {c['raw_frame']}" if c.get("raw_frame") else "") + _light_line(c)) return 0 if c["ok"] else 1 @@ -1337,7 +1410,8 @@ def _cmd_status(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int print(json.dumps(v, indent=1, default=str)) else: print(f"{device.name}: bed {v['bed']}, plate {v['plate']} (Z {_fz(v.get('z'))}; {v['source']}, " - f"{v.get('age_s', 0):.0f} s ago)\n {v.get('reason', '')}" + (f"\n frame: {v['frame']}" if v.get("frame") else "")) + f"{v.get('age_s', 0):.0f} s ago)\n {v.get('reason', '')}" + (f"\n frame: {v['frame']}" if v.get("frame") else "") + + _light_line(v)) return 0 diff --git a/lib/printer_ops/jobwatch_sources.py b/lib/printer_ops/jobwatch_sources.py index b4cfb8f4..9d8778c4 100644 --- a/lib/printer_ops/jobwatch_sources.py +++ b/lib/printer_ops/jobwatch_sources.py @@ -4,8 +4,9 @@ `LiveSource` one GET per poll of the objects the always-on watch subscribes to (`moonraker_subscription`), fed to the SAME `MoonrakerDetector` (filament runout, heater loss, bed off, exception, G-code error, homing lost) plus the layer, progress, file position and toolhead Z; the printer's own - defect detection from klippy.log (`KlippyLogTap`); the camera snapshot (`open_camera`). Read-only: - GETs and the `camera.start_monitor` keepalive only. + defect detection from klippy.log (`KlippyLogTap`); the camera snapshot (`open_camera`), taken + with the printer's light on when it was off and restored right after (bed_light.py; the only + write, and only for the frame). Otherwise read-only: GETs and the `camera.start_monitor` keepalive. `FixtureSource` the same surface from a scenario JSON on a virtual clock, with a `FixtureAdapter` that records the PAUSE/CANCEL it is given. This is the dry-run/demo mode: `3d printer watch NAME --job --fixture scenario.json`, nothing is ever sent to a printer or a phone. @@ -34,6 +35,8 @@ PrinterCommError, PrinterStatus, ) +from printer_ops.bed import bed_config +from printer_ops.bed_light import LightConfig, LightReport, LightSession, write_block from printer_ops.jobwatch import Observation from printer_ops.watch import Event, MoonrakerDetector, moonraker_subscription @@ -68,15 +71,31 @@ def __init__(self, adapter: Any, camera: Any, tap: Any | None = None) -> None: self.tap = tap self.detector = MoonrakerDetector(adapter.name) self._query: str | None = None + self._names: list[str] | None = None + # `watch.bed.light` of the printer (the job watch has no bed block of its own: a printer + # without one uses the defaults; an invalid block fails here, at the start of the watch) + self._device = getattr(adapter, "device", None) + self.light_config = ( + bed_config(self._device).light if self._device is not None else LightConfig() + ) + self.light_report: LightReport | None = ( + None # what the light did for the last frame + ) self._layers: tuple[float, float] | None | bool = ( False # (first layer height, layer height); False = unasked ) - def _objects_query(self) -> str: - if self._query is None: + def _object_names(self) -> list[str]: + """`printer.objects.list`, read once.""" + if self._names is None: listed = self.adapter._get("/printer/objects/list") names = listed.get("objects", []) if isinstance(listed, dict) else [] - self._query = query_string([n for n in names if isinstance(n, str)]) + self._names = [n for n in names if isinstance(n, str)] + return self._names + + def _objects_query(self) -> str: + if self._query is None: + self._query = query_string(self._object_names()) return self._query def poll(self, now: float) -> Observation: @@ -133,8 +152,27 @@ def noodles(self) -> list[tuple[str, float]]: return [] def grab(self) -> bytes: + """One camera frame, taken with the printer's light on when it was off and put back right after. + The job is printing, so this is the only moment the light is touched (`printing_ok`), and only + when it was off; no light, or a failed switch, only means an unlit frame (`self.light_report`).""" self.camera.keepalive() - return bytes(self.camera.grab().jpeg) + try: + names: list[str] | None = self._object_names() + except PrinterCommError: + names = None # the light session reads the list itself and reports it as unreadable + light = LightSession( + self.adapter, + self.light_config, + printing_ok=True, + note="the job watch frame", + blocked=write_block(self._device), + objects=names, + ) + with light: + light.on() + jpeg = bytes(self.camera.grab().jpeg) + self.light_report = light.report + return jpeg # ---- fixtures --------------------------------------------------------------------------------- diff --git a/tests/test_bed_light_frames.py b/tests/test_bed_light_frames.py new file mode 100644 index 00000000..d736818d --- /dev/null +++ b/tests/test_bed_light_frames.py @@ -0,0 +1,352 @@ +"""The printer's light around every camera frame of the bed (issue #723): `bed check`, `bed status`, the +reference capture and the job watch's frame (`LiveSource.grab`), against `LightPrinter` (test_bed_light: +a FakePrinter that lists and drives a light and logs every script in order) and `ZCamera` (saved frames +by the bed's Z). Nothing here touches a real printer or camera. +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from printer_ops import bed as pbed +from printer_ops import bed_light as BL +from printer_ops.jobwatch_sources import LiveSource +from test_bed_light import NOW, PIN, LightPrinter, collapsed +from test_bed_view import CFG, capture, device + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_state as B # noqa: E402 (needs numpy + OpenCV) + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def refs() -> None: + capture(200.0, "u1_z200_clear_ref.jpg") + capture(70.0, "u1_z10_clear_ref.jpg") + + +# ---- the bed check: light on, bed move, capture, bed restore, light restore ------------------------ + + +def test_check_view_drives_the_light_around_the_whole_move_and_capture( + refs: None, +) -> None: + p = LightPrinter("complete", 200.0) + c = B.check_view( + device(), + CFG, + adapter=p, + camera=p.camera(), + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + sequence = collapsed(p.log) + print( + "\n".join(f" {i}. {entry}" for i, entry in enumerate(sequence, 1)) + ) # the demo evidence + assert sequence == [ + "SET_PIN PIN=chamber_light VALUE=1", + "G90 | G1 Z70.000 F600 | M400", + "capture lit=True", + "G90 | G1 Z200.000 F600 | M400", + "SET_PIN PIN=chamber_light VALUE=0", + ] + assert (c["result"], c["ok"]) == ("empty", True) and p.z == 200.0 and p.lit is False + assert ( + c["light"]["switched"] is True + and c["light"]["restored"] is True + and c["light"]["object"] == PIN + ) + + +def test_a_light_that_is_already_on_stays_on_through_the_check(refs: None) -> None: + p = LightPrinter("complete", 200.0, lit=True) + c = B.check_view( + device(), + CFG, + adapter=p, + camera=p.camera(), + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + assert [e for e in p.log if e.startswith("SET_")] == [] and p.lit is True + assert c["light"]["was_on"] is True and c["light"]["switched"] is False + + +def test_the_light_is_restored_when_the_camera_fails_mid_check(refs: None) -> None: + p = LightPrinter("complete", 200.0) + cam = p.camera() + cam.fail = OSError("camera unplugged") + with pytest.raises(OSError, match="unplugged"): + B.check_view( + device(), + CFG, + adapter=p, + camera=cam, + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + assert p.lit is False and p.z == 200.0 + assert collapsed(p.log)[-1] == "SET_PIN PIN=chamber_light VALUE=0" + + +def test_no_move_check_still_lights_the_frame(refs: None) -> None: + p = LightPrinter("complete", 200.0) + c = B.check_view( + device(), + CFG, + mode="never", + adapter=p, + camera=p.camera(), + sleep=NOW, + settle_s=0.0, + ) + assert collapsed(p.log) == [ + "SET_PIN PIN=chamber_light VALUE=1", + "capture lit=True", + "SET_PIN PIN=chamber_light VALUE=0", + ] + assert c["view"]["action"] == "not moved" + + +def test_a_printing_printer_is_not_asked_and_the_light_is_not_touched( + refs: None, +) -> None: + p = LightPrinter("printing", 150.0) + c = B.check_view( + device(), CFG, adapter=p, camera=p.camera(), sleep=NOW, settle_s=0.0 + ) + assert c["result"] == "busy" and p.log == [] + assert "left alone" in c["light"]["text"] + + +def test_a_printer_without_a_light_says_so_in_the_verdict_and_is_judged_anyway( + refs: None, +) -> None: + p = LightPrinter("complete", 200.0, light=None) + c = B.check_view( + device(), + CFG, + adapter=p, + camera=p.camera(), + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + assert c["result"] == "empty" and c["light"]["found"] is False + assert c["light"]["text"].startswith("no light found") + + +def test_light_config_off_leaves_the_light_alone_for_the_check(refs: None) -> None: + p = LightPrinter("complete", 200.0) + cfg = replace(CFG, light=BL.LightConfig(enabled=False)) + c = B.check_view( + device(), + cfg, + adapter=p, + camera=p.camera(), + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + assert [e for e in p.log if e.startswith("SET_")] == [] and c["light"]["skipped"] + + +def test_a_read_only_printer_gets_no_light_script(refs: None) -> None: + p = LightPrinter("complete", 200.0) + c = B.check_view( + device(access="read-only"), + CFG, + adapter=p, + camera=p.camera(), + part_height=20.0, + sleep=NOW, + settle_s=0.0, + ) + assert p.log[:1] == ["capture lit=False"] or all( + not e.startswith("SET_") for e in p.log + ) + assert "no writes" in c["light"]["text"] + + +def test_status_with_a_fresh_monitor_verdict_does_not_light_the_chamber( + refs: None, +) -> None: + """--max-age: a verdict the monitor confirmed recently is served without a frame, so without the light.""" + import json + import time + + p = LightPrinter("complete", 200.0) + path = pbed.state_path("u1") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "printer": "u1", + "bed": "clear", + "plate": "present", + "z": 200.0, + "checked": time.time(), + "reason": "r", + } + ), + encoding="utf-8", + ) + v = B.status_view( + device(), + CFG, + mode="never", + max_age=60.0, + adapter=p, + camera=p.camera(), + sleep=NOW, + settle_s=0.0, + ) + assert v["source"] == "watch" and p.log == [] + stale = B.status_view( + device(), + CFG, + mode="never", + max_age=0.0001, + adapter=p, + camera=p.camera(), + sleep=NOW, + settle_s=0.0, + ) + assert ( + stale["source"] == "camera now" + and collapsed(p.log)[0] == "SET_PIN PIN=chamber_light VALUE=1" + ) + + +# ---- reference capture ---------------------------------------------------------------------------- + + +def test_a_best_view_reference_is_taken_lit_and_the_light_is_restored_last() -> None: + p = LightPrinter("complete", 200.0) + r = B.capture_reference_best_view( + device(), + CFG, + plate=True, + part_height=20.0, + adapter=p, + camera=p.camera(), + now=1.0, + sleep=NOW, + settle_s=0.0, + ) + seq = collapsed(p.log) + assert ( + seq[0] == "SET_PIN PIN=chamber_light VALUE=1" + and seq[-1] == "SET_PIN PIN=chamber_light VALUE=0" + ) + assert seq[-2].startswith("G90 | G1 Z200") and "capture lit=True" in seq + assert r["light"]["switched"] is True and r["light"]["restored"] is True + + +def test_a_reference_from_a_saved_frame_never_touches_the_light() -> None: + p = LightPrinter("complete", 200.0) + data = Path(__file__).parent / "data" / "bed" / "u1_z200_clear_ref.jpg" + B.capture_reference( + device(), + CFG, + plate=True, + frame=data.read_bytes(), + frame_z=200.0, + now=1.0, + before_frame=lambda: p.log.append("light!"), + ) + assert p.log == [] + + +# ---- the job watch frame ---------------------------------------------------------------------------- + + +def live( + p: LightPrinter, light_cfg: BL.LightConfig | None = None, access: str = "full" +) -> LiveSource: + dev_ = replace( + device(access=access, watch={"bed": {"light": {"settle_s": 0}}}), + ) + p.name = "u1" # type: ignore[attr-defined] + p.device = dev_ # type: ignore[attr-defined] + return LiveSource(p, p.camera(), None) + + +def test_the_job_watch_frame_is_taken_with_the_light_on_and_restored_right_after() -> ( + None +): + p = LightPrinter("printing", 12.0) + src = live(p) + jpeg = src.grab() + assert jpeg[:2] == b"\xff\xd8" + assert p.log == [ + "SET_PIN PIN=chamber_light VALUE=1", + "capture lit=True", + "SET_PIN PIN=chamber_light VALUE=0", + ] + assert ( + p.lit is False + and src.light_report is not None + and src.light_report.restored is True + ) + p.log.clear() + src.grab() # the object list is read once, the light again for every frame + assert len(p.log) == 3 + + +def test_the_job_watch_leaves_a_lit_chamber_alone_and_survives_no_light() -> None: + lit = LightPrinter("printing", 12.0, lit=True) + live(lit).grab() + assert [e for e in lit.log if e.startswith("SET_")] == [] and lit.lit is True + dark = LightPrinter("printing", 12.0, light=None) + src = live(dark) + assert ( + src.grab()[:2] == b"\xff\xd8" + and src.light_report is not None + and src.light_report.found is False + ) + + +def test_the_job_watch_restores_the_light_when_the_camera_fails() -> None: + p = LightPrinter("printing", 12.0) + src = live(p) + src.camera.fail = OSError("snapshot timeout") # type: ignore[attr-defined] + with pytest.raises(OSError, match="snapshot"): + src.grab() + assert p.lit is False + + +def test_a_live_source_without_a_device_uses_the_default_light() -> None: + p = LightPrinter("printing", 12.0) + p.name = "u1" # type: ignore[attr-defined] + src = LiveSource( + p, + SimpleNamespace( + keepalive=lambda: None, grab=lambda: SimpleNamespace(jpeg=b"\xff\xd8x") + ), + ) + src.light_config = replace(src.light_config, settle_s=0.0) + assert ( + src.grab() == b"\xff\xd8x" and p.log[0] == "SET_PIN PIN=chamber_light VALUE=1" + ) From 46fbccc232c29c5263c1d93c354a1508f449735d Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:53:17 +0100 Subject: [PATCH 08/30] feat(preflight): the `bed` line runs the full bed check and FAILs only 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) --- docs/architecture/lib-map.md | 5 +- docs/commands/printer.md | 49 ++- lib/printer_ops/preflight.py | 393 +++++++++++++----------- tests/e2e/test_printer_bed_workflows.py | 120 +++++++- tests/test_bed_preflight.py | 338 ++++++++++++++++++++ tests/test_bed_state.py | 6 +- tests/test_bed_view.py | 10 +- 7 files changed, 729 insertions(+), 192 deletions(-) create mode 100644 tests/test_bed_preflight.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 89326093..19b91569 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -111,6 +111,9 @@ Target package: `lib/geometry/`, with subpackages only when a real boundary appe `dossier.py` / `dossier_render.py` / `webui.py` / `render.py` (`3d printers show NAME`: name resolution, the concurrent read-only live reads, the web UI probe, the text; `render.py` is the status text shared with `3d printer status`), `preflight.py` (pre-print checks), + `bed.py` / `bed_light.py` (the bed-state config and start gate; the printer's light switched on before + every camera frame of the bed and restored after it: Moonraker `led` / `output_pin` autodetect or + `watch.bed.light`, `LightSession`, never while a job prints except for the job watch's frame), `move_bounds.py` (G-code moves and arc extremes vs axis travel / printable area), `force_hatch.py` (the owner's Telegram hatch approval that `3d printer start --force` needs), `watch.py` / `watch_run.py` / `watch_sinks.py` (`3d printer watch`: detectors, streams, @@ -321,7 +324,7 @@ Every current root Python module is listed here so future additions are intentio - `vision_geometry.py` — printer camera model (pinhole/fisheye), bed/volume/silhouette masks at the current Z, calibration fit CLI; the U1 calibration ships in `lib/data/vision/`. - `vision_ghost.py` — 3D ghost of the rest of a print over a printer camera frame: collects a job's walls from one G-code pass, paints them through a `vision_geometry` camera into a sparse per-frame blend table (used by the U1 camera proxy). - `vision_scene.py` — opt-in bed-change detectors for `3d printer vision --scene` (foreign object, moved part, no growth). -- `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier, change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); config, events, start gate and clear ack live in `printer_ops/bed.py`. +- `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier (`occupied_confident`: the start gate FAILs only on a confident find), change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); the printer's light is on for every frame of `check` / `status` / `reference --capture` (`printer_ops/bed_light.py`); config, events, start gate and clear ack live in `printer_ops/bed.py`. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. - `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration; without one the plate is FOLLOWED on the picture — a chained ECC affine transform saved with every healthy footprint, `--track`/`--save-track`, so the baseline survives the bed's drift), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view, cannot be followed, or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 95a492f0..e39d0901 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -708,6 +708,28 @@ until someone takes them off. The camera can see that, so nobody has to tell an The `3d printer watch` monitor never moves the bed, and judges nothing (no verdict, no event, no ack) while a check has the bed away from where it was found. Config: `watch.bed.auto_view: true|false`, `auto_view_min`, `auto_view_part_mm` (see below). +- **The light** (`watch.bed.light`, on unless `enabled: false`). A frame of a dark chamber is not + comparable to the reference, so every camera frame of the bed — `bed check`, `bed status` when it + takes a frame, `bed reference --capture`, the preflight `bed` line and the [job + watch](#job-watch) frame — is taken with the printer's own light on, and the light goes back as it + was found (in a `finally`: also after a failed frame, Ctrl-C or SIGTERM). The light is found among + Moonraker's `printer.objects.list`: `led NAME`, `neopixel NAME`, `dotstar NAME` (switched with + `SET_LED`) or `output_pin NAME` (`SET_PIN`) whose name says light, lamp, led or chamber (light/lamp + first, LEDs before pins). Its state is read first (read-only `objects/query`: `value` of a pin, + `color_data` of a LED chain); a light that is already on is not touched; after switching on it waits + `settle_s` (1.5). Name it when the guess is wrong, or give a macro: + `light: {object: "output_pin chamber_light", on_gcode: LIGHT_ON, off_gcode: LIGHT_OFF, settle_s: 1.5}`. + Every `on_gcode`/`off_gcode` command must be `SET_LED`, `SET_PIN` or listed in the printer's + `trusted_macros` (checked when the config is read, like `clear_ack`); a macro whose light state + cannot be read is switched on and, its previous state being unknown, left on (the report says so). + `enabled: false` opts out. **No light found:** the verdict says `no light found (...)` and the + frame is taken as it is. **Never touched while a job prints or is paused**, nor on a printer that is + not full-access; the one exception is the job watch, whose frame is taken during the print: only + when the light was off, switched on for the frame and off right after. The `3d printer watch` bed + *monitor* only reads idle frames and never touches the light. The verdict (`--json`) carries + `light`: `found`, `object`, `was_on`, `switched`, `restored`, `on_for_frame`, `error`, `text`, and + `check` / `status` / `reference` print it as a `light:` line. `light.on_for_frame` tells a later + comparison whether the frame was lit. - **Bed free, once.** `3d printer subscribe --session ID --printer u1 --once --on bed-free`: when the running job ends (a `3d printer watch` sees printing/paused → idle / complete / cancelled) the watcher waits 20 s for the end macro, runs the auto-view bed check, sends **one** `bed_free` @@ -754,16 +776,23 @@ until someone takes them off. The camera can see that, so nobody has to tell an (`watch.tg_fallback`). Subscribe an agent to them: `3d printer subscribe --session ID --kinds 'bed_*,plate_*' --min-severity info`. The last verdict is in `~/.local/state/3d-cli/bed/NAME.json` and in the watch heartbeat. -- **Start gate.** `3d printer start` and `3d print-preflight --printer` add a `bed` line: FAIL on - objects or a missing plate, PASS on a clear bed with the plate, WARN when the camera cannot - tell (no reference at this Z, out of view, stale, plate out of view, camera down); a [forced start](#forcing-a-start) overrides it. - On an idle printer with `watch.bed.auto_view` the line is judged at the best Z (the bed moved in Z - only, and back; `--no-bed-move` judges at the current Z), see *Auto view* above. +- **Start gate.** `3d printer start` and `3d print-preflight --printer` add a `bed` line. On an idle printer + the line runs the **full [bed check](#3d-printer-bed-check) path itself**: a fresh frame from the best + view (the bed moved in Z only, and back, even when `watch.bed.auto_view` is off), with the printer's + light on (see *The light* above) and restored — never the watcher's verdict at whatever Z the bed rests at. + `--no-bed-move` judges at the current Z (light still on). **FAIL only on a confident `occupied`** + (a changed region of the normal occupied size with at least `watch.bed.auto_view_min` of the plate in + the frame at the Z it was judged at; the verdict's `occupied_confident`) or a missing plate. PASS on a + clear bed with the plate. **WARN** (never FAIL) for `unknown` (no reference at this Z, out of view, + plate out of view, camera down) and for an occupied seen at a poor view: the U1 at Z 200 sees 15 % of + the plate and a window light there once read as «16.0 % changed: occupied», so that line is a WARN + that gives the reason, the share of the plate in view and `3d printer bed check NAME` to run. A + [forced start](#forcing-a-start) overrides a FAIL. - **Clear acknowledgement** (`watch.bed.clear_ack`, off by default): after `bed_clear` the printer makes one motor sound so the person at it knows the camera saw the empty bed. Neither printer has a beeper, so the command is `STEPPER_BUZZ STEPPER=stepper_z` (Klipper: ±1 mm ×10, ~10 s, net zero, works unhomed); a list of lines is allowed (the K1 SE can use shorter - `FORCE_MOVE` pulses). It is the only write the bed code makes: every command word must be in + `FORCE_MOVE` pulses). Besides the auto view's Z move and the light's on/off, it is the only write the bed code makes: every command word must be in `trusted_macros`, it is sent only when the printer reads idle/complete/cancelled right before, once per `bed_clear`, at most once per `debounce_minutes` (10), never below `min_z_mm` (10). @@ -777,6 +806,7 @@ u1: auto_view: true # move the bed in Z to where the camera sees the whole plate (fleet U1: true; default false) auto_view_min: 0.85 # share of the plate in the frame below which it is moved auto_view_part_mm: 60 # optional: the tallest thing that may stand on the bed (else the last job's object_height) + light: {object: "output_pin chamber_light", on_gcode: LIGHT_ON, off_gcode: LIGHT_OFF, settle_s: 1.5} # optional: autodetected when absent; enabled: false opts out k1se: watch: bed: @@ -791,7 +821,8 @@ A Home Assistant bed sensor is a follow-up: [#305](https://github.com/alex-mextn `3d printer bed check NAME [--expect empty|part] [--no-move] [--part-height MM] [--json]` is **the** tool to look at the bed: a fresh frame judged from where the camera sees the plate. An idle printer's bed is moved in Z only (the guarded move of auto view, even when `watch.bed.auto_view` is off), judged and put -back; `--no-move` judges at the current Z. +back; `--no-move` judges at the current Z. The frame is taken with the printer's light on (switched on +before the bed moves, restored after the bed is back; see *The light* under [Bed state](#bed-state)). - **Result:** `empty` | `objects` | `out-of-view` | `unknown` | `busy` (printing: the bed belongs to the job, nothing is moved; use `3d printer watch NAME --job`). `--expect part` reads the same @@ -827,7 +858,9 @@ quarter, half and all of the first layer — the first-layer marks need `--gcode `spaghetti` (much new material beyond it) and the `arm` state. A finding needs **2 readings of the last 4**: a toolhead passing over the part is not a finding. With the plate out of view, no baseline, or a plate that cannot be followed (no camera calibration: see - [below](#job-watch-no-calibration)) the camera says `blind` — **never "gone"**. + [below](#job-watch-no-calibration)) the camera says `blind` — **never "gone"**. Every + frame is taken with the printer's light on when it was off (and off again right after: the only + write the watcher makes besides pause/cancel; `watch.bed.light`, see *The light* under [Bed state](#bed-state)). - *Signals.* The same Moonraker events as the always-on watch (filament runout, heater loss, bed off, exception, G-code error, homing lost), and the printer's own defect detection (`noodle` verdicts with p >= 0.8, read from klippy.log). diff --git a/lib/printer_ops/preflight.py b/lib/printer_ops/preflight.py index 3e5eef47..44b84587 100644 --- a/lib/printer_ops/preflight.py +++ b/lib/printer_ops/preflight.py @@ -5,7 +5,9 @@ (lib/gcode_thumbnails.py check(), the `3d gcode-thumbnails` checker); - printer the target printer is free (not printing/paused/busy/unknown); - bed (printers with `watch.bed.enabled`) the camera saw the bed clear with the plate - on (printer_ops/bed.py, issue #303): objects or no plate FAIL, unknown WARN; + on, from the best view with the printer's light on (printer_ops/bed.py, issue + #303/#723): a missing plate or a CONFIDENT occupied FAIL, unknown or an occupied + seen at a poor view WARN with the command to run; - slot Tn for every tool the G-code uses: the printer's head is loaded, and its colour and material equal the G-code's `; filament_colour` / `; filament_type` (needs per-head data: Moonraker printers exposing print_task_config, i.e. U1); @@ -151,8 +153,8 @@ def _hex(value: str | None) -> str | None: ) -def read_gcode_meta(path: Path) -> GcodeMeta: - """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" +def _scan_gcode(path: Path) -> tuple[dict[str, str], set[int]]: + """The slicer's `; key = value` config comments and the tools named by `Tn` lines.""" config: dict[str, str] = {} tool_lines: set[int] = set() with open(path, encoding="utf-8", errors="replace") as fh: @@ -165,16 +167,23 @@ def read_gcode_meta(path: Path) -> GcodeMeta: t = _TOOL_LINE.match(line.strip()) if t: tool_lines.add(int(t.group(1))) + return config, tool_lines + + +def _amount(value: str) -> float: + try: + return float(value) + except ValueError: # an unparsable amount counts as unused, keep reading + return 0.0 + + +def read_gcode_meta(path: Path) -> GcodeMeta: + """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" + config, tool_lines = _scan_gcode(path) used: list[int] used_mm = config.get("filament used [mm]") if used_mm: - amounts = [] - for v in used_mm.split(","): - try: - amounts.append(float(v)) - except ValueError: # noqa: PERF203 — an unparsable amount counts as unused, keep reading - amounts.append(0.0) - used = [i for i, a in enumerate(amounts) if a > 0] + used = [i for i, a in enumerate(map(_amount, used_mm.split(","))) if a > 0] elif tool_lines: used = sorted(tool_lines) else: @@ -211,58 +220,47 @@ def check_printer_free(status: PrinterStatus) -> Check: return Check("printer", PASS, f"{status.state} [{status.raw_state}]") +def _check_slot(meta: GcodeMeta, status: PrinterStatus, t: int) -> Check: + """The `slot Tn` line of one tool the G-code uses.""" + name = f"slot T{t}" + if t >= len(status.slots): + return Check( + name, + FAIL, + f"G-code uses tool {t} but the printer has {len(status.slots)} heads", + ) + slot = status.slots[t] + if not slot.present: + return Check( + name, + FAIL, + f"head {slot.label} is used by the G-code but has no filament / is disabled", + ) + want_c = meta.colours[t] if t < len(meta.colours) else None + want_m = meta.types[t] if t < len(meta.types) else None + problems = [] + if want_c and slot.color and want_c != slot.color: + problems.append(f"colour: G-code {want_c}, loaded {slot.color}") + if want_m and slot.material and want_m.upper() != slot.material.upper(): + problems.append(f"material: G-code {want_m}, loaded {slot.material}") + if problems: + return Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems)) + if not want_c: + return Check( + name, + WARN, + f"head {slot.label}: G-code has no filament_colour for tool {t}; " + f"loaded {slot.color} {slot.material}", + ) + return Check( + name, PASS, f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip() + ) + + def check_slots(meta: GcodeMeta, status: PrinterStatus) -> list[Check]: if not status.slots: return [Check("slots", SKIP, "the printer reports no per-head filament data")] - out = [] - for t in meta.used_tools: - name = f"slot T{t}" - if t >= len(status.slots): - out.append( - Check( - name, - FAIL, - f"G-code uses tool {t} but the printer has {len(status.slots)} heads", - ) - ) - continue - slot = status.slots[t] - if not slot.present: - out.append( - Check( - name, - FAIL, - f"head {slot.label} is used by the G-code but has no filament / is disabled", - ) - ) - continue - want_c = meta.colours[t] if t < len(meta.colours) else None - want_m = meta.types[t] if t < len(meta.types) else None - problems = [] - if want_c and slot.color and want_c != slot.color: - problems.append(f"colour: G-code {want_c}, loaded {slot.color}") - if want_m and slot.material and want_m.upper() != slot.material.upper(): - problems.append(f"material: G-code {want_m}, loaded {slot.material}") - if problems: - out.append(Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems))) - elif not want_c: - out.append( - Check( - name, - WARN, - f"head {slot.label}: G-code has no filament_colour for tool {t}; " - f"loaded {slot.color} {slot.material}", - ) - ) - else: - out.append( - Check( - name, - PASS, - f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), - ) - ) - return out + return [_check_slot(meta, status, t) for t in meta.used_tools] def run_halves_check(source: Path) -> tuple[int, str, str]: @@ -390,17 +388,7 @@ def check_head_sequence( f"({len(objs)} object(s), {project_source})", ) if status == FAIL: - lines = [ - f"{o.get('status')} {o.get('name')}: {o.get('detail') or 'heads ' + str(o.get('heads'))}" - for o in objs - if o.get("status") != "ok" - ] - return Check( - "head seq", - FAIL, - f"{result.get('failures')} object(s) off their recipes ({project_source})", - lines, - ) + return _head_seq_failed(result, objs, project_source) return Check( "head seq", PASS, @@ -409,6 +397,22 @@ def check_head_sequence( ) +def _head_seq_failed( + result: dict[str, Any], objs: list[dict[str, Any]], project_source: str +) -> Check: + lines = [ + f"{o.get('status')} {o.get('name')}: {o.get('detail') or 'heads ' + str(o.get('heads'))}" + for o in objs + if o.get("status") != "ok" + ] + return Check( + "head seq", + FAIL, + f"{result.get('failures')} object(s) off their recipes ({project_source})", + lines, + ) + + def check_mix_faces(project: Path | None, project_source: str, gcode: Path) -> Check: """Issues #411 / #419: a part in a mixed filament shows its colour on walls, not on a flat top. By the project 3MF when there is one (exact: it knows which parts are mixed, FAIL); else by the @@ -531,90 +535,80 @@ def fmt(names: list[str]) -> str: ] -def check_moves( - rep: BoundsReport, - axis_limits: Limits | None, - limits_source: str, - trusted_macros: frozenset[str] = frozenset(), -) -> list[Check]: - out: list[Check] = [] +def _check_axis_travel( + rep: BoundsReport, axis_limits: Limits | None, limits_source: str +) -> Check: if axis_limits is None: why = f"{limits_source}; " if limits_source else "" - out.append( - Check( - "axis travel", - SKIP, - f"printer axis limits unknown ({why}pass --axis-limits " - f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", - ) - ) - elif rep.axis_out_count: - out.append( - Check( - "axis travel", - FAIL, - f"{fmt_limits(axis_limits)} ({limits_source}): " - + _first(rep.axis_out, rep.axis_out_count), - ) + return Check( + "axis travel", + SKIP, + f"printer axis limits unknown ({why}pass --axis-limits " + f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", ) - else: - out.append( - Check( - "axis travel", - PASS, - f"all moves {fmt_extent(rep.all_extent)} within " - f"{fmt_limits(axis_limits)} ({limits_source})", - ) + if rep.axis_out_count: + return Check( + "axis travel", + FAIL, + f"{fmt_limits(axis_limits)} ({limits_source}): " + + _first(rep.axis_out, rep.axis_out_count), ) + return Check( + "axis travel", + PASS, + f"all moves {fmt_extent(rep.all_extent)} within " + f"{fmt_limits(axis_limits)} ({limits_source})", + ) + +def _check_print_area(rep: BoundsReport) -> Check: if not rep.plate_found: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " - "the plate section cannot be told from start/end G-code", - ) + return Check( + "print area", + WARN, + "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " + "the plate section cannot be told from start/end G-code", ) - else: - # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it - parts = [] - if rep.area_out_count and rep.printable_area: - parts.append( - f"printable_area {fmt_area(rep.printable_area)}: " - + _first(rep.area_out, rep.area_out_count) - ) - if rep.excluded_in_count and rep.exclude_area: - parts.append( - f"bed_exclude_area {fmt_area(rep.exclude_area)}: " - + _first(rep.excluded_in, rep.excluded_in_count, "inside") - ) - if parts: - out.append( - Check( - "print area", - FAIL, - f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), - ) - ) - elif rep.printable_area is None: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: the G-code has no `; printable_area`", - ) - ) - else: - out.append( - Check( - "print area", - PASS, - f"plate moves {fmt_extent(rep.plate_extent)} within " - f"{fmt_area(rep.printable_area)}", - ) - ) + # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it + parts = [] + if rep.area_out_count and rep.printable_area: + parts.append( + f"printable_area {fmt_area(rep.printable_area)}: " + + _first(rep.area_out, rep.area_out_count) + ) + if rep.excluded_in_count and rep.exclude_area: + parts.append( + f"bed_exclude_area {fmt_area(rep.exclude_area)}: " + + _first(rep.excluded_in, rep.excluded_in_count, "inside") + ) + if parts: + return Check( + "print area", + FAIL, + f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), + ) + if rep.printable_area is None: + return Check( + "print area", WARN, "UNVERIFIED: the G-code has no `; printable_area`" + ) + return Check( + "print area", + PASS, + f"plate moves {fmt_extent(rep.plate_extent)} within " + f"{fmt_area(rep.printable_area)}", + ) + +def check_moves( + rep: BoundsReport, + axis_limits: Limits | None, + limits_source: str, + trusted_macros: frozenset[str] = frozenset(), +) -> list[Check]: + out: list[Check] = [ + _check_axis_travel(rep, axis_limits, limits_source), + _check_print_area(rep), + ] if rep.unverified_count: more = ", ..." if rep.unverified_count > len(rep.unverified) else "" out.append( @@ -645,40 +639,96 @@ def bounds_report( ) -def check_bed_verdict(v: dict[str, Any]) -> Check: - """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view - moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" - bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") +def _bed_when(v: dict[str, Any], view: dict[str, Any]) -> str: + """Where the verdict came from, and how the bed got to the Z it was judged at.""" age = v.get("age_s") when = ( f"{v.get('source', '?')}, {age:.0f} s ago" if isinstance(age, (int, float)) else str(v.get("source", "?")) ) - raw_view = v.get("view") - view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} if view.get("action") == "moved": when += ( f", judged at Z {view.get('z_used', v.get('z'))} after moving the bed from Z " f"{view.get('moved_from')}" + (" and back" if view.get("restored") else "") ) - frame = [f"frame: {v['frame']}"] if v.get("frame") else [] - why = str(v.get("reason") or "") + return when + + +def _bed_evidence(v: dict[str, Any], view: dict[str, Any]) -> list[str]: + """The frame, a bed left away from where it was found, and what the printer's light did.""" + lines = [f"frame: {v['frame']}"] if v.get("frame") else [] if view.get("action") == "moved" and not view.get("restored"): - frame.append( + lines.append( f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" ) - if plate != "missing" and bed != "occupied": - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + light = v.get("light") + if isinstance(light, dict) and light.get("text"): + lines.append(f"light: {light['text']}") + return lines + + +def _bed_check_command(v: dict[str, Any]) -> str: + return f"3d printer bed check {v.get('printer') or 'NAME'}" + + +def _check_bed_occupied( + v: dict[str, Any], when: str, lines: list[str], view_min: float +) -> Check: + """FAIL only on a CONFIDENT `occupied` (bed_state.occupied_confident: a region of the normal + occupied size with at least `view_min` of the plate in the frame). The same pixels at a poor view + (the U1 at Z 200 sees 15 % of the plate: a window light read as 16 % changed) or in a verdict + that does not say it is confident are a WARN with the way to look properly.""" + why = str(v.get("reason") or "") + if v.get("occupied_confident") is True: + return Check("bed", FAIL, f"objects on the bed ({when}): {why}", lines) + in_view = (v.get("metrics") or {}).get("bed_in_view") + low = ( + f"only {in_view:.0%} of the plate is in the camera's view at this Z (needs {view_min:.0%})" + if isinstance(in_view, (int, float)) and in_view < view_min + else "the verdict does not mark it as a confident find" + ) + return Check( + "bed", + WARN, + f"objects suspected on the bed but not confirmed: {low} ({when}): {why}; " + f"run `{_bed_check_command(v)}` (best view, light on) and look at the frame", + lines, + ) + + +def check_bed_verdict(v: dict[str, Any], *, view_min: float | None = None) -> Check: + """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view + moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN. + FAIL on a missing plate or a CONFIDENT `occupied` only; `unknown` and an occupied that is not + confident (low view of the plate, `view_min` = watch.bed.auto_view_min) are a WARN naming the command.""" + from printer_ops.bed import AUTO_VIEW_MIN + + bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") + raw_view = v.get("view") + view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} + when, lines = _bed_when(v, view), _bed_evidence(v, view) + why = str(v.get("reason") or "") + left_away = view.get("action") == "moved" and not view.get("restored") + if left_away and plate != "missing" and bed != "occupied": + return Check("bed", WARN, f"bed state {bed} ({when}): {why}", lines) if plate == "missing": - return Check("bed", FAIL, f"build plate missing ({when}): {why}", frame) + return Check("bed", FAIL, f"build plate missing ({when}): {why}", lines) if bed == "occupied": - return Check("bed", FAIL, f"objects on the bed ({when}): {why}", frame) + return _check_bed_occupied( + v, when, lines, AUTO_VIEW_MIN if view_min is None else view_min + ) if bed == "clear" and plate == "present": - return Check("bed", PASS, f"clear, plate present ({when})", frame) + return Check("bed", PASS, f"clear, plate present ({when})", lines) if bed == "clear": - return Check("bed", WARN, f"clear, plate not judged ({when}): {why}", frame) - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return Check("bed", WARN, f"clear, plate not judged ({when}): {why}", lines) + return Check( + "bed", + WARN, + f"bed state {bed} ({when}): {why}; " + f"run `{_bed_check_command(v)}` for the best view with the light on", + lines, + ) def check_bed( @@ -688,11 +738,12 @@ def check_bed( runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, timeout: float | None = None, ) -> Check: - """The camera `bed` line (printer_ops/bed.py): SKIP when the printer's bed monitor is off, - else the watcher's verdict when fresher than `stale_s` at the same Z, else a fresh one-shot - camera check (`bed_state.py status`). With `watch.bed.auto_view` and an idle printer that check - moves the bed in Z to where the camera sees the whole plate and back (`move=False`: --no-move). - A check that cannot run is a WARN, never a crash.""" + """The camera `bed` line (printer_ops/bed.py): SKIP when the printer's bed monitor is off, else + the FULL bed check path of an idle printer (`bed_state.py status --best-view`, what `3d printer + bed check` does): a fresh frame judged from the best view (the bed moved in Z only, put back), + with the printer's light on and restored, instead of the watcher's verdict at whatever Z the + bed rests at. `move=False` (--no-bed-move) judges at the current Z, light still on. FAIL only + on a confident `occupied` (check_bed_verdict). A check that cannot run is a WARN, never a crash.""" from printer_ops.bed import ( STATUS_MOVE_TIMEOUT_S, STATUS_TIMEOUT_S, @@ -710,11 +761,9 @@ def check_bed( if ( timeout is None ): # a move is two Z moves, a settle wait and a frame wait; never kill it half-way - timeout = STATUS_MOVE_TIMEOUT_S if move and cfg.auto_view else STATUS_TIMEOUT_S + timeout = STATUS_MOVE_TIMEOUT_S if move else STATUS_TIMEOUT_S try: - argv = status_argv( - device.name, max_age=cfg.stale_s, move="auto" if move else "never" - ) + argv = status_argv(device.name, move="force" if move else "never") r = runner(argv, capture_output=True, text=True, timeout=timeout) except Exception as exc: # no runtime / timeout return Check("bed", WARN, f"bed check could not run: {exc}") @@ -727,7 +776,7 @@ def check_bed( ) if not isinstance(verdict, dict): return Check("bed", WARN, f"bed check returned {str(verdict)[:200]}") - return check_bed_verdict(verdict) + return check_bed_verdict(verdict, view_min=cfg.auto_view_min) def run_preflight( diff --git a/tests/e2e/test_printer_bed_workflows.py b/tests/e2e/test_printer_bed_workflows.py index 45105891..8d17ce0d 100644 --- a/tests/e2e/test_printer_bed_workflows.py +++ b/tests/e2e/test_printer_bed_workflows.py @@ -6,7 +6,10 @@ import json import threading import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler from pathlib import Path +from urllib.parse import urlsplit import pytest @@ -37,7 +40,10 @@ def u1_after_job(state: str = "complete") -> FakeMoonraker: return FakeMoonraker(status=status, camera=jpeg("u1_z200_clear_ref.jpg")) -def write_u1(tmp_path: Path, moon: FakeMoonraker) -> None: +HAND_ROI = "[[0.05, 0.05], [0.95, 0.05], [0.95, 0.95], [0.05, 0.95]]" + + +def write_u1(tmp_path: Path, moon: FakeMoonraker, bed: str = "{enabled: true, interval_s: 0.2}") -> None: d = tmp_path / "xdg-config" / "3d-cli" d.mkdir(parents=True, exist_ok=True) (d / "printer-devices.yaml").write_text(f"""u1: @@ -48,7 +54,7 @@ def write_u1(tmp_path: Path, moon: FakeMoonraker) -> None: trusted_macros: [T0, T1, T2, T3] watch: vision: {{keepalive: false}} - bed: {{enabled: true, interval_s: 0.2}} + bed: {bed} """, encoding="utf-8") @@ -78,11 +84,12 @@ def test_reference_then_status_sees_parts_and_the_cleared_bed(tmp_path: Path) -> def test_start_is_refused_while_parts_are_on_the_bed(tmp_path: Path) -> None: - """The user starts a print with the last job's parts still on the bed: start refuses; --force overrides only with the owner's Telegram approval.""" + """The user starts a print with the last job's parts still on the bed: start refuses; --force overrides only with the owner's Telegram approval. + The printer's bed ROI is drawn by hand (no camera calibration, so the whole polygon is the view): a find in it is confident.""" moon = u1_after_job() (tmp_path / "part.gcode").write_text(GCODE, encoding="utf-8") with moon.serve(): - write_u1(tmp_path, moon) + write_u1(tmp_path, moon, bed=f"{{enabled: true, interval_s: 0.2, roi: {HAND_ROI}}}") assert run_cli(tmp_path, "printer", "bed", "reference", "u1", "--capture").returncode == 0 assert run_cli(tmp_path, "printer", "upload", "u1", "part.gcode").returncode == 0 moon.camera = jpeg("u1_occupied_1351.jpg") @@ -97,6 +104,111 @@ def test_start_is_refused_while_parts_are_on_the_bed(tmp_path: Path) -> None: assert "FORCED" in forced.stdout and "bed" in forced.stdout.split("FORCED", 1)[1] +def test_a_poor_view_of_parts_is_a_warn_with_the_command_not_a_refusal(tmp_path: Path) -> None: + """#723: the calibrated U1 at its Z 200 rest sees 15 % of the plate. The same frame that failed the start + (parts on the bed) is now only a WARN that names `3d printer bed check u1`; the start is not blocked by it.""" + moon = u1_after_job() + (tmp_path / "part.gcode").write_text(GCODE, encoding="utf-8") + with moon.serve(): + write_u1(tmp_path, moon) + assert run_cli(tmp_path, "printer", "bed", "reference", "u1", "--capture").returncode == 0 + assert run_cli(tmp_path, "printer", "upload", "u1", "part.gcode").returncode == 0 + moon.camera = jpeg("u1_occupied_1351.jpg") + pre = run_cli(tmp_path, "printer", "start", "u1", "part.gcode", "--yes") + + assert "WARN bed" in pre.stdout and "FAIL bed" not in pre.stdout + assert "3d printer bed check u1" in pre.stdout and "not confirmed" in pre.stdout + + +@dataclass +class LightMoonraker(FakeMoonraker): + """A U1 with a chamber light: Moonraker lists it, reports its value and obeys SET_PIN.""" + + lit: bool = False + light: str = "output_pin chamber_light" + + def run_script(self, script: str) -> str | None: + error = super().run_script(script) + if script.startswith("SET_PIN") and error is None: + self.lit = "VALUE=1" in script + return error + + def handler(self) -> type[BaseHTTPRequestHandler]: + fake = self + base = super().handler() + + class H(base): # type: ignore[valid-type, misc] + def do_GET(self) -> None: + if urlsplit(self.path).path == "/printer/objects/list": + self._reply({"result": {"objects": ["print_stats", "toolhead", fake.light]}}) # type: ignore[attr-defined] + return + fake.status[fake.light] = {"value": 1.0 if fake.lit else 0.0} + super().do_GET() + + return H + + +def u1_with_light(lit: bool = False) -> LightMoonraker: + moon = u1_after_job() + return LightMoonraker(status=moon.status, camera=moon.camera, lit=lit) + + +LIGHT_ON, LIGHT_OFF = "SET_PIN PIN=chamber_light VALUE=1", "SET_PIN PIN=chamber_light VALUE=0" + + +def test_reference_status_and_check_switch_the_chamber_light_on_and_put_it_back(tmp_path: Path) -> None: + """#723: every frame is taken with the printer's light on; it is back off after each command.""" + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed="{enabled: true, interval_s: 0.2, light: {settle_s: 0}}") + ref = run_cli(tmp_path, "printer", "bed", "reference", "u1", "--capture") + after_ref = list(moon.scripts) + status = run_cli(tmp_path, "printer", "bed", "status", "u1", "--json") + after_status = list(moon.scripts) + check = run_cli(tmp_path, "printer", "bed", "check", "u1", "--no-move") + lit_at_end = moon.lit + + assert ref.returncode == 0, ref.stderr + assert after_ref == [LIGHT_ON, LIGHT_OFF] + assert "light: light output_pin chamber_light switched on for the reference frame and restored" in ref.stdout + light = json.loads(status.stdout)["light"] + assert after_status == [LIGHT_ON, LIGHT_OFF, LIGHT_ON, LIGHT_OFF] + assert (light["found"], light["object"], light["was_on"], light["switched"], light["restored"]) == ( + True, "output_pin chamber_light", False, True, True) + assert check.returncode in (0, 1) and "light: light output_pin chamber_light switched on" in check.stdout + assert moon.scripts == [LIGHT_ON, LIGHT_OFF] * 3 and lit_at_end is False + + +def test_a_light_that_is_already_on_is_left_on_and_never_written(tmp_path: Path) -> None: + moon = u1_with_light(lit=True) + with moon.serve(): + write_u1(tmp_path, moon, bed="{enabled: true, interval_s: 0.2, light: {settle_s: 0}}") + status = run_cli(tmp_path, "printer", "bed", "status", "u1", "--json") + + assert json.loads(status.stdout)["light"]["was_on"] is True + assert moon.scripts == [] and moon.lit is True + + +def test_no_light_on_the_printer_is_said_in_the_verdict_and_the_bed_is_still_judged(tmp_path: Path) -> None: + moon = u1_after_job() # this fake lists no objects at all + with moon.serve(): + write_u1(tmp_path, moon) + status = run_cli(tmp_path, "printer", "bed", "status", "u1") + + assert status.returncode == 0, status.stderr + assert "bed unknown" in status.stdout and "light: no light found" in status.stdout + assert moon.writes() == [] + + +def test_a_light_macro_outside_trusted_macros_is_refused_when_the_config_is_read(tmp_path: Path) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed="{enabled: true, light: {on_gcode: LIGHT_ON}}") + out = run_cli(tmp_path, "printer", "bed", "status", "u1") + + assert out.returncode != 0 and "trusted_macros" in out.stderr and moon.scripts == [] + + def _swap_after(moon: FakeMoonraker, delay: float, name: str) -> threading.Thread: def swap() -> None: time.sleep(delay) diff --git a/tests/test_bed_preflight.py b/tests/test_bed_preflight.py new file mode 100644 index 00000000..70fc322a --- /dev/null +++ b/tests/test_bed_preflight.py @@ -0,0 +1,338 @@ +"""The `bed` line of `3d print-preflight --printer` / `3d printer start` fails only on a CONFIDENT find (#723). + +The U1 rests at Z 200 after a job, where its camera sees 15 % of the plate. A window light there read as +"16.0 % changed: occupied" and a print was started with --force. The line now runs the full bed-check path +(best view, the printer's light on, everything put back) and FAILs only when parts show in a view of at +least `watch.bed.auto_view_min` of the plate; `unknown`, and an occupied seen at a poor view, are a WARN +that names the reason and the command to run. The printer is `FakePrinter`/`LightPrinter` and the camera +`ZCamera` (saved frames by the bed's Z); nothing here touches a real printer or camera. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from printer_ops import bed as pbed +from printer_ops import preflight +from test_bed_light import PIN, LightPrinter, collapsed +from test_bed_robust import LIGHT_DRIFT, OLD, u1_ref_1016 +from test_bed_state import frame, u1_match +from test_bed_view import CFG, FRAMES, FakePrinter, ZCamera, capture, device + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_state as B # noqa: E402 (needs numpy + OpenCV) + +DATA = Path(__file__).parent / "data" / "bed" +WATCH = { + "bed": {"enabled": True, "auto_view": True}, + "vision": {"camera": {"kind": "moonraker-monitor"}}, +} + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def refs() -> None: + capture(200.0, "u1_z200_clear_ref.jpg") + capture(70.0, "u1_z10_clear_ref.jpg") + + +def verdict_dict(v: B.Verdict, z: float) -> dict[str, Any]: + """What `bed_state.py status --json` prints for a classifier verdict (no occupied_confident mark).""" + return { + "printer": "u1", + "bed": v.bed, + "plate": v.plate, + "z": z, + "source": "camera now", + "age_s": 0.0, + "reason": v.reason, + "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, + "frame": "/f.jpg", + } + + +def part_at_z70() -> bytes: + """The empty plate as the camera sees it from Z 70 with a 56 px part standing on it.""" + img = cv2.imread(str(DATA / "u1_z10_clear_ref.jpg")) + vol, _, _ = B.build_masks(device(), CFG, 70.0, img.shape[:2]) + ys, xs = np.nonzero(vol) + cy, cx = int(np.percentile(ys, 40)), int(np.percentile(xs, 60)) + img[cy : cy + 56, cx : cx + 56] = (30, 90, 30) + return cv2.imencode(".jpg", img)[1].tobytes() + + +def runner_for(printer: FakePrinter, cam: ZCamera, seen: list[list[str]]) -> Any: + """A runner that does what `bed_state.py status` does, in this process, against the fakes.""" + + def run(argv: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: + seen.append(argv) + mode = ( + "never" + if "--no-move" in argv + else "force" + if "--best-view" in argv + else "auto" + ) + max_age = ( + float(argv[argv.index("--max-age") + 1]) if "--max-age" in argv else None + ) + v = B.status_view( + device(), + CFG, + mode=mode, + max_age=max_age, + part_height=20.0, + adapter=printer, + camera=cam, + sleep=lambda s: None, + settle_s=0.0, + ) + return subprocess.CompletedProcess(argv, 0, json.dumps(v, default=str), "") + + return run + + +# ---- the former misfire ----------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", LIGHT_DRIFT) +def test_the_u1_z200_window_light_is_a_warn_and_never_a_fail(name: str) -> None: + """15 % of the plate in view, 16 % 'changed' by the window light: the classifier says unknown (#723).""" + v = B.classify(frame(name), [u1_ref_1016()], 200.2, "u1", match=u1_match()) + c = preflight.check_bed_verdict(verdict_dict(v, 200.2)) + assert v.bed == "unknown" and c.status == "WARN", c.detail + assert "`3d printer bed check u1`" in c.detail + + +@pytest.mark.parametrize("name", LIGHT_DRIFT) +def test_even_the_old_classifier_s_occupied_at_that_view_is_not_a_fail( + name: str, +) -> None: + """Teeth: the pre-#723 classifier called both frames occupied; at a 15 % view that is a suspicion.""" + v = B.classify(frame(name), [u1_ref_1016()], 200.2, "u1", OLD, u1_match()) + assert v.bed == "occupied" and v.metrics["bed_in_view"] < 0.2 + assert not B.occupied_confident(v, OLD, pbed.AUTO_VIEW_MIN) + c = preflight.check_bed_verdict(verdict_dict(v, 200.2)) + assert ( + c.status == "WARN" + and "only 15% of the plate is in the camera's view" in c.detail + ) + assert "`3d printer bed check u1`" in c.detail and "light on" in c.detail + + +def test_the_incident_frame_at_the_poor_view_is_judged_but_not_failed( + refs: None, +) -> None: + """The saved Z 200 frame with parts, judged where the bed rests (--no-bed-move): WARN, with both commands.""" + printer = FakePrinter("complete", 200.0) + c = preflight.check_bed( + device(watch=WATCH), + move=False, + runner=runner_for(printer, ZCamera(printer, FRAMES), []), + ) + assert ( + c.status == "WARN" + and "3d printer bed check u1" in c.detail + and "15%" in c.detail + ) + assert printer.scripts == [] # --no-bed-move: nothing moves + + +# ---- a confident find ---------------------------------------------------------------------------- + + +def test_parts_in_a_good_view_fail_the_line_and_the_bed_is_put_back(refs: None) -> None: + printer = FakePrinter("complete", 200.0) + cam = ZCamera(printer, FRAMES) + cam.frames[70.0] = part_at_z70() + seen: list[list[str]] = [] + c = preflight.check_bed(device(watch=WATCH), runner=runner_for(printer, cam, seen)) + assert c.status == "FAIL" and "objects on the bed" in c.detail + assert "judged at Z 70.0 after moving the bed from Z 200.0 and back" in c.detail + assert printer.z == 200.0 and seen[0][-1] == "--best-view" + + +def test_the_confident_mark_needs_a_good_view_and_a_region_of_the_normal_size( + refs: None, +) -> None: + printer = FakePrinter("complete", 200.0) + cam = ZCamera(printer, FRAMES) + cam.frames[70.0] = part_at_z70() + good = B.status_view( + device(), + CFG, + mode="force", + part_height=20.0, + adapter=printer, + camera=cam, + sleep=lambda s: None, + settle_s=0.0, + ) + assert ( + good["bed"] == "occupied" + and good["metrics"]["bed_in_view"] >= pbed.AUTO_VIEW_MIN + ) + assert good["occupied_confident"] is True + poor = B.status_view( + device(), + CFG, + mode="never", + adapter=printer, + camera=ZCamera(printer, FRAMES), + sleep=lambda s: None, + settle_s=0.0, + ) + assert poor["bed"] == "occupied" and poor["metrics"]["bed_in_view"] < 0.2 + assert poor["occupied_confident"] is False + # the same pixels judged against a stricter watch.bed.auto_view_min than the view allows + th = B.thresholds_for(CFG) + v = B.Verdict( + "occupied", + "present", + "r", + {"changed": 0.2, "blob_px": 900.0, "bed_in_view": 0.9}, + ) + assert B.occupied_confident(v, th, 0.85) and not B.occupied_confident(v, th, 0.95) + small = B.Verdict( + "occupied", + "present", + "r", + {"changed": 0.001, "blob_px": 10.0, "bed_in_view": 1.0}, + ) + assert not B.occupied_confident(small, th, 0.85) + assert B.occupied_confident( + B.Verdict("occupied", "present", "r", {"changed": 0.1}), th, 0.85 + ) # no calibration + + +def test_a_missing_plate_still_fails_and_a_clear_bed_still_passes(refs: None) -> None: + assert ( + preflight.check_bed_verdict( + {"bed": "unknown", "plate": "missing", "reason": "r"} + ).status + == "FAIL" + ) + ok = preflight.check_bed_verdict( + {"bed": "clear", "plate": "present", "source": "camera now", "age_s": 0.0} + ) + assert ok.status == "PASS" + + +def test_a_confident_flag_from_the_tool_decides_not_the_view_alone() -> None: + base = { + "printer": "u1", + "bed": "occupied", + "plate": "present", + "source": "camera now", + "age_s": 0.0, + "reason": "r", + "metrics": {"bed_in_view": 0.9, "changed": 0.3}, + } + assert ( + preflight.check_bed_verdict({**base, "occupied_confident": True}).status + == "FAIL" + ) + unmarked = preflight.check_bed_verdict(base) + assert ( + unmarked.status == "WARN" + and "does not mark it as a confident find" in unmarked.detail + ) + assert ( + preflight.check_bed_verdict({**base, "occupied_confident": False}).status + == "WARN" + ) + + +# ---- unknown is a WARN with the way out --------------------------------------------------------------- + + +def test_unknown_is_a_warn_naming_the_command_and_the_reason() -> None: + c = preflight.check_bed_verdict( + { + "printer": "u1", + "bed": "unknown", + "plate": "unknown", + "reason": "no reference", + "source": "camera now", + "age_s": 0.0, + } + ) + assert c.status == "WARN" and "no reference" in c.detail + assert "`3d printer bed check u1`" in c.detail and "light on" in c.detail + + +# ---- the full path ----------------------------------------------------------------------------------- + + +def test_the_line_runs_the_full_check_with_the_light_and_shows_what_the_light_did( + refs: None, +) -> None: + printer = LightPrinter("complete", 200.0) + c = preflight.check_bed( + device(watch=WATCH), runner=runner_for(printer, printer.camera(), []) + ) + assert c.status == "PASS" and "judged at Z 70.0" in c.detail + assert collapsed(printer.log)[0] == f"SET_PIN PIN={PIN.split()[1]} VALUE=1" + assert ( + collapsed(printer.log)[-1].endswith("VALUE=0") + and printer.lit is False + and printer.z == 200.0 + ) + assert any( + line.startswith("light: ") and "switched on" in line and "restored" in line + for line in c.lines + ) + + +def test_the_line_never_reads_the_watchers_verdict_for_a_poor_z(refs: None) -> None: + """A fresh monitor verdict at the rest Z used to answer `--max-age`; the full path always takes a frame.""" + printer = FakePrinter("complete", 200.0) + path = pbed.state_path("u1") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "printer": "u1", + "bed": "occupied", + "plate": "present", + "z": 200.0, + "checked": 4e9, + "reason": "from the monitor", + } + ), + encoding="utf-8", + ) + c = preflight.check_bed( + device(watch=WATCH), runner=runner_for(printer, ZCamera(printer, FRAMES), []) + ) + assert ( + c.status == "PASS" + and "camera now" in c.detail + and "from the monitor" not in c.detail + ) + + +def test_a_printing_printer_is_a_warn_and_nothing_is_lit_or_moved(refs: None) -> None: + printer = LightPrinter("printing", 150.0) + c = preflight.check_bed( + device(watch=WATCH), runner=runner_for(printer, printer.camera(), []) + ) + assert c.status == "WARN" and printer.log == [] diff --git a/tests/test_bed_state.py b/tests/test_bed_state.py index c3f95aef..b7bf81bc 100644 --- a/tests/test_bed_state.py +++ b/tests/test_bed_state.py @@ -436,7 +436,7 @@ def test_clear_ack_through_moonraker_posts_one_gcode_script(xdg: Path) -> None: ]) def test_verdict_check(bed: str, plate: str, status: str) -> None: c = preflight.check_bed_verdict({"bed": bed, "plate": plate, "source": "watch", "age_s": 12.0, "reason": "r", - "frame": "/f.jpg"}) + "frame": "/f.jpg", "occupied_confident": True}) assert (c.name, c.status) == ("bed", status) assert c.lines == ["frame: /f.jpg"] and "watch, 12 s ago" in c.detail @@ -455,8 +455,8 @@ def ok(argv: list[str], **kw: Any) -> Any: return subprocess.CompletedProcess(argv, 0, json.dumps({"bed": "occupied", "plate": "present", "source": "camera now", "age_s": 0}), "") - assert preflight.check_bed(dev, runner=ok).status == "FAIL" - assert seen[0][-5:] == ["status", "u1", "--json", "--max-age", "30"] + assert preflight.check_bed(dev, runner=ok).status == "WARN" # occupied, but the verdict is not marked confident + assert seen[0][-4:] == ["status", "u1", "--json", "--best-view"] # the full check path, not the watcher's verdict broken = preflight.check_bed(dev, runner=lambda argv, **kw: subprocess.CompletedProcess(argv, 1, "", "boom")) assert broken.status == "WARN" and "boom" in broken.detail diff --git a/tests/test_bed_view.py b/tests/test_bed_view.py index 12dba4fa..bef2b19e 100644 --- a/tests/test_bed_view.py +++ b/tests/test_bed_view.py @@ -485,12 +485,14 @@ def test_preflight_bed_line_judges_the_moved_view_and_names_the_z_used(refs: Non c = preflight.check_bed(dev, runner=run_status_in_process(printer, seen)) assert c.status == "PASS" assert "judged at Z 70.0 after moving the bed from Z 200.0 and back" in c.detail - assert seen[0][-5:] == ["status", "u1", "--json", "--max-age", "30"] and "--no-move" not in seen[0] + assert seen[0][-4:] == ["status", "u1", "--json", "--best-view"] and "--no-move" not in seen[0] + assert "--max-age" not in seen[0] # never the monitor's verdict at whatever Z the bed rests at assert printer.z == 200.0 sliver = FakePrinter("complete", 200.0) c2 = preflight.check_bed(dev, move=False, runner=run_status_in_process(sliver, seen)) assert "--no-move" in seen[-1] and sliver.scripts == [] - assert c2.status == "FAIL" # judged at the 15 % sliver, as before: the old verdict, never silently clear + # judged at the 15 % sliver: parts are suspected, never silently clear, and no longer a FAIL + assert c2.status == "WARN" and "3d printer bed check u1" in c2.detail and "15%" in c2.detail def test_preflight_while_printing_is_a_warn_that_says_nothing_was_moved(refs: None) -> None: @@ -512,8 +514,8 @@ def runner(argv: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: preflight.check_bed(dev, runner=runner) preflight.check_bed(dev, move=False, runner=runner) off = device(watch={"bed": {"enabled": True}, "vision": {"camera": {"kind": "moonraker-monitor"}}}) - preflight.check_bed(off, runner=runner) # auto_view off: no move, no extra time - assert timeouts == [pbed.STATUS_MOVE_TIMEOUT_S, pbed.STATUS_TIMEOUT_S, pbed.STATUS_TIMEOUT_S] + preflight.check_bed(off, runner=runner) # auto_view off: the preflight still runs the full check path + assert timeouts == [pbed.STATUS_MOVE_TIMEOUT_S, pbed.STATUS_TIMEOUT_S, pbed.STATUS_MOVE_TIMEOUT_S] assert pbed.STATUS_MOVE_TIMEOUT_S > 2 * V.MOVE_TIMEOUT_S + pbed.AUTO_VIEW_SETTLE_S + 2 * B.FROZEN_CHECK_S From 620b9d685db9cf5e9fb78816141f1da5924e78cb Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:13:03 +0100 Subject: [PATCH 09/30] =?UTF-8?q?feat(bed):=20lighting-normalised=20verdic?= =?UTF-8?q?t=20=E2=80=94=20an=20empty=20bed=20in=20another=20light=20is=20?= =?UTF-8?q?never=20`occupied`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/architecture/lib-map.md | 1 + docs/commands/printer.md | 12 +- lib/bed_norm.py | 151 ++++++++++++++++++++ lib/bed_state.py | 101 ++++++++++--- lib/printer_ops/bed.py | 2 +- tests/test_bed_norm.py | 268 +++++++++++++++++++++++++++++++++++ tests/test_bed_robust.py | 8 +- 7 files changed, 522 insertions(+), 21 deletions(-) create mode 100644 lib/bed_norm.py create mode 100644 tests/test_bed_norm.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 19b91569..435fae4b 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -325,6 +325,7 @@ Every current root Python module is listed here so future additions are intentio - `vision_ghost.py` — 3D ghost of the rest of a print over a printer camera frame: collects a job's walls from one G-code pass, paints them through a `vision_geometry` camera into a sparse per-frame blend table (used by the U1 camera proxy). - `vision_scene.py` — opt-in bed-change detectors for `3d printer vision --scene` (foreign object, moved part, no growth). - `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier (`occupied_confident`: the start gate FAILs only on a confident find), change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); the printer's light is on for every frame of `check` / `status` / `reference --capture` (`printer_ops/bed_light.py`); config, events, start gate and clear ack live in `printer_ops/bed.py`. +- `bed_norm.py` — the lighting-normalised evidence channel of the bed verdict (#723): brightness mismatch of the frame against the reference (dark/middle/bright quantiles of the bed ROI, or a known light-state difference) and the local-texture difference (luminance over its neighbourhood spread, invariant to gain/offset/gamma) a pixel needs in addition to the colour difference when the light differs; `bed_state.py` wires it (`_under_light_change`). Numpy + OpenCV. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. - `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration; without one the plate is FOLLOWED on the picture — a chained ECC affine transform saved with every healthy footprint, `--track`/`--save-track`, so the baseline survives the bed's drift), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view, cannot be followed, or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index e39d0901..fe01a2e4 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -751,7 +751,15 @@ until someone takes them off. The camera can see that, so nobody has to tell an `3d printer bed check NAME` looks from a better Z» and the age of the reference. A real part is not hidden by this (the U1 frames of parts on the bed are 2700–3900 px regions, a 16 px object on the K1 SE stays `occupied`); a thing smaller than that from a 15 % view is - `unknown`, never `clear`. Every evidence image has the untouched camera frame beside it + `unknown`, never `clear`. **Another light** (day vs night, the printer's light on vs off, a gamma + or a white-balance shift of the camera): when the brightness of the bed at its dark, middle or + bright end is off by more than ×0.8…×1.25 from the reference's, a pixel also needs its local + *texture* (luminance over the spread of its neighbourhood, `lib/bed_norm.py`) to have changed — + gain, offset and any tone curve leave the texture alone, a part does not. Pixels only the colour + difference flags are the light: a frame that would read `occupied` by colour alone is `unknown` + («the light, not a part», never `occupied`), a real part changes both and stays `occupied`. At a + low view the texture channel may rescue an `occupied` but does not call the bed `clear`. + `thresholds.light_norm: 0` turns it off for a printer. Every evidence image has the untouched camera frame beside it (`…-label.raw.jpg`), so a doubtful verdict can be re-judged. ROI: `watch.bed.roi` (a polygon of the bed, 0..1 of the frame), else the camera calibration (bed plus 80 mm above it at the current Z), else the whole frame. On the U1 at Z 200 only parts taller than a few cm show, and only the plate's front @@ -762,7 +770,7 @@ until someone takes them off. The camera can see that, so nobody has to tell an `watch.bed.thresholds: {clear_max: 0.05, level: 30, ...}` overrides any of them for one printer (`level`, `detrend_px`, `edge_px`, `clear_blob_px`, `clear_max`, `occupied_blob_px`, `occupied_min`, `plate_missing_min`, `plate_tie`, `surface_min`, `change_min`, `settle_frames`, - `match_px`, `view_min`, `shift_px`, `open_px`, `low_view`, `occupied_blob_low_px`). The camera is the printer's `watch.vision.camera`; enabling the bed check + `match_px`, `view_min`, `shift_px`, `open_px`, `low_view`, `occupied_blob_low_px`, `light_norm`). The camera is the printer's `watch.vision.camera`; enabling the bed check on a printer without one is refused when the config is read. - **Monitor.** `3d printer watch` runs `bed monitor NAME` as a child for every Moonraker printer with `watch.bed.enabled: true`. Nothing is grabbed while a job runs; otherwise one snapshot per diff --git a/lib/bed_norm.py b/lib/bed_norm.py new file mode 100644 index 00000000..c025f34a --- /dev/null +++ b/lib/bed_norm.py @@ -0,0 +1,151 @@ +"""bed_norm.py — the lighting-normalised evidence channel of the bed verdict (issue #723, item 3). + +WHY: `bed_state.difference` maps the current frame onto the reference colours with a per-channel + gain/offset fit. That absorbs an exposure or white-balance change, not a NONLINEAR one (gamma, a + light switched on or off, the camera going from daylight to night mode): a bright patch of the + empty plate then reads as a changed region. + +WHAT: when the two frames differ in light (the mean luminance of the bed ROI is off by more than + `LIGHT_RATIO_MIN..LIGHT_RATIO_MAX`, or the reference and the frame were taken with a different + printer light state), a pixel only counts as changed when TWO channels say so: + colour the existing aligned difference (a part has another colour than the empty plate); + structure the local texture: luminance minus its neighbourhood mean over its neighbourhood + spread (`local_contrast`), compared with the reference. Gain, offset and any monotone + tone curve leave it (nearly) alone; a part replaces the plate's texture with its own + edges or with flat colour. + A flat part has structure only at its rim: the rim is closed and filled (`_filled`) so the + interior of a part is confirmed by its outline. Without a light mismatch nothing changes: the + colour channel alone decides, as before. + +Numpy + OpenCV (run through cli.pyrun with bed_state.py). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import cv2 +import numpy as np + +# mean luminance of the frame over the reference's, inside the ROI: outside this range -> light mismatch +LIGHT_RATIO_MIN = 0.8 +LIGHT_RATIO_MAX = 1.25 +# grey levels added to both sides of a brightness ratio: a black corner must not make a ratio of noise +BRIGHTNESS_FLOOR = 8.0 +# px of the 480-px frame: the neighbourhood of the local contrast +CONTRAST_SIGMA = 6.0 +# noise floor of the contrast: this share of the ROI's mean luminance plus FLOOR_ABS grey levels +# (the spread of a flat area is noise) +FLOOR_REL = 0.04 +FLOOR_ABS = 2.0 +# |local contrast difference| above which the texture counts as changed. Measured on the K1 SE frames +# (tests/data/bed): an empty bed under gain 0.25-4 / gamma 0.5-2 stays under 0.35 at the 99.9th +# percentile (saturated frames reach 0.7), the rim of a 24-px part is 0.7 (median). +STRUCTURE_LEVEL = 0.5 +# the rim of a flat part is closed with this square kernel before it is filled +CLOSE_PX = 9 + + +@dataclass(frozen=True) +class LightStats: + ratio: float # the brightness ratio (frame / reference, inside the ROI) farthest from 1 + mismatch: bool + why: str + + +def luma(img: np.ndarray) -> np.ndarray: + """Luminance (float32) of a BGR frame.""" + f = img.astype(np.float32) + return f[..., 0] * 0.114 + f[..., 1] * 0.587 + f[..., 2] * 0.299 + + +def _worst_ratio(ref: np.ndarray, cur: np.ndarray, roi: np.ndarray) -> float: + """The ratio of the frame's luminance to the reference's, at the dark, middle and bright end of the ROI, + that is farthest from 1: a tone curve (gamma) leaves the mean alone and moves the ends.""" + qs = (10, 50, 90) + a = np.percentile(luma(ref)[roi], qs) + BRIGHTNESS_FLOOR + b = np.percentile(luma(cur)[roi], qs) + BRIGHTNESS_FLOOR + ratios = b / a + return float(ratios[int(np.argmax(np.abs(np.log(ratios))))]) + + +def light_stats( + ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, light_differs: bool = False +) -> LightStats: + """Do the frames differ in light? By the brightness of the ROI, or because the caller knows the light + state of the reference and of the frame differ (`light_differs`).""" + state = "light state differs" if light_differs else "" + if not roi.any(): + return LightStats(1.0, light_differs, state) + ratio = _worst_ratio(ref, cur, roi) + off = not LIGHT_RATIO_MIN <= ratio <= LIGHT_RATIO_MAX + return LightStats( + ratio, + off or light_differs, + f"brightness x{ratio:.2f} of the reference" if off else state, + ) + + +def local_contrast(grey: np.ndarray, floor: float) -> np.ndarray: + """(grey - local mean) / sqrt(local variance + floor^2): texture without brightness.""" + g = cv2.GaussianBlur(grey, (5, 5), 0) + mu = cv2.GaussianBlur(g, (0, 0), CONTRAST_SIGMA) + var = cv2.GaussianBlur((g - mu) ** 2, (0, 0), CONTRAST_SIGMA) + return (g - mu) / np.sqrt(var + floor * floor) + + +def _shifted_min(cur: np.ndarray, ref: np.ndarray, shift: int) -> np.ndarray: + """|cur - ref| where each pixel may match the reference within `shift` px (the registration tolerance + of `bed_state.difference`).""" + h, w = ref.shape + padded = cv2.copyMakeBorder(ref, shift, shift, shift, shift, cv2.BORDER_REPLICATE) + best = np.abs(cur - ref) + for dy in range(-shift, shift + 1): + for dx in range(-shift, shift + 1): + if dx or dy: + best = np.minimum( + best, + np.abs( + cur + - padded[ + shift + dy : shift + dy + h, shift + dx : shift + dx + w + ] + ), + ) + return best + + +def structure_changed( + ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, shift: int = 0 +) -> np.ndarray: + """Bool mask: where the local texture of `cur` differs from `ref` (see the module docstring).""" + if not roi.any(): + return np.zeros(roi.shape, bool) + gr, gc = luma(ref), luma(cur) + floor = FLOOR_REL * float(gr[roi].mean()) + FLOOR_ABS + floor_c = FLOOR_REL * float(gc[roi].mean()) + FLOOR_ABS + nr, nc = local_contrast(gr, floor), local_contrast(gc, floor_c) + diff = _shifted_min(nc, nr, shift) if shift > 0 else np.abs(nc - nr) + return (diff > STRUCTURE_LEVEL) & roi + + +def _filled(mask: np.ndarray) -> np.ndarray: + """`mask` with its small gaps closed and its enclosed holes filled: the rim of a flat part becomes the part.""" + closed = cv2.morphologyEx( + mask.astype(np.uint8), cv2.MORPH_CLOSE, np.ones((CLOSE_PX, CLOSE_PX), np.uint8) + ) + h, w = closed.shape + flood = np.pad(closed, 1).copy() + cv2.floodFill(flood, np.zeros((h + 4, w + 4), np.uint8), (0, 0), 2) + holes = flood[1:-1, 1:-1] == 0 + return (closed > 0) | holes + + +def confirm(changed: np.ndarray, structure: np.ndarray) -> np.ndarray: + """The colour-changed pixels whose neighbourhood also changed in structure.""" + return changed & _filled(structure) + + +def describe(stats: LightStats) -> dict[str, Any]: + return {"light_ratio": round(stats.ratio, 3), "light_mismatch": stats.mismatch} diff --git a/lib/bed_state.py b/lib/bed_state.py index c8d423a9..de066c8e 100644 --- a/lib/bed_state.py +++ b/lib/bed_state.py @@ -15,7 +15,9 @@ Both frames are decoded at reduced size (480 px wide), blurred, the current one is mapped onto the reference colour by a robust per-channel gain/offset fit inside the ROI (auto-exposure and room light change the whole frame, a part changes a region), and the pixels still off by more - than `level` count as changed. + than `level` count as changed. When the light differs from the reference's (day vs night, the + printer's light on vs off, a gamma) that is not enough: lib/bed_norm.py adds the local texture as a + second channel and a pixel needs both to count (`_under_light_change`). view: with a calibration, under `view_min` of the bed footprint in the frame at this Z (the U1 bed at Z >= ~215, Z 275 after a home: none of it) -> unknown, "out of the camera's view" — no reference can fix that, and none is captured there. @@ -91,6 +93,8 @@ if TYPE_CHECKING: import numpy as np + from bed_norm import LightStats + from printer_ops.devices import Device from vision_geometry import CameraModel @@ -129,6 +133,7 @@ class Thresholds: open_px: int = 5 # objects: changed regions are opened with this square kernel (thinner lines go) low_view: float = 0.5 # calibrated: under this share of the bed in the frame the ROI is mostly the room ... occupied_blob_low_px: int = 1200 # ... and only a region this big (or occupied_min of the ROI) says occupied + light_norm: int = 1 # 1: when the light differs from the reference's, a pixel needs colour AND texture change (0: off) TH = Thresholds() @@ -234,13 +239,14 @@ def _best_shift(cur: np.ndarray, ref: np.ndarray, shift: int) -> np.ndarray: def difference(ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, level: float = TH.level, - detrend: int = 0, shift: int = 0, open_px: int = 3) -> Diff: + detrend: int = 0, shift: int = 0, open_px: int = 3, norm: bool = False) -> Diff: """Changed pixels of `cur` against `ref` inside `roi` after `align`. `detrend` (odd px > 0) also subtracts the median of the signed difference over that window: a light gradient or a reflection that moved with the room light is smooth and goes, a part narrower than half the window stays (used for objects; the plate test keeps the plain difference). `shift` (px) lets a pixel match anything within that distance in the reference, and changed regions are opened with an - `open_px` square: what is left is a patch of new colour, not the outline of an unchanged scene.""" + `open_px` square: what is left is a patch of new colour, not the outline of an unchanged scene. + `norm` (the frames differ in light, bed_norm.py): a pixel also needs the local texture to have changed.""" import cv2 import numpy as np @@ -256,7 +262,12 @@ def difference(ref: np.ndarray, cur: np.ndarray, roi: np.ndarray, level: float = packed = np.clip(signed / 2 + 128, 0, 255).astype(np.uint8) signed -= (cv2.medianBlur(packed, detrend | 1).astype(np.float32) - 128) * 2 d = np.abs(signed).max(axis=2) - changed = ((d > level) & roi).astype(np.uint8) + changed = (d > level) & roi + if norm: + import bed_norm + + changed = bed_norm.confirm(changed, bed_norm.structure_changed(ref, cur, roi, shift)) + changed = changed.astype(np.uint8) changed = cv2.morphologyEx(changed, cv2.MORPH_OPEN, np.ones((open_px, open_px), np.uint8)) n, _, stats, _ = cv2.connectedComponentsWithStats(changed) blob = int(stats[1:, cv2.CC_STAT_AREA].max()) if n > 1 else 0 @@ -473,15 +484,15 @@ class Verdict: def _judge_plate(img: np.ndarray, plate_ref: Reference | None, noplate_ref: Reference | None, - z: float | None, th: Thresholds) -> tuple[str, str, dict[str, float]]: + z: float | None, th: Thresholds, norm: bool = False) -> tuple[str, str, dict[str, float]]: base = plate_ref or noplate_ref assert base is not None cover = float(base.surface.mean()) if cover < th.surface_min: return UNKNOWN, f"plate not judged: the bed surface is out of view at Z {_fz(z)} ({cover:.1%} of the frame)", {} if plate_ref is not None and noplate_ref is not None: - ds = difference(plate_ref.image, img, plate_ref.surface, th.level).frac - dn = difference(noplate_ref.image, img, noplate_ref.surface, th.level).frac + ds = difference(plate_ref.image, img, plate_ref.surface, th.level, norm=norm).frac + dn = difference(noplate_ref.image, img, noplate_ref.surface, th.level, norm=norm).frac if abs(ds - dn) < th.plate_tie: # equally far from both: a human must look return UNKNOWN, (f"plate unclear: bed surface {ds:.0%} off the with-plate reference, " f"{dn:.0%} off the bare-bed one"), {"surface_vs_plate": ds, "surface_vs_noplate": dn} @@ -489,13 +500,13 @@ def _judge_plate(img: np.ndarray, plate_ref: Reference | None, noplate_ref: Refe return plate, (f"bed surface {ds:.0%} off the with-plate reference, {dn:.0%} off the bare-bed one"), \ {"surface_vs_plate": ds, "surface_vs_noplate": dn} if plate_ref is not None: - ds = difference(plate_ref.image, img, plate_ref.surface, th.level).frac + ds = difference(plate_ref.image, img, plate_ref.surface, th.level, norm=norm).frac if ds >= th.plate_missing_min: return MISSING, (f"{ds:.0%} of the bed surface differs from the with-plate reference " "(plate off, or the bed covered)"), {"surface_vs_plate": ds} return PRESENT, f"bed surface {ds:.0%} off the with-plate reference", {"surface_vs_plate": ds} assert noplate_ref is not None - dn = difference(noplate_ref.image, img, noplate_ref.surface, th.level).frac + dn = difference(noplate_ref.image, img, noplate_ref.surface, th.level, norm=norm).frac if dn >= th.plate_missing_min: return PRESENT, (f"{dn:.0%} of the bed surface differs from the bare-bed reference " "(plate on, or the bed covered)"), {"surface_vs_noplate": dn} @@ -549,6 +560,57 @@ def _select_refs(img: np.ndarray, refs: list[Reference], z: float | None, printe return plate_ref, noplate_ref, None +def _light_stats(base: Reference, img: np.ndarray, th: Thresholds) -> LightStats | None: + """bed_norm.LightStats of the frame against the reference's bed ROI, or None when `light_norm` is off.""" + if not th.light_norm: + return None + import bed_norm + + return bed_norm.light_stats(base.image, img, base.volume) + + +def _under_light_change(plain: Verdict, normed: Verdict, stats: LightStats, in_view: float | None, th: Thresholds, + printer: str) -> Verdict: + """The verdict when the light differs from the reference's. `plain` is the colour difference alone, + `normed` needs colour AND texture (bed_norm.py). What both channels show is a part (`normed` occupied); + pixels only the colour difference flags are the light, so `plain` occupied without `normed` is unknown, never + occupied. `normed` may call the bed clear where `plain` could not, but not at a low view (most of the ROI + is the room there: see `_objects_verdict`).""" + note = f"light differs from the reference ({stats.why}): judged by colour and texture" + low_view = in_view is not None and in_view < th.low_view + light = {"light_ratio": stats.ratio, "light_mismatch": 1.0} + if plain.bed == CLEAR: + return replace(plain, metrics={**plain.metrics, **light}) + if normed.bed == OCCUPIED or (normed.bed == CLEAR and not low_view): + return replace(normed, reason=f"{normed.reason}; {note}", metrics={**normed.metrics, **light}) + if plain.bed == UNKNOWN: + return replace(plain, reason=f"{plain.reason}; {note}", metrics={**plain.metrics, **light}) + changed = plain.metrics.get("changed", 0.0) + return replace(normed, bed=UNKNOWN, metrics={**normed.metrics, **light, "plain_changed": changed}, + reason=(f"{changed:.1%} of the bed view differs by colour but the texture did not change: the " + f"light, not a part ({stats.why}); {normed.reason}; look at the frame or " + f"`3d printer bed check {printer}`")) + + +def _judge_objects(img: np.ndarray, ref: Reference, inner: np.ndarray, plate_info: tuple[str, str, dict[str, float]], + where: tuple[float | None, float | None, str], th: Thresholds, + stats: LightStats | None) -> Verdict: + """The objects verdict of `img` against `ref`; with a light change also the normalised one, combined.""" + in_view, z, printer = where + plate, plate_why, plate_metrics = plate_info + + def once(norm: bool) -> Verdict: + d = difference(ref.image, img, inner, th.level, th.detrend_px, th.shift_px, th.open_px, norm) + blob_px = d.blob * int(inner.sum()) + metrics = {**plate_metrics, "changed": d.frac, "blob": d.blob, "blob_px": blob_px} + return _objects_verdict(d, blob_px, ref, (plate, plate_why, metrics), inner, in_view, z, printer, th) + + plain = once(False) + if stats is None or not stats.mismatch: + return plain + return _under_light_change(plain, once(True), stats, in_view, th, printer) + + def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, th: Thresholds = TH, match: ZMatch | None = None) -> Verdict: import cv2 @@ -563,7 +625,10 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if early is not None: return early assert plate_ref is not None or noplate_ref is not None - plate, plate_why, metrics = _judge_plate(img, plate_ref, noplate_ref, z, th) + base = plate_ref or noplate_ref + assert base is not None + stats = _light_stats(base, img, th) + plate, plate_why, metrics = _judge_plate(img, plate_ref, noplate_ref, z, th, stats is not None and stats.mismatch) ref = noplate_ref if plate == MISSING else plate_ref if ref is None: why = ("plate missing and no bare-bed reference to judge objects against" if plate == MISSING @@ -575,10 +640,7 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if inner.sum() < 100: # a degenerate ROI inspects nothing: never call that clear return Verdict(UNKNOWN, plate, f"the bed ROI of {ref.path.name} is empty or tiny ({int(inner.sum())} px): " f"fix watch.bed.roi and capture the reference again; {plate_why}", metrics) - d = difference(ref.image, img, inner, th.level, th.detrend_px, th.shift_px, th.open_px) - blob_px = d.blob * int(inner.sum()) - metrics.update(changed=d.frac, blob=d.blob, blob_px=blob_px) - return _objects_verdict(d, blob_px, ref, (plate, plate_why, metrics), inner, match.in_view(z), z, printer, th) + return _judge_objects(img, ref, inner, (plate, plate_why, metrics), (match.in_view(z), z, printer), th, stats) def occupied_confident(v: Verdict, th: Thresholds, view_min: float) -> bool: @@ -1261,14 +1323,21 @@ def list_references(printer: str) -> list[dict[str, Any]]: (level, detrend_px, edge_px, clear_blob_px, clear_max, occupied_blob_px, occupied_min, plate_missing_min, plate_tie, surface_min, change_min, settle_frames, match_px, view_min, shift_px, open_px, low_view, - occupied_blob_low_px) + occupied_blob_low_px, light_norm) Objects are judged robustly: a pixel that matches the reference within shift_px (3) counts as unchanged (a bed parked at another Z, a vibrating camera), changed regions are opened with an open_px (5) square (a moved cable, an edge line), and where the camera sees less than low_view (0.5) of the bed (U1 at Z 200: 15 %) most of the ROI is the machine and the room behind it, so only a region of occupied_blob_low_px (1200) or occupied_min of the ROI says "occupied"; a smaller one stays "unknown" with the hint to `bed check` from a better Z. Light and exposure - changes are mapped out per channel, then smooth gradients are detrended. + changes are mapped out per channel, then smooth gradients are detrended. When the light differs + from the reference's (the brightness of the bed at its dark, middle or bright end off by more than + x0.8..x1.25: day vs night, the printer's light on vs off, a gamma; lib/bed_norm.py) a pixel also needs + its local TEXTURE (luminance over its neighbourhood spread) to have changed, which gain, offset and + tone curves leave alone: pixels only the colour difference flags are the light, so a frame that would + read `occupied` by colour alone is `unknown` (the verdict says "the light, not a part"), never + `occupied`; a real part changes both and stays `occupied`. At a low view (see low_view) the texture + channel may rescue an `occupied` but does not call the bed clear. thresholds.light_norm: 0 turns it off. The camera is the printer's watch.vision.camera (a printer without one cannot enable the bed check). clear_ack: {enabled: true, command: STEPPER_BUZZ STEPPER=stepper_z, debounce_minutes: 10, min_z_mm: 10} one motor sound after bed_clear (the command must be in diff --git a/lib/printer_ops/bed.py b/lib/printer_ops/bed.py index c923161a..1c0f1b35 100644 --- a/lib/printer_ops/bed.py +++ b/lib/printer_ops/bed.py @@ -83,7 +83,7 @@ # another camera may need other values): every field of that dataclass, a test keeps the two in step. THRESHOLD_KEYS = ("level", "detrend_px", "edge_px", "clear_blob_px", "clear_max", "occupied_blob_px", "occupied_min", "plate_missing_min", "plate_tie", "surface_min", "change_min", "settle_frames", "match_px", "view_min", - "shift_px", "open_px", "low_view", "occupied_blob_low_px") + "shift_px", "open_px", "low_view", "occupied_blob_low_px", "light_norm") ACK_KEYS = ("enabled", "command", "debounce_minutes", "min_z_mm") LIGHT_KEYS = ("enabled", "object", "on_gcode", "off_gcode", "settle_s") diff --git a/tests/test_bed_norm.py b/tests/test_bed_norm.py new file mode 100644 index 00000000..7a2cfc76 --- /dev/null +++ b/tests/test_bed_norm.py @@ -0,0 +1,268 @@ +"""The lighting-normalised evidence channel of the bed verdict (lib/bed_norm.py, issue #723). + +Day/night and light on/off on an EMPTY bed must never read as objects; the real parts of the fixtures +(U1 parts after the cancel, the spaghetti frame, squares pasted on the K1 SE bed) still do, also under +the same lighting changes. The lighting changes are generated on the real frames: a gain (up to x4 +brighter, x0.25 darker), a gamma (0.5..2) and a colour cast, with clipping as a camera does it. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import Any + +import pytest + +from test_bed_state import frame, k1_ref, u1_match, u1_ref, u1_z10_ref + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_norm as N # noqa: E402 (needs numpy + OpenCV) +import bed_state as B # noqa: E402 +from test_bed_robust import u1_ref_1016 # noqa: E402 + +# (gain, gamma, per-channel cast as B, G, R gains) +NEUTRAL = (1.0, 1.0, 1.0) +CAST = (1.2, 1.0, 0.8) +LIGHTS = [ + (0.25, 1.0, NEUTRAL), + (0.5, 0.5, NEUTRAL), + (0.5, 2.0, CAST), + (2.0, 0.5, NEUTRAL), + (2.0, 1.0, CAST), + (4.0, 1.0, NEUTRAL), + (1.0, 0.5, CAST), + (1.0, 2.0, NEUTRAL), + (2.0, 2.0, NEUTRAL), + (4.0, 2.0, CAST), +] +MODERATE = [ + (0.5, 1.0, NEUTRAL), + (2.0, 1.0, CAST), + (1.0, 0.5, CAST), + (1.0, 2.0, NEUTRAL), +] + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var in ("XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"): + monkeypatch.setenv(var, str(tmp_path / var.lower())) + return tmp_path + + +def lit(img: Any, gain: float, gamma: float, cast: tuple[float, float, float]) -> Any: + """`img` under another light: x^gamma, times the gain and the per-channel cast, clipped like a camera.""" + x = (img.astype(np.float32) / 255.0) ** gamma * gain * np.array(cast, np.float32) + return (np.clip(x, 0.0, 1.0) * 255).astype(np.uint8) + + +def k1( + name: str, light: tuple[float, float, tuple[float, float, float]] | None = None +) -> B.Verdict: + img = frame(name) + return B.classify( + img if light is None else lit(img, *light), [k1_ref()], 125.0, "k1se" + ) + + +def u1( + name: str, light: tuple[float, float, tuple[float, float, float]] | None = None +) -> B.Verdict: + img = frame(name) + return B.classify( + img if light is None else lit(img, *light), + [u1_ref()], + 200.0, + "u1", + match=u1_match(), + ) + + +def u1_window( + name: str, light: tuple[float, float, tuple[float, float, float]] | None = None +) -> B.Verdict: + img = frame(name) + refs = [u1_ref_1016()] + return B.classify( + img if light is None else lit(img, *light), refs, 200.2, "u1", match=u1_match() + ) + + +# ---- the channel on its own -------------------------------------------------------------------- + + +def test_light_stats_sees_a_gain_and_a_tone_curve_but_not_a_same_light_frame() -> None: + img = frame("k1_z125_clear_ref.jpg") + roi = np.ones(img.shape[:2], bool) + assert not N.light_stats(img, img, roi).mismatch + assert N.light_stats(img, lit(img, 0.5, 1.0, NEUTRAL), roi).mismatch + assert N.light_stats(img, lit(img, 4.0, 1.0, NEUTRAL), roi).ratio > 2 + # a tone curve that leaves the MEAN alone still moves the dark and the bright end + assert N.light_stats(img, lit(img, 2.0, 2.0, NEUTRAL), roi).mismatch + # the caller knows the light state differs even when the pixels do not say so + assert N.light_stats(img, img, roi, light_differs=True).mismatch + assert "light state differs" in N.light_stats(img, img, roi, light_differs=True).why + + +def test_a_part_changes_the_texture_and_a_gain_does_not() -> None: + base = frame("k1_z125_clear_ref.jpg") + roi = np.ones(base.shape[:2], bool) + part = base.copy() + part[180:204, 300:324] = (20, 20, 20) + assert N.structure_changed(base, lit(base, 0.5, 2.0, CAST), roi, 3).sum() < 50 + changed = N.structure_changed(base, lit(part, 0.5, 2.0, CAST), roi, 3) + assert ( + changed[180:204, 300:324].sum() > 100 + ) # the rim and the flat inside of the part + + +def test_confirm_keeps_a_colour_region_only_where_the_texture_changed() -> None: + colour = np.zeros((60, 80), bool) + colour[10:30, 10:30] = True # a colour change with texture evidence around it + colour[40:55, 50:70] = True # a glare: colour only + texture = np.zeros_like(colour) + texture[10:30, 10:30] = True + texture[12:28, 12:28] = ( + False # a flat part has structure at its rim only: filled by `confirm` + ) + kept = N.confirm(colour, texture) + assert kept[10:30, 10:30].all() + assert not kept[40:55, 50:70].any() + + +def test_no_roi_is_no_evidence() -> None: + img = frame("k1_z125_clear_ref.jpg") + none = np.zeros(img.shape[:2], bool) + assert not N.structure_changed(img, img, none).any() + assert not N.light_stats(img, img, none).mismatch + + +# ---- an empty bed under another light is never occupied ----------------------------------------- + + +@pytest.mark.parametrize("light", LIGHTS) +@pytest.mark.parametrize("name", ["k1_z125_clear_ref.jpg", "k1_z125_clear_later.jpg"]) +def test_the_empty_k1_bed_in_any_light_is_never_occupied(name: str, light: Any) -> None: + v = k1(name, light) + assert v.bed != "occupied", (v.reason, v.metrics) + + +@pytest.mark.parametrize("light", MODERATE) +@pytest.mark.parametrize("name", ["k1_z125_clear_ref.jpg", "k1_z125_clear_later.jpg"]) +def test_the_empty_k1_bed_stays_clear_in_a_moderate_light_change( + name: str, light: Any +) -> None: + v = k1(name, light) + assert v.bed == "clear", (v.reason, v.metrics) + + +def test_k1_daylight_reference_against_the_dark_room_is_clear_through_the_normalised_channel() -> ( + None +): + """The fixture pair of the incident: the same empty K1 bed, daylight and a dark room.""" + v = k1("k1_z125_clear_later.jpg") + assert v.bed == "clear" + assert not N.light_stats( + k1_ref().image, frame("k1_z125_clear_later.jpg"), k1_ref().volume + ).mismatch or (v.metrics.get("light_mismatch") == 1.0) + + +@pytest.mark.parametrize("light", LIGHTS) +@pytest.mark.parametrize("name", ["u1_z200_clear_1403.jpg", "u1_z200_clear_1426.jpg"]) +def test_the_empty_u1_bed_in_any_light_is_never_occupied(name: str, light: Any) -> None: + v = u1(name, light) + assert v.bed != "occupied", (v.reason, v.metrics) + + +@pytest.mark.parametrize("light", MODERATE) +@pytest.mark.parametrize( + "name", + ["u1_z200_clear_1403.jpg", "u1_z200_clear_1405.jpg", "u1_z200_clear_1412.jpg"], +) +def test_the_empty_u1_bed_at_a_low_view_in_a_moderate_light_change_is_clear_or_names_the_best_view( + name: str, light: Any +) -> None: + """The U1 at Z 200 sees 15 % of the plate: the room behind it is most of the ROI, so the normalised + channel may rescue a false `occupied` there but not call the bed clear (`unknown` + the way out).""" + v = u1(name, light) + assert v.bed in ("clear", "unknown"), (v.reason, v.metrics) + if v.bed == "unknown": + assert "3d printer bed check u1" in v.reason + + +@pytest.mark.parametrize("light", MODERATE) +def test_the_empty_u1_bed_at_a_good_view_stays_clear_in_a_moderate_light_change( + light: Any, +) -> None: + """Z 10 after a G28: 98 % of the plate in the frame, the same empty bed under another light.""" + ref = u1_z10_ref() + v = B.classify(lit(ref.image, *light), [ref], 10.0, "u1", match=u1_match()) + assert v.bed == "clear", (v.reason, v.metrics) + assert v.metrics["light_mismatch"] == 1.0 + + +@pytest.mark.parametrize("light", [None, *LIGHTS]) +@pytest.mark.parametrize("name", ["u1_z200_light_1703.jpg", "u1_z200_light_2013.jpg"]) +def test_the_window_light_frames_in_any_light_are_never_occupied( + name: str, light: Any +) -> None: + """The preflight incident (U1 Z 200.2, 15 % of the plate in view): never occupied, never a FAIL.""" + v = u1_window(name, light) + assert v.bed != "occupied", (v.reason, v.metrics) + + +# ---- a real part is still found, in any light -------------------------------------------------- + + +@pytest.mark.parametrize("light", [None, *LIGHTS]) +@pytest.mark.parametrize("size", [24, 16]) +def test_a_part_on_the_k1_bed_is_found_in_any_light(size: int, light: Any) -> None: + img = frame("k1_z125_clear_ref.jpg").copy() + img[180 : 180 + size, 300 : 300 + size] = (20, 20, 20) + v = B.classify( + img if light is None else lit(img, *light), [k1_ref()], 125.0, "k1se" + ) + assert v.bed == "occupied", (v.reason, v.metrics) + + +@pytest.mark.parametrize( + "name", + ["u1_occupied_1351.jpg", "u1_occupied_1400.jpg", "u1_spaghetti_printing.jpg"], +) +@pytest.mark.parametrize( + "light", [None, (0.7, 1.0, NEUTRAL), (0.5, 1.0, CAST), (1.0, 0.5, NEUTRAL)] +) +def test_the_u1_parts_and_the_spaghetti_are_found_in_a_changed_light( + name: str, light: Any +) -> None: + v = u1(name, light) + assert v.bed == "occupied", (v.reason, v.metrics) + + +def test_parts_the_colour_difference_alone_would_report_in_another_light_are_unknown_not_occupied() -> ( + None +): + """The decision itself: the plain colour difference on a gamma-2 frame of the empty K1 bed is a region (the + bright plate edge), the texture did not change: unknown at worst, and the verdict says why.""" + img = lit(frame("k1_z125_clear_later.jpg"), 2.0, 2.0, NEUTRAL) + plain = B.classify( + img, [k1_ref()], 125.0, "k1se", dataclasses.replace(B.TH, light_norm=0) + ) + assert plain.bed == "occupied" # the old classifier, teeth + v = B.classify(img, [k1_ref()], 125.0, "k1se") + assert v.bed in ("clear", "unknown"), v.reason + assert v.metrics["light_mismatch"] == 1.0 + if v.bed == "unknown": + assert "the light, not a part" in v.reason or "light differs" in v.reason + + +def test_the_light_norm_threshold_is_a_per_printer_switch() -> None: + from printer_ops import bed as pbed + from test_bed_state import device + + cfg = pbed.bed_config( + device(watch={"bed": {"enabled": True, "thresholds": {"light_norm": 0}}}) + ) + assert B.thresholds_for(cfg).light_norm == 0 diff --git a/tests/test_bed_robust.py b/tests/test_bed_robust.py index 2855bded..1aa67848 100644 --- a/tests/test_bed_robust.py +++ b/tests/test_bed_robust.py @@ -31,8 +31,12 @@ import bed_state as B # noqa: E402 (needs numpy + OpenCV) OLD = dataclasses.replace( - B.TH, shift_px=0, open_px=3, occupied_blob_low_px=B.TH.occupied_blob_px -) # the classifier before #723 + B.TH, + shift_px=0, + open_px=3, + occupied_blob_low_px=B.TH.occupied_blob_px, + light_norm=0, +) # the classifier before #723 (no shift tolerance, no lighting-normalised channel) @pytest.fixture(autouse=True) From 9bdeefb6728061022aa22b452bc6f82bb8bf3c99 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:12:09 +0100 Subject: [PATCH 10/30] =?UTF-8?q?feat(print-preflight):=20slot=20colour=20?= =?UTF-8?q?by=20CIEDE2000=20=CE=94E,=20--fix=20rewrites=20the=20file=20to?= =?UTF-8?q?=20the=20loaded=20colour=20(Refs=20#728)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/architecture/lib-map.md | 2 + docs/commands/print-preflight.md | 6 +- lib/commands/print_preflight.py | 238 +++++++--- lib/printer_ops/colour_fix.py | 438 ++++++++++++++++++ lib/printer_ops/preflight.py | 363 ++++++++------- tests/colour_fix_fixtures.py | 80 ++++ .../test_print_preflight_colour_workflows.py | 124 +++++ tests/test_colour_fix.py | 323 +++++++++++++ 8 files changed, 1349 insertions(+), 225 deletions(-) create mode 100644 lib/printer_ops/colour_fix.py create mode 100644 tests/colour_fix_fixtures.py create mode 100644 tests/e2e/test_print_preflight_colour_workflows.py create mode 100644 tests/test_colour_fix.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 0b29ff75..395bd142 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -111,6 +111,8 @@ Target package: `lib/geometry/`, with subpackages only when a real boundary appe `dossier.py` / `dossier_render.py` / `webui.py` / `render.py` (`3d printers show NAME`: name resolution, the concurrent read-only live reads, the web UI probe, the text; `render.py` is the status text shared with `3d printer status`), `preflight.py` (pre-print checks), + `colour_fix.py` (the CIEDE2000 slot colour tolerance and the `--fix` / `--fix-colour` rewrite of a + G-code + project 3MF + sidecars to the loaded colour), `move_bounds.py` (G-code moves and arc extremes vs axis travel / printable area), `force_hatch.py` (the owner's Telegram hatch approval that `3d printer start --force` needs), `watch.py` / `watch_run.py` / `watch_sinks.py` (`3d printer watch`: detectors, streams, diff --git a/docs/commands/print-preflight.md b/docs/commands/print-preflight.md index 85ab46d9..01b4bfa2 100644 --- a/docs/commands/print-preflight.md +++ b/docs/commands/print-preflight.md @@ -11,6 +11,7 @@ input is missing is SKIP and its line says what to pass — it never passes sile 3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] [--no-bed-move] + [--colour-delta-e N] [--fix [--fix-max-delta-e N]] ``` | Check | When | FAIL means | @@ -18,7 +19,7 @@ input is missing is SKIP and its line says what to pass — it never passes sile | `thumbnails` | always | The G-code lacks preview images its `; thumbnails =` spec asks for (fix: `3d gcode-thumbnails add FILE`). | | `printer` | `--printer` | The printer is printing, paused, busy, or its state cannot be read. | | `bed` | `--printer` with `watch.bed.enabled` (SKIP otherwise) | The camera sees objects on the bed or the build plate missing against the empty-bed reference; WARN when it cannot tell (no reference at this Z, plate out of view, camera down). See [Bed state](printer.md#bed-state). On an idle printer with `watch.bed.auto_view` (the U1: its camera sees 15 % of the plate at the Z 200 rest) the bed is moved in Z only to where the whole plate is in view, judged there and put back, and the line names the Z used; never while printing/paused, never homing; `--no-bed-move` judges at the current Z. | -| `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, or its colour/material differs from `; filament_colour` / `; filament_type`. | +| `slot Tn` | `--printer` on a printer with per-head data (Snapmaker U1) | Tool *n* is used by the G-code but head T*n+1* is empty, its material differs from `; filament_type`, or its colour is further than `--colour-delta-e` (CIEDE2000, default 10) from `; filament_colour`. The line shows the distance; `--fix` aligns the file with the printer, [`3d printer slot set`](printer.md#slot-colour) the printer with the file. | | `axis travel` | always (SKIP when the limits are unknown) | A move, or the bulge of an arc, leaves the printer's X/Y travel anywhere in the file. See [`3d gcode-bounds`](gcode-bounds.md). | | `print area` | always | A plate move leaves the G-code's `; printable_area`, or an extruding path crosses `; bed_exclude_area` (vendor blocks exempt: `;TYPE:Custom`; on Bambu Studio G-code everything outside its object blocks, wipe tower and features — see [`3d gcode-bounds`](gcode-bounds.md)). | | `macros` | when the G-code calls macros | A macro (PAUSE, a vendor toolchange, …) is not in the printer's `trusted_macros`: its moves cannot be checked. | @@ -133,6 +134,9 @@ Options: - `--bridge-max MM` — longest span the overhangs gate counts as a bridge; overrides the printer's `bridge_max_mm`: `3d print-preflight out/porch.gcode --axis-limits u1 --bridge-max 25` - `--json` — JSON report (`checks[]` with `name`, `status`, `detail`, `lines`): `3d print-preflight out/part.gcode --printer u1 --json | jq '.checks[] | select(.status=="FAIL")'` - `--no-bed-move` — the `bed` line judges at the current Z: the printer's bed is not moved to the best inspection Z (`watch.bed.auto_view`): `3d print-preflight out/part.gcode --printer u1 --no-bed-move` +- `--colour-delta-e N` — the `slot Tn` colour tolerance, CIEDE2000 (default 10, the `colour.warn_delta_e` of `fleet/policy.yaml`). The slot line shows the distance either way. The beige PETG of 2026-11-04 (sliced `#E8D8B0`, shown by the U1 as `#FEE5A5`) is ΔE 6.4 and passes; white against that beige is 20.0 and fails; red against blue is 52.9. A **material** mismatch is always a FAIL, whatever the colour: `3d print-preflight out/part.gcode --printer u1 --colour-delta-e 5` +- `--fix` — needs a live `--printer`. Before the checks, rewrite `; filament_colour` / `; extruder_colour` for every tool the G-code uses to the colour the printer reports, in the G-code **and** in its project 3MF (`--source-3mf`, else `NAME.3mf` next to the G-code; `Metadata/project_settings.config`, `Metadata/plate_N.json`). The toolpath bytes, mode and mtime stay; `NAME.slice-warnings.json` follows the new G-code SHA-256 and `NAME.manifest.json` gets the new slot colour; the old and new SHA-256 of every rewritten file go to stderr (the file's hash changed: upload it again). A head further than `--fix-max-delta-e` away is refused with nothing written (another filament, not the same one reported differently). It is the one option that writes: `3d print-preflight out/plate.gcode --printer u1 --fix` +- `--fix-max-delta-e N` — the largest ΔE `--fix` rewrites across (default 30): `3d print-preflight out/plate.gcode --printer u1 --fix --fix-max-delta-e 40` - `--secs S` — Creality: seconds to listen for pushed state: `3d print-preflight out/roof.gcode --printer k1se --secs 5` - `--printops-api URL` — 3d-api for the PrintOps gates (default `$PRINTOPS_API`, else `printops.yaml`): `3d print-preflight out/part.gcode --printer u1 --printops-api http://192.168.0.39:8765` - `--offline` — no network: no printer contact, PrintOps state from the snapshot (the slicer hook mode); together with `--printops-api` it is a usage error: `3d print-preflight out/part.gcode --offline` diff --git a/lib/commands/print_preflight.py b/lib/commands/print_preflight.py index c96d6c50..c7e0ae6b 100644 --- a/lib/commands/print_preflight.py +++ b/lib/commands/print_preflight.py @@ -19,18 +19,28 @@ https://github.com/alex-mextner/3d-cli/issues/96); running it alone answers "is this file ready to go on that printer?" without touching anything. It only READS from the printer. -INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside run(). +INVARIANTS: stdlib-only at import time; printer_ops imported lazily inside the helpers and run(). """ from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + from cli.registry import Command from errors import GateFailure, InputNotFound, UsageError +if TYPE_CHECKING: # typing-only: printer_ops stays a lazy import inside the helpers + from printer_ops.base import PrinterStatus + from printer_ops.devices import Device + from printer_ops.move_bounds import Limits + from printer_ops.preflight import Check + USAGE = """3d print-preflight FILE.gcode [--printer NAME] [--axis-limits X0:X1,Y0:Y1|NAME] [--source-3mf FILE.3mf] [--visible [OBJ:]SPEC]... [--bridge-max MM] [--printops-api URL] [--offline] [--printops-snapshot FILE] [--accept preset_match|mix_calibration --reason TEXT] [--json] [--secs S] - [--no-bed-move] - Read-only checks before a print. `3d printer start` runs the same checks and refuses on FAIL. + [--no-bed-move] [--colour-delta-e N] [--fix [--fix-max-delta-e N]] + Read-only checks before a print (the one write is opt-in: --fix). `3d printer start` runs the same checks and refuses on FAIL. Every G-code gate runs; one whose input is missing is SKIP and says what to pass. thumbnails preview images required by the G-code's `; thumbnails =` spec are present @@ -42,8 +52,10 @@ is in view, judged there and put back; the line names the Z used. Never while printing/paused, never homing; --no-bed-move judges at the current Z slot Tn (--printer, per-head printers such as the Snapmaker U1) each head the G-code - uses is loaded, and its colour and material equal `; filament_colour` / - `; filament_type` for that tool + uses is loaded, its material equals `; filament_type` for that tool (a + mismatch is always a FAIL) and its colour is within --colour-delta-e of + `; filament_colour` (CIEDE2000, default 10; the line shows the distance: the + beige #E8D8B0 sliced for a head reporting #FEE5A5 is ΔE 6.4 and passes) axis travel every G0-G3 move (arcs by their real extremes, start/end G-code included) stays inside the printer's X/Y limits; SKIP when the limits are unknown print area plate moves stay inside the G-code's `; printable_area`; no extruding path @@ -118,6 +130,16 @@ carries --reason; only those two; needs --reason TEXT --reason TEXT why the accepted mismatch is printed --no-bed-move the bed line judges at the current Z: the printer's bed is not moved + --colour-delta-e N slot Tn colour tolerance, CIEDE2000 (default 10) + --fix (needs --printer) first rewrite `; filament_colour` / `; extruder_colour` of + every tool the G-code uses to the colour the printer reports, in the G-code and + its project 3MF (--source-3mf, else NAME.3mf beside it; Metadata/ + project_settings.config), keeping mode/mtime; NAME.slice-warnings.json and + NAME.manifest.json follow; old and new sha256 of each file are printed to + stderr. A head whose colour is further than --fix-max-delta-e away is refused + (another filament, nothing written). Then the checks run. The only option + that writes (re-upload the file after it: its hash changed) + --fix-max-delta-e N the largest ΔE --fix rewrites across (default 30) --json JSON report on stdout --secs S Creality: seconds to listen for pushed state (default 3) @@ -131,46 +153,65 @@ 3d print-preflight out/roof.gcode --axis-limits -5:229,-0.5:227 3d print-preflight print/k1se/k1_base_4_v3.gcode --axis-limits k1se 3d print-preflight out/walls.gcode --visible 'WALL*:y<104.8' + 3d print-preflight out/plate.gcode --printer u1 --fix # same beige PETG, other hex: rewrite + sha256s 3d print-preflight out/part.gcode --printer u1 --json | jq '.checks[] | select(.status=="FAIL")' 3d print-preflight out/part.gcode --printer u1 --printops-api http://192.168.0.39:8765 3d print-preflight out/part.gcode --offline # slicer hook: PrintOps snapshot, no network 3d print-preflight out/plate.gcode --printer k1se --accept preset_match --reason 'stock flow on purpose'""" -def run(argv: list[str]) -> int: - if not argv: - print(USAGE) - return 1 - if argv[0] in ("-h", "--help"): - print(USAGE) - return 0 - import json - from pathlib import Path +_VALUE_OPTIONS = ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", + "--printops-api", "--printops-snapshot", "--accept", "--reason", "--colour-delta-e", + "--fix-max-delta-e") +_FLAG_OPTIONS = ("--json", "--offline", "--no-bed-move", "--fix") + + +@dataclass +class _Target: + """What the checks run against: the printer's device/status/limits, or an offline stand-in.""" + + device: Device | None + status: PrinterStatus | None + limits: Limits | None + limits_source: str + trusted: frozenset[str] + + +def _record_flag_or_positional(a: str, pos: list[str], opts: dict[str, str]) -> None: + """One argv token that takes no value: a flag, the G-code path, or an unknown option.""" + if a in _FLAG_OPTIONS: + opts[a] = "1" + elif a.startswith("--"): + raise UsageError(f"unknown option {a}", command="print-preflight", + remediation=["See `3d print-preflight --help`."]) + else: + pos.append(a) + +def _parse_args(argv: list[str]) -> tuple[list[str], dict[str, str], list[str]]: + """argv -> (positionals, options by flag, repeatable --visible values).""" pos: list[str] = [] opts: dict[str, str] = {} visible: list[str] = [] i = 0 while i < len(argv): a = argv[i] - if a in ("--printer", "--source-3mf", "--secs", "--axis-limits", "--visible", "--bridge-max", - "--printops-api", "--printops-snapshot", "--accept", "--reason"): - if i + 1 >= len(argv): - raise UsageError(f"{a} needs a value", command="print-preflight") - if a == "--visible": - visible.append(argv[i + 1]) - else: - opts[a] = argv[i + 1] - i += 2 + if a not in _VALUE_OPTIONS: + _record_flag_or_positional(a, pos, opts) + i += 1 continue - if a in ("--json", "--offline", "--no-bed-move"): - opts[a] = "1" - elif a.startswith("--"): - raise UsageError(f"unknown option {a}", command="print-preflight", - remediation=["See `3d print-preflight --help`."]) + if i + 1 >= len(argv): + raise UsageError(f"{a} needs a value", command="print-preflight") + if a == "--visible": + visible.append(argv[i + 1]) else: - pos.append(a) - i += 1 + opts[a] = argv[i + 1] + i += 2 + return pos, opts, visible + + +def _resolve_inputs(pos: list[str], opts: dict[str, str]) -> tuple[Path, Path | None]: + """The G-code and the optional source 3MF, both required to exist.""" if len(pos) != 1: raise UsageError(f"expected one G-code file, got {len(pos)}", command="print-preflight") gcode = Path(pos[0]) @@ -179,52 +220,117 @@ def run(argv: list[str]) -> int: source = Path(opts["--source-3mf"]) if "--source-3mf" in opts else None if source is not None and not source.is_file(): raise InputNotFound(str(source), command="print-preflight") + return gcode, source - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - from printer_ops import adapter_by_name, bridge_max_for, device_for, limits_from_option, trusted_macros_for +def _offline_profile(opts: dict[str, str], printer: str | None, offline: bool) -> Device | None: + """The static profile standing in for --printer when it is offline or --offline is given. + + Such a printer is never contacted: its profile replaces --printer, and an explicit --axis-limits + replaces only its limits. Mutates `opts` accordingly.""" from printer_ops.devices import get_device + profile = get_device(printer, offline_ok=True) if printer is not None else None + if profile is None or not (offline or profile.access == "offline"): + return None + del opts["--printer"] + opts.setdefault("--axis-limits", profile.name) + return profile + + +def _static_device(opts: dict[str, str], profile: Device | None) -> tuple[Device | None, frozenset[str]]: + """Device and trusted macros known without contacting a printer.""" + from printer_ops import device_for, trusted_macros_for + + if profile is not None: + return profile, profile.trusted_macros + if "--printer" in opts: + return None, frozenset() + spec = opts.get("--axis-limits") + return device_for(spec), trusted_macros_for(spec) + + +def _fix_colours(gcode: Path, source: Path | None, status: PrinterStatus, opts: dict[str, str]) -> None: + """--fix: rewrite the file to the loaded colours before the checks run (report on stderr).""" + import sys + + from printer_ops.colour_fix import DEFAULT_FIX_MAX_DELTA_E, fix_to_loaded, parse_delta_e + + limit = parse_delta_e(opts.get("--fix-max-delta-e"), "--fix-max-delta-e", DEFAULT_FIX_MAX_DELTA_E, + command="print-preflight") + fixed = fix_to_loaded(gcode, status, source, limit, command="print-preflight") + print("\n".join(fixed.lines()) if fixed else "--fix: the loaded colours already equal the G-code's", + file=sys.stderr) + + +def _connect(opts: dict[str, str], profile: Device | None, gcode: Path, source: Path | None) -> _Target: + """Device, live status, axis limits and trusted macros for the run; reads the printer if --printer + (and, with --fix, rewrites the file to the loaded colours).""" + from printer_ops import adapter_by_name, limits_from_option + + device, trusted = _static_device(opts, profile) + limits: Limits | None = None + limits_source = "no --printer or --axis-limits given" + if "--axis-limits" in opts: + limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") + if "--printer" not in opts: + return _Target(device, None, limits, limits_source, trusted) + adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") + status = adapter.status() + if "--fix" in opts: + _fix_colours(gcode, source, status, opts) + if "--axis-limits" not in opts: + limits, limits_source = adapter.axis_limits() + # this printer's macros only, whatever --axis-limits names + return _Target(adapter.device, status, limits, limits_source, adapter.trusted_macros) + + +def _bed_line(opts: dict[str, str], device: Device | None) -> Check | None: + """The camera bed line, only for a real --printer whose device is known.""" + if "--printer" not in opts or device is None: + return None + from printer_ops.preflight import check_bed + + return check_bed(device, move="--no-bed-move" not in opts) + + +def run(argv: list[str]) -> int: + if not argv: + print(USAGE) + return 1 + if argv[0] in ("-h", "--help"): + print(USAGE) + return 0 + import json + + pos, opts, visible = _parse_args(argv) + gcode, source = _resolve_inputs(pos, opts) + + from printer_ops import bridge_max_for + from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, parse_delta_e + from printer_ops.preflight import FAIL, run_preflight + from printer_ops.printops_checks import context_from_options + + colour_de = parse_delta_e(opts.get("--colour-delta-e"), "--colour-delta-e", DEFAULT_SLOT_DELTA_E, + command="print-preflight") printer = opts.get("--printer") offline = "--offline" in opts - profile = get_device(printer, offline_ok=True) if printer is not None else None - if profile is not None and (offline or profile.access == "offline"): - # an offline fleet printer, or --offline: never contacted, its static profile stands in for - # --printer, an explicit --axis-limits replaces only its limits - del opts["--printer"] - opts.setdefault("--axis-limits", profile.name) - else: - profile = None + profile = _offline_profile(opts, printer, offline) + if "--fix" in opts and "--printer" not in opts: + raise UsageError("--fix needs the loaded colours of a live --printer (not --offline, not an offline printer)", + command="print-preflight", + remediation=["3d print-preflight FILE --printer NAME --fix"]) printops = context_from_options(printer, opts.get("--axis-limits"), api=opts.get("--printops-api"), offline=offline, snapshot=opts.get("--printops-snapshot"), command="print-preflight", accept=opts.get("--accept"), reason=opts.get("--reason")) - - status = None - device = profile or (None if "--printer" in opts else device_for(opts.get("--axis-limits"))) - limits, limits_source = None, "no --printer or --axis-limits given" - trusted = profile.trusted_macros if profile else \ - frozenset() if "--printer" in opts else trusted_macros_for(opts.get("--axis-limits")) - if "--axis-limits" in opts: - limits, limits_source = limits_from_option(opts["--axis-limits"], command="print-preflight") - if "--printer" in opts: - adapter = adapter_by_name(opts["--printer"], secs=opts.get("--secs"), command="print-preflight") - trusted = adapter.trusted_macros # this printer's macros only, whatever --axis-limits names - device = adapter.device - status = adapter.status() - if "--axis-limits" not in opts: - limits, limits_source = adapter.axis_limits() - - bed = None - if "--printer" in opts and device is not None: - from printer_ops.preflight import check_bed - - bed = check_bed(device, move="--no-bed-move" not in opts) - bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), device, command="print-preflight") - report = run_preflight(gcode, status=status, source_3mf=source, axis_limits=limits, limits_source=limits_source, - trusted_macros=trusted, visible=visible, bridge_max=bridge, bridge_source=bridge_source, - bed=bed, printops=printops) + target = _connect(opts, profile, gcode, source) + bed = _bed_line(opts, target.device) + bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), target.device, command="print-preflight") + report = run_preflight(gcode, status=target.status, source_3mf=source, axis_limits=target.limits, + limits_source=target.limits_source, trusted_macros=target.trusted, visible=visible, + bridge_max=bridge, bridge_source=bridge_source, bed=bed, printops=printops, + colour_delta_e=colour_de) print(json.dumps(report.to_json(), indent=1) if "--json" in opts else report.render()) if report.overall == FAIL: raise GateFailure("preflight FAIL", command="print-preflight", silent=True) diff --git a/lib/printer_ops/colour_fix.py b/lib/printer_ops/colour_fix.py new file mode 100644 index 00000000..7bd15a42 --- /dev/null +++ b/lib/printer_ops/colour_fix.py @@ -0,0 +1,438 @@ +"""colour_fix.py — slot colour tolerance and the rewrite that aligns a G-code with the loaded filament. + +WHAT: two things behind the preflight `slot Tn` line. + - `colour_delta(a, b)`: CIEDE2000 between two `#RRGGBB` colours (CIELAB, D65; printops/color.py). + `DEFAULT_SLOT_DELTA_E` (10) is the tolerance of the slot check: two beiges of the same PETG + (#E8D8B0 sliced, #FEE5A5 loaded: ΔE 6.4) are the same filament; white vs beige (16.7) or red vs + blue (52.9) are not. A material mismatch is never tolerated (preflight.py). + - `plan_colour_fix` + `apply_colour_fix` (`3d print-preflight --fix`, `3d printer start + --fix-colour`): for every tool the G-code uses, write the colour the PRINTER reports into the + `; filament_colour` / `; extruder_colour` entries of the G-code and into the 3MF project it was + sliced from (`Metadata/project_settings.config`, `Metadata/plate_N.json filament_colors`), so the + files record what is really loaded. Sidecars follow: `NAME.slice-warnings.json` gets the new + G-code SHA-256 (`slice_warnings.follow_edit`), `NAME.manifest.json` the new slot colour. Old and + new SHA-256 of every rewritten file are returned for the operator to print. + +WHY: on 2026-11-04 a SIMBOL plate sliced with #E8D8B0 met a printer that showed the same beige PETG + as #FEE5A5: the preflight FAILed on string inequality and the only way out was `--force`, whose + Telegram approval then failed. The cause is metadata, and the right fix is at the source, in + seconds, with the old and new hashes on record. + +INVARIANTS: stdlib only (print-preflight imports it in-process). The toolpath bytes are never + touched: only the colour entries of `; filament_colour =` / `; extruder_colour =` lines change; + mode and mtime are kept (a staleness check G-code vs project must not be fooled), and both files + are staged to temp files first, so a 3MF that cannot be rewritten leaves the G-code untouched. A + tool whose loaded colour is further than `max_delta_e` from the sliced one is REFUSED, not + rewritten: that is a different filament, and relabelling it would hide a wrong-colour print. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +from printops.color import hex_delta_e, parse_hex + +from printer_ops.base import PrinterRefused + +if TYPE_CHECKING: + from printer_ops.base import PrinterStatus + from printer_ops.preflight import GcodeMeta + +DEFAULT_SLOT_DELTA_E = 10.0 # CIEDE2000; the `colour.warn_delta_e` of fleet/policy.yaml +DEFAULT_FIX_MAX_DELTA_E = ( + 30.0 # above this the loaded filament is another colour: no rewrite +) + +_COLOUR_LINE = re.compile( + rb"^(;[ \t]*(?:filament_colour|extruder_colour)[ \t]*=[ \t]*)(.*?)(\r?\n?)$" +) +_COLOUR_ENTRY = re.compile(r"#?[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?") +_PROJECT_SETTINGS = "Metadata/project_settings.config" +_PLATE_JSON = re.compile(r"Metadata/plate_\d+\.json") +_CHUNK = 1 << 22 + + +def colour_delta(a: str | None, b: str | None) -> float | None: + """CIEDE2000 between two colours (`#RRGGBB`, RGBA and bare forms accepted); None when either is not one.""" + pa, pb = parse_hex(a), parse_hex(b) + if pa is None or pb is None: + return None + return hex_delta_e(pa, pb) + + +def parse_delta_e( + value: str | None, option: str, default: float, *, command: str +) -> float: + """`--colour-delta-e` / `--fix-max-delta-e` value: a positive number (CIEDE2000), else `default`.""" + from errors import InvalidArgument + + if value is None: + return default + try: + number = float(value) + except ValueError: + number = -1.0 + if not number > 0: + raise InvalidArgument( + option, value, ["a positive number (CIEDE2000), e.g. 10"], command=command + ) + return number + + +@dataclass(frozen=True) +class ColourEdit: + """One tool's sliced colour replaced by the loaded one.""" + + tool: int # G-code tool number (0-based) + label: str # the printer's name of that head (U1: T1..T4) + old: str + new: str + delta_e: float + + +@dataclass(frozen=True) +class FileChange: + path: Path + old_sha256: str + new_sha256: str + + +@dataclass +class FixResult: + edits: list[ColourEdit] + gcode: FileChange + project: FileChange | None = None + manifest: Path | None = None # NAME.manifest.json whose slot colours were updated + sidecar: Path | None = ( + None # NAME.slice-warnings.json re-bound to the new G-code hash + ) + notes: list[str] = field(default_factory=list) + + def lines(self) -> list[str]: + out = [ + f"colour fix: tool T{e.tool} ({e.label}) {e.old} -> {e.new} (ΔE {e.delta_e:.1f})" + for e in self.edits + ] + out.extend( + f" {c.path}\n sha256 old {c.old_sha256}\n sha256 new {c.new_sha256}" + for c in (self.gcode, self.project) + if c is not None + ) + if self.manifest is not None: + out.append(f" {self.manifest}: slot colours updated") + if self.sidecar is not None: + out.append(f" {self.sidecar}: re-bound to the new G-code sha256") + out.extend(f" note: {n}" for n in self.notes) + return out + + +def plan_colour_fix( + meta: GcodeMeta, status: PrinterStatus, max_delta_e: float = DEFAULT_FIX_MAX_DELTA_E +) -> tuple[list[ColourEdit], list[str]]: + """(edits, refusals): the used tools whose sliced colour differs from the loaded one, and the + reasons a tool is not rewritten (empty head, a different colour beyond `max_delta_e`).""" + edits: list[ColourEdit] = [] + refusals: list[str] = [] + for tool in meta.used_tools: + slot = status.slots[tool] if tool < len(status.slots) else None + old = meta.colours[tool] if tool < len(meta.colours) else None + if ( + slot is None + or not slot.present + or not slot.color + or not old + or old == slot.color + ): + continue + de = colour_delta(old, slot.color) + if de is None: + continue + if de > max_delta_e: + refusals.append( + f"tool T{tool} ({slot.label}): G-code {old}, loaded {slot.color}: ΔE {de:.1f} > {max_delta_e:g}, " + "another colour, not the same filament reported differently" + ) + else: + edits.append(ColourEdit(tool, slot.label, old, slot.color, de)) + return edits, refusals + + +def _ident(st: os.stat_result) -> tuple[int, int, int]: + return st.st_ino, st.st_size, st.st_mtime_ns + + +def _entries(value: str, wanted: dict[int, str]) -> str: + """`a;b;c` with entry `t` of `wanted` replaced by its colour (quotes and spacing kept).""" + parts = value.split(";") + for tool, new in wanted.items(): + if tool < len(parts) and _COLOUR_ENTRY.search(parts[tool]): + parts[tool] = _COLOUR_ENTRY.sub(new, parts[tool], count=1) + return ";".join(parts) + + +def _patch_line(line: bytes, wanted: dict[int, str]) -> bytes: + m = _COLOUR_LINE.match(line) + if m is None: + return line + value = m.group(2).decode("utf-8", errors="surrogateescape") + return ( + m.group(1) + + _entries(value, wanted).encode("utf-8", errors="surrogateescape") + + m.group(3) + ) + + +def _stage_gcode( + path: Path, wanted: dict[int, str] +) -> tuple[Path, FileChange, os.stat_result]: + """Copy `path` with the colour lines patched to a temp file beside it; (temp, hashes, source stat).""" + old, new = hashlib.sha256(), hashlib.sha256() + fd, tmp = tempfile.mkstemp(prefix=".colour-fix-", dir=path.resolve().parent) + changed = 0 + try: + with open(path, "rb") as src, os.fdopen(fd, "wb") as dst: + st = os.fstat(src.fileno()) + for line in src: + old.update(line) + out = ( + _patch_line(line, wanted) + if line[:1] == b";" and b"colour" in line + else line + ) + changed += out != line + new.update(out) + dst.write(out) + if not changed: + raise PrinterRefused( + f"{path} has no `; filament_colour` / `; extruder_colour` entry to rewrite for tools " + f"{', '.join(f'T{t}' for t in sorted(wanted))}", + command="print-preflight", + remediation=[ + "The G-code carries no slicer config block: re-slice it, or load the colour that was sliced." + ], + ) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return Path(tmp), FileChange(path, old.hexdigest(), new.hexdigest()), st + + +def _indent_of(text: str) -> int: + for line in text.splitlines()[1:3]: + stripped = line.lstrip(" ") + if stripped and stripped != line: + return len(line) - len(stripped) + return 4 + + +def _patch_json( + raw: bytes, keys: tuple[str, ...], wanted: dict[int, str] +) -> bytes | None: + """JSON with entry `t` of each list in `keys` set to its colour; None when nothing changes.""" + text = raw.decode("utf-8") + data = json.loads(text) + if not isinstance(data, dict): + return None + changed = False + for key in keys: + arr = data.get(key) + if not isinstance(arr, list): + continue + for tool, new in wanted.items(): + if ( + tool < len(arr) + and isinstance(arr[tool], str) + and arr[tool].upper() != new.upper() + ): + arr[tool] = new + changed = True + if not changed: + return None + out = json.dumps(data, indent=_indent_of(text), ensure_ascii=False) + return (out + "\n" if text.endswith("\n") else out).encode("utf-8") + + +class _NotAProject(ValueError): + """The 3MF is not a slicer project: there is no colour list in it to rewrite.""" + + +def _sha256_and_stat(path: Path) -> tuple[str, os.stat_result]: + digest = hashlib.sha256() + with open(path, "rb") as fh: + st = os.fstat(fh.fileno()) + while chunk := fh.read(_CHUNK): + digest.update(chunk) + return digest.hexdigest(), st + + +def _patched_members(zin: zipfile.ZipFile, wanted: dict[int, str]) -> dict[str, bytes]: + """The members of the project whose colour lists change, as their new bytes.""" + patched: dict[str, bytes] = {} + for info in zin.infolist(): + if info.filename == _PROJECT_SETTINGS: + keys: tuple[str, ...] = ("filament_colour", "extruder_colour") + elif _PLATE_JSON.fullmatch(info.filename): + keys = ("filament_colors",) + else: + continue + if (new := _patch_json(zin.read(info), keys, wanted)) is not None: + patched[info.filename] = new + return patched + + +def _write_patched(zin: zipfile.ZipFile, patched: dict[str, bytes], tmp: Path) -> None: + """Copy every member of `zin` to the zip `tmp`, the `patched` ones with their new bytes.""" + with tmp.open("wb") as raw, zipfile.ZipFile(raw, "w") as zout: + for info in zin.infolist(): + zout.writestr( + info, + patched.get(info.filename) or zin.read(info), + compress_type=info.compress_type, + ) + + +def _stage_project( + path: Path, wanted: dict[int, str] +) -> tuple[Path, FileChange, os.stat_result] | None: + """The 3MF with its colour lists patched, as a temp file beside it; None when it already agrees.""" + old_sha, st = _sha256_and_stat(path) + try: + zin = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: + raise _NotAProject(f"{path} is not a 3MF (zip): {exc}") from exc + with zin: + if _PROJECT_SETTINGS not in zin.namelist(): + raise _NotAProject( + f"{path} has no {_PROJECT_SETTINGS}: not a slicer project 3MF" + ) + patched = _patched_members(zin, wanted) + if _PROJECT_SETTINGS not in patched: + return None + fd, name = tempfile.mkstemp(prefix=".colour-fix-", dir=path.resolve().parent) + os.close(fd) + tmp = Path(name) + try: + _write_patched(zin, patched, tmp) + except BaseException: + tmp.unlink(missing_ok=True) + raise + new_sha = hashlib.sha256(tmp.read_bytes()).hexdigest() + return tmp, FileChange(path, old_sha, new_sha), st + + +def _install(tmp: Path, dst: Path, st: os.stat_result) -> None: + """Replace `dst` by the staged `tmp`, refusing when `dst` changed meanwhile; mode and mtime kept.""" + os.chmod(tmp, st.st_mode & 0o7777) + os.utime(tmp, ns=(st.st_atime_ns, st.st_mtime_ns)) + if _ident(os.stat(dst)) != _ident(st): + raise PrinterRefused( + f"{dst} changed while its colours were being rewritten; run again", + command="print-preflight", + ) + os.replace(tmp, dst) + + +def _update_manifest(project: Path, wanted: dict[int, str]) -> Path | None: + """NAME.manifest.json beside NAME.3mf: the slot colours (`filaments[].hex`, tool = slot - 1).""" + path = project.with_name(project.stem + ".manifest.json") + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + data = json.loads(text) + changed = False + for fil in data.get("filaments", []) if isinstance(data, dict) else []: + tool = fil.get("tool") if isinstance(fil, dict) else None + new = wanted.get(tool) if isinstance(tool, int) else None + if new is not None and str(fil.get("hex", "")).upper() != new.upper(): + fil["hex"] = new + changed = True + if not changed: + return None + path.write_text( + json.dumps(data, ensure_ascii=False, indent=_indent_of(text)), encoding="utf-8" + ) + return path + + +def apply_colour_fix( + gcode: Path, project: Path | None, edits: list[ColourEdit] +) -> FixResult: + """Rewrite the colours of `edits` in `gcode` (and `project`, a 3MF) in place; see the module doc.""" + wanted = {e.tool: e.new for e in edits} + staged_g, change_g, st_g = _stage_gcode(gcode, wanted) + staged_p = None + result = FixResult(edits, change_g) + try: + staged_p = _stage_project(project, wanted) if project is not None else None + except _NotAProject as exc: + project = None + result.notes.append(f"{exc}; left untouched") + except BaseException: + staged_g.unlink(missing_ok=True) + raise + try: + _install(staged_g, gcode, st_g) + if staged_p is not None and project is not None: + _install(staged_p[0], project, staged_p[2]) + result.project = staged_p[1] + finally: + staged_g.unlink(missing_ok=True) + if staged_p is not None: + staged_p[0].unlink(missing_ok=True) + from slicing.slice_warnings import follow_edit + + result.sidecar = follow_edit( + gcode, gcode, change_g.old_sha256, change_g.new_sha256, "colour fix" + ) + if project is not None: + result.manifest = _update_manifest(project, wanted) + if staged_p is None: + result.notes.append(f"{project} already carries the loaded colours") + elif not result.notes: + result.notes.append( + "no project 3MF found (--source-3mf / NAME.3mf): only the G-code was rewritten" + ) + return result + + +def fix_to_loaded( + gcode: Path, + status: PrinterStatus, + source_3mf: Path | None, + max_delta_e: float = DEFAULT_FIX_MAX_DELTA_E, + *, + command: str, +) -> FixResult | None: + """Rewrite `gcode` (+ its project 3MF: `source_3mf`, else NAME.3mf beside it) to the colours the + printer reports for the tools it uses. None when nothing differs; PrinterRefused (nothing written) + when the printer reports no per-head data or a tool's loaded colour is another colour.""" + from printer_ops.preflight import find_project_3mf, read_gcode_meta + + if not status.slots: + raise PrinterRefused( + "the printer reports no per-head filament data: there is no loaded colour to rewrite the file to", + command=command, + ) + edits, refusals = plan_colour_fix(read_gcode_meta(gcode), status, max_delta_e) + if refusals: + raise PrinterRefused( + "colour fix refused, nothing written: " + refusals[0], + command=command, + remediation=[ + *refusals[1:], + "Load the filament that was sliced (`3d printer slot set` fixes the printer's record), or, if " + "this really is the same filament, raise the limit: --fix-max-delta-e N.", + ], + ) + if not edits: + return None + project, _ = find_project_3mf(gcode, source_3mf) + return apply_colour_fix(gcode, project, edits) diff --git a/lib/printer_ops/preflight.py b/lib/printer_ops/preflight.py index 3e5eef47..be4d1618 100644 --- a/lib/printer_ops/preflight.py +++ b/lib/printer_ops/preflight.py @@ -6,8 +6,11 @@ - printer the target printer is free (not printing/paused/busy/unknown); - bed (printers with `watch.bed.enabled`) the camera saw the bed clear with the plate on (printer_ops/bed.py, issue #303): objects or no plate FAIL, unknown WARN; - - slot Tn for every tool the G-code uses: the printer's head is loaded, and its colour - and material equal the G-code's `; filament_colour` / `; filament_type` + - slot Tn for every tool the G-code uses: the printer's head is loaded, its material + equals the G-code's `; filament_type` and its colour is within a CIEDE2000 + tolerance of `; filament_colour` (default 10, the distance is shown; a beige + reported as #FEE5A5 for a #E8D8B0 slice passes) — printer_ops/colour_fix.py, + which `print-preflight --fix` uses to rewrite the file to the loaded colour (needs per-head data: Moonraker printers exposing print_task_config, i.e. U1); - halves `3d halves-check <3mf> --json` when the source 3MF is given; - axis travel every move (arcs by their real extremes) inside the printer's X/Y limits, @@ -57,7 +60,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from printer_ops.base import PrinterStatus +from printer_ops.base import PrinterStatus, Slot +from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, colour_delta from printer_ops.move_bounds import ( BoundsReport, Limits, @@ -151,8 +155,8 @@ def _hex(value: str | None) -> str | None: ) -def read_gcode_meta(path: Path) -> GcodeMeta: - """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" +def _scan_gcode_config(path: Path) -> tuple[dict[str, str], set[int]]: + """(first value of every `; key = value` slicer comment, tool numbers of the `Tn` lines).""" config: dict[str, str] = {} tool_lines: set[int] = set() with open(path, encoding="utf-8", errors="replace") as fh: @@ -165,24 +169,34 @@ def read_gcode_meta(path: Path) -> GcodeMeta: t = _TOOL_LINE.match(line.strip()) if t: tool_lines.add(int(t.group(1))) - used: list[int] + return config, tool_lines + + +def _amount(value: str) -> float: + """One entry of `; filament used [mm]`; an unparsable amount counts as unused.""" + try: + return float(value) + except ValueError: + return 0.0 + + +def _used_tools(config: dict[str, str], tool_lines: set[int]) -> list[int]: + """Non-zero entries of `; filament used [mm]`, else the `Tn` lines, else tool 0.""" used_mm = config.get("filament used [mm]") if used_mm: - amounts = [] - for v in used_mm.split(","): - try: - amounts.append(float(v)) - except ValueError: # noqa: PERF203 — an unparsable amount counts as unused, keep reading - amounts.append(0.0) - used = [i for i, a in enumerate(amounts) if a > 0] - elif tool_lines: - used = sorted(tool_lines) - else: - used = [0] + return [ + i for i, a in enumerate(_amount(v) for v in used_mm.split(",")) if a > 0 + ] + return sorted(tool_lines) or [0] + + +def read_gcode_meta(path: Path) -> GcodeMeta: + """Filament colours/types and the tools actually used, from slicer comments + Tn lines.""" + config, tool_lines = _scan_gcode_config(path) return GcodeMeta( colours=[_hex(c) for c in _split(config.get("filament_colour", ""))], types=[(t or None) for t in _split(config.get("filament_type", ""))], - used_tools=used, + used_tools=_used_tools(config, tool_lines), ) @@ -211,58 +225,74 @@ def check_printer_free(status: PrinterStatus) -> Check: return Check("printer", PASS, f"{status.state} [{status.raw_state}]") -def check_slots(meta: GcodeMeta, status: PrinterStatus) -> list[Check]: - if not status.slots: - return [Check("slots", SKIP, "the printer reports no per-head filament data")] - out = [] - for t in meta.used_tools: - name = f"slot T{t}" - if t >= len(status.slots): - out.append( - Check( - name, - FAIL, - f"G-code uses tool {t} but the printer has {len(status.slots)} heads", - ) - ) - continue - slot = status.slots[t] - if not slot.present: - out.append( - Check( - name, - FAIL, - f"head {slot.label} is used by the G-code but has no filament / is disabled", - ) - ) - continue - want_c = meta.colours[t] if t < len(meta.colours) else None - want_m = meta.types[t] if t < len(meta.types) else None - problems = [] - if want_c and slot.color and want_c != slot.color: - problems.append(f"colour: G-code {want_c}, loaded {slot.color}") - if want_m and slot.material and want_m.upper() != slot.material.upper(): - problems.append(f"material: G-code {want_m}, loaded {slot.material}") - if problems: - out.append(Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems))) - elif not want_c: - out.append( - Check( - name, - WARN, - f"head {slot.label}: G-code has no filament_colour for tool {t}; " - f"loaded {slot.color} {slot.material}", - ) +def _slot_problems( + want_c: str | None, want_m: str | None, slot: Slot, colour_delta_e: float +) -> tuple[list[str], str]: + """(problems, colour note): the mismatches of one loaded slot against the G-code's colour and + material, and the ΔE note of a colour that differs but is within tolerance.""" + problems: list[str] = [] + note = "" + if want_c and slot.color and want_c != slot.color: + de = colour_delta(want_c, slot.color) + if de is None or de > colour_delta_e: + shown = "" if de is None else f" (ΔE {de:.1f} > {colour_delta_e:g})" + problems.append( + f"colour: G-code {want_c}, loaded {slot.color}{shown}; " + "fix: print-preflight --fix / printer start --fix-colour" ) else: - out.append( - Check( - name, - PASS, - f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip(), - ) + note = ( + f" (colour ΔE {de:.1f} from G-code {want_c}, within {colour_delta_e:g})" ) - return out + if want_m and slot.material and want_m.upper() != slot.material.upper(): + problems.append(f"material: G-code {want_m}, loaded {slot.material}") + return problems, note + + +def _slot_check( + tool: int, meta: GcodeMeta, status: PrinterStatus, colour_delta_e: float +) -> Check: + name = f"slot T{tool}" + if tool >= len(status.slots): + return Check( + name, + FAIL, + f"G-code uses tool {tool} but the printer has {len(status.slots)} heads", + ) + slot = status.slots[tool] + if not slot.present: + return Check( + name, + FAIL, + f"head {slot.label} is used by the G-code but has no filament / is disabled", + ) + want_c = meta.colours[tool] if tool < len(meta.colours) else None + want_m = meta.types[tool] if tool < len(meta.types) else None + problems, note = _slot_problems(want_c, want_m, slot, colour_delta_e) + if problems: + return Check(name, FAIL, f"head {slot.label}: " + "; ".join(problems)) + if not want_c: + return Check( + name, + WARN, + f"head {slot.label}: G-code has no filament_colour for tool {tool}; " + f"loaded {slot.color} {slot.material}", + ) + return Check( + name, + PASS, + f"head {slot.label}: {slot.color} {slot.material or ''}".rstrip() + note, + ) + + +def check_slots( + meta: GcodeMeta, status: PrinterStatus, colour_delta_e: float = DEFAULT_SLOT_DELTA_E +) -> list[Check]: + """One line per used tool. The colour matches within `colour_delta_e` (CIEDE2000, the distance is + in the line); a material mismatch is always a FAIL.""" + if not status.slots: + return [Check("slots", SKIP, "the printer reports no per-head filament data")] + return [_slot_check(t, meta, status, colour_delta_e) for t in meta.used_tools] def run_halves_check(source: Path) -> tuple[int, str, str]: @@ -377,6 +407,11 @@ def check_head_sequence( result = preflight_check(str(gcode), str(project)) except ProjectError as exc: return Check("head seq", SKIP, f"{project_source}: {exc}") + return _head_sequence_verdict(result, project_source) + + +def _head_sequence_verdict(result: dict[str, Any], project_source: str) -> Check: + """The `head seq` line of one `slicing.head_sequence.preflight_check` result.""" objects = result.get("objects") objs = ( [o for o in objects if isinstance(o, dict)] if isinstance(objects, list) else [] @@ -531,90 +566,86 @@ def fmt(names: list[str]) -> str: ] -def check_moves( - rep: BoundsReport, - axis_limits: Limits | None, - limits_source: str, - trusted_macros: frozenset[str] = frozenset(), -) -> list[Check]: - out: list[Check] = [] +def _axis_travel_check( + rep: BoundsReport, axis_limits: Limits | None, limits_source: str +) -> Check: if axis_limits is None: why = f"{limits_source}; " if limits_source else "" - out.append( - Check( - "axis travel", - SKIP, - f"printer axis limits unknown ({why}pass --axis-limits " - f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", - ) + return Check( + "axis travel", + SKIP, + f"printer axis limits unknown ({why}pass --axis-limits " + f"X0:X1,Y0:Y1 or set axis_limits in {PROFILE_FILES})", ) - elif rep.axis_out_count: - out.append( - Check( - "axis travel", - FAIL, - f"{fmt_limits(axis_limits)} ({limits_source}): " - + _first(rep.axis_out, rep.axis_out_count), - ) + if rep.axis_out_count: + return Check( + "axis travel", + FAIL, + f"{fmt_limits(axis_limits)} ({limits_source}): " + + _first(rep.axis_out, rep.axis_out_count), ) - else: - out.append( - Check( - "axis travel", - PASS, - f"all moves {fmt_extent(rep.all_extent)} within " - f"{fmt_limits(axis_limits)} ({limits_source})", - ) + return Check( + "axis travel", + PASS, + f"all moves {fmt_extent(rep.all_extent)} within " + f"{fmt_limits(axis_limits)} ({limits_source})", + ) + + +def _print_area_violations(rep: BoundsReport) -> list[str]: + """printable_area / bed_exclude_area breaches; each is evaluated on its own, so a missing + printable_area must not hide an exclusion.""" + parts = [] + if rep.area_out_count and rep.printable_area: + parts.append( + f"printable_area {fmt_area(rep.printable_area)}: " + + _first(rep.area_out, rep.area_out_count) + ) + if rep.excluded_in_count and rep.exclude_area: + parts.append( + f"bed_exclude_area {fmt_area(rep.exclude_area)}: " + + _first(rep.excluded_in, rep.excluded_in_count, "inside") ) + return parts + +def _print_area_check(rep: BoundsReport) -> Check: if not rep.plate_found: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " - "the plate section cannot be told from start/end G-code", - ) + return Check( + "print area", + WARN, + "UNVERIFIED: no ;LAYER_CHANGE / ; CHANGE_LAYER markers, " + "the plate section cannot be told from start/end G-code", ) - else: - # bed_exclude_area is evaluated on its own: a missing printable_area must not hide it - parts = [] - if rep.area_out_count and rep.printable_area: - parts.append( - f"printable_area {fmt_area(rep.printable_area)}: " - + _first(rep.area_out, rep.area_out_count) - ) - if rep.excluded_in_count and rep.exclude_area: - parts.append( - f"bed_exclude_area {fmt_area(rep.exclude_area)}: " - + _first(rep.excluded_in, rep.excluded_in_count, "inside") - ) - if parts: - out.append( - Check( - "print area", - FAIL, - f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), - ) - ) - elif rep.printable_area is None: - out.append( - Check( - "print area", - WARN, - "UNVERIFIED: the G-code has no `; printable_area`", - ) - ) - else: - out.append( - Check( - "print area", - PASS, - f"plate moves {fmt_extent(rep.plate_extent)} within " - f"{fmt_area(rep.printable_area)}", - ) - ) + parts = _print_area_violations(rep) + if parts: + return Check( + "print area", + FAIL, + f"plate moves {fmt_extent(rep.plate_extent)}; " + "; ".join(parts), + ) + if rep.printable_area is None: + return Check( + "print area", WARN, "UNVERIFIED: the G-code has no `; printable_area`" + ) + return Check( + "print area", + PASS, + f"plate moves {fmt_extent(rep.plate_extent)} within " + f"{fmt_area(rep.printable_area)}", + ) + +def check_moves( + rep: BoundsReport, + axis_limits: Limits | None, + limits_source: str, + trusted_macros: frozenset[str] = frozenset(), +) -> list[Check]: + out = [ + _axis_travel_check(rep, axis_limits, limits_source), + _print_area_check(rep), + ] if rep.unverified_count: more = ", ..." if rep.unverified_count > len(rep.unverified) else "" out.append( @@ -645,31 +676,25 @@ def bounds_report( ) -def check_bed_verdict(v: dict[str, Any]) -> Check: - """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view - moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" - bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") +def _bed_when(v: dict[str, Any], view: dict[str, Any]) -> str: + """Where the verdict came from, and how the bed got to the Z it was judged at.""" age = v.get("age_s") when = ( f"{v.get('source', '?')}, {age:.0f} s ago" if isinstance(age, (int, float)) else str(v.get("source", "?")) ) - raw_view = v.get("view") - view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} if view.get("action") == "moved": when += ( f", judged at Z {view.get('z_used', v.get('z'))} after moving the bed from Z " f"{view.get('moved_from')}" + (" and back" if view.get("restored") else "") ) - frame = [f"frame: {v['frame']}"] if v.get("frame") else [] - why = str(v.get("reason") or "") - if view.get("action") == "moved" and not view.get("restored"): - frame.append( - f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" - ) - if plate != "missing" and bed != "occupied": - return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return when + + +def _bed_verdict_line( + bed: object, plate: object, when: str, why: str, frame: list[str] +) -> Check: if plate == "missing": return Check("bed", FAIL, f"build plate missing ({when}): {why}", frame) if bed == "occupied": @@ -681,6 +706,24 @@ def check_bed_verdict(v: dict[str, Any]) -> Check: return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) +def check_bed_verdict(v: dict[str, Any]) -> Check: + """`bed_state.py status --json` (issue #303) -> the `bed` line. A verdict judged after the auto view + moved the bed names the Z it was judged at; a bed left away from where it was found is a WARN.""" + bed, plate = v.get("bed", "unknown"), v.get("plate", "unknown") + raw_view = v.get("view") + view: dict[str, Any] = raw_view if isinstance(raw_view, dict) else {} + when = _bed_when(v, view) + frame = [f"frame: {v['frame']}"] if v.get("frame") else [] + why = str(v.get("reason") or "") + if view.get("action") == "moved" and not view.get("restored"): + frame.append( + f"BED NOT PUT BACK to Z {view.get('moved_from')}: {view.get('restore_error')}" + ) + if plate != "missing" and bed != "occupied": + return Check("bed", WARN, f"bed state {bed} ({when}): {why}", frame) + return _bed_verdict_line(bed, plate, when, why, frame) + + def check_bed( device: Device, *, @@ -744,8 +787,10 @@ def run_preflight( bridge_source: str = "default", bed: Check | None = None, printops: PrintopsContext | None = None, + colour_delta_e: float = DEFAULT_SLOT_DELTA_E, ) -> Report: - """`limits_source` names where `axis_limits` came from, or why they are unknown. + """`colour_delta_e` is the CIEDE2000 tolerance of the `slot Tn` colour (default 10; the line + shows the distance). `limits_source` names where `axis_limits` came from, or why they are unknown. `origin` is where the G-code lives when `gcode` is a snapshot copy (`printer start`): the sibling NAME.3mf and NAME.slice-warnings.json are looked up there. `bed` is the camera bed line (`check_bed` above), computed by the caller that knows the device.""" @@ -753,7 +798,9 @@ def run_preflight( report.checks.append(check_thumbnails(gcode)) if status is not None: report.checks.append(check_printer_free(status)) - report.checks.extend(check_slots(read_gcode_meta(gcode), status)) + report.checks.extend( + check_slots(read_gcode_meta(gcode), status, colour_delta_e) + ) if bed is not None: report.checks.append(bed) report.checks.extend( diff --git a/tests/colour_fix_fixtures.py b/tests/colour_fix_fixtures.py new file mode 100644 index 00000000..4b9c4d53 --- /dev/null +++ b/tests/colour_fix_fixtures.py @@ -0,0 +1,80 @@ +"""Fixtures for the slot colour tolerance and `--fix` tests: a G-code, its project 3MF, manifest and +slice-warnings sidecar for the SIMBOL case (beige PETG sliced as #E8D8B0, the U1 reporting #FEE5A5), +and a fake U1 that holds that PETG in head T4 (G-code tool 3). Never a real printer.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from printer_fakes import FakeMoonraker + +SLICED = "#E8D8B0" # what the slicer wrote +LOADED = "#FEE5A5" # what the U1 screen shows for the same beige PETG +COLOURS = ["#000000", "#808080", "#1B5E20", SLICED] +TOOLPATH = "G28\nT3\nG1 X10 Y10 E1\nG1 X20 Y20 E1\n" + + +def gcode_text(colour: str = SLICED, material: str = "PETG") -> str: + colours = ";".join([*COLOURS[:3], colour]) + return ( + "; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n" + + TOOLPATH + + "; filament used [mm] = 0.00, 0.00, 0.00, 30.50\n" + + f"; extruder_colour = {colours}\n" + + f"; filament_colour = {colours}\n" + + f"; filament_type = PLA;PLA;PLA;{material}\n" + ) + + +def write_project(path: Path, colour: str = SLICED) -> None: + """A slicer project 3MF: project_settings.config (4-space JSON), plate json, one model part.""" + colours = [*COLOURS[:3], colour] + settings = { + "filament_colour": colours, + "extruder_colour": list(colours), + "filament_type": ["PLA", "PLA", "PLA", "PETG"], + "layer_height": "0.2", + } + plate = {"filament_colors": list(colours), "filament_ids": [0, 1, 2, 3]} + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", "") + z.writestr("3D/3dmodel.model", "kept byte for byte") + z.writestr( + "Metadata/project_settings.config", json.dumps(settings, indent=4) + "\n" + ) + z.writestr("Metadata/plate_1.json", json.dumps(plate, indent=4)) + + +def write_manifest(path: Path, colour: str = SLICED) -> None: + filaments = [ + {"slot": i + 1, "key": f"f{i}", "name": f"F{i}", "hex": c, "tool": i} + for i, c in enumerate([*COLOURS[:3], colour]) + ] + path.write_text( + json.dumps({"file": "plate.3mf", "filaments": filaments}, indent=1), + encoding="utf-8", + ) + + +def write_plate(directory: Path, name: str = "plate") -> Path: + """NAME.gcode + NAME.3mf + NAME.manifest.json + NAME.slice-warnings.json; the G-code's path.""" + from slicing import slice_warnings as sw + + gcode = directory / f"{name}.gcode" + gcode.write_text(gcode_text(), encoding="utf-8") + write_project(directory / f"{name}.3mf") + write_manifest(directory / f"{name}.manifest.json") + sw.record(gcode, logs=[("slice.log", "")]) + return gcode + + +def load_beige_petg( + moon: FakeMoonraker, head: int = 3, colour: str = LOADED, material: str = "PETG" +) -> None: + """The fake U1 holds `material` of `colour` in head index `head` (label T).""" + cfg = moon.status["print_task_config"] + cfg["filament_exist"][head] = True + cfg["filament_color_rgba"][head] = colour.lstrip("#") + "FF" + cfg["filament_type"][head] = material diff --git a/tests/e2e/test_print_preflight_colour_workflows.py b/tests/e2e/test_print_preflight_colour_workflows.py new file mode 100644 index 00000000..82811073 --- /dev/null +++ b/tests/e2e/test_print_preflight_colour_workflows.py @@ -0,0 +1,124 @@ +"""E2E stories through `bin/3d print-preflight` for the 2026-11-04 SIMBOL print: beige PETG sliced as +#E8D8B0 on a U1 that shows it as #FEE5A5. The slot colour is judged by CIEDE2000 distance (no `--force` +for a same-family beige), and `--fix` rewrites the files to the printer's colour. Loopback fakes only.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from colour_fix_fixtures import LOADED, SLICED, gcode_text, load_beige_petg, write_plate +from printer_fakes import FakeMoonraker, write_devices +from slicing import slice_warnings as sw + +from .workflow_helper import run_cli + + +def slot_line(stdout: str) -> str: + return next(line for line in stdout.splitlines() if "slot T3" in line) + + +def test_preflight_accepts_the_same_beige_and_prints_the_distance( + tmp_path: Path, +) -> None: + """The user preflights the SIMBOL plate: the slot line passes and says how far the colours are.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + default = run_cli(tmp_path, "print-preflight", str(g), "--printer", "u1") + strict = run_cli( + tmp_path, + "print-preflight", + str(g), + "--printer", + "u1", + "--colour-delta-e", + "5", + ) + + assert slot_line(default.stdout).split()[0] == "PASS" and "ΔE 6.4" in slot_line( + default.stdout + ) + assert slot_line(strict.stdout).split()[ + 0 + ] == "FAIL" and "(ΔE 6.4 > 5)" in slot_line(strict.stdout) + assert moon.writes() == [] + + +def test_preflight_still_fails_a_wrong_material_and_a_different_colour( + tmp_path: Path, +) -> None: + moon = FakeMoonraker() + load_beige_petg(moon) + pla = tmp_path / "pla.gcode" + pla.write_text(gcode_text(SLICED, "PLA"), encoding="utf-8") + blue = tmp_path / "blue.gcode" + blue.write_text(gcode_text("#0000FF"), encoding="utf-8") + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + wrong_material = run_cli( + tmp_path, "print-preflight", str(pla), "--printer", "u1" + ) + wrong_colour = run_cli( + tmp_path, "print-preflight", str(blue), "--printer", "u1" + ) + refused_fix = run_cli( + tmp_path, "print-preflight", str(blue), "--printer", "u1", "--fix" + ) + + assert ( + slot_line(wrong_material.stdout).split()[0] == "FAIL" + and "material: G-code PLA, loaded PETG" in wrong_material.stdout + ) + assert slot_line(wrong_colour.stdout).split()[0] == "FAIL" and "ΔE" in slot_line( + wrong_colour.stdout + ) + assert refused_fix.returncode == 1 and "another colour" in refused_fix.stderr + assert blue.read_text(encoding="utf-8") == gcode_text( + "#0000FF" + ) # nothing was rewritten + + +def test_preflight_fix_aligns_gcode_3mf_and_sidecars_and_prints_the_hashes( + tmp_path: Path, +) -> None: + """`--fix`: one command rewrites the colours at the source; old and new sha256 are on record.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + old = sw.sha256_file(g) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + fixed = run_cli(tmp_path, "print-preflight", str(g), "--printer", "u1", "--fix") + + assert f"colour fix: tool T3 (T4) {SLICED} -> {LOADED} (ΔE 6.4)" in fixed.stderr + assert ( + old in fixed.stderr + and sw.sha256_file(g) in fixed.stderr + and "sha256 new" in fixed.stderr + ) + assert ( + slot_line(fixed.stdout).split()[0] == "PASS" and "slicer warn" in fixed.stdout + ) + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}" in g.read_text( + encoding="utf-8" + ) + with zipfile.ZipFile(g.with_suffix(".3mf")) as z: + assert ( + json.loads(z.read("Metadata/project_settings.config"))["filament_colour"][3] + == LOADED + ) + assert ( + json.loads((tmp_path / "plate.manifest.json").read_text())["filaments"][3][ + "hex" + ] + == LOADED + ) + assert sw.load(sw.sidecar_path(g))["gcode_sha256"] == sw.sha256_file(g) + assert [line for line in fixed.stdout.splitlines() if "slicer warn" in line][ + 0 + ].split()[0] == "PASS" + assert moon.writes() == [] diff --git a/tests/test_colour_fix.py b/tests/test_colour_fix.py new file mode 100644 index 00000000..03d2778f --- /dev/null +++ b/tests/test_colour_fix.py @@ -0,0 +1,323 @@ +"""Unit tests for lib/printer_ops/colour_fix.py: the CIEDE2000 slot colour tolerance and the rewrite +that aligns a G-code, its project 3MF, manifest and slice-warnings sidecar with the loaded colour +(issue https://github.com/alex-mextner/3d-cli/issues/728).""" + +from __future__ import annotations + +import hashlib +import json +import os +import zipfile +from pathlib import Path + +import pytest +from colour_fix_fixtures import ( + LOADED, + SLICED, + TOOLPATH, + gcode_text, + load_beige_petg, + write_plate, + write_project, +) +from printer_fakes import FakeMoonraker + +from errors import InvalidArgument +from printer_ops import colour_fix, moonraker, preflight +from printer_ops.base import PrinterRefused, PrinterStatus +from slicing import slice_warnings as sw + + +def status(colour: str = LOADED) -> PrinterStatus: + moon = FakeMoonraker() + load_beige_petg(moon, colour=colour) + return moonraker.parse_status(moon.status) + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +# ---- the distance ------------------------------------------------------------------------ + + +def test_the_real_beige_pair_is_close_and_other_colours_are_far() -> None: + assert colour_fix.colour_delta(SLICED, LOADED) == pytest.approx(6.4, abs=0.1) + assert colour_fix.colour_delta("#FFFFFF", SLICED) == pytest.approx(16.7, abs=0.1) + assert colour_fix.colour_delta("#FF0000", "#0000FF") == pytest.approx(52.9, abs=0.1) + assert colour_fix.colour_delta(SLICED, SLICED) == 0.0 + assert colour_fix.colour_delta("nonsense", SLICED) is None + + +@pytest.mark.parametrize("bad", ["0", "-3", "abc", "nan"]) +def test_delta_e_option_must_be_a_positive_number(bad: str) -> None: + with pytest.raises(InvalidArgument): + colour_fix.parse_delta_e( + bad, "--colour-delta-e", 10.0, command="print-preflight" + ) + assert colour_fix.parse_delta_e(None, "--colour-delta-e", 10.0, command="x") == 10.0 + assert colour_fix.parse_delta_e("7.5", "--colour-delta-e", 10.0, command="x") == 7.5 + + +# ---- the slot check ---------------------------------------------------------------------- + + +def slots_of( + tmp_path: Path, text: str, st: PrinterStatus, **kw: float +) -> preflight.Check: + g = tmp_path / "p.gcode" + g.write_text(text, encoding="utf-8") + (check,) = preflight.check_slots(preflight.read_gcode_meta(g), st, **kw) + return check + + +def test_same_family_beige_passes_and_reports_the_distance(tmp_path: Path) -> None: + check = slots_of(tmp_path, gcode_text(), status()) + assert check.status == "PASS" + assert ( + "ΔE 6.4" in check.detail and SLICED in check.detail and "PETG" in check.detail + ) + + +def test_a_different_colour_fails_with_the_distance_and_the_fix(tmp_path: Path) -> None: + check = slots_of(tmp_path, gcode_text("#FFFFFF"), status()) + assert check.status == "FAIL" + assert "G-code #FFFFFF, loaded #FEE5A5 (ΔE 20.0 > 10)" in check.detail + assert "--fix" in check.detail + + +def test_the_threshold_is_configurable(tmp_path: Path) -> None: + assert ( + slots_of(tmp_path, gcode_text(), status(), colour_delta_e=5.0).status == "FAIL" + ) + assert ( + slots_of(tmp_path, gcode_text("#FFFFFF"), status(), colour_delta_e=25.0).status + == "PASS" + ) + + +def test_a_material_mismatch_stays_a_fail_even_for_the_same_colour( + tmp_path: Path, +) -> None: + check = slots_of(tmp_path, gcode_text(SLICED, "PLA"), status()) + assert ( + check.status == "FAIL" and "material: G-code PLA, loaded PETG" in check.detail + ) + assert ( + slots_of(tmp_path, gcode_text(LOADED, "PLA"), status(colour=LOADED)).status + == "FAIL" + ) + + +# ---- the plan ---------------------------------------------------------------------------- + + +def test_plan_edits_the_used_tool_and_refuses_another_colour(tmp_path: Path) -> None: + g = write_plate(tmp_path) + edits, refusals = colour_fix.plan_colour_fix(preflight.read_gcode_meta(g), status()) + assert refusals == [] and [(e.tool, e.label, e.old, e.new) for e in edits] == [ + (3, "T4", SLICED, LOADED) + ] + assert edits[0].delta_e == pytest.approx(6.4, abs=0.1) + + edits, refusals = colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour="#0000FF") + ) + assert edits == [] and len(refusals) == 1 + assert "tool T3 (T4)" in refusals[0] and "another colour" in refusals[0] + # the operator may widen the limit on purpose + edits, _ = colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour="#0000FF"), max_delta_e=100.0 + ) + assert [e.new for e in edits] == ["#0000FF"] + + +def test_plan_leaves_matching_and_empty_heads_alone(tmp_path: Path) -> None: + g = write_plate(tmp_path) + assert colour_fix.plan_colour_fix( + preflight.read_gcode_meta(g), status(colour=SLICED) + ) == ([], []) + moon = FakeMoonraker() + moon.status["print_task_config"]["filament_exist"][3] = False + empty = moonraker.parse_status(moon.status) + assert colour_fix.plan_colour_fix(preflight.read_gcode_meta(g), empty) == ([], []) + + +# ---- the rewrite ------------------------------------------------------------------------- + + +def fix( + g: Path, st: PrinterStatus | None = None, source: Path | None = None +) -> colour_fix.FixResult: + result = colour_fix.fix_to_loaded( + g, st or status(), source, command="print-preflight" + ) + assert result is not None + return result + + +def test_fix_rewrites_gcode_3mf_manifest_and_sidecar_consistently( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + project, manifest, side = ( + g.with_suffix(".3mf"), + tmp_path / "plate.manifest.json", + sw.sidecar_path(g), + ) + old_g, old_p = sha(g), sha(project) + g.chmod(0o640) + os.utime(g, (1_700_000_000, 1_700_000_000)) + os.utime(project, (1_700_000_100, 1_700_000_100)) + model_before = zipfile.ZipFile(project).read("3D/3dmodel.model") + + result = fix(g) + + # G-code: only the colour entries of tool 3 changed, the toolpath bytes did not + text = g.read_text(encoding="utf-8") + assert text.startswith("; HEADER_BLOCK_START\n; HEADER_BLOCK_END\n" + TOOLPATH) + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}\n" in text + assert f"; extruder_colour = #000000;#808080;#1B5E20;{LOADED}\n" in text + assert "; filament_type = PLA;PLA;PLA;PETG\n" in text + assert (g.stat().st_mode & 0o777, g.stat().st_mtime_ns) == ( + 0o640, + 1_700_000_000 * 10**9, + ) + assert project.stat().st_mtime_ns == 1_700_000_100 * 10**9 + # the reported hashes are the real ones + assert (result.gcode.old_sha256, result.gcode.new_sha256) == ( + old_g, + sha(g), + ) and old_g != sha(g) + assert result.project is not None and ( + result.project.old_sha256, + result.project.new_sha256, + ) == (old_p, sha(project)) + # 3MF: colour lists + plate json updated, the other members untouched + with zipfile.ZipFile(project) as z: + cfg = json.loads(z.read("Metadata/project_settings.config")) + assert cfg["filament_colour"][3] == cfg["extruder_colour"][3] == LOADED + assert ( + cfg["filament_colour"][:3] == ["#000000", "#808080", "#1B5E20"] + and cfg["layer_height"] == "0.2" + ) + assert ( + z.read("Metadata/project_settings.config") + .decode() + .startswith('{\n "filament_colour"') + ) + assert ( + json.loads(z.read("Metadata/plate_1.json"))["filament_colors"][3] == LOADED + ) + assert z.read("3D/3dmodel.model") == model_before and z.testzip() is None + # manifest + slice-warnings sidecar follow + assert [f["hex"] for f in json.loads(manifest.read_text())["filaments"]][ + 3 + ] == LOADED + data = json.loads(side.read_text(encoding="utf-8")) + assert ( + data["gcode_sha256"] == sha(g) + and data["post_processed"][-1]["step"] == "colour fix" + ) + assert sw.evaluate(g)[0] == "PASS" + assert result.manifest == manifest and result.sidecar == side + lines = "\n".join(result.lines()) + assert ( + f"{SLICED} -> {LOADED} (ΔE 6.4)" in lines and old_g in lines and sha(g) in lines + ) + # nothing is left behind and a second run finds nothing to do + assert [ + p.name for p in tmp_path.iterdir() if p.name.startswith(".colour-fix") + ] == [] + assert ( + colour_fix.fix_to_loaded(g, status(), None, command="print-preflight") is None + ) + + +def test_fix_uses_an_explicit_project_and_tolerates_a_non_project_3mf( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + other = tmp_path / "other.3mf" + write_project(other) + assert fix(g, source=other).project is not None + assert ( + json.loads(zipfile.ZipFile(other).read("Metadata/project_settings.config"))[ + "filament_colour" + ][3] + == LOADED + ) + assert ( + json.loads( + zipfile.ZipFile(g.with_suffix(".3mf")).read( + "Metadata/project_settings.config" + ) + )["filament_colour"][3] + == SLICED + ) # the sibling was not the project asked for + + h = write_plate(tmp_path, "model") + with zipfile.ZipFile(h.with_suffix(".3mf"), "w") as z: + z.writestr( + "3D/3dmodel.model", "" + ) # a plain model, not a slicer project + result = fix(h) + assert result.project is None and "not a slicer project 3MF" in "\n".join( + result.lines() + ) + assert LOADED in h.read_text(encoding="utf-8") + + +def test_fix_without_a_project_rewrites_only_the_gcode(tmp_path: Path) -> None: + g = tmp_path / "solo.gcode" + g.write_text(gcode_text(), encoding="utf-8") + result = fix(g) + assert result.project is None and result.manifest is None and result.sidecar is None + assert "no project 3MF found" in "\n".join(result.lines()) + + +def test_fix_refuses_another_colour_and_writes_nothing(tmp_path: Path) -> None: + g = write_plate(tmp_path) + before = {p.name: p.read_bytes() for p in tmp_path.iterdir()} + with pytest.raises(PrinterRefused, match="another colour"): + colour_fix.fix_to_loaded( + g, status(colour="#0000FF"), None, command="print-preflight" + ) + assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == before + + +def test_fix_without_a_colour_line_refuses_before_touching_the_3mf( + tmp_path: Path, +) -> None: + g = write_plate(tmp_path) + g.write_text( + TOOLPATH + + "; filament used [mm] = 0.00, 0.00, 0.00, 30.50\n; filament_type = PLA;PLA;PLA;PETG\n" + ) + project = g.with_suffix(".3mf") + before = project.read_bytes() + edits = [colour_fix.ColourEdit(3, "T4", SLICED, LOADED, 6.4)] + with pytest.raises(PrinterRefused, match="no `; filament_colour`"): + colour_fix.apply_colour_fix(g, project, edits) + assert project.read_bytes() == before + + +def test_fix_refuses_a_printer_without_per_head_data(tmp_path: Path) -> None: + g = write_plate(tmp_path) + with pytest.raises(PrinterRefused, match="no per-head filament data"): + colour_fix.fix_to_loaded( + g, PrinterStatus("idle", "standby"), None, command="print-preflight" + ) + + +def test_fix_keeps_quotes_crlf_and_the_other_entries(tmp_path: Path) -> None: + g = tmp_path / "crlf.gcode" + g.write_bytes( + b"G28\r\nT3\r\n" + b'; filament_colour = "#000000";"#808080";"#1B5E20";"#E8D8B0FF"\r\n' + b"; filament used [mm] = 0.00, 0.00, 0.00, 30.50\r\n" + b"; filament_type = PLA;PLA;PLA;PETG\r\n" + ) + fix(g) + assert b'; filament_colour = "#000000";"#808080";"#1B5E20";"#FEE5A5"\r\n' in g.read_bytes() + assert g.read_bytes().count(b"\r\n") == 5 and g.read_bytes().startswith(b"G28\r\nT3\r\n") From d657c9d7df8c3ac63f0fec233dae17f8f25d36c3 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:13:41 +0100 Subject: [PATCH 11/30] fix(printer): retry a start --force approval Telegram could not post, then point to the printer screen (Refs #728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/printer_ops/force_hatch.py | 83 +++++++++++++++++++++++++++++++--- tests/e2e/hatch_runner.py | 11 +++-- tests/test_force_hatch.py | 76 +++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 9 deletions(-) diff --git a/lib/printer_ops/force_hatch.py b/lib/printer_ops/force_hatch.py index a1217fba..d18980cd 100644 --- a/lib/printer_ops/force_hatch.py +++ b/lib/printer_ops/force_hatch.py @@ -17,6 +17,14 @@ (fix the gate). Each approved or refused force is therefore an issue to analyse and a root cause to fix, not a habit — the refusal message says so. +RETRY (issue https://github.com/alex-mextner/3d-cli/issues/728): the tg-ctl daemon answers a card it + could not send with `ask-forward not-posted: … daemon declined: reason=send-failed` (tg-cli + `forwardAndPostQuestion`: ONE `sendMessage`, no retry; on 2026-11-04 22:02 and 22:03 it was + `getaddrinfo EAI_AGAIN api.telegram.org`, a resolver blip, while a plain `tg` a moment later + worked). Nothing was posted, so asking again is safe: the ask is repeated after each pause of + RETRY_DELAYS_S (3, 10, 30 s). When it still fails the refusal says how to start from the + printer's own screen and where the daemon logs the cause. + INVARIANTS: the helper is loaded from a FIXED path under the account's real home (never an env var, never the repo or $PATH), and only when its file is owned by you or root and not group/world writable; a helper that is missing or untrusted refuses the start (fail closed). @@ -31,6 +39,7 @@ import shlex import stat import sys +import time from collections.abc import Sequence from pathlib import Path from typing import Protocol, cast @@ -45,7 +54,15 @@ # agent-tools checkouts searched, relative to the real home; the first one that has the helper wins. _CHECKOUTS = (Path("dev") / "agent-tools", Path("xp") / "agent-tools") _DETAIL_CHARS = 240 -_CARD_VALUE_CHARS = 1000 # the shared hatch helper cuts each context value at this length +_CARD_VALUE_CHARS = ( + 1000 # the shared hatch helper cuts each context value at this length +) +# The daemon's own wording for a card it could not post (agent-tools `_ask_forward_verdict`, tg-cli +# question-cards.ts `dropAsk(socket, "send-failed")`). +_SEND_FAILED = "reason=send-failed" +# Pauses before each further ask while the card is not posted: a transient resolver/network failure +# of the daemon clears in seconds, so the owner still gets the card and the start does not stall. +RETRY_DELAYS_S = (3.0, 10.0, 30.0) class _Verdict(Protocol): @@ -129,7 +146,9 @@ def failing_summary(checks: Sequence[Check], budget: int | None = None) -> str: return "\n".join(lines) -def _refusal(failing: str, why: str, command: str) -> PrinterRefused: +def _refusal( + failing: str, why: str, command: str, extra: Sequence[str] = () +) -> PrinterRefused: return PrinterRefused( f"--force does not override preflight FAIL by itself: {why}", command="printer", @@ -139,13 +158,59 @@ def _refusal(failing: str, why: str, command: str) -> PrinterRefused: f"Only if forcing is truly needed, ask the owner for a one-time Telegram approval: " f'{ENV_VAR}="" {command}', "Every forced case is analysed afterwards and its root cause fixed.", + *extra, "Failing checks:\n" + failing, ], ) +def _send_failed(verdict: _Verdict) -> bool: + """The daemon declined to post the card (`ask-forward not-posted … reason=send-failed`): its + Telegram send failed (a DNS or network blip, 2026-11-04 `getaddrinfo EAI_AGAIN api.telegram.org`). + No card exists then, so asking again cannot double-approve.""" + return not verdict.approved and _SEND_FAILED in verdict.reason + + +def _ask_with_retry( + hatch: _HatchLib, context: dict[str, object], command: str +) -> _Verdict: + """`request_hatch_approval`, asked again after each `RETRY_DELAYS_S` pause while the card could not + be posted. Any other verdict (approved, denied, timed out, not requested) is final at once.""" + verdict = hatch.request_hatch_approval( + HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True + ) + for delay in RETRY_DELAYS_S: + if not _send_failed(verdict): + break + print( + f"hatch: the approval card was not posted to Telegram ({verdict.reason}); asking again in {delay:g} s", + file=sys.stderr, + ) + time.sleep(delay) + verdict = hatch.request_hatch_approval( + HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True + ) + return verdict + + +def _screen_hint(remote_name: str | None) -> list[str]: + """How to start without Telegram: from the printer's own screen.""" + what = f"`{remote_name}`" if remote_name else "the file" + return [ + f"Start from the printer screen instead: {what} is already stored on the printer (Files, then Print). " + "The screen does NOT run these checks: only do it when the FAIL lines below are understood.", + "Why Telegram failed: tg-ctl's daemon could not send the card (plain `tg` can still work); see " + "`~/.config/tg-cli/tg-ctl.*.log`, lines `sendMessage failed` / `ask-forward dropped: reason=send-failed`.", + ] + + def require_force_approval( - *, printer: str, file: str, sha256: str, checks: Sequence[Check] + *, + printer: str, + file: str, + sha256: str, + checks: Sequence[Check], + remote_name: str | None = None, ) -> str: """Return the approval reason when the owner approved the forced start, else raise PrinterRefused. @@ -169,11 +234,17 @@ def require_force_approval( "sha256": sha256, "failing_checks": failing_summary(checks, _CARD_VALUE_CHARS - 50), } - verdict = hatch.request_hatch_approval( - HOOK_ID, context, cwd=os.getcwd(), command=command, critical=True - ) + verdict = _ask_with_retry(hatch, context, command) if verdict.approved: return verdict.reason + if _send_failed(verdict): + raise _refusal( + failing, + f"the approval request could not be posted to Telegram after {1 + len(RETRY_DELAYS_S)} attempts " + f"({verdict.reason})", + command, + extra=_screen_hint(remote_name), + ) if verdict.should_stop: raise _refusal( failing, diff --git a/tests/e2e/hatch_runner.py b/tests/e2e/hatch_runner.py index 3ef6b8fe..a02f51c6 100644 --- a/tests/e2e/hatch_runner.py +++ b/tests/e2e/hatch_runner.py @@ -3,8 +3,10 @@ An e2e test cannot tap Approve on a real phone, and the shared hatch helper refuses every tg-ctl outside the test sandbox. This runner is `python hatch_runner.py <3d args>`: it swaps `printer_ops.force_hatch.load_hatch` for a fake whose verdict comes from THREED_TEST_HATCH_VERDICT -("approve" | "deny") and appends each request (hook id, context, command) as one JSON line to the +("approve" | "deny" | "send-failed") and appends each request (hook id, context, command) as one JSON line to the file in THREED_TEST_HATCH_LOG, then runs the real dispatcher. Everything else is the real CLI. +Verdict "send-failed" answers every request as the tg-ctl daemon does when its Telegram send failed +(`ask-forward not-posted … reason=send-failed`); the retry pauses are zeroed so the test does not wait. """ from __future__ import annotations @@ -52,8 +54,11 @@ def request_hatch_approval( ) + "\n" ) - if os.environ["THREED_TEST_HATCH_VERDICT"] == "approve": + verdict = os.environ["THREED_TEST_HATCH_VERDICT"] + if verdict == "approve": return _Verdict(True, True, "test double approved") + if verdict == "send-failed": + return _Verdict(False, True, "tg-ctl ask: ask-forward not-posted: daemon declined: reason=send-failed") return _Verdict(False, True, "test double denied") @@ -62,5 +67,5 @@ def _fake_hatch() -> _FakeHatch: if __name__ == "__main__": - with patch.object(force_hatch, "load_hatch", _fake_hatch): + with patch.object(force_hatch, "load_hatch", _fake_hatch), patch.object(force_hatch, "RETRY_DELAYS_S", (0.0,) * 3): sys.exit(main(sys.argv[1:])) diff --git a/tests/test_force_hatch.py b/tests/test_force_hatch.py index 81a746c5..4180b571 100644 --- a/tests/test_force_hatch.py +++ b/tests/test_force_hatch.py @@ -179,3 +179,79 @@ def test_missing_helper_fails_closed( _home(monkeypatch, tmp_path) with pytest.raises(force_hatch.HatchUnavailable, match="not found"): force_hatch.load_hatch() + + +# ---- tg-ctl `ask-forward not-posted … reason=send-failed` (issue #728) -------------------- + +NOT_POSTED = ( + "tg-ctl ask: ask-forward not-posted: agent=claude kind=permission req=hatch-3d-printer-start-force-d6543f0fc5b0 " + "daemon declined: reason=send-failed" +) + + +class ScriptedHatch: + """The shared hatch helper answering a fixed sequence of verdicts, one per request.""" + + def __init__(self, *verdicts: tuple[bool, bool, str]) -> None: + self.verdicts = list(verdicts) + self.requests = 0 + + def request_hatch_approval(self, hook_id: str, context: dict[str, object], **_: object) -> SimpleNamespace: + self.requests += 1 + approved, should_stop, reason = self.verdicts.pop(0) + return SimpleNamespace(approved=approved, should_stop=should_stop, reason=reason) + + +@pytest.fixture() +def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: + slept: list[float] = [] + monkeypatch.setattr(force_hatch.time, "sleep", slept.append) + return slept + + +def ask(hatch: ScriptedHatch, monkeypatch: pytest.MonkeyPatch) -> str: + monkeypatch.setattr(force_hatch, "load_hatch", lambda: hatch) + return force_hatch.require_force_approval( + printer="u1", file="plate.gcode", sha256="0" * 64, checks=CHECKS, remote_name="plate-0123456789abcdef.gcode" + ) + + +def test_a_card_that_was_not_posted_is_asked_again_with_backoff_until_the_owner_answers( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float], capsys: pytest.CaptureFixture[str] +) -> None: + hatch = ScriptedHatch((False, True, NOT_POSTED), (False, True, NOT_POSTED), (True, True, "approved by tg-ctl ask")) + assert ask(hatch, monkeypatch) == "approved by tg-ctl ask" + assert hatch.requests == 3 and sleeps == [3.0, 10.0] + assert "asking again in 3 s" in capsys.readouterr().err + + +def test_a_card_that_never_posts_ends_with_the_printer_screen_way( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + hatch = ScriptedHatch(*[(False, True, NOT_POSTED)] * 4) + with pytest.raises(PrinterRefused) as exc: + ask(hatch, monkeypatch) + assert hatch.requests == 4 and sleeps == [3.0, 10.0, 30.0] + assert "could not be posted to Telegram after 4 attempts" in exc.value.message + text = "\n".join(exc.value.remediation) + assert "Start from the printer screen" in text and "plate-0123456789abcdef.gcode" in text + assert "does NOT run these checks" in text and "tg-ctl.*.log" in text and "- macros:" in text + + +@pytest.mark.parametrize( + "verdict", + [ + (False, True, "tg-ctl ask decision was 'deny' (expected 'allow')"), + (False, True, "tg-ctl ask: ask-forward expired: messageId=7 — posted to Telegram; no decision"), + (False, True, "tg-ctl ask: ask-forward not-posted: daemon declined: reason=not-registered"), + (False, False, "RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE is not set"), + ], +) +def test_other_outcomes_are_final_and_never_retried( + monkeypatch: pytest.MonkeyPatch, sleeps: list[float], verdict: tuple[bool, bool, str] +) -> None: + hatch = ScriptedHatch(verdict) + with pytest.raises(PrinterRefused) as exc: + ask(hatch, monkeypatch) + assert hatch.requests == 1 and sleeps == [] + assert "printer screen" not in "\n".join(exc.value.remediation) From 48ad3cab63e0db25ade20612cb780b5d7b9b4f15 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:14:47 +0100 Subject: [PATCH 12/30] feat(printer): start --fix-colour and `printer slot set|show` align file 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 --material M --color #RRGGBB [--brand B]` writes the head's filament record the other way round: it is `3d printers filament set NAME/T` (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. --- docs/commands/printer.md | 63 ++++ lib/commands/printer.py | 401 +++++++++++++++------ tests/e2e/test_printer_colour_workflows.py | 168 +++++++++ tests/test_printer_colour_commands.py | 365 +++++++++++++++++++ 4 files changed, 888 insertions(+), 109 deletions(-) create mode 100644 tests/e2e/test_printer_colour_workflows.py create mode 100644 tests/test_printer_colour_commands.py diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..0f55f03d 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -18,6 +18,9 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). 3d printer status NAME [--json] [--secs S] 3d printer upload NAME FILE.gcode [--as REMOTE] 3d printer start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] [--no-bed-move] + [--colour-delta-e N] [--fix-colour [--fix-max-delta-e N]] +3d printer slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] [--force-official] [--secs S] [--json] +3d printer slot show NAME [--secs S] [--json] 3d printer cancel NAME --yes 3d printer evidence NAME [--job ID | --last] [--out DIR] [--gcode FILE.gcode] [--note TEXT] [--klippy-log FILE] [--job-file FILE] 3d printer guard NAME --job-id ID --yes [--interval S] [--evidence DIR] @@ -86,6 +89,56 @@ Options: head-sequence check on it and `3d halves-check` (without it, the head-sequence check uses `NAME.3mf` next to `NAME.gcode` when present). `3d printer start u1 out/base.gcode --yes --source-3mf print/base.3mf` +- `--colour-delta-e N` — `start`: the preflight `slot Tn` colour tolerance, CIEDE2000, default 10 + (see [Slot colour](#slot-colour)). `3d printer start u1 out/plate.gcode --yes --colour-delta-e 8` +- `--fix-colour` — `start`: first rewrite the file (and its project 3MF) to the colours the printer + reports, print the old/new SHA-256, upload the fixed file, then run the preflight and start. + `3d printer start u1 out/plate.gcode --yes --fix-colour` +- `--fix-max-delta-e N` — the largest ΔE `--fix-colour` rewrites across (default 30). + `3d printer start u1 out/plate.gcode --yes --fix-colour --fix-max-delta-e 40` + +## Slot colour + +The preflight `slot Tn` line compares the colour the slicer wrote (`; filament_colour`) with the +colour the printer reports for that head by **CIEDE2000 ΔE**, default tolerance 10 +(`--colour-delta-e`), and prints the distance. The same beige PETG is `#E8D8B0` in the slice and +`#FEE5A5` on the U1 screen: ΔE 6.4, PASS. A material mismatch is always a FAIL. Three ways out of a +real mismatch, none of them `--force`: + +```bash +# 1. the file is wrong about the colour: rewrite it at the source (G-code + project 3MF + sidecars) +3d print-preflight out/plate.gcode --printer u1 --fix +3d printer start u1 out/plate.gcode --yes --fix-colour # rewrite, upload the fixed file, preflight, start +# 2. the printer is wrong about what is loaded: write the head's record (idle printers only) +3d printer slot set u1 T4 --material PETG --color '#E8D8B0' --brand Elegoo --dry-run +3d printer slot set u1 T4 --material PETG --color '#E8D8B0' --brand Elegoo +# 3. load the filament the file was sliced for +``` + +`--fix` / `--fix-colour` set entry *n* of `filament_colour` and `extruder_colour` to the printer's +colour for every tool the G-code uses, in the G-code and in its project 3MF +(`Metadata/project_settings.config`, `plate_N.json`), keep mode and mtime, move +`NAME.slice-warnings.json` to the new G-code SHA-256 and update `NAME.manifest.json`, and print the +old and new SHA-256 of each file (the hash is part of the stored file name, hence the upload in +`start --fix-colour`). A head whose colour is further than `--fix-max-delta-e` (default 30) from the +slice is refused with nothing written: that is another filament, and relabelling it would hide a +wrong-colour print. + +`slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] +[--force-official]` is `3d printers filament set NAME/T` (`--brand` is its `--vendor`); `T` +is the label the printer shows (U1: T1..T4 = G-code tools T0..T3). `slot show NAME` reads the heads. + +**What the Snapmaker U1 accepts** (Snapmaker/u1-klipper `print_task_config.py`, issue #400): the +touchscreen's own Klipper command `SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER= VENDOR=… FILAMENT_TYPE=… +FILAMENT_SUBTYPE=… FILAMENT_COLOR_RGBA=RRGGBBFF`, sent through `POST /printer/gcode/script` (no +webhook endpoint exists for it) and read back from `print_task_config`. It needs vendor, type and +sub-type together (defaults Generic / Basic), persists to `print_task.json`, resets the head's +pressure advance (the answer prints the K it has now), and refuses a head with official RFID filament +unless `--force-official`. 3d refuses before sending when the fleet entry is not `access: full` +(read-only and offline printers), while printing or paused, or when Klipper is not ready. Not used: +the RFID path `FILAMENT_DT_UPDATE` / the Extended Firmware's `POST /printer/filament_detect/set` +(it marks the slot official, which locks later writes). Other adapters (Creality) have no +per-head write: `slot set` exits 2 and `slot show` is the read-only comparison. ## Examples @@ -827,6 +880,16 @@ RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE="loaded_filament_match: slice has no co `RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE=approved:hgr-…`, redeems. - Approved: the start continues, prints `FORCED: starting despite preflight FAIL (...)`, and the helper appends the use to `~/.config/agent-tools/overrides.log`. +- If the daemon could not even post the card (`tg-ctl ask-forward not-posted … daemon declined: + reason=send-failed`: its Telegram `sendMessage` failed, 2026-11-04 it was `getaddrinfo EAI_AGAIN + api.telegram.org`, a resolver blip while plain `tg` worked) nothing was posted, so the ask is + repeated after 3, 10 and 30 s (four requests in all, each reported on stderr). If it still + fails the start is refused with the way around Telegram: the file is already stored on the + printer, so start it **from the printer screen** (Files, then Print) — the screen does not run + these checks, do it only when the listed FAILs are understood — and with the log to read + (`~/.config/tg-cli/tg-ctl.*.log`, lines `sendMessage failed` / `ask-forward dropped`). + The daemon itself sends once and never retries (tg-cli `forwardAndPostQuestion`), which is why + the retry lives here. - Without `--force`, without the variable, denied, timed out, or without a trusted `tg-ctl` / helper (`~/dev/agent-tools`, then `~/xp/agent-tools`, owned by you or root and not group/world-writable): exit 1, nothing is sent to the printer. diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..5bd65ab3 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -27,6 +27,7 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus @@ -47,13 +48,21 @@ history, settings: `3d printers show NAME`) upload NAME FILE.gcode [--as REMOTE] store the file on the printer; never starts a print start NAME FILE.gcode --yes [--as REMOTE] [--source-3mf X.3mf] [--bridge-max MM] [--force] [--secs S] - [--no-bed-move] + [--no-bed-move] [--colour-delta-e N] [--fix-colour [--fix-max-delta-e N]] start a file that was uploaded before; runs `3d print-preflight` first, plus the camera bed check (objects on the bed / plate missing FAIL) when the printer has watch.bed.enabled (an idle printer with watch.bed.auto_view has its bed moved in Z to where the camera sees the whole plate, and back: --no-bed-move) + slot set NAME T --material M --color #RRGGBB [--brand B] [--sub-type S] [--dry-run] [--force-official] + [--secs S] [--json] + write the filament one head shows it holds (U1: the touchscreen's + SET_PRINT_FILAMENT_CONFIG, read back) so the printer's record equals + what is loaded; T is the label on the printer (T1..T4 = G-code + T0..T3). Idle, `access: full` printers only; --dry-run prints the + before/after and every rule that would refuse, writing nothing. + `slot show NAME` reads the heads (see `3d printers filament --help`) cancel NAME --yes cancel the running/paused job evidence NAME [--job ID | --last] [--out DIR] [--gcode F] [--note T] after EVERY print (done/cancelled/failed): save the Moonraker @@ -117,6 +126,14 @@ --bridge-max MM preflight overhangs gate: longest bridge; overrides the printer's bridge_max_mm in printer-devices.yaml (default 15) --no-bed-move start: the camera bed check judges at the current Z (the bed is not moved) + --colour-delta-e N start: slot Tn colour tolerance, CIEDE2000 (default 10; the preflight line shows the + distance; a material mismatch is always a FAIL) + --fix-colour start: first rewrite `; filament_colour` / `; extruder_colour` of every tool the file + uses to the colour the printer reports, in FILE and its project 3MF (--source-3mf, + else NAME.3mf beside it), keeping mtime/mode; slice-warnings and manifest follow; + old/new sha256 are printed; the fixed FILE is uploaded (its hash, so its stored + name, changed), then the preflight runs. A head whose colour is further than + --fix-max-delta-e (default 30) away is refused: another filament Safety: start refuses while the printer is printing, paused, busy or unreadable, and when the content-addressed remote file is missing, differs in size, or (Moonraker, read back) @@ -134,6 +151,8 @@ 3d printer upload k1se "out/roof left.gcode" --as roof_left.gcode 3d printer start u1 out/part.gcode --yes 3d printer start u1 out/base.gcode --yes --source-3mf print/base.3mf + 3d printer start u1 out/plate.gcode --yes --fix-colour # slicer beige != the screen's beige: rewrite, upload, start + 3d printer slot set u1 T3 --material PETG --color '#FEE5A5' --brand Elegoo --dry-run RIG_HATCH_REQUEST_3D_PRINTER_START_FORCE="roof overhang FAIL is a stale map, issue #N" \\ 3d printer start k1se out/roof.gcode --yes --force # waits for the owner's Telegram tap 3d printer cancel k1se --yes @@ -150,8 +169,8 @@ 3d printer todo list u1 3d printer -n k1se issues add --title "X belt squeaks" --type inspect""" -_VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max"} -_FLAGS = {"--json", "--yes", "--force", "--no-bed-move"} +_VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max", "--colour-delta-e", "--fix-max-delta-e"} +_FLAGS = {"--json", "--yes", "--force", "--no-bed-move", "--fix-colour"} def _parse(argv: list[str], allowed: set[str], n_pos: int, sub: str) -> tuple[list[str], dict[str, str]]: @@ -196,32 +215,81 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: + """The fleet-declared columns of a `printer list` row (empty facts when the printer is not in the fleet).""" from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _device_facts(device: Device | None) -> dict[str, Any]: + """The device-derived columns of a `printer list` row (the device that would be contacted).""" + if device is None: + return {"adapter": None, "address": None, "settings": None} + return {"adapter": device.adapter, "address": f"{device.host}:{device.port}", "settings": str(device.source)} + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) + dev = _device_facts(device) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, - "adapter": device.adapter if device else None, - "address": f"{device.host}:{device.port}" if device else None, - "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "adapter": dev["adapter"], + "address": dev["address"], + "settings": dev["settings"], + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + """The `printer list` hint when nothing is configured: where we looked and an example.""" + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + """One human-readable `printer list` line plus its unusable-head and settings notes.""" + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -232,22 +300,11 @@ def _cmd_list(argv: list[str]) -> int: print(json.dumps(rows, indent=1, ensure_ascii=False)) return 0 if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) + _print_no_printers() return 0 print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") + _print_list_row(r) if fleet is not None: print(f"\nfleet: {fleet.path}") return 0 @@ -331,7 +388,8 @@ def _cmd_start(argv: list[str]) -> int: import tempfile from pathlib import Path - (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", "--no-bed-move"}, 2, + (name, file), opts = _parse(argv, {"--yes", "--force", "--as", "--source-3mf", "--secs", "--bridge-max", "--no-bed-move", + "--colour-delta-e", "--fix-colour", "--fix-max-delta-e"}, 2, "start") if "--yes" not in opts: raise UsageError( @@ -351,20 +409,11 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _require_stored_file(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + """Refuse unless the printer holds `remote` with exactly the local size.""" + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -373,6 +422,12 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local: Path) -> str: + """Check the stored file is the local one; returns the local sha256.""" + from printer_ops.base import PrinterRefused, file_sha256 + # Identity of what will be printed with what is checked below: the read-back hash where # the firmware can return the file, else the hash in the name plus the size. remote_hash = adapter.remote_sha256(remote) @@ -385,11 +440,20 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P ) identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + return local_hash + + +def _preflight_report(name: str, adapter: Adapter, st: PrinterStatus, original: Path, local: Path, + source: Path | None, opts: dict[str, str]) -> Any: + """Run and print the preflight on the snapshot; the report is relabelled with the original path.""" + from printer_ops import bridge_max_for + from printer_ops.colour_fix import DEFAULT_SLOT_DELTA_E, parse_delta_e + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) @@ -398,22 +462,41 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P report = run_preflight(local, status=st, source_3mf=source, axis_limits=limits, limits_source=limits_source, trusted_macros=adapter.trusted_macros, origin=original, bridge_max=bridge, bridge_source=bridge_source, printops=printops, + colour_delta_e=parse_delta_e(opts.get("--colour-delta-e"), "--colour-delta-e", + DEFAULT_SLOT_DELTA_E, command="printer"), bed=check_bed(adapter.device, move="--no-bed-move" not in opts) if adapter.device is not None else None) report.file = f"{original} (snapshot)" print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + return report + + +def _require_preflight_pass(report: Any, name: str, original: Path, local_hash: str, remote: str, + opts: dict[str, str]) -> None: + """Refuse on a preflight FAIL unless --force is given and the owner approves it.""" + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks, + remote_name=remote) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _start_and_report(adapter: Adapter, name: str, remote: str) -> None: + """Send the start and print the state after it; any unreadable outcome is reported as UNKNOWN.""" + from printer_ops.base import UNKNOWN, PrinterCommError + from printer_ops.render import render_status _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] @@ -440,9 +523,79 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=dont_resend, ) print(render_status(name, adapter, after)) + + +def _fix_colour_and_upload(name: str, original: Path, local: Path, source: Path | None, st: PrinterStatus, + adapter: Adapter, opts: dict[str, str]) -> None: + """start --fix-colour: rewrite ORIGINAL (and its project 3MF) to the colours the printer reports, + refresh the snapshot LOCAL from it and upload it: its hash, so its stored name, changed.""" + import shutil + + from printer_ops.base import safe_remote_name + from printer_ops.colour_fix import DEFAULT_FIX_MAX_DELTA_E, fix_to_loaded, parse_delta_e + + limit = parse_delta_e(opts.get("--fix-max-delta-e"), "--fix-max-delta-e", DEFAULT_FIX_MAX_DELTA_E, + command="printer") + fixed = fix_to_loaded(original, st, source, limit, command="printer") + if fixed is None: + print("--fix-colour: the loaded colours already equal the G-code's; nothing rewritten") + return + print("\n".join(fixed.lines())) + shutil.copyfile(original, local) + res = adapter.upload(local, safe_remote_name(local, opts.get("--as"))) + print(f"uploaded the colour-fixed file to {name} as {res.name}: {res.size} B, size verified on the printer") + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import safe_remote_name + + safe_remote_name(local, opts.get("--as")) # a name the printer would refuse fails before anything is written + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + if "--fix-colour" in opts: + _fix_colour_and_upload(name, original, local, source, st, adapter, opts) + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + _require_stored_file(adapter, name, file, remote, size, opts) + local_hash = _verify_remote_identity(adapter, name, file, remote, local) + report = _preflight_report(name, adapter, st, original, local, source, opts) + _require_preflight_pass(report, name, original, local_hash, remote, opts) + _start_and_report(adapter, name, remote) return 0 +_SLOT_PASS_THROUGH = {"--brand": "--vendor", "--colour": "--color"} # `slot set` spellings -> `printers filament set` + + +def _cmd_slot(argv: list[str]) -> int: + """`slot set NAME T --material M --color #RRGGBB [--brand B]` and `slot show NAME`: the head + filament record of the printer, through `3d printers filament` (ONE implementation of the write: + Adapter.set_head_filament, read back; refusals for non-`full` access, a running job, official RFID).""" + import re + + from printers_cli.filament import run as filament + + if not argv or argv[0] in ("-h", "--help", "help"): + print(USAGE) + return 0 if argv else 1 + action, rest = argv[0], argv[1:] + if action == "show": + return filament(["show", *rest]) + if action != "set": + raise UsageError(f"unknown slot action {action!r}; accepted: set, show", command="printer", + remediation=["3d printer slot set NAME T --material PETG --color '#RRGGBB'"]) + if len(rest) < 2 or rest[0].startswith("-") or rest[1].startswith("-"): + raise UsageError("slot set needs the printer and the head: slot set NAME T --material M --color #RRGGBB", + command="printer", remediation=["3d printer slot set u1 T3 --material PETG --color '#FEE5A5'"]) + name, head, options = rest[0], rest[1], rest[2:] + if not re.fullmatch(r"[Tt][0-9]+", head): + raise UsageError(f"{head!r} is not a head label: T as the printer shows it (U1: T1..T4)", command="printer") + return filament(["set", f"{name}/{head.upper()}", *(_SLOT_PASS_THROUGH.get(o, o) for o in options)]) + + def _cmd_cancel(argv: list[str]) -> int: from printer_ops.base import ACTIVE_JOB_STATES, PrinterRefused from printer_ops.render import render_status @@ -487,6 +640,81 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _run_evidence(rest: list[str], name: str | None) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _run_watch(rest: list[str], name: str | None) -> int: + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _run_supervise(rest: list[str], name: str | None) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _run_guard(rest: list[str], name: str | None) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _run_agent(rest: list[str], name: str | None) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _run_doctor(rest: list[str], name: str | None) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _run_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + +def _run_subscriptions(sub: str) -> Callable[[list[str], str | None], int]: + """Handler for subscribe/unsubscribe/subscriptions: the module takes the subcommand word too.""" + def handler(rest: list[str], name: str | None) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + return handler + + +# subcommand -> handler(rest, -n/--name value). Add new subcommands here. +_SUBCOMMANDS: dict[str, Callable[[list[str], str | None], int]] = { + "list": lambda rest, name: _cmd_list(rest), + "status": lambda rest, name: _cmd_status(rest), + "upload": lambda rest, name: _cmd_upload(rest), + "start": lambda rest, name: _cmd_start(rest), + "slot": lambda rest, name: _cmd_slot(rest), + "cancel": lambda rest, name: _cmd_cancel(rest), + "evidence": _run_evidence, + "watch": _run_watch, + "supervise": _run_supervise, + "vision": lambda rest, name: _cmd_vision(rest), + "guard": _run_guard, + "bed": lambda rest, name: _cmd_bed(rest), + "subscribe": _run_subscriptions("subscribe"), + "unsubscribe": _run_subscriptions("unsubscribe"), + "subscriptions": _run_subscriptions("subscriptions"), + "agent": _run_agent, + "doctor": _run_doctor, + "todo": _run_todo, + "issues": _run_todo, +} + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -502,57 +730,12 @@ def run(argv: list[str]) -> int: if name is not None and sub not in ("todo", "issues"): raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) - if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) - raise UsageError(f"unknown subcommand '{sub}'", command="printer", - remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + raise UsageError(f"unknown subcommand '{sub}'", command="printer", + remediation=["Use list, status, upload, start, slot, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " + "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + return handler(rest, name) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: @@ -574,7 +757,7 @@ def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: COMMAND = Command( name="printer", group="SLICING", - summary="drive configured printers: list / status / upload / start --yes / cancel --yes / evidence / watch / vision / subscribe / agent / todo", + summary="drive configured printers: list / status / upload / start --yes / slot set / cancel --yes / evidence / watch / vision / subscribe / agent / todo", usage=USAGE, run=run, ) diff --git a/tests/e2e/test_printer_colour_workflows.py b/tests/e2e/test_printer_colour_workflows.py new file mode 100644 index 00000000..8df8109e --- /dev/null +++ b/tests/e2e/test_printer_colour_workflows.py @@ -0,0 +1,168 @@ +"""E2E stories through `bin/3d printer` for the slot colour: `start --fix-colour` (rewrite, upload the +fixed file, start) and `slot set` / `slot show` (align the printer's head record with the file) on a +loopback fake U1; never a real printer.""" + +from __future__ import annotations + +import json + +from pathlib import Path + +from colour_fix_fixtures import LOADED, SLICED, load_beige_petg, write_plate +from fleet_fixtures import u1_entry, write_fleet +from printer_fakes import FakeMoonraker, u1_status, write_devices + +from .workflow_helper import hatch_requests, run_cli, run_cli_with_hatch + + +def slot_line(stdout: str) -> str: + return next(line for line in stdout.splitlines() if "slot T3" in line) + + +def test_printer_start_fix_colour_needs_no_force_for_the_slot_colour( + tmp_path: Path, +) -> None: + """The slot colour no longer blocks: `start --fix-colour` rewrites, uploads the fixed file and starts it; + only the unrelated thumbnail FAIL of this bare fixture needs the (approved) force.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + started = run_cli_with_hatch( + tmp_path, + "printer", + "start", + "u1", + str(g), + "--yes", + "--fix-colour", + "--force", + ) + + assert started.returncode == 0, started.stdout + started.stderr + assert ( + f"{SLICED} -> {LOADED}" in started.stdout + and "uploaded the colour-fixed file" in started.stdout + ) + assert slot_line(started.stdout).split()[0] == "PASS" + (request,) = hatch_requests(tmp_path) + failing = json.dumps(request["context"]) + assert "slot T3" not in failing and "- thumbnails:" in failing + assert [r.path for r in moon.writes()] == [ + "/server/files/upload", + "/printer/print/start", + ] + assert moon.contents[moon.writes()[1].query["filename"][0]] == g.read_bytes() + + +def test_printer_slot_set_dry_run_then_write_aligns_the_printer_with_the_file( + tmp_path: Path, +) -> None: + """The other direction: the printer's record is set to the file's colour (U1 SET_PRINT_FILAMENT_CONFIG).""" + moon = FakeMoonraker() + with moon.serve(): + env = { + "THREED_FLEET": str(write_fleet(tmp_path / "fleet.yaml", u1_entry(moon))) + } + dry = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + "--brand", + "Elegoo", + "--dry-run", + env_extra=env, + ) + assert moon.scripts == [] + wrote = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + "--brand", + "Elegoo", + env_extra=env, + ) + shown = run_cli(tmp_path, "printer", "slot", "show", "u1", env_extra=env) + moon.status = u1_status("printing", "big.gcode") + busy = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PLA", + "--color", + "#FFFFFF", + env_extra=env, + ) + + assert dry.returncode == 0, dry.stderr + assert "DRY RUN" in dry.stdout and SLICED in dry.stdout + assert wrote.returncode == 0, wrote.stderr + assert f"u1/T4 now shows PETG {SLICED} Elegoo Basic (read back)" in wrote.stdout + assert any( + f"PETG {SLICED}" in line and line.lstrip().startswith("T4") + for line in shown.stdout.splitlines() + ) + assert busy.returncode == 1 and "never done during a job" in busy.stderr + assert len(moon.scripts) == 1 and moon.scripts[0].startswith( + "SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER=3 " + ) + + +def test_slot_set_refuses_an_offline_printer_and_a_missing_head(tmp_path: Path) -> None: + moon = FakeMoonraker() + with moon.serve(): + env = { + "THREED_FLEET": str( + write_fleet(tmp_path / "fleet.yaml", u1_entry(moon, access="read-only")) + ) + } + readonly = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + SLICED, + env_extra=env, + ) + no_head = run_cli( + tmp_path, + "printer", + "slot", + "set", + "u1", + "--material", + "PETG", + "--color", + SLICED, + env_extra=env, + ) + + assert readonly.returncode == 1 and "read-only" in readonly.stderr + assert ( + no_head.returncode == 2 and "needs the printer and the head" in no_head.stderr + ) + assert moon.scripts == [] diff --git a/tests/test_printer_colour_commands.py b/tests/test_printer_colour_commands.py new file mode 100644 index 00000000..b0cbc69a --- /dev/null +++ b/tests/test_printer_colour_commands.py @@ -0,0 +1,365 @@ +"""`3d print-preflight --colour-delta-e/--fix`, `3d printer start --fix-colour` and `3d printer slot set` +against a loopback fake U1 (tests/printer_fakes.py); the printer is never real. Issue +https://github.com/alex-mextner/3d-cli/issues/728.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest +from colour_fix_fixtures import LOADED, SLICED, gcode_text, load_beige_petg, write_plate +from fleet_fixtures import u1_entry, write_fleet +from printer_fakes import FakeMoonraker, u1_status, write_devices + +from errors import GateFailure, InvalidArgument, UsageError +from printer_ops import preflight +from printer_ops.base import PrinterRefused, safe_remote_name +from slicing import slice_warnings as sw + + +@pytest.fixture() +def moon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeMoonraker]: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + fake = FakeMoonraker() + load_beige_petg(fake) + with fake.serve(): + write_devices(tmp_path / "xdg", moon=fake) + yield fake + + +@pytest.fixture() +def gates_ok(monkeypatch: pytest.MonkeyPatch) -> None: + """The thumbnail / overhang / slicer-warning gates have their own tests: pin them to PASS.""" + monkeypatch.setattr( + preflight, + "check_thumbnails", + lambda p: preflight.Check("thumbnails", "PASS", "stub"), + ) + monkeypatch.setattr( + preflight, + "check_overhangs", + lambda p, *_: preflight.Check("overhangs", "PASS", "stub"), + ) + monkeypatch.setattr( + preflight, + "check_slice_warnings", + lambda o, g: preflight.Check("slicer warn", "PASS", "stub"), + ) + + +def preflight_cli(*argv: str) -> int: + from commands.print_preflight import run + + return run(list(argv)) + + +def printer(*argv: str) -> int: + from commands.printer import run + + return run(list(argv)) + + +# ---- print-preflight --------------------------------------------------------------------- + + +def test_preflight_passes_the_beige_pair_and_shows_the_distance( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + assert preflight_cli(str(g), "--printer", "u1") == 0 + out = capsys.readouterr().out + assert "slot T3" in out and "ΔE 6.4" in out and f"G-code {SLICED}" in out + assert moon.writes() == [] + + +def test_preflight_threshold_option_and_validation( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + with pytest.raises(GateFailure): + preflight_cli(str(g), "--printer", "u1", "--colour-delta-e", "5") + assert ( + f"colour: G-code {SLICED}, loaded {LOADED} (ΔE 6.4 > 5)" + in capsys.readouterr().out + ) + with pytest.raises(InvalidArgument): + preflight_cli(str(g), "--printer", "u1", "--colour-delta-e", "0") + + +def test_preflight_fails_a_different_colour_and_a_wrong_material( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + other = tmp_path / "other.gcode" + other.write_text(gcode_text("#0000FF"), encoding="utf-8") + with pytest.raises(GateFailure): + preflight_cli(str(other), "--printer", "u1") + assert "G-code #0000FF, loaded #FEE5A5 (ΔE " in capsys.readouterr().out + petg_as_pla = tmp_path / "pla.gcode" + petg_as_pla.write_text(gcode_text(SLICED, "PLA"), encoding="utf-8") + with pytest.raises(GateFailure): + preflight_cli(str(petg_as_pla), "--printer", "u1") + assert "material: G-code PLA, loaded PETG" in capsys.readouterr().out + + +def test_preflight_fix_rewrites_then_passes_and_prints_old_and_new_sha( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + old = sw.sha256_file(g) + assert preflight_cli(str(g), "--printer", "u1", "--fix") == 0 + cap = capsys.readouterr() + assert ( + f"{SLICED} -> {LOADED}" in cap.err + and old in cap.err + and sw.sha256_file(g) in cap.err + ) + assert f"G-code {LOADED}" not in cap.out # exact match now: no note + assert "slot T3" in cap.out and "PASS" in cap.out + assert f"; filament_colour = #000000;#808080;#1B5E20;{LOADED}" in g.read_text( + encoding="utf-8" + ) + assert moon.writes() == [] # --fix writes files, never the printer + + assert preflight_cli(str(g), "--printer", "u1", "--fix") == 0 + assert "already equal" in capsys.readouterr().err + + +def test_preflight_fix_json_stays_machine_readable( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + assert preflight_cli(str(g), "--printer", "u1", "--fix", "--json") == 0 + cap = capsys.readouterr() + assert any(c["name"] == "slot T3" for c in json.loads(cap.out)["checks"]) + assert "sha256 old" in cap.err + + +def test_preflight_fix_refuses_another_colour_and_needs_a_live_printer( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = tmp_path / "blue.gcode" + g.write_text(gcode_text("#0000FF"), encoding="utf-8") + before = g.read_bytes() + with pytest.raises(PrinterRefused, match="another colour"): + preflight_cli(str(g), "--printer", "u1", "--fix") + assert g.read_bytes() == before + # widening the limit is an explicit, per-run decision + assert preflight_cli(str(g), "--printer", "u1", "--fix", "--fix-max-delta-e", "100") == 0 + assert LOADED in g.read_text(encoding="utf-8") + g.write_bytes(before) + with pytest.raises(UsageError, match="--fix needs"): + preflight_cli(str(g), "--fix") + with pytest.raises(UsageError, match="--fix needs"): + preflight_cli(str(g), "--printer", "u1", "--offline", "--fix") + assert g.read_bytes() == before + + +# ---- printer start --fix-colour ---------------------------------------------------------- + + +def test_start_fix_colour_rewrites_uploads_the_fixed_file_and_starts( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + old_name = safe_remote_name(g) + assert printer("start", "u1", str(g), "--yes", "--fix-colour") == 0 + out = capsys.readouterr().out + assert f"{SLICED} -> {LOADED}" in out and "uploaded the colour-fixed file" in out + new_name = safe_remote_name(g) + assert ( + new_name != old_name and new_name in moon.files and old_name not in moon.files + ) + assert ( + moon.contents[new_name] == g.read_bytes() + and LOADED.encode() in moon.contents[new_name] + ) + assert [(r.path, r.query.get("filename")) for r in moon.writes()] == [ + ("/server/files/upload", None), + ("/printer/print/start", [new_name]), + ] + assert "slot T3" in out and "start acknowledged by u1" in out + + +def test_start_fix_colour_refuses_another_colour_before_writing_anything( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = tmp_path / "blue.gcode" + g.write_text(gcode_text("#0000FF"), encoding="utf-8") + before = g.read_bytes() + with pytest.raises(PrinterRefused, match="another colour"): + printer("start", "u1", str(g), "--yes", "--fix-colour") + assert g.read_bytes() == before and moon.writes() == [] + + +def test_start_without_fix_colour_passes_the_beige_without_rewriting( + moon: FakeMoonraker, + tmp_path: Path, + gates_ok: None, + capsys: pytest.CaptureFixture[str], +) -> None: + g = write_plate(tmp_path) + moon.put(safe_remote_name(g), g.read_bytes()) + before = g.read_bytes() + assert printer("start", "u1", str(g), "--yes") == 0 + assert "ΔE 6.4" in capsys.readouterr().out and g.read_bytes() == before + + +def test_start_fix_colour_is_refused_while_printing( + moon: FakeMoonraker, tmp_path: Path, gates_ok: None +) -> None: + g = write_plate(tmp_path) + before = g.read_bytes() + moon.status = u1_status("printing", "x.gcode") + load_beige_petg(moon) + with pytest.raises(PrinterRefused, match="printing"): + printer("start", "u1", str(g), "--yes", "--fix-colour") + assert g.read_bytes() == before and moon.writes() == [] + + +# ---- printer slot set -------------------------------------------------------------------- + + +@pytest.fixture() +def fleet_moon( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[FakeMoonraker]: + fake = FakeMoonraker() + with fake.serve(): + monkeypatch.setenv( + "THREED_FLEET", str(write_fleet(tmp_path / "fleet.yaml", u1_entry(fake))) + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + yield fake + + +def test_slot_set_writes_the_head_and_reads_it_back( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + printer( + "slot", + "set", + "u1", + "t4", + "--material", + "PETG", + "--color", + "fee5a5", + "--brand", + "Elegoo", + ) + == 0 + ) + assert ( + "u1/T4 now shows PETG #FEE5A5 Elegoo Basic (read back)" + in capsys.readouterr().out + ) + (script,) = fleet_moon.scripts + assert ( + script.startswith("SET_PRINT_FILAMENT_CONFIG CONFIG_EXTRUDER=3 ") + and 'VENDOR="Elegoo"' in script + ) + assert "FILAMENT_COLOR_RGBA=FEE5A5FF" in script and 'FILAMENT_TYPE="PETG"' in script + + +def test_slot_set_dry_run_sends_nothing_and_predicts_refusals( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + == 0 + ) + out = capsys.readouterr().out + assert "DRY RUN" in out and "FEE5A5" in out + assert fleet_moon.writes() == [] + + fleet_moon.status = u1_status("printing", "x.gcode") + with pytest.raises(GateFailure): + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + assert "WOULD BE REFUSED" in capsys.readouterr().out and fleet_moon.writes() == [] + + +def test_slot_set_refuses_a_running_job_a_read_only_printer_and_bad_arguments( + tmp_path: Path, fleet_moon: FakeMoonraker, monkeypatch: pytest.MonkeyPatch +) -> None: + fleet_moon.status = u1_status("printing", "x.gcode") + with pytest.raises(PrinterRefused, match="never done during a job"): + printer("slot", "set", "u1", "T4", "--material", "PETG", "--color", "#FEE5A5") + assert fleet_moon.scripts == [] + + monkeypatch.setenv( + "THREED_FLEET", + str( + write_fleet(tmp_path / "ro.yaml", u1_entry(fleet_moon, access="read-only")) + ), + ) + with pytest.raises(PrinterRefused, match="read-only"): + printer( + "slot", + "set", + "u1", + "T4", + "--material", + "PETG", + "--color", + "#FEE5A5", + "--dry-run", + ) + assert fleet_moon.scripts == [] + + with pytest.raises(UsageError, match="not a head label"): + printer("slot", "set", "u1", "4", "--material", "PETG", "--color", "#FEE5A5") + with pytest.raises(UsageError, match="needs the printer and the head"): + printer("slot", "set", "u1") + with pytest.raises(UsageError, match="unknown slot action"): + printer("slot", "wipe", "u1") + + +def test_slot_show_lists_the_heads( + fleet_moon: FakeMoonraker, capsys: pytest.CaptureFixture[str] +) -> None: + assert printer("slot", "show", "u1") == 0 + out = capsys.readouterr().out + assert "T4" in out and "PLA" in out and fleet_moon.writes() == [] From 612148cadbcfe3f8c236887b238ac8d18c858044 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:16:02 +0100 Subject: [PATCH 13/30] test(printer): e2e for a start --force approval that Telegram could not post (Refs #728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../e2e/test_printer_force_retry_workflows.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/e2e/test_printer_force_retry_workflows.py diff --git a/tests/e2e/test_printer_force_retry_workflows.py b/tests/e2e/test_printer_force_retry_workflows.py new file mode 100644 index 00000000..0a2de8d8 --- /dev/null +++ b/tests/e2e/test_printer_force_retry_workflows.py @@ -0,0 +1,44 @@ +"""E2E: a `3d printer start --force` approval that tg-ctl could not post to Telegram +(`ask-forward not-posted … reason=send-failed`, issue #728) is asked again, then refused with the way to +start from the printer's own screen. The hatch is a test double; the printer a loopback fake.""" + +from __future__ import annotations + +from pathlib import Path + +from colour_fix_fixtures import load_beige_petg, write_plate +from printer_fakes import FakeMoonraker, write_devices + +from .workflow_helper import hatch_requests, run_cli, run_cli_with_hatch + + +def test_force_approval_that_telegram_could_not_post_is_retried_then_points_to_the_printer_screen( + tmp_path: Path, +) -> None: + """tg-ctl said `ask-forward not-posted … reason=send-failed`: the CLI asks again (4 requests in all), + then refuses with how to start from the printer's own screen; nothing is started.""" + moon = FakeMoonraker() + load_beige_petg(moon) + g = write_plate(tmp_path) + with moon.serve(): + write_devices(tmp_path / "xdg-config", moon=moon) + assert run_cli(tmp_path, "printer", "upload", "u1", str(g)).returncode == 0 + refused = run_cli_with_hatch( + tmp_path, + "printer", + "start", + "u1", + str(g), + "--yes", + "--force", + verdict="send-failed", + ) + + assert refused.returncode == 1, refused.stdout + refused.stderr + assert "could not be posted to Telegram after 4 attempts" in refused.stderr + assert ( + "Start from the printer screen" in refused.stderr and "plate-" in refused.stderr + ) + assert refused.stderr.count("asking again in 0 s") == 3 + assert len(hatch_requests(tmp_path)) == 4 + assert "/printer/print/start" not in [r.path for r in moon.writes()] From 79f4c90afc96acfa056cb1c4d1f026d5ac020733 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:29:58 +0100 Subject: [PATCH 14/30] feat(bed): references per plate type and light state with an age, `bed 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) --- docs/architecture/lib-map.md | 2 + docs/commands/printer.md | 32 +- lib/bed_refresh.py | 251 +++++++++ lib/bed_refs.py | 205 ++++++++ lib/bed_state.py | 266 +++++++--- lib/commands/printer.py | 4 +- lib/printer_ops/bed.py | 22 +- tests/e2e/test_printer_bed_refresh.py | 206 ++++++++ tests/test_bed_refs.py | 728 ++++++++++++++++++++++++++ 9 files changed, 1651 insertions(+), 65 deletions(-) create mode 100644 lib/bed_refresh.py create mode 100644 lib/bed_refs.py create mode 100644 tests/e2e/test_printer_bed_refresh.py create mode 100644 tests/test_bed_refs.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 435fae4b..5df50cfb 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -326,6 +326,8 @@ Every current root Python module is listed here so future additions are intentio - `vision_scene.py` — opt-in bed-change detectors for `3d printer vision --scene` (foreign object, moved part, no growth). - `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier (`occupied_confident`: the start gate FAILs only on a confident find), change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); the printer's light is on for every frame of `check` / `status` / `reference --capture` (`printer_ops/bed_light.py`); config, events, start gate and clear ack live in `printer_ops/bed.py`. - `bed_norm.py` — the lighting-normalised evidence channel of the bed verdict (#723): brightness mismatch of the frame against the reference (dark/middle/bright quantiles of the bed ROI, or a known light-state difference) and the local-texture difference (luminance over its neighbourhood spread, invariant to gain/offset/gamma) a pixel needs in addition to the colour difference when the light differs; `bed_state.py` wires it (`_under_light_change`). Numpy + OpenCV. +- `bed_refs.py` — which empty-bed reference stands for a frame (#723): the identity (kind, plate type `watch.bed.plate`, light state on/off/unknown, Z) and file names, the preference order (same plate and light first, another light state a marked fallback), the age and staleness (`watch.bed.reference_max_age_days`), the `JudgeContext` a verdict is made in, whether a verdict may become the reference (`refresh_refusal`), and the journal `NAME.refs.log`. Stdlib only. +- `bed_refresh.py` — `3d printer bed reference NAME --refresh [--assume-empty WHY]` and `watch.bed.auto_reference` (#723): the bed check's flow (best view, light on, bed back) whose confident clear frame replaces the stale reference, the owner's word as the alternative, the journal; numpy + OpenCV via `bed_state.py`. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. - `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration; without one the plate is FOLLOWED on the picture — a chained ECC affine transform saved with every healthy footprint, `--track`/`--save-track`, so the baseline survives the bed's drift), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view, cannot be followed, or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index fe01a2e4..82180878 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -28,7 +28,7 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). 3d printer supervise uninstall [NAME...|--all] [--yes] [--unit-dir DIR] 3d printer supervise quiet NAME [--reason TEXT] | --clear 3d printer vision NAME [--interval S] [--once] [--dry-run] [--scene] [--no-detach] ... | vision eval ... | vision record NAME DIR [--timeout S] | vision fetch-model -3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] | bed reference NAME [--capture [--no-plate]] | bed monitor NAME +3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] | bed reference NAME [--capture [--best-view] [--no-plate]] | bed reference NAME --refresh [--assume-empty WHY] | bed monitor NAME 3d printer subscribe --session ID [--printer P,P] [--kinds K,K] [--min-severity LEVEL] ... 3d printer subscribe --session ID --printer P --once --on bed-free 3d printer unsubscribe --session ID | --id SUBID | --all @@ -653,7 +653,28 @@ until someone takes them off. The camera can see that, so nobody has to tell an the K1 SE) only within `z_tol_mm` (0.5) of the current Z. `--capture --no-plate` (plate off) adds the bare-bed reference that makes the plate test reliable. `--capture --from FRAME.jpg --z 199.7` stores a saved snapshot of the empty bed taken at that Z instead. Stored in - `~/.local/share/3d-cli/bed/NAME/`; `3d printer bed reference u1` lists them. + `~/.local/share/3d-cli/bed/NAME/`; `3d printer bed reference u1` lists them (with plate type, + light state and age). +- **References keep themselves in order** ([#723](https://github.com/alex-mextner/3d-cli/issues/723)). + A reference is per printer, **plate type** (`watch.bed.plate`, default `default`), **light state** + (the printer's light `on` / `off` / `unknown` when the frame was taken) and Z, and has its time. + A frame is judged against the reference of its own light state first; the other light state is a + fallback that the verdict marks `reference_light_mismatch` and judges by colour *and* texture (see + *Another light* below). A reference older than `watch.bed.reference_max_age_days` (default 30), or + taken with another plate type, never makes the verdict `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. Without `--assume-empty` it stores the frame only when that very + frame is a **confident `clear` with the plate on, in a good view (`auto_view_min`) with the + light on** (a printer without a light needs no light); otherwise it refuses, says why and + stores nothing (exit 1). `--assume-empty "why"` is your word instead of that verdict; the reason + is kept in the reference (`assumed_empty`) and in the journal. With `watch.bed.auto_reference: + true` (default off) `bed status` / `bed check`, the preflight `bed` line and the one-shot + `bed-free` check at the end of a job do it by themselves after such a confident clear on an + idle printer (`auto_reference` in the verdict). Every refresh is one JSON line (`ts`, `mode` + `manual` | `assumed` | `auto`, `why`, `z`, `light`, `verdict`) in + `~/.local/state/3d-cli/bed/NAME.refs.log`. Config: `plate`, `reference_max_age_days`, + `auto_reference` under `watch.bed`. - **Out of view.** The calibration also says how much of the bed is in the frame: the U1 camera sits about 12 mm above the bed's Z 0, so the lower the bed goes the less of it shows (Z 10: 98 %, Z 200: 15 %, Z 215: under 10 %, Z 275 — where a home leaves it — none). Under 10 % the state is @@ -815,6 +836,9 @@ u1: auto_view_min: 0.85 # share of the plate in the frame below which it is moved auto_view_part_mm: 60 # optional: the tallest thing that may stand on the bed (else the last job's object_height) light: {object: "output_pin chamber_light", on_gcode: LIGHT_ON, off_gcode: LIGHT_OFF, settle_s: 1.5} # optional: autodetected when absent; enabled: false opts out + plate: textured # plate type in use (default `default`): references are kept per plate type + reference_max_age_days: 30 # an older reference never makes the verdict `occupied` + auto_reference: false # refresh the reference after a confident clear check (default false) k1se: watch: bed: @@ -841,6 +865,10 @@ before the bed moves, restored after the bed is back; see *The light* under [Bed `out-of-view`, `busy`), else 0. - **Reference at the best Z:** `3d printer bed reference NAME --capture --best-view` moves the bed to where the camera sees the plate, takes the empty-bed reference there and puts the bed back. + `3d printer bed reference NAME --refresh [--assume-empty "why"] [--part-height MM] [--json]` is the + same flow for replacing a stale reference (see *References keep themselves in order* under + [Bed state](#bed-state)); examples: `3d printer bed reference u1 --refresh`, + `3d printer bed reference u1 --refresh --assume-empty "plate wiped, I looked at it"`. ## Job watch diff --git a/lib/bed_refresh.py b/lib/bed_refresh.py new file mode 100644 index 00000000..6b49f4a0 --- /dev/null +++ b/lib/bed_refresh.py @@ -0,0 +1,251 @@ +"""bed_refresh.py — keep the empty-bed reference current (issue #723, item 3). + +`3d printer bed reference NAME --refresh [--assume-empty "why"]` re-captures the reference from the best view +with the printer's light on and replaces the stale one of the same plate type, light state and view +(bed_refs.py). The frame must earn it: it is judged against the existing references first and stored only +when that very frame is a confident `clear` with the plate on, in a good view (`watch.bed.auto_view_min` of the +plate in the frame), with the light on; otherwise nothing is stored and the reason is said. `--assume-empty +"why"` is the owner's word instead of that verdict (the reason is kept in the reference and in the journal). + +`watch.bed.auto_reference: true` does the same by itself: `bed_state.status_view` (`bed status`, `bed check`, +the preflight `bed` line, the one-shot `bed-free` check at the job's end) calls `auto_refresh` with the +verdict it just made; an idle printer, a confident clear in a good view with the light on, and the frame of +that verdict becomes the reference. Every refresh, manual or automatic, is one JSON line in +~/.local/state/3d-cli/bed/NAME.refs.log. + +WRITES: the reference files (data dir) and the journal, plus what `status_view` already does (the guarded +Z-only move and back, the light). Imports bed_state (numpy + OpenCV) like bed_part.py does. +""" + +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import bed_refs as R +import bed_state as B +from printer_ops.bed import AUTO_VIEW_SETTLE_S, PRINTING, BedConfig +from printer_ops.bed_light import LightSession, write_block + +if TYPE_CHECKING: + from printer_ops.devices import Device + + +def _fz(z: object) -> str: + return f"{z:.1f}" if isinstance(z, (int, float)) else "?" + + +def _light_state_of(light: LightSession) -> Callable[[], str]: + return lambda: R.light_state(light.report.to_dict()) + + +def _auto_refusal(cfg: BedConfig, verdict: dict[str, Any]) -> str | None: + """Why this verdict does not refresh the reference by itself, or None.""" + if verdict.get("source") != "camera now": + return f"the verdict is not from a fresh frame (source {verdict.get('source')})" + view = verdict.get("view") or {} + if view.get("action") == "moved" and not view.get("restored"): + return "the bed was not put back where it was found" + if not verdict.get("raw_frame") or not isinstance(verdict.get("z"), (int, float)): + return "the verdict has no camera frame to store" + return R.refresh_refusal(verdict, cfg.auto_view_min) + + +def auto_refresh( + device: Device, cfg: BedConfig, verdict: dict[str, Any], *, now: float | None = None +) -> dict[str, Any]: + """`watch.bed.auto_reference`: store the camera frame of `verdict` as the reference when it is a confident + clear (see the module docstring). -> {refreshed, reason | path}; never raises: the verdict stands.""" + why = _auto_refusal(cfg, verdict) + if why is not None: + return {"refreshed": False, "reason": why} + try: + frame = Path(str(verdict["raw_frame"])).read_bytes() + ref = B.capture_reference( + device, + cfg, + plate=True, + frame=frame, + frame_z=float(verdict["z"]), + now=now, + light_state=lambda: R.light_state(verdict.get("light")), + ) + except Exception as exc: # noqa: BLE001 — a failed refresh never changes the verdict it came from + return {"refreshed": False, "reason": f"{type(exc).__name__}: {exc}"} + R.journal_refresh( + device.name, + why="a confident clear check in a good view with the light on", + z=ref["z"], + light=ref["light"], + verdict=f"{verdict.get('bed')}/{verdict.get('plate')}", + now=now, + ) + return { + "refreshed": True, + "path": ref["path"], + "z": ref["z"], + "light": ref["light"], + } + + +def _printing_refusal(device: Device) -> dict[str, Any]: + return { + "refreshed": False, + "refused": f"{device.name} is printing: a reference needs the empty bed of an idle printer", + } + + +def _take( + device: Device, + cfg: BedConfig, + *, + assume: str | None, + light: LightSession, + judge: Callable[[], dict[str, Any]], + now: float | None, +) -> dict[str, Any]: + """Judge a fresh frame (`judge`), then store it as the reference when it may be. The frame that is judged is + the frame that is stored.""" + v = {**judge(), "light": light.report.to_dict()} # what the light did for THIS frame + if v.get("bed") == PRINTING: + return {**v, **_printing_refusal(device)} + refusal = None if assume is not None else R.refresh_refusal(v, cfg.auto_view_min) + if refusal is None and not ( + v.get("raw_frame") and isinstance(v.get("z"), (int, float)) + ): + refusal = "the check returned no camera frame to store" + if refusal is not None: + return {**v, "refreshed": False, "refused": refusal} + ref = B.capture_reference( + device, + cfg, + plate=True, + frame=Path(str(v["raw_frame"])).read_bytes(), + frame_z=float(v["z"]), + now=now, + light_state=_light_state_of(light), + assumed=assume, + ) + R.journal_refresh( + device.name, + why=assume or "a confident clear check in a good view with the light on", + z=ref["z"], + light=ref["light"], + verdict=f"{v.get('bed')}/{v.get('plate')}", + now=now, + mode="assumed" if assume is not None else "manual", + ) + return {**v, "refreshed": True, "new_reference": ref} + + +def refresh_reference( + device: Device, + cfg: BedConfig, + *, + assume_empty: str | None = None, + part_height: float | None = None, + adapter: Any = None, + camera: Any = None, + now: float | None = None, + sleep: Callable[[float], None] = time.sleep, + settle_s: float | None = None, +) -> dict[str, Any]: + """`bed reference --refresh`: the bed check's flow (best view, light on, bed back), and the frame it judges + becomes the reference when it is a confident clear (or the owner says `assume_empty`). Without + `assume_empty` a Z with a reference is needed (the verdict is judged against it); with it any Z that shows + the plate will do. The result is the bed verdict with `refreshed`, `refused` (why nothing was stored) and + `new_reference`.""" + import bed_view + from printer_ops import make_adapter + + adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) + camera = camera if camera is not None else B.camera_for(device) + match = B.z_match(device, cfg) + refs = B.RefStore(device.name).load() + light = LightSession( + adapter, + cfg.light, + note="the reference frame", + blocked=write_block(device), + sleep=sleep, + ) + + def printer_fn(_: Device) -> tuple[str, float | None]: + m = bed_view.read_motion(adapter) + return m.state, m.z + + def judge(_: float | None) -> dict[str, Any]: + return _take( + device, + cfg, + assume=assume_empty, + light=light, + now=now, + judge=lambda: B.one_shot( + device, + cfg, + max_age=None, + camera=camera, + printer_fn=printer_fn, + now=now, + sleep=sleep, + before_frame=light.on, + light_state=_light_state_of(light), + ), + ) + + def view(z: float) -> float | None: + return 1.0 if match.cam is None else match.in_view(z) + + with light: + light.on() # before the move: the settle overlaps the Z travel + res = bed_view.auto_view( + device, + cfg, + mode="force", + adapter=adapter, + judge=judge, + view=view, + ref_zs=[r.z for r in refs if r.z is not None], + stands=match, + part_height_mm=part_height, + sleep=sleep, + need_reference=assume_empty is None, + settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s, + ) + res["light"] = light.report.to_dict() + return res + + +def _text(device: Device, res: dict[str, Any]) -> str: + light = res.get("light") or {} + tail = f"\n light: {light['text']}" if light.get("text") else "" + if not res.get("refreshed"): + return ( + f"{device.name}: the reference was NOT refreshed, nothing was stored: {res.get('refused')}\n" + f" bed check: bed {res.get('bed')}, plate {res.get('plate')} (Z {_fz(res.get('z'))}): " + f"{res.get('reason', '')}{tail}" + ) + new = res["new_reference"] + owner = ( + f"; the owner says it is empty: {new['assumed_empty']}" + if new.get("assumed_empty") + else "" + ) + return ( + f"{device.name}: {new['kind']} reference refreshed at Z {new['z']:.1f} (plate {new['plate']}, light " + f"{new['light']}){owner}\n {new['path']}{tail}" + ) + + +def cmd_refresh(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + """`bed reference NAME --refresh`: exit 0 when the reference was replaced, 1 when nothing was stored.""" + res = refresh_reference( + device, cfg, assume_empty=args.assume_empty, part_height=args.part_height + ) + print(json.dumps(res, indent=1, default=str) if args.json else _text(device, res)) + return 0 if res.get("refreshed") else 1 diff --git a/lib/bed_refs.py b/lib/bed_refs.py new file mode 100644 index 00000000..83017fd9 --- /dev/null +++ b/lib/bed_refs.py @@ -0,0 +1,205 @@ +"""bed_refs.py — which empty-bed reference stands for a frame, how old it is, and the journal of automatic +refreshes (issue #723, item 3). Stdlib only; the pixels stay in bed_state.py. + +A reference is identified by (kind, plate type, light state, Z): + plate type `watch.bed.plate` of the printer (default `default`): a smooth and a textured plate look + different; a reference of another plate type is judged against only as a last resort and then + never makes the verdict `occupied`. + light state `on` / `off` / `unknown`: whether the printer's light was on when the reference was taken + (the `light` report of the bed check). A frame is judged against a reference of its own light + state first; another light state is a fallback that is marked `reference_light_mismatch` and + judged by the lighting-normalised compare (bed_norm.py). + age `ts`; older than `watch.bed.reference_max_age_days` (default 30) the reference is stale: an + `occupied` against it is `unknown` with the command that takes a new one. + +Files: `-z.json` (the legacy name: plate `default`, light `unknown`) and +`-z[-][-].json`, each with its `.jpg` / `.volume.png` / `.surface.png` next to it. +""" + +from __future__ import annotations + +import json +import re +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DEFAULT_PLATE = "default" +LIGHT_ON, LIGHT_OFF, LIGHT_UNKNOWN = "on", "off", "unknown" +LIGHT_STATES = (LIGHT_ON, LIGHT_OFF, LIGHT_UNKNOWN) +PLATE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$") +REFERENCE_MAX_AGE_DAYS = 30.0 + + +def light_state(report: Mapping[str, Any] | None) -> str: + """`on` | `off` | `unknown` from the `light` of a bed verdict (printer_ops.bed_light.LightReport.to_dict): + was the bed lit when the frame was taken?""" + lit = (report or {}).get("on_for_frame") + return LIGHT_ON if lit is True else LIGHT_OFF if lit is False else LIGHT_UNKNOWN + + +def light_unavailable(report: Mapping[str, Any] | None) -> bool: + """Is there no light to switch for this printer (none found, or turned off by its config)? Then a frame + is as lit as it can be and `light on` cannot be asked for.""" + r = report or {} + skipped = str(r.get("skipped") or "") + return not r.get("error") and ( + skipped.startswith("off by config") or (not r.get("found") and not skipped) + ) + + +def stem( + kind: str, z: float, plate: str = DEFAULT_PLATE, light: str = LIGHT_UNKNOWN +) -> str: + """File stem of a reference; the legacy name for the default plate and an unknown light.""" + return ( + f"{kind}-z{z:.1f}" + + ("" if plate == DEFAULT_PLATE else f"-{plate}") + + ("" if light == LIGHT_UNKNOWN else f"-{light}") + ) + + +def rank( + ref_plate: str, ref_light: str, plate: str | None, light: str | None +) -> tuple[int, int]: + """Sort key of a candidate reference (smaller is better): the plate type first (0 same, 1 another), then the + light state (0 same, 1 one of them unknown, 2 another known state). `None` asks for no preference.""" + p = 0 if plate is None or ref_plate == plate else 1 + if light is None or light == ref_light: + lt = 0 + elif LIGHT_UNKNOWN in (light, ref_light): + lt = 1 + else: + lt = 2 + return p, lt + + +def light_differs(ref_light: str, light: str) -> bool: + """Are both light states known and different (a reference taken lit, a frame taken dark, or the reverse)?""" + return LIGHT_UNKNOWN not in (ref_light, light) and ref_light != light + + +WALL_CLOCK_FROM = 946684800.0 # 2000-01-01: an earlier `ts` is not a time of day (0 = never recorded, 1.0 = a test) + + +def age_days(ts: float, now: float) -> float | None: + """Age of a reference in days; None when its time was never recorded (ts 0, or not a wall-clock time).""" + return None if ts < WALL_CLOCK_FROM else max(0.0, (now - ts) / 86400.0) + + +def refresh_command(printer: str) -> str: + return f"3d printer bed reference {printer} --refresh" + + +@dataclass(frozen=True) +class Tags: + """What a stored reference records besides its pixels: the plate type, the light state, and the owner's + reason when it was stored on their word (`--assume-empty`).""" + + light: str = LIGHT_UNKNOWN + plate: str = DEFAULT_PLATE + assumed: str | None = None + + +@dataclass(frozen=True) +class JudgeContext: + """What a verdict needs to know about the frame beyond its pixels: its light state, the plate type in + use, and when a reference counts as stale (`max_age_days` None: never).""" + + light: str = LIGHT_UNKNOWN + plate: str = DEFAULT_PLATE + max_age_days: float | None = None + now: float = field(default_factory=time.time) + + +def describe( + ref_ts: float, ref_light: str, ref_plate: str, ref_path: Path, ctx: JudgeContext +) -> dict[str, Any]: + """The `reference` block of a verdict: which reference it was judged against, its age, and why it may be + unfit (`unfit`: another plate type or older than the limit).""" + age = age_days(ref_ts, ctx.now) + stale = age is not None and ctx.max_age_days is not None and age > ctx.max_age_days + return { + "path": str(ref_path), + "plate": ref_plate, + "light": ref_light, + "age_days": None if age is None else round(age, 1), + "stale": stale, + "other_plate": ref_plate != ctx.plate, + } + + +def unfit_reason( + info: Mapping[str, Any], ctx: JudgeContext, printer: str +) -> str | None: + """Why `occupied` against this reference is not to be trusted (stale, or taken with another plate), or None.""" + why = [] + if info.get("stale"): + why.append( + f"the reference is {info['age_days']:.0f} days old (watch.bed.reference_max_age_days: " + f"{ctx.max_age_days:g})" + ) + if info.get("other_plate"): + why.append( + f"the reference is of plate type '{info['plate']}', this printer's plate is '{ctx.plate}'" + ) + if not why: + return None + return f"{' and '.join(why)}; take a new one with the owner's confirmation: `{refresh_command(printer)}`" + + +# ---- may a frame become the reference? ------------------------------------------------------ + + +def refresh_refusal(verdict: Mapping[str, Any], view_min: float) -> str | None: + """Why the bed check `verdict` (a fresh frame, from `bed_state.status_view`) is not good enough to become + the reference, or None: a CONFIDENT `clear` with the plate on, in a good view (`view_min` of the plate in + the frame, `watch.bed.auto_view_min`), with the printer's light on (or no light to switch).""" + if verdict.get("bed") != "clear" or verdict.get("plate") != "present": + return f"the bed check says bed {verdict.get('bed')}, plate {verdict.get('plate')}, not clear with the plate on" + metrics = verdict.get("metrics") or {} + seen = metrics.get("bed_in_view") + if isinstance(seen, (int, float)) and seen < view_min: + return f"only {seen:.0%} of the plate is in the camera's view (needs {view_min:.0%})" + light = verdict.get("light") + if light_state(light) != LIGHT_ON and not light_unavailable(light): + text = (light or {}).get("text") if isinstance(light, Mapping) else None + return f"the frame was not taken with the light on ({text or 'light state unknown'})" + return None + + +# ---- the journal of refreshes ----------------------------------------------------------------- + + +def journal_path(printer: str) -> Path: + from cli.paths import state_dir + + return state_dir() / "bed" / f"{printer}.refs.log" + + +def journal_refresh( + printer: str, + *, + why: str, + z: float | None, + light: str, + verdict: str, + now: float | None = None, + mode: str = "auto", +) -> Path: + """Append one JSON line: when, why, at which Z, with which light, the verdict that allowed it.""" + path = journal_path(printer) + path.parent.mkdir(parents=True, exist_ok=True) + line = { + "ts": time.time() if now is None else now, + "mode": mode, + "why": why, + "z": z, + "light": light, + "verdict": verdict, + } + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(line) + "\n") + return path diff --git a/lib/bed_state.py b/lib/bed_state.py index de066c8e..0387673c 100644 --- a/lib/bed_state.py +++ b/lib/bed_state.py @@ -88,6 +88,7 @@ ) from printer_ops.bed_light import LightSession, write_block # noqa: E402 +import bed_refs # noqa: E402 from bed_view import view_in_progress # noqa: E402 if TYPE_CHECKING: @@ -286,6 +287,8 @@ class Reference: volume: np.ndarray # bool: where objects on the bed show surface: np.ndarray # bool: the bed surface (plate) path: Path + light: str = bed_refs.LIGHT_UNKNOWN # the printer's light when it was taken: on | off | unknown + plate: str = bed_refs.DEFAULT_PLATE # `watch.bed.plate` when it was taken def polygon_mask(poly: tuple[tuple[float, float], ...], shape: tuple[int, int]) -> np.ndarray: @@ -456,15 +459,24 @@ def _load_reference(meta_path: Path) -> Reference | None: raise ValueError(f"masks {vol.shape}/{surf.shape} do not match the frame {image.shape[:2]}") return Reference(str(meta["kind"]), None if meta.get("z") is None else float(meta["z"]), float(meta.get("ts", 0)), str(meta.get("roi_source", "")), image, vol > 0, surf > 0, - meta_path) + meta_path, _meta_light(meta), str(meta.get("plate") or bed_refs.DEFAULT_PLATE)) except (OSError, ValueError, KeyError, TypeError) as exc: _log(f"skipping reference {meta_path}: {exc}") return None -def nearest(refs: list[Reference], kind: str, z: float | None, match: ZMatch) -> Reference | None: +def _meta_light(meta: dict[str, Any]) -> str: + light = meta.get("light") + return light if light in bed_refs.LIGHT_STATES else bed_refs.LIGHT_UNKNOWN + + +def nearest(refs: list[Reference], kind: str, z: float | None, match: ZMatch, light: str | None = None, + plate: str | None = None) -> Reference | None: + """The reference of `kind` that stands for Z `z`: of the frame's plate type and light state first (bed_refs.rank), + then the nearest Z, then the newest. Another light state or plate type is a fallback the verdict says so.""" cands = [r for r in refs if r.kind == kind and match(r.z, z)] - return min(cands, key=lambda r: (abs((r.z or 0) - (z or 0)), -r.ts)) if cands else None + return min(cands, key=lambda r: (*bed_refs.rank(r.plate, r.light, plate, light), abs((r.z or 0) - (z or 0)), + -r.ts)) if cands else None def _fz(z: float | None) -> str: @@ -481,6 +493,7 @@ class Verdict: metrics: dict[str, float] = field(default_factory=dict) changed: Any = None # bool mask of the changed pixels (evidence), or None roi: Any = None # bool mask of the bed ROI (evidence), or None + info: dict[str, Any] = field(default_factory=dict) # `reference` (which one, its age), `reference_light_mismatch` def _judge_plate(img: np.ndarray, plate_ref: Reference | None, noplate_ref: Reference | None, @@ -542,9 +555,11 @@ def _objects_verdict(d: Diff, blob_px: float, ref: Reference, plate_info: tuple[ return Verdict(UNKNOWN, plate, f"{seen}; {plate_why}", metrics, d.mask, inner) -def _select_refs(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, - match: ZMatch) -> tuple[Reference | None, Reference | None, Verdict | None]: - plate_ref, noplate_ref = nearest(refs, "plate", z, match), nearest(refs, "noplate", z, match) +def _select_refs(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, match: ZMatch, + ctx: bed_refs.JudgeContext | None = None) -> tuple[Reference | None, Reference | None, Verdict | None]: + light, plate = (ctx.light, ctx.plate) if ctx is not None else (None, None) + plate_ref = nearest(refs, "plate", z, match, light, plate) + noplate_ref = nearest(refs, "noplate", z, match, light, plate) if plate_ref is not None and noplate_ref is not None and noplate_ref.image.shape != plate_ref.image.shape: noplate_ref = None # taken at another resolution: judge against the with-plate one only if plate_ref is None and noplate_ref is None: @@ -560,13 +575,36 @@ def _select_refs(img: np.ndarray, refs: list[Reference], z: float | None, printe return plate_ref, noplate_ref, None -def _light_stats(base: Reference, img: np.ndarray, th: Thresholds) -> LightStats | None: - """bed_norm.LightStats of the frame against the reference's bed ROI, or None when `light_norm` is off.""" +def _light_stats(base: Reference, img: np.ndarray, th: Thresholds, + ctx: bed_refs.JudgeContext | None) -> LightStats | None: + """bed_norm.LightStats of the frame against the reference's bed ROI (a known difference of the light + states counts as a mismatch whatever the pixels say), or None when `light_norm` is off.""" if not th.light_norm: return None import bed_norm - return bed_norm.light_stats(base.image, img, base.volume) + differs = ctx is not None and bed_refs.light_differs(base.light, ctx.light) + return bed_norm.light_stats(base.image, img, base.volume, differs) + + +def _vet_reference(v: Verdict, ref: Reference, ctx: bed_refs.JudgeContext | None, printer: str) -> Verdict: + """Say which reference the verdict stands on (`info`: its age, plate type, light) and whether it was taken + in another light (`reference_light_mismatch`, judged by colour and texture). An `occupied` against a reference + that is stale or of another plate type is `unknown`, never `occupied`, with the command that takes a new one.""" + if ctx is None: + return v + mismatch = bed_refs.light_differs(ref.light, ctx.light) + ref_info = bed_refs.describe(ref.ts, ref.light, ref.plate, ref.path, ctx) + info: dict[str, Any] = {"reference": ref_info, "reference_light_mismatch": mismatch} + reason = v.reason + if mismatch: + reason += (f"; reference_light_mismatch: the reference was taken with the light {ref.light}, this frame with " + f"it {ctx.light}") + why = bed_refs.unfit_reason(ref_info, ctx, printer) if v.bed == OCCUPIED else None + if why is None: + return replace(v, reason=reason, info=info) + return replace(v, bed=UNKNOWN, reason=f"{reason}; not `occupied`: {why}", info=info, + metrics={**v.metrics, "reference_unfit": 1.0}) def _under_light_change(plain: Verdict, normed: Verdict, stats: LightStats, in_view: float | None, th: Thresholds, @@ -612,7 +650,7 @@ def once(norm: bool) -> Verdict: def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: str, - th: Thresholds = TH, match: ZMatch | None = None) -> Verdict: + th: Thresholds = TH, match: ZMatch | None = None, ctx: bed_refs.JudgeContext | None = None) -> Verdict: import cv2 import numpy as np @@ -621,13 +659,12 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if hidden is not None: # before the references: no reference can show what the camera cannot see return Verdict(UNKNOWN, UNKNOWN, f"{hidden}: the camera cannot tell whether it is clear", {"bed_in_view": match.in_view(z) or 0.0}) - plate_ref, noplate_ref, early = _select_refs(img, refs, z, printer, match) + plate_ref, noplate_ref, early = _select_refs(img, refs, z, printer, match, ctx) if early is not None: return early - assert plate_ref is not None or noplate_ref is not None base = plate_ref or noplate_ref assert base is not None - stats = _light_stats(base, img, th) + stats = _light_stats(base, img, th, ctx) plate, plate_why, metrics = _judge_plate(img, plate_ref, noplate_ref, z, th, stats is not None and stats.mismatch) ref = noplate_ref if plate == MISSING else plate_ref if ref is None: @@ -640,7 +677,8 @@ def classify(img: np.ndarray, refs: list[Reference], z: float | None, printer: s if inner.sum() < 100: # a degenerate ROI inspects nothing: never call that clear return Verdict(UNKNOWN, plate, f"the bed ROI of {ref.path.name} is empty or tiny ({int(inner.sum())} px): " f"fix watch.bed.roi and capture the reference again; {plate_why}", metrics) - return _judge_objects(img, ref, inner, (plate, plate_why, metrics), (match.in_view(z), z, printer), th, stats) + v = _judge_objects(img, ref, inner, (plate, plate_why, metrics), (match.in_view(z), z, printer), th, stats) + return _vet_reference(v, ref, ctx, printer) def occupied_confident(v: Verdict, th: Thresholds, view_min: float) -> bool: @@ -997,7 +1035,11 @@ def _run_monitor(device: Device, cfg: BedConfig, *, duration: float | None = Non prev = read_state(name) or {} known_bed, known_plate = prev.get("known_bed", prev.get("bed")), prev.get("known_plate", prev.get("plate")) match = z_match(device, cfg) - mon = Monitor(lambda img, z: classify(img, store.load(), z, name, th, match), th, cfg.z_tol_mm) + def judge(img: np.ndarray, z: float | None) -> Verdict: + ctx = bed_refs.JudgeContext(plate=cfg.plate, max_age_days=cfg.reference_max_age_days, now=clock()) + return classify(img, store.load(), z, name, th, match, ctx) + + mon = Monitor(judge, th, cfg.z_tol_mm) w = _Watch(device, cfg, out, ack_fn, store, match, mon, prev, known_bed, known_plate) end = None if duration is None else clock() + duration _log(f"{name}: watching the bed every {cfg.interval_s:g} s while the printer is not printing " @@ -1023,10 +1065,13 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, th: Thresholds | None = None, now: float | None = None, sleep: Callable[[float], None] = time.sleep, - before_frame: Callable[[], object] | None = None) -> dict[str, Any]: + before_frame: Callable[[], object] | None = None, + light_state: Callable[[], str] | None = None) -> dict[str, Any]: """The bed now: the monitor's last verdict when it is fresher than `max_age` at this Z, else one fresh camera frame classified here (never written to the monitor's state). - `before_frame` runs right before the first camera grab of a fresh judgement (the light, status_view).""" + `before_frame` runs right before the first camera grab of a fresh judgement (the light, status_view); + `light_state` then says whether the frame is lit (`on` / `off` / `unknown`, bed_refs.light_state): the + reference of that light state is preferred and another one is marked `reference_light_mismatch`.""" th = thresholds_for(cfg) if th is None else th now = time.time() if now is None else now state, z = printer_fn(device) @@ -1042,31 +1087,44 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A return {**last, "source": "watch", "age_s": round(now - float(last["checked"]), 1)} if before_frame is not None: before_frame() + jpeg, frozen = _fresh_frame(device, camera, sleep) + img = _decode_frame(device, jpeg) + ctx = bed_refs.JudgeContext(bed_refs.LIGHT_UNKNOWN if light_state is None else light_state(), cfg.plate, + cfg.reference_max_age_days, now) + v = classify(img, RefStore(device.name).load(), z, device.name, th, match, ctx) + # a possibly stale frame may still show parts or a missing plate (a FAIL), never a clear bed + if frozen and v.bed == CLEAR: + v = Verdict(UNKNOWN, v.plate if v.plate == MISSING else UNKNOWN, + f"the camera returned the same frame over {FROZEN_CHECK_S:g} s: frozen camera? " + f"(`3d printer vision NAME --once` to check); the stale frame looked clear", v.metrics, + v.changed, v.roi, v.info) + frame = save_evidence(device.name, jpeg, v, f"status-{v.bed}", now) + return {"printer": device.name, "bed": v.bed, "plate": v.plate, "z": z, "source": "camera now", "age_s": 0.0, + "reason": v.reason, "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, "frame": frame, + "raw_frame": str(raw_frame_path(frame)), "occupied_confident": occupied_confident(v, th, cfg.auto_view_min), + **v.info} + + +def _fresh_frame(device: Device, camera: Any, sleep: Callable[[float], None]) -> tuple[bytes, bool]: + """(a fresh camera frame, whether the camera looks frozen): a live camera never repeats a frame byte for + byte, so the same frame over FROZEN_CHECK_S seconds is a frozen one (the newer frame is the one judged).""" camera = camera if camera is not None else camera_for(device) first = jpeg = grab(camera) waited = 0.0 - while jpeg == first and waited < FROZEN_CHECK_S: # a live camera never repeats a frame byte for byte + while jpeg == first and waited < FROZEN_CHECK_S: sleep(1.0) waited += 1.0 - jpeg = grab(camera) # the newer frame is the one judged - frozen = jpeg == first + jpeg = grab(camera) + return jpeg, jpeg == first + + +def _decode_frame(device: Device, jpeg: bytes) -> np.ndarray: try: - img = decode_small(jpeg) + return decode_small(jpeg) except ValueError as exc: from printer_ops.base import PrinterCommError raise PrinterCommError(f"{device.name}: camera frame unusable: {exc}", command="printer bed") from exc - v = classify(img, RefStore(device.name).load(), z, device.name, th, match) - # a possibly stale frame may still show parts or a missing plate (a FAIL), never a clear bed - if frozen and v.bed == CLEAR: - v = Verdict(UNKNOWN, v.plate if v.plate == MISSING else UNKNOWN, - f"the camera returned the same frame over {FROZEN_CHECK_S:g} s: frozen camera? " - f"(`3d printer vision NAME --once` to check); the stale frame looked clear", v.metrics, - v.changed, v.roi) - frame = save_evidence(device.name, jpeg, v, f"status-{v.bed}", now) - return {"printer": device.name, "bed": v.bed, "plate": v.plate, "z": z, "source": "camera now", "age_s": 0.0, - "reason": v.reason, "metrics": {k: round(x, 4) for k, x in v.metrics.items()}, "frame": frame, - "raw_frame": str(raw_frame_path(frame)), "occupied_confident": occupied_confident(v, th, cfg.auto_view_min)} def status_view(device: Device, cfg: BedConfig, *, mode: str = "auto", max_age: float | None = None, @@ -1093,7 +1151,7 @@ def printer_fn(_: Device) -> tuple[str, float | None]: def judge(age: float | None) -> dict[str, Any]: return one_shot(device, cfg, max_age=age, camera=camera, printer_fn=printer_fn, th=th, now=now, sleep=sleep, - before_frame=light.on) + before_frame=light.on, light_state=lambda: bed_refs.light_state(light.report.to_dict())) def view(z: float) -> float | None: return 1.0 if match.cam is None else match.in_view(z) # a roi polygon is in the frame by definition @@ -1106,6 +1164,10 @@ def view(z: float) -> float | None: part_height_mm=part_height, sleep=sleep, settle_s=AUTO_VIEW_SETTLE_S if settle_s is None else settle_s) verdict["light"] = light.report.to_dict() + if cfg.auto_reference: + import bed_refresh + + verdict["auto_reference"] = bed_refresh.auto_refresh(device, cfg, verdict, now=now) return verdict @@ -1167,7 +1229,8 @@ def printer_fn(_: Device) -> tuple[str, float | None]: def take(_: float | None) -> dict[str, Any]: return capture_reference(device, cfg, plate=plate, camera=camera, printer_fn=printer_fn, now=now, - before_frame=light.on) + before_frame=light.on, + light_state=lambda: bed_refs.light_state(light.report.to_dict())) def view(z: float) -> float | None: return 1.0 if match.cam is None else match.in_view(z) @@ -1207,10 +1270,14 @@ def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: Any = None, printer_fn: Callable[[Device], tuple[str, float | None]] = read_printer, now: float | None = None, frame: bytes | None = None, - frame_z: float | None = None, before_frame: Callable[[], object] | None = None) -> dict[str, Any]: + frame_z: float | None = None, before_frame: Callable[[], object] | None = None, + light_state: Callable[[], str] | None = None, assumed: str | None = None) -> dict[str, Any]: """Store the current camera frame (or `frame`, a saved JPEG of the empty bed taken at gcode - Z `frame_z`, e.g. an earlier snapshot) as the printer's reference at that Z. `before_frame` runs right - before the camera is asked (the light), never for a saved frame.""" + Z `frame_z`, e.g. an earlier snapshot) as the printer's reference at that Z, for the printer's plate type + (`watch.bed.plate`) and the light state of the frame (`light_state()` after `before_frame` ran; unknown + for a saved frame). It replaces the reference of the same kind, plate type, light state and view. + `before_frame` runs right before the camera is asked (the light), never for a saved frame. `assumed`: the + owner's reason when they vouch for an empty bed (`--assume-empty`), kept in the reference.""" from errors import UsageError now = time.time() if now is None else now @@ -1232,32 +1299,50 @@ def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: An d = refs_dir(device.name) d.mkdir(parents=True, exist_ok=True) with _flock(d / ".capture.lock"): # two captures must not interleave their sibling files - return _store_reference(d, kind, z, jpeg, img, volume, surface, source, match, now) - + tags = bed_refs.Tags(bed_refs.LIGHT_UNKNOWN if light_state is None else light_state(), cfg.plate, assumed) + return _store_reference(d, kind, z, jpeg, img, volume, surface, source, match, now, tags) -def _store_reference(d: Path, kind: str, z: float, jpeg: bytes, img: Any, volume: Any, surface: Any, source: str, - match: ZMatch, now: float) -> dict[str, Any]: - import cv2 - for old in d.glob(f"{kind}-z*.json"): # one reference per kind and view (ZMatch) +def _drop_replaced(d: Path, kind: str, z: float, match: ZMatch, tags: bed_refs.Tags) -> None: + """Remove the stored reference a new one replaces: the same kind, plate type and light state at a Z the + new one stands for (ZMatch). References of another plate type or light state stay.""" + for old in d.glob(f"{kind}-z*.json"): try: - oz = float(json.loads(old.read_text(encoding="utf-8")).get("z")) - except (OSError, ValueError, TypeError): + meta = json.loads(old.read_text(encoding="utf-8")) + oz = float(meta.get("z")) + except (OSError, ValueError, TypeError, AttributeError): continue - if match(oz, z): + same = (str(meta.get("plate") or bed_refs.DEFAULT_PLATE) == tags.plate + and (meta.get("light") if meta.get("light") in bed_refs.LIGHT_STATES else bed_refs.LIGHT_UNKNOWN) + == tags.light) + if same and match(oz, z): for p in d.glob(old.stem + ".*"): p.unlink(missing_ok=True) - meta_path = d / f"{kind}-z{z:.1f}.json" + + +def _store_reference(d: Path, kind: str, z: float, jpeg: bytes, img: Any, volume: Any, surface: Any, source: str, + match: ZMatch, now: float, tags: bed_refs.Tags) -> dict[str, Any]: + import cv2 + + _drop_replaced(d, kind, z, match, tags) + meta_path = d / f"{bed_refs.stem(kind, z, tags.plate, tags.light)}.json" _sibling(meta_path, ".jpg").write_bytes(jpeg) cv2.imwrite(str(_sibling(meta_path, ".volume.png")), volume.astype("uint8") * 255) cv2.imwrite(str(_sibling(meta_path, ".surface.png")), surface.astype("uint8") * 255) meta = {"kind": kind, "z": z, "ts": now, "roi_source": source, "width": img.shape[1], "height": img.shape[0], - "volume_share": round(float(volume.mean()), 4), "surface_share": round(float(surface.mean()), 4)} + "volume_share": round(float(volume.mean()), 4), "surface_share": round(float(surface.mean()), 4), + "plate": tags.plate, "light": tags.light} + if tags.assumed: + meta["assumed_empty"] = tags.assumed meta_path.write_text(json.dumps(meta, indent=1), encoding="utf-8") # last: RefStore keys on the .json return {**meta, "path": str(_sibling(meta_path, ".jpg"))} -def list_references(printer: str) -> list[dict[str, Any]]: +def list_references(printer: str, cfg: BedConfig | None = None, now: float | None = None) -> list[dict[str, Any]]: + """The stored references with their plate type, light state and age (`stale` against + `cfg.reference_max_age_days` when the config is given).""" + now = time.time() if now is None else now + max_days = None if cfg is None else cfg.reference_max_age_days out = [] for p in sorted(refs_dir(printer).glob("*.json")): try: @@ -1268,7 +1353,10 @@ def list_references(printer: str) -> list[dict[str, Any]]: except (OSError, ValueError, KeyError, TypeError) as exc: _log(f"skipping reference {p}: {exc}") continue - out.append({**meta, "path": str(_sibling(p, ".jpg"))}) + age = bed_refs.age_days(float(meta.get("ts", 0)), now) + out.append({**meta, "plate": str(meta.get("plate") or bed_refs.DEFAULT_PLATE), "light": _meta_light(meta), + "path": str(_sibling(p, ".jpg")), "age_days": None if age is None else round(age, 1), + "stale": age is not None and max_days is not None and age > max_days}) return out @@ -1277,6 +1365,7 @@ def list_references(printer: str) -> list[dict[str, Any]]: USAGE = """3d printer bed check NAME [--expect empty|part] [--no-move] [--part-height MM] [--json] 3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] 3d printer bed reference NAME [--capture [--best-view] [--no-plate] [--from FRAME.jpg --z Z]] [--json] +3d printer bed reference NAME --refresh [--assume-empty "WHY"] [--part-height MM] [--json] 3d printer bed monitor NAME [--duration S] Bed state from the printer's camera (issue #303): is the bed clear, is the build plate on. Needs one reference frame of the EMPTY bed WITH the plate per printer and resting Z, taken @@ -1308,6 +1397,11 @@ def list_references(printer: str) -> list[dict[str, Any]]: empty bed taken at gcode Z instead of grabbing one. Stored in ~/.local/share/3d-cli/bed/NAME/. --best-view moves the bed (as `check`) to where the camera sees the plate and takes the reference THERE, then puts the bed back. + --refresh replaces a stale reference (see "References"): the best view, the light on, + the bed put back; it stores the frame only when that very frame is a confident `clear` + with the plate on in a good view with the light on (else it refuses, with the reason, + and nothing is stored); --assume-empty "WHY" is the owner's word that the bed is empty: + it replaces that check (WHY is kept in the reference and in the journal). monitor the loop `3d printer watch` runs per printer with `watch.bed.enabled: true`: JSON lines on stdout (events bed_clear, bed_occupied, plate_present, plate_missing; the warning bed_unknown when the first verdict after a job @@ -1348,6 +1442,20 @@ def list_references(printer: str) -> list[dict[str, Any]]: auto_view_part_mm: 60 declared height of the tallest thing that may stand on the bed (mm) light: {object: "output_pin chamber_light", on_gcode: LIGHT_ON, off_gcode: LIGHT_OFF, settle_s: 1.5, enabled: false} the printer's light for every camera frame (see "Light"); all keys optional + plate: textured the plate type in use (default `default`): references are kept per plate type + reference_max_age_days: 30 a reference older than this never makes a verdict `occupied` (see "References") + auto_reference: true refresh the reference by itself after a confident clear check (default false) + +References (per printer, plate type, light state and view): + Each reference records the plate type (`watch.bed.plate`), whether the printer's light was on, and when + it was taken. A frame is judged against the reference of its own light state first; the other light + state is a fallback, judged by colour and texture (see light_norm) and marked `reference_light_mismatch` + in the verdict. A reference older than `watch.bed.reference_max_age_days` (30), or taken with another + plate type, makes an `occupied` an `unknown` with the reason and `bed reference NAME --refresh`. + `--refresh` replaces the reference of the same plate type, light state and view. With + `watch.bed.auto_reference: true` `bed status` / `bed check` and the one-shot `bed-free` check at the + job's end refresh it by themselves when the check is a confident `clear` in a good view with the light on + (idle printer only); each automatic refresh is one JSON line in ~/.local/state/3d-cli/bed/NAME.refs.log. Auto view (status; also the `bed` line of `3d print-preflight --printer` and `3d printer start`): The U1 rests at Z 200 after a job, where its camera sees ~15 % of the plate: window light there @@ -1388,6 +1496,8 @@ def list_references(printer: str) -> list[dict[str, Any]]: 3d printer bed check k1se --no-move --expect empty 3d printer bed reference u1 --capture --best-view # new empty-bed reference from the best view 3d printer bed reference u1 --capture + 3d printer bed reference u1 --refresh # new reference after a confident clear check + 3d printer bed reference u1 --refresh --assume-empty "plate wiped and empty, I looked" 3d printer bed status u1 3d printer bed status u1 --no-move 3d printer bed status u1 --best-view --part-height 0 # the plate is empty: look at the whole of it @@ -1395,9 +1505,14 @@ def list_references(printer: str) -> list[dict[str, Any]]: def _cmd_reference(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: - """`bed reference`: list the references, or --capture one (from the camera, the best view or a saved frame).""" + """`bed reference`: list the references, --capture one (from the camera, the best view or a saved frame), + or --refresh the stale one (bed_refresh.py).""" + if args.refresh: + import bed_refresh + + return bed_refresh.cmd_refresh(args, device, cfg) if not args.capture: - return _list_references_cmd(args, device) + return _list_references_cmd(args, device, cfg) frame = None if args.from_file: path = Path(args.from_file).expanduser() @@ -1432,7 +1547,8 @@ def _capture_lit(device: Device, cfg: BedConfig, *, plate: bool, frame: bytes | light = LightSession(make_adapter(device, timeout=10.0), cfg.light, note="the reference frame", blocked=write_block(device)) with light: - ref = capture_reference(device, cfg, plate=plate, frame_z=frame_z, before_frame=light.on) + ref = capture_reference(device, cfg, plate=plate, frame_z=frame_z, before_frame=light.on, + light_state=lambda: bed_refs.light_state(light.report.to_dict())) ref["light"] = light.report.to_dict() return ref @@ -1443,8 +1559,8 @@ def _light_line(v: dict[str, Any]) -> str: return f"\n light: {light['text']}" if isinstance(light, dict) and light.get("text") else "" -def _list_references_cmd(args: argparse.Namespace, device: Device) -> int: - refs = list_references(device.name) +def _list_references_cmd(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + refs = list_references(device.name, cfg) if args.json: print(json.dumps(refs, indent=1)) elif not refs: @@ -1453,7 +1569,9 @@ def _list_references_cmd(args: argparse.Namespace, device: Device) -> int: else: for r in refs: stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(r.get("ts", 0)))) - print(f"{r['kind']:<8} Z {float(r['z']):6.1f} {stamp} {r['path']} ROI: {r.get('roi_source')}") + age = "" if r["age_days"] is None else f", {r['age_days']:.0f} d old" + (" STALE" if r["stale"] else "") + print(f"{r['kind']:<8} Z {float(r['z']):6.1f} {stamp} plate {r['plate']}, light {r['light']}{age} " + f"{r['path']} ROI: {r.get('roi_source')}") return 0 @@ -1484,16 +1602,37 @@ def _cmd_status(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int return 0 +def _allowed_flags(args: argparse.Namespace) -> set[str]: + """The options each subcommand takes (`reference` depends on its mode).""" + reference = {"json", "capture", "refresh"} + if args.capture: + reference |= {"no_plate", "from_file", "z", "best_view", "part_height"} + if args.refresh: + reference |= {"assume_empty", "part_height"} + return {"check": {"json", "expect", "no_move", "part_height"}, + "status": {"json", "max_age", "best_view", "no_move", "part_height"}, "monitor": {"duration"}, + "reference": reference}[args.sub] + + +def _validate_refresh(args: argparse.Namespace) -> None: + from errors import UsageError + + if args.refresh and args.capture: + raise UsageError("--refresh and --capture contradict each other", command="printer bed", + remediation=["--capture stores the bed as it is now (you say it is empty); --refresh replaces " + "a stale reference after a confident clear check, or after your --assume-empty."]) + if args.assume_empty is not None and not args.assume_empty.strip(): + raise UsageError("--assume-empty needs the reason: why is the bed empty?", command="printer bed", + remediation=['e.g. --assume-empty "I looked, the plate is empty and clean"']) + + def _validate_args(args: argparse.Namespace, rest: list[str]) -> None: if rest: from errors import UsageError raise UsageError(f"unknown arguments for `printer bed {args.sub}`: {' '.join(rest)}", command="printer bed", remediation=["See `3d printer bed --help`."]) - allowed = {"check": {"json", "expect", "no_move", "part_height"}, - "status": {"json", "max_age", "best_view", "no_move", "part_height"}, "monitor": {"duration"}, - "reference": {"json", "capture"} | ({"no_plate", "from_file", "z", "best_view", "part_height"} - if args.capture else set())}[args.sub] + allowed = _allowed_flags(args) stray = [f"--{k.replace('_', '-')}" if k != "from_file" else "--from" for k, v in vars(args).items() if k not in {"sub", "name", "help"} | allowed and v is not None and v is not False] if stray: @@ -1506,6 +1645,7 @@ def _validate_args(args: argparse.Namespace, rest: list[str]) -> None: raise UsageError("--best-view and --no-move contradict each other", command="printer bed", remediation=["Pass one: --best-view moves the bed to the best inspection Z, --no-move never."]) + _validate_refresh(args) if args.part_height is not None and not (math.isfinite(args.part_height) and args.part_height >= 0): from errors import InvalidArgument @@ -1533,6 +1673,8 @@ def main(argv: list[str]) -> int: ap.add_argument("--no-move", action="store_true") ap.add_argument("--part-height", type=float) ap.add_argument("--expect", choices=("empty", "part")) + ap.add_argument("--refresh", action="store_true") + ap.add_argument("--assume-empty", dest="assume_empty") ap.add_argument("-h", "--help", action="store_true") args, rest = ap.parse_known_args(argv) if args.help: @@ -1541,7 +1683,8 @@ def main(argv: list[str]) -> int: _validate_args(args, rest) device = get_device(args.name) cfg = bed_config(device) - if args.sub in ("monitor", "status", "check") or (args.sub == "reference" and args.capture and not args.from_file): + capturing = args.refresh or (args.capture and not args.from_file) + if args.sub in ("monitor", "status", "check") or (args.sub == "reference" and capturing): camera_for(device) # a printer without a camera is refused here, before any printer request if args.sub == "monitor": run_monitor(device, cfg, duration=args.duration) @@ -1556,6 +1699,7 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": from errors import ThreeDError + sys.modules.setdefault("bed_state", sys.modules[__name__]) # bed_refresh imports this very module, not a copy try: sys.exit(main(sys.argv[1:])) except ThreeDError as exc: diff --git a/lib/commands/printer.py b/lib/commands/printer.py index e6eeffe3..e7bec171 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -96,7 +96,9 @@ a fresh frame (empty / objects / part-present, the changed share, annotated + raw frame) and puts the bed back (--no-move, --expect empty|part); `reference NAME --capture [--best-view]` - takes the empty-bed reference + takes the empty-bed reference, `reference NAME --refresh + [--assume-empty WHY]` replaces a stale one (confident clear + check, or your word) (see `3d printer bed --help`) subscribe --session ID [..] printer alerts go to that omp session (it looks at the frame, tells the user only if real); `unsubscribe`, diff --git a/lib/printer_ops/bed.py b/lib/printer_ops/bed.py index 1c0f1b35..e4584610 100644 --- a/lib/printer_ops/bed.py +++ b/lib/printer_ops/bed.py @@ -77,7 +77,8 @@ BED_VIEW_EVENT = "bed_free" # the event of the one-shot `3d printer subscribe --once --on bed-free` BED_KEYS = ("enabled", "interval_s", "roi", "volume_height_mm", "z_tol_mm", "stale_s", "clear_ack", "thresholds", - "auto_view", "auto_view_min", "auto_view_part_mm", "light") + "auto_view", "auto_view_min", "auto_view_part_mm", "light", "plate", "reference_max_age_days", + "auto_reference") # The per-printer overrides of bed_state.Thresholds (tuned on the U1 and K1 SE frames of tests/data/bed; # another camera may need other values): every field of that dataclass, a test keeps the two in step. @@ -115,6 +116,9 @@ class BedConfig: auto_view_min: float = AUTO_VIEW_MIN # share of the plate in the frame below which the bed is moved auto_view_part_mm: float | None = None # declared height of the tallest thing that may stand on the bed light: LightConfig = LightConfig() # the printer's light for every camera frame (bed_light.py) + plate: str = "default" # the plate type in use: references are kept per plate type (bed_refs.py) + reference_max_age_days: float = 30.0 # an older reference never makes a verdict `occupied` + auto_reference: bool = False # refresh the reference after a confident clear check (bed_refresh.py) def _num(value: Any, what: str, minimum: float) -> float: @@ -234,6 +238,21 @@ def _light(value: Any, device: Device) -> LightConfig: settle_s=_num(value.get("settle_s", LightConfig.settle_s), f"{where}.settle_s", 0)) +def _plate(value: Any, what: str) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,39}", value): + raise InvalidArgument(what, str(value), ["a name of letters, digits, `_`, `-` or `.` (at most 40), e.g. textured"], + command="printer") + return value + + +def _reference_config(raw: dict[str, Any], where: str) -> dict[str, Any]: + """The keys of `watch.bed` about the references: plate type, age limit, the automatic refresh.""" + return {"plate": _plate(raw.get("plate", "default"), f"{where}.plate"), + "reference_max_age_days": _num(raw.get("reference_max_age_days", 30.0), + f"{where}.reference_max_age_days", 0), + "auto_reference": _flag(raw.get("auto_reference", False), f"{where}.auto_reference")} + + def bed_config(device: Device) -> BedConfig: """`watch.bed` of a device, validated (absent = monitor off).""" raw = (device.watch or {}).get("bed") @@ -260,6 +279,7 @@ def bed_config(device: Device) -> BedConfig: auto_view_part_mm=_num(raw["auto_view_part_mm"], f"{where}.auto_view_part_mm", 0) if "auto_view_part_mm" in raw else None, light=_light(raw["light"], device) if "light" in raw else LightConfig(), + **_reference_config(raw, where), ) if cfg.enabled: from printer_ops.camera import camera_spec diff --git a/tests/e2e/test_printer_bed_refresh.py b/tests/e2e/test_printer_bed_refresh.py new file mode 100644 index 00000000..235cb1fd --- /dev/null +++ b/tests/e2e/test_printer_bed_refresh.py @@ -0,0 +1,206 @@ +"""`3d printer bed reference NAME --refresh [--assume-empty WHY]`, `watch.bed.auto_reference` and the age of a +reference, end to end through bin/3d against a loopback fake Moonraker with a chamber light that serves real +U1 camera frames (tests/data/bed). The printer's bed ROI is a hand-drawn polygon (no camera calibration), so +the camera sees the whole bed by definition and nothing moves.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from .test_printer_bed_workflows import ( + HAND_ROI, + LIGHT_OFF, + LIGHT_ON, + jpeg, + u1_with_light, + write_u1, +) +from .workflow_helper import run_cli + +pytest.importorskip("numpy") +pytest.importorskip("cv2") + +BED = f"{{enabled: true, interval_s: 0.2, roi: {HAND_ROI}, light: {{settle_s: 0}}" + + +def refs(tmp_path: Path) -> list[Path]: + return sorted(tmp_path.glob("xdg-data/**/bed/u1/*.json")) + + +def journal(tmp_path: Path) -> list[dict[str, object]]: + path = tmp_path / "xdg-state" / "3d-cli" / "bed" / "u1.refs.log" + return ( + [json.loads(x) for x in path.read_text(encoding="utf-8").splitlines()] + if path.is_file() + else [] + ) + + +def test_refresh_replaces_the_reference_after_a_confident_clear_with_the_light_on( + tmp_path: Path, +) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed=BED + "}") + assert ( + run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--capture" + ).returncode + == 0 + ) + moon.camera = jpeg("u1_z200_clear_1426.jpg") + moon.scripts.clear() + out = run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--refresh", "--json" + ) + listed = run_cli(tmp_path, "printer", "bed", "reference", "u1") + lit_after = moon.lit + + assert out.returncode == 0, out.stdout + out.stderr + res = json.loads(out.stdout) + assert res["refreshed"] is True and res["new_reference"]["light"] == "on" + assert res["new_reference"]["plate"] == "default" and res["bed"] == "clear" + assert ( + moon.scripts == [LIGHT_ON, LIGHT_OFF] and lit_after is False + ) # the light is back as found + assert len(refs(tmp_path)) == 1 # the capture was lit too: replaced, not added + assert "plate default, light on" in listed.stdout + [line] = journal(tmp_path) + assert line["mode"] == "manual" and line["light"] == "on" + + +def test_refresh_refuses_parts_on_the_bed_and_stores_nothing_but_the_owners_word_does( + tmp_path: Path, +) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed=BED + "}") + assert ( + run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--capture" + ).returncode + == 0 + ) + before = [p.read_text(encoding="utf-8") for p in refs(tmp_path)] + moon.camera = jpeg("u1_occupied_1351.jpg") + refused = run_cli(tmp_path, "printer", "bed", "reference", "u1", "--refresh") + unchanged = [p.read_text(encoding="utf-8") for p in refs(tmp_path)] + empty_why = run_cli( + tmp_path, + "printer", + "bed", + "reference", + "u1", + "--refresh", + "--assume-empty", + " ", + ) + assumed = run_cli( + tmp_path, + "printer", + "bed", + "reference", + "u1", + "--refresh", + "--assume-empty", + "plate wiped, I looked", + ) + + assert refused.returncode == 1 + assert "NOT refreshed" in refused.stdout and "nothing was stored" in refused.stdout + assert unchanged == before and journal(tmp_path)[0]["mode"] == "assumed" + assert empty_why.returncode == 2 and "needs the reason" in empty_why.stderr + assert assumed.returncode == 0, assumed.stdout + assumed.stderr + assert "the owner says it is empty: plate wiped, I looked" in assumed.stdout + assert ( + json.loads(refs(tmp_path)[0].read_text(encoding="utf-8"))["assumed_empty"] + == "plate wiped, I looked" + ) + + +def test_a_stale_reference_turns_parts_on_the_bed_into_unknown_with_the_refresh_command( + tmp_path: Path, +) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed=BED + "}") + assert ( + run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--capture" + ).returncode + == 0 + ) + moon.camera = jpeg("u1_occupied_1351.jpg") + fresh = json.loads( + run_cli(tmp_path, "printer", "bed", "status", "u1", "--json").stdout + ) + [meta] = refs(tmp_path) + data = json.loads(meta.read_text(encoding="utf-8")) + data["ts"] -= 45 * 86400 + meta.write_text(json.dumps(data), encoding="utf-8") + stale = json.loads( + run_cli(tmp_path, "printer", "bed", "status", "u1", "--json").stdout + ) + listed = run_cli(tmp_path, "printer", "bed", "reference", "u1") + + assert fresh["bed"] == "occupied" and fresh["reference"]["stale"] is False + assert stale["bed"] == "unknown" and stale["occupied_confident"] is False + assert ( + stale["reference"]["age_days"] == pytest.approx(45.0, abs=0.1) + and stale["reference"]["stale"] is True + ) + assert "3d printer bed reference u1 --refresh" in stale["reason"] + assert "STALE" in listed.stdout + + +def test_auto_reference_refreshes_after_a_confident_clear_status_and_is_off_by_default( + tmp_path: Path, +) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed=BED + "}") + assert ( + run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--capture" + ).returncode + == 0 + ) + moon.camera = jpeg("u1_z200_clear_1412.jpg") + off = json.loads( + run_cli(tmp_path, "printer", "bed", "status", "u1", "--json").stdout + ) + write_u1(tmp_path, moon, bed=BED + ", auto_reference: true}") + on = json.loads( + run_cli(tmp_path, "printer", "bed", "status", "u1", "--json").stdout + ) + + assert "auto_reference" not in off, off + assert on["bed"] == "clear", on + assert on["auto_reference"]["refreshed"] is True, on + [line] = journal(tmp_path) + assert line["mode"] == "auto" and line["light"] == "on" + + +def test_refresh_flags_are_validated(tmp_path: Path) -> None: + moon = u1_with_light() + with moon.serve(): + write_u1(tmp_path, moon, bed=BED + "}") + both = run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--refresh", "--capture" + ) + stray = run_cli(tmp_path, "printer", "bed", "status", "u1", "--refresh") + listing = run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--assume-empty", "x" + ) + helped = run_cli(tmp_path, "printer", "bed", "--help") + + assert both.returncode == 2 and "contradict" in both.stderr + assert stray.returncode == 2 and "does not take --refresh" in stray.stderr + assert listing.returncode == 2 and "does not take --assume-empty" in listing.stderr + assert ( + "--refresh [--assume-empty" in helped.stdout + and "reference_max_age_days" in helped.stdout + ) diff --git a/tests/test_bed_refs.py b/tests/test_bed_refs.py new file mode 100644 index 00000000..14b665c7 --- /dev/null +++ b/tests/test_bed_refs.py @@ -0,0 +1,728 @@ +"""The empty-bed references keep themselves in order (issue #723, item 3): per printer, plate type and light +state with their age (lib/bed_refs.py), a stale or other-plate reference never makes `occupied`, the other +light state is a marked fallback, `bed reference --refresh` re-captures from the best view with the light on +(lib/bed_refresh.py), and `watch.bed.auto_reference` does it after a confident clear check. + +Printers are the in-process fakes of test_bed_view / test_bed_light (nothing touches a real printer or camera). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +import bed_refs as R +from errors import InvalidArgument +from printer_ops import bed as pbed +from printer_ops import bed_light as BL +from test_bed_light import PIN, LightPrinter +from test_bed_state import DATA, K1_ROI, device as state_device, frame, k1_ref +from test_bed_view import FakePrinter, ZCamera, device + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_refresh as RF # noqa: E402 (needs numpy + OpenCV) +import bed_state as B # noqa: E402 + +CFG = pbed.BedConfig(enabled=True, auto_view=True) +NOW = 1_800_000_000.0 # a wall-clock time: references are dated against it +DAY = 86400.0 +CLEAR_AT_70 = {200.0: "u1_occupied_1400.jpg", 70.0: "u1_z10_clear_ref.jpg"} + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def occupied(xdg: Path) -> dict[float, str]: + """The camera frames of a bed with a part on it: at Z 70 the empty-plate frame with a green block pasted on.""" + img = cv2.imread(str(DATA / "u1_z10_clear_ref.jpg")) + h, w = img.shape[:2] + img[int(h * 0.55) : int(h * 0.55) + 90, int(w * 0.45) : int(w * 0.45) + 90] = (30, 90, 30) + path = xdg / "part-at-70.jpg" + cv2.imwrite(str(path), img) + return {200.0: "u1_occupied_1400.jpg", 70.0: str(path)} + + +def capture( + z: float, + name: str, + *, + now: float = NOW, + light: str = "unknown", + cfg: pbed.BedConfig = CFG, +) -> dict[str, Any]: + return B.capture_reference( + device(), + cfg, + plate=True, + frame=(DATA / name).read_bytes(), + frame_z=z, + now=now, + light_state=lambda: light, + ) + + +def old_refs(now: float = NOW, light: str = "unknown") -> None: + capture(200.0, "u1_z200_clear_ref.jpg", now=now, light=light) + capture(70.0, "u1_z10_clear_ref.jpg", now=now, light=light) + + +def ctx(**kw: Any) -> R.JudgeContext: + return R.JudgeContext(**{"now": NOW, **kw}) + + +def journal(printer: str = "u1") -> list[dict[str, Any]]: + path = R.journal_path(printer) + return ( + [json.loads(x) for x in path.read_text(encoding="utf-8").splitlines()] + if path.is_file() + else [] + ) + + +# ---- the identity of a reference (pure) ------------------------------------------------------- + + +def test_light_state_of_a_verdict_report() -> None: + assert R.light_state({"on_for_frame": True}) == "on" + assert R.light_state({"on_for_frame": False}) == "off" + assert R.light_state({"on_for_frame": None}) == "unknown" + assert R.light_state(None) == "unknown" + + +def test_file_names_keep_the_legacy_one_for_the_default_plate_and_an_unknown_light() -> ( + None +): + assert R.stem("plate", 200.0) == "plate-z200.0" + assert R.stem("plate", 70.0, "default", "on") == "plate-z70.0-on" + assert R.stem("noplate", 70.0, "textured", "off") == "noplate-z70.0-textured-off" + + +def test_a_plate_type_outranks_a_light_state_and_an_unknown_light_beats_the_other_one() -> ( + None +): + assert R.rank("default", "on", "default", "on") == (0, 0) + assert R.rank("default", "unknown", "default", "on") == (0, 1) + assert R.rank("default", "off", "default", "on") == (0, 2) + assert R.rank("smooth", "on", "default", "on") == (1, 0) + assert R.rank("default", "off", None, None) == (0, 0) # no preference asked + assert R.light_differs("on", "off") and not R.light_differs("on", "unknown") + + +def test_age_is_unknown_for_a_time_that_was_never_recorded() -> None: + assert R.age_days(0.0, NOW) is None and R.age_days(1.0, NOW) is None + assert R.age_days(NOW - 3 * DAY, NOW) == pytest.approx(3.0) + + +@pytest.mark.parametrize( + ("verdict", "fragment"), + [ + ({"bed": "occupied", "plate": "present"}, "not clear with the plate on"), + ({"bed": "clear", "plate": "unknown"}, "not clear with the plate on"), + ( + { + "bed": "clear", + "plate": "present", + "metrics": {"bed_in_view": 0.15}, + "light": {"on_for_frame": True}, + }, + "15%", + ), + ( + { + "bed": "clear", + "plate": "present", + "light": {"on_for_frame": False, "found": True, "text": "light x"}, + }, + "not taken with the light on", + ), + ( + { + "bed": "clear", + "plate": "present", + "light": {"found": True, "on_for_frame": None, "error": "boom"}, + }, + "not taken with the light on", + ), + ], +) +def test_a_frame_that_is_not_a_confident_clear_in_a_good_view_with_the_light_on_is_refused( + verdict: dict[str, Any], fragment: str +) -> None: + assert fragment in str(R.refresh_refusal(verdict, 0.85)) + + +@pytest.mark.parametrize( + "light", + [ + {"on_for_frame": True}, + { + "found": False, + "skipped": None, + "error": None, + }, # the printer has no light to switch + {"found": False, "skipped": "off by config (watch.bed.light.enabled: false)"}, + ], +) +def test_a_lit_frame_or_a_printer_without_a_light_may_become_the_reference( + light: dict[str, Any], +) -> None: + v = { + "bed": "clear", + "plate": "present", + "metrics": {"bed_in_view": 1.0}, + "light": light, + } + assert R.refresh_refusal(v, 0.85) is None + + +# ---- the verdict against stale, other-plate and other-light references --------------------------- + + +def k1_part(size: int = 24) -> Any: + img = frame("k1_z125_clear_ref.jpg").copy() + img[180 : 180 + size, 300 : 300 + size] = (20, 20, 20) + return img + + +def k1_with(**kw: Any) -> Any: + ref = k1_ref() + for k, v in kw.items(): + setattr(ref, k, v) + return ref + + +def test_a_part_is_occupied_against_a_young_reference_and_unknown_against_a_stale_one() -> ( + None +): + img = k1_part() + young = B.classify( + img, [k1_with(ts=NOW - 5 * DAY)], 125.0, "k1se", ctx=ctx(max_age_days=30) + ) + assert young.bed == "occupied" and young.info["reference"]["stale"] is False + old = B.classify( + img, [k1_with(ts=NOW - 40 * DAY)], 125.0, "k1se", ctx=ctx(max_age_days=30) + ) + assert old.bed == "unknown", old.reason + assert ( + "40 days old" in old.reason + and "3d printer bed reference k1se --refresh" in old.reason + ) + assert ( + old.info["reference"]["stale"] is True + and old.info["reference"]["age_days"] == 40.0 + ) + assert not B.occupied_confident(old, B.TH, 0.85) + # no context (a caller that does not know the age) judges as before + assert ( + B.classify(img, [k1_with(ts=NOW - 400 * DAY)], 125.0, "k1se").bed == "occupied" + ) + + +def test_the_age_limit_is_the_printers() -> None: + img = k1_part() + ref = [k1_with(ts=NOW - 40 * DAY)] + assert ( + B.classify(img, ref, 125.0, "k1se", ctx=ctx(max_age_days=90)).bed == "occupied" + ) + assert B.classify(img, ref, 125.0, "k1se", ctx=ctx(max_age_days=7)).bed == "unknown" + + +def test_a_clear_bed_against_a_stale_reference_stays_clear_and_says_how_old_it_is() -> ( + None +): + v = B.classify( + frame("k1_z125_clear_later.jpg"), + [k1_with(ts=NOW - 90 * DAY)], + 125.0, + "k1se", + ctx=ctx(max_age_days=30), + ) + assert v.bed == "clear" and v.info["reference"]["age_days"] == 90.0 + + +def test_a_reference_of_another_plate_type_never_makes_occupied() -> None: + img = k1_part() + v = B.classify( + img, [k1_with(plate="smooth")], 125.0, "k1se", ctx=ctx(plate="textured") + ) + assert ( + v.bed == "unknown" + and "plate type 'smooth'" in v.reason + and "'textured'" in v.reason + ) + assert v.info["reference"]["other_plate"] is True + assert ( + B.classify( + img, [k1_with(plate="textured")], 125.0, "k1se", ctx=ctx(plate="textured") + ).bed + == "occupied" + ) + + +def test_the_reference_of_the_same_plate_type_and_light_state_is_the_one_judged_against() -> ( + None +): + other_plate = k1_with(plate="smooth", ts=NOW) + on, off = k1_with(light="on", ts=NOW - DAY), k1_with(light="off", ts=NOW - 2 * DAY) + unknown = k1_with(light="unknown", ts=NOW) + img = frame("k1_z125_clear_ref.jpg") + refs = [other_plate, unknown, on, off] + for light, want in (("on", "on"), ("off", "off"), ("unknown", "unknown")): + v = B.classify(img, refs, 125.0, "k1se", ctx=ctx(light=light)) + assert ( + v.info["reference"]["light"] == want + and v.info["reference"]["plate"] == "default" + ) + assert v.info["reference_light_mismatch"] is False + + +def test_another_light_state_is_a_fallback_marked_reference_light_mismatch_and_judged_by_texture() -> ( + None +): + """The reference was taken lit, this frame dark: the empty bed is clear, the verdict says which reference it + used and that the light differed.""" + v = B.classify( + frame("k1_z125_clear_later.jpg"), + [k1_with(light="on")], + 125.0, + "k1se", + ctx=ctx(light="off"), + ) + assert v.bed == "clear", v.reason + assert ( + v.info["reference_light_mismatch"] is True + and "reference_light_mismatch" in v.reason + ) + assert v.metrics["light_mismatch"] == 1.0 + # a real part is still found through the fallback + part = B.classify( + k1_part(), [k1_with(light="on")], 125.0, "k1se", ctx=ctx(light="off") + ) + assert part.bed == "occupied" and part.info["reference_light_mismatch"] is True + + +def test_a_known_light_difference_alone_switches_the_normalised_compare_on() -> None: + """The same pixels: with both light states known and different the texture channel is on.""" + img = frame("k1_z125_clear_ref.jpg") + same = B.classify(img, [k1_with(light="on")], 125.0, "k1se", ctx=ctx(light="on")) + differs = B.classify( + img, [k1_with(light="on")], 125.0, "k1se", ctx=ctx(light="off") + ) + assert ( + "light_mismatch" not in same.metrics + and differs.metrics["light_mismatch"] == 1.0 + ) + + +# ---- storing references: per plate type and light state -------------------------------------------- + + +def k1_store( + light: str, plate: str = "default", now: float = NOW, z: float = 125.0 +) -> dict[str, Any]: + cfg = pbed.BedConfig(enabled=True, roi=K1_ROI, plate=plate) + return B.capture_reference( + state_device("k1se", model=None), + cfg, + plate=True, + frame=(DATA / "k1_z125_clear_ref.jpg").read_bytes(), + frame_z=z, + now=now, + light_state=lambda: light, + ) + + +def test_a_reference_records_its_plate_type_light_state_and_time() -> None: + ref = k1_store("on", "textured") + assert (ref["plate"], ref["light"], ref["ts"]) == ("textured", "on", NOW) + assert Path(ref["path"]).name == "plate-z125.0-textured-on.jpg" + [listed] = B.list_references("k1se", pbed.BedConfig(enabled=True), NOW + 3 * DAY) + assert (listed["plate"], listed["light"], listed["age_days"], listed["stale"]) == ( + "textured", + "on", + 3.0, + False, + ) + + +def test_a_capture_replaces_only_the_reference_of_its_own_plate_type_and_light_state() -> ( + None +): + k1_store("on", now=NOW - 40 * DAY) + k1_store("off", now=NOW - 40 * DAY) + k1_store("on", "smooth", now=NOW - 40 * DAY) + assert len(B.list_references("k1se")) == 3 + fresh = k1_store("on", now=NOW) + refs = {(r["plate"], r["light"]): r["ts"] for r in B.list_references("k1se")} + assert refs == { + ("default", "on"): NOW, + ("default", "off"): NOW - 40 * DAY, + ("smooth", "on"): NOW - 40 * DAY, + } + assert Path(fresh["path"]).is_file() + + +def test_the_listing_flags_a_stale_reference_and_a_legacy_one_has_default_plate_and_unknown_light() -> ( + None +): + k1_store("unknown", now=NOW - 45 * DAY) + [r] = B.list_references("k1se", pbed.BedConfig(enabled=True), NOW) + assert (r["plate"], r["light"], r["stale"], r["age_days"]) == ( + "default", + "unknown", + True, + 45.0, + ) + assert Path(r["path"]).name == "plate-z125.0.jpg" + # a meta file written before this version has neither key + meta = next((Path(r["path"]).parent).glob("plate-z125.0.json")) + data = json.loads(meta.read_text(encoding="utf-8")) + for key in ("plate", "light"): + data.pop(key) + meta.write_text(json.dumps(data), encoding="utf-8") + [legacy] = B.list_references("k1se") + assert (legacy["plate"], legacy["light"]) == ("default", "unknown") + assert B.RefStore("k1se").load()[0].plate == "default" + + +# ---- config ------------------------------------------------------------------------------------------- + + +def test_the_reference_keys_default_and_validate() -> None: + cfg = pbed.bed_config(state_device(watch={"bed": {"enabled": False}})) + assert (cfg.plate, cfg.reference_max_age_days, cfg.auto_reference) == ( + "default", + 30.0, + False, + ) + cfg = pbed.bed_config( + state_device( + watch={ + "bed": { + "plate": "textured-pei", + "reference_max_age_days": 7, + "auto_reference": True, + } + } + ) + ) + assert (cfg.plate, cfg.reference_max_age_days, cfg.auto_reference) == ( + "textured-pei", + 7.0, + True, + ) + for bad in ( + {"plate": "a b"}, + {"plate": ""}, + {"plate": 3}, + {"reference_max_age_days": -1}, + {"auto_reference": "yes"}, + ): + with pytest.raises(InvalidArgument): + pbed.bed_config(state_device(watch={"bed": bad})) + + +# ---- the verdict through the bed check ---------------------------------------------------------------- + + +def judge( + printer: FakePrinter, + frames: dict[float, str], + cfg: pbed.BedConfig = CFG, + now: float = NOW, +) -> dict[str, Any]: + return B.status_view( + device(), + cfg, + mode="force", + max_age=None, + part_height=20.0, + adapter=printer, + camera=ZCamera(printer, frames), + sleep=lambda s: None, + settle_s=0.0, + now=now, + ) + + +def test_the_bed_check_names_the_reference_it_used_and_degrades_occupied_against_a_stale_one(occupied: dict[float, str]) -> None: + old_refs(now=NOW - 40 * DAY) + v = judge(FakePrinter("complete", 200.0), occupied, now=NOW) + assert v["bed"] == "unknown", v["reason"] + assert v["reference"]["stale"] is True and "--refresh" in v["reason"] + assert v["occupied_confident"] is False + fresh = judge(FakePrinter("complete", 200.0), occupied, now=NOW - 40 * DAY) + assert fresh["bed"] == "occupied" and fresh["reference"]["stale"] is False + + +def test_the_bed_check_prefers_the_reference_taken_with_the_same_light_state() -> None: + capture(70.0, "u1_z10_clear_ref.jpg", light="off") + capture(70.0, "u1_z10_clear_ref.jpg", light="on") + capture(200.0, "u1_z200_clear_ref.jpg") + lit = judge( + LightPrinter("complete", 200.0, lit=False), CLEAR_AT_70 + ) # the check switches the light on + assert ( + lit["reference"]["light"] == "on" and lit["reference_light_mismatch"] is False + ) + dark = judge( + FakePrinter("complete", 200.0), CLEAR_AT_70 + ) # no light on this printer: unknown state + assert dark["reference_light_mismatch"] is False + + +def test_a_dark_reference_only_is_a_marked_light_mismatch_for_a_lit_frame() -> None: + capture(70.0, "u1_z10_clear_ref.jpg", light="off") + capture(200.0, "u1_z200_clear_ref.jpg", light="off") + v = judge(LightPrinter("complete", 200.0, lit=False), CLEAR_AT_70) + assert ( + v["reference_light_mismatch"] is True + and "reference_light_mismatch" in v["reason"] + ) + assert v["bed"] == "clear" and v["reference"]["light"] == "off" + + +# ---- bed reference --refresh ----------------------------------------------------------------------------- + + +def refresh( + printer: FakePrinter, + frames: dict[float, str], + *, + assume: str | None = None, + cfg: pbed.BedConfig = CFG, + cam: ZCamera | None = None, +) -> dict[str, Any]: + return RF.refresh_reference( + device(), + cfg, + assume_empty=assume, + part_height=20.0, + adapter=printer, + camera=cam or ZCamera(printer, frames), + sleep=lambda s: None, + settle_s=0.0, + now=NOW, + ) + + +def moves(printer: FakePrinter) -> list[str]: + return [ + ln + for s in printer.scripts + for ln in s.splitlines() + if ln.startswith(("G0", "G1")) + ] + + +def test_refresh_after_a_confident_clear_stores_the_lit_frame_from_the_best_view_and_puts_everything_back() -> ( + None +): + old_refs(now=NOW - 60 * DAY) # stale, legacy: plate default, light unknown + p = LightPrinter("complete", 200.0, lit=False) + res = refresh(p, CLEAR_AT_70) + assert res["refreshed"] is True, res.get("refused") + new = res["new_reference"] + assert (new["z"], new["light"], new["plate"], new["ts"]) == ( + 70.0, + "on", + "default", + NOW, + ) + assert ( + Path(new["path"]).name == "plate-z70.0-on.jpg" and Path(new["path"]).is_file() + ) + assert res["view"]["action"] == "moved" and res["view"]["restored"] is True + assert p.z == 200.0 and p.lit is False # the bed and the light are back as found + refs = {(r["z"], r["light"]) for r in B.list_references("u1")} + assert refs == {(200.0, "unknown"), (70.0, "unknown"), (70.0, "on")} + [line] = journal() + assert ( + line["mode"] == "manual" + and line["z"] == 70.0 + and line["light"] == "on" + and line["verdict"] == "clear/present" + ) + + +def test_refresh_replaces_the_stale_reference_of_the_same_light_state_and_view() -> ( + None +): + capture(70.0, "u1_z10_clear_ref.jpg", now=NOW - 60 * DAY, light="on") + capture(200.0, "u1_z200_clear_ref.jpg", now=NOW - 60 * DAY, light="on") + res = refresh(LightPrinter("complete", 200.0, lit=False), CLEAR_AT_70) + assert res["refreshed"] is True + fresh = { + (r["z"], r["light"]): r["age_days"] for r in B.list_references("u1", CFG, NOW) + } + assert fresh == {(70.0, "on"): 0.0, (200.0, "on"): 60.0} + + +def test_refresh_refuses_a_frame_that_is_not_a_confident_clear_and_stores_nothing(occupied: dict[float, str]) -> None: + old_refs() + before = sorted(p.name for p in pbed.refs_dir("u1").iterdir()) + res = refresh(LightPrinter("complete", 200.0), occupied) + assert res["refreshed"] is False and "not clear with the plate on" in res["refused"] + assert sorted(p.name for p in pbed.refs_dir("u1").iterdir()) == before + assert journal() == [] + + +def test_refresh_refuses_when_the_light_could_not_be_switched_on() -> None: + old_refs() + p = LightPrinter("complete", 200.0, lit=False) + p.fail_scripts = {BL.on_script(PIN, BL.LightConfig())} + res = refresh(p, CLEAR_AT_70) + assert res["refreshed"] is False and "not taken with the light on" in res["refused"] + assert len(B.list_references("u1")) == 2 + + +def test_refresh_on_a_printer_without_a_light_stores_the_frame_with_an_unknown_light() -> ( + None +): + old_refs(now=NOW - 60 * DAY) + res = refresh(FakePrinter("complete", 200.0), CLEAR_AT_70) + assert res["refreshed"] is True and res["new_reference"]["light"] == "unknown" + assert [r["ts"] for r in B.list_references("u1") if r["z"] == 70.0] == [ + NOW + ] # replaced the legacy one + + +def test_refresh_never_touches_a_printing_printer() -> None: + old_refs() + p = LightPrinter("printing", 3.0) + cam = ZCamera(p, CLEAR_AT_70) + res = refresh(p, CLEAR_AT_70, cam=cam) + assert res["refreshed"] is False and "is printing" in res["refused"] + assert cam.grabs == 0 and p.scripts == [] + assert len(B.list_references("u1")) == 2 + + +def test_assume_empty_stores_the_frame_on_the_owners_word_and_keeps_the_reason(occupied: dict[float, str]) -> None: + old_refs() + p = LightPrinter("complete", 200.0, lit=False) + res = refresh(p, occupied, assume="plate wiped, I looked at it") + assert res["refreshed"] is True + new = res["new_reference"] + assert ( + new["assumed_empty"] == "plate wiped, I looked at it" and new["light"] == "on" + ) + meta = json.loads( + Path(new["path"]).with_suffix(".json").read_text(encoding="utf-8") + ) + assert meta["assumed_empty"] == "plate wiped, I looked at it" + [line] = journal() + assert line["mode"] == "assumed" and line["why"] == "plate wiped, I looked at it" + assert p.z == 200.0 and p.lit is False + + +def test_assume_empty_needs_no_reference_at_the_view_and_still_refuses_a_printing_printer() -> ( + None +): + res = refresh( + LightPrinter("complete", 200.0), CLEAR_AT_70, assume="empty" + ) # no reference at all yet + assert res["refreshed"] is True and len(B.list_references("u1")) == 1 + p = LightPrinter("printing", 3.0) + assert ( + refresh(p, CLEAR_AT_70, assume="empty")["refreshed"] is False + and p.scripts == [] + ) + + +def test_refresh_without_any_reference_cannot_judge_and_names_the_capture() -> None: + res = refresh(LightPrinter("complete", 200.0), CLEAR_AT_70) + assert res["refreshed"] is False and B.list_references("u1") == [] + + +# ---- watch.bed.auto_reference --------------------------------------------------------------------------- + + +AUTO = pbed.BedConfig(enabled=True, auto_view=True, auto_reference=True) + + +def test_auto_reference_refreshes_after_a_confident_clear_and_journals_it() -> None: + old_refs(now=NOW - 60 * DAY) + p = LightPrinter("complete", 200.0, lit=False) + v = judge(p, CLEAR_AT_70, cfg=AUTO) + assert v["bed"] == "clear" and v["auto_reference"]["refreshed"] is True + assert {(r["z"], r["light"]) for r in B.list_references("u1")} == { + (200.0, "unknown"), + (70.0, "unknown"), + (70.0, "on"), + } + [line] = journal() + assert ( + line["mode"] == "auto" + and line["z"] == 70.0 + and line["light"] == "on" + and line["verdict"] == "clear/present" + ) + assert set(line) == {"ts", "mode", "why", "z", "light", "verdict"} + assert p.lit is False and p.z == 200.0 + + +def test_auto_reference_is_off_by_default() -> None: + old_refs() + v = judge(LightPrinter("complete", 200.0), CLEAR_AT_70) + assert ( + "auto_reference" not in v + and journal() == [] + and len(B.list_references("u1")) == 2 + ) + + +def test_auto_reference_never_stores_a_frame_that_is_not_a_confident_clear(occupied: dict[float, str]) -> None: + old_refs() + v = judge(LightPrinter("complete", 200.0), occupied, cfg=AUTO) + assert ( + v["auto_reference"]["refreshed"] is False + and "not clear" in v["auto_reference"]["reason"] + ) + assert journal() == [] and len(B.list_references("u1")) == 2 + + +def test_auto_reference_never_touches_a_printing_printer() -> None: + old_refs() + v = judge(LightPrinter("printing", 3.0), CLEAR_AT_70, cfg=AUTO) + assert v["bed"] == "printing" and v["auto_reference"]["refreshed"] is False + assert journal() == [] and len(B.list_references("u1")) == 2 + + +def test_auto_reference_refuses_a_poor_view_and_a_frame_taken_dark() -> None: + old_refs() + p = LightPrinter("complete", 200.0, lit=False) + p.fail_scripts = {BL.on_script(PIN, BL.LightConfig())} + v = judge(p, CLEAR_AT_70, cfg=AUTO) + assert ( + v["auto_reference"]["refreshed"] is False + and "light" in v["auto_reference"]["reason"] + ) + low = { + "bed": "clear", + "plate": "present", + "source": "camera now", + "raw_frame": "x.jpg", + "z": 200.0, + "metrics": {"bed_in_view": 0.15}, + "light": {"on_for_frame": True}, + } + assert "15%" in str(RF.auto_refresh(device(), AUTO, low)["reason"]) + watch = {**low, "source": "watch", "metrics": {}} + assert "not from a fresh frame" in str( + RF.auto_refresh(device(), AUTO, watch)["reason"] + ) From 36f710b5b4d8d9759743898d44de03ee0dc9b812 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:01:25 +0100 Subject: [PATCH 15/30] feat(bed): `3d printer bed calibrate NAME` learns the plate's visible share per Z; `watch.bed.views`, `camera`, `plate_mask` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/architecture/lib-map.md | 1 + docs/commands/printer.md | 28 +- lib/bed_calibrate.py | 633 ++++++++++++++++++++++++ lib/bed_state.py | 125 ++++- lib/bed_view.py | 45 +- lib/commands/printer.py | 4 +- lib/printer_ops/bed.py | 105 +++- tests/e2e/test_printer_bed_calibrate.py | 191 +++++++ tests/test_bed_calibrate.py | 600 ++++++++++++++++++++++ tests/test_bed_state.py | 2 +- 10 files changed, 1694 insertions(+), 40 deletions(-) create mode 100644 lib/bed_calibrate.py create mode 100644 tests/e2e/test_printer_bed_calibrate.py create mode 100644 tests/test_bed_calibrate.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 5df50cfb..184250be 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -326,6 +326,7 @@ Every current root Python module is listed here so future additions are intentio - `vision_scene.py` — opt-in bed-change detectors for `3d printer vision --scene` (foreign object, moved part, no growth). - `bed_state.py` — pixel half of the printer bed state (#303): empty-bed references per Z, robust photometric difference in the bed ROI, clear/occupied + plate classifier (`occupied_confident`: the start gate FAILs only on a confident find), change-gated monitor loop (`3d printer bed`, the `3d printer watch` child); the printer's light is on for every frame of `check` / `status` / `reference --capture` (`printer_ops/bed_light.py`); config, events, start gate and clear ack live in `printer_ops/bed.py`. - `bed_norm.py` — the lighting-normalised evidence channel of the bed verdict (#723): brightness mismatch of the frame against the reference (dark/middle/bright quantiles of the bed ROI, or a known light-state difference) and the local-texture difference (luminance over its neighbourhood spread, invariant to gain/offset/gamma) a pixel needs in addition to the colour difference when the light differs; `bed_state.py` wires it (`_under_light_change`). Numpy + OpenCV. +- `bed_calibrate.py` — `3d printer bed calibrate NAME` (#723): how much of the plate the camera sees at each Z for a printer without a camera calibration — the guarded Z-only grid (`bed_view.excursion`: lock, displaced-Z marker, restore in a `finally`), one lit frame per Z, the plate followed from its outline in the empty-bed reference / `watch.bed.plate_mask` by chained ECC affine transforms (visible share of the moving polygon per Z; a plate that cannot be followed ends the chain, the Zs are not invented), stored in `calibration.json` that `bed_state.z_match` reads (`watch.bed.views` overrides). Numpy + OpenCV. - `bed_refs.py` — which empty-bed reference stands for a frame (#723): the identity (kind, plate type `watch.bed.plate`, light state on/off/unknown, Z) and file names, the preference order (same plate and light first, another light state a marked fallback), the age and staleness (`watch.bed.reference_max_age_days`), the `JudgeContext` a verdict is made in, whether a verdict may become the reference (`refresh_refusal`), and the journal `NAME.refs.log`. Stdlib only. - `bed_refresh.py` — `3d printer bed reference NAME --refresh [--assume-empty WHY]` and `watch.bed.auto_reference` (#723): the bed check's flow (best view, light on, bed back) whose confident clear frame replaces the stale reference, the owner's word as the alternative, the journal; numpy + OpenCV via `bed_state.py`. - `bed_view.py` — the auto view of the printer bed (#303 follow-up): pure picker of the best inspection Z (share of the plate in the camera frame, reference at that Z, clearance floor, axis ceiling), the guarded Z-only move + judge + restore flow `bed_state.py status` runs (`watch.bed.auto_view`). Stdlib only; the pixels stay in `bed_state.py`. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 82180878..92ddeb94 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -28,7 +28,7 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). 3d printer supervise uninstall [NAME...|--all] [--yes] [--unit-dir DIR] 3d printer supervise quiet NAME [--reason TEXT] | --clear 3d printer vision NAME [--interval S] [--once] [--dry-run] [--scene] [--no-detach] ... | vision eval ... | vision record NAME DIR [--timeout S] | vision fetch-model -3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] | bed reference NAME [--capture [--best-view] [--no-plate]] | bed reference NAME --refresh [--assume-empty WHY] | bed monitor NAME +3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] | bed reference NAME [--capture [--best-view] [--no-plate]] | bed reference NAME --refresh [--assume-empty WHY] | bed calibrate NAME [--z-from A --z-to B --z-step S] [--part-height MM] | bed monitor NAME 3d printer subscribe --session ID [--printer P,P] [--kinds K,K] [--min-severity LEVEL] ... 3d printer subscribe --session ID --printer P --once --on bed-free 3d printer unsubscribe --session ID | --id SUBID | --all @@ -675,6 +675,29 @@ until someone takes them off. The camera can see that, so nobody has to tell an `manual` | `assumed` | `auto`, `why`, `z`, `light`, `verdict`) in `~/.local/state/3d-cli/bed/NAME.refs.log`. Config: `plate`, `reference_max_age_days`, `auto_reference` under `watch.bed`. +- **Calibrate** (`3d printer bed calibrate NAME [--z-from A --z-to B --z-step S] [--part-height MM] [--json]`, + [#723](https://github.com/alex-mextner/3d-cli/issues/723)). A printer without a camera calibration (the + K1 SE) used to count the plate as «in the frame by definition» at every Z, so the check never knew a poor view + and never moved the bed to a better one. `bed calibrate` learns it: on an **idle, homed, writable** printer + (refused otherwise, nothing moves) the bed goes through a grid of Zs **in Z only** — the guarded move of + `bed check`, within the clearance floor (`--part-height` + 15 mm, never below Z 10) and the axis ceiling + (`resume.z_max_mm` - 5), `--z-from` / `--z-to` default to those limits and `--z-step` to 20 mm (at most 60 + Zs; a grid outside the limits is refused), with the printer's light on — takes one fresh frame per Z, and puts the + bed back in a `finally` (exceptions, Ctrl-C, SIGTERM; the displaced-Z marker is the same as the auto view's). + Then, without touching the printer, the plate is **followed** over the frames: from its outline in the + nearest empty-bed reference (`watch.bed.plate_mask`, the alias of `roi`, drawn on it; else the reference's own + bed surface; with neither, the largest dark low-saturation region of the frame — rough, the result says + «check it») frame to frame by the affine transform that carries the plate onto the next frame (ECC, + correlation based, so a light change does not move it; the polygon moves with it, so the part of the plate that + has left the frame is still counted). The visible share of the polygon per Z, the plate polygon where most of it + shows and the grid are stored in `~/.local/share/3d-cli/bed/NAME/calibration.json`. A plate that cannot be + followed (too little of it in the frame, no texture, correlation under 0.5) ends the chain: those Zs are listed + as `unfollowed` and **not stored** (no view is invented). `bed_in_view` / the auto view / `out of view` then use + the learnt shares (linear between the Zs, unknown beyond them) for a printer that has **neither a camera + calibration nor a `watch.bed.roi`**; `watch.bed.views: {70: 1.0, 200: 0.15}` overrides them. Limits: the + model is affine (a big move's perspective is followed approximately), the plate must show texture, the step must + be small enough for the plate to move a few tens of px between two Zs, and the share is relative to the plate's + area at that Z. Exit 1 when the bed could not be put back. - **Out of view.** The calibration also says how much of the bed is in the frame: the U1 camera sits about 12 mm above the bed's Z 0, so the lower the bed goes the less of it shows (Z 10: 98 %, Z 200: 15 %, Z 215: under 10 %, Z 275 — where a home leaves it — none). Under 10 % the state is @@ -839,6 +862,9 @@ u1: plate: textured # plate type in use (default `default`): references are kept per plate type reference_max_age_days: 30 # an older reference never makes the verdict `occupied` auto_reference: false # refresh the reference after a confident clear check (default false) + views: {70: 1.0, 200: 0.15} # a camera without a calibration: share of the plate in the frame per Z (overrides `bed calibrate`) + camera: http://k1se:8080/?action=snapshot # the bed camera's snapshot URL (default watch.vision.camera, else the model's) + plate_mask: [[0.31, 0.48], [0.72, 0.27], [0.92, 0.55], [0.92, 0.97], [0.41, 0.97]] # the plate polygon (alias of roi) k1se: watch: bed: diff --git a/lib/bed_calibrate.py b/lib/bed_calibrate.py new file mode 100644 index 00000000..ea0f6f7b --- /dev/null +++ b/lib/bed_calibrate.py @@ -0,0 +1,633 @@ +"""bed_calibrate.py — `3d printer bed calibrate NAME`: how much of the plate the camera sees at each Z (#723). + +A printer with a camera calibration (the U1's ships with 3d-cli) knows where the bed is in the frame at every +Z: `bed_state.bed_in_view`. One without (the K1 SE) judged every Z as "the plate is in the frame by definition", +so the check never moved the bed to a better view and never knew that a low view was a poor one. This learns it. + +WHAT: on an IDLE printer the bed is moved in Z ONLY (bed_view.excursion: the guarded move of `bed check`, the + clearance floor and ceiling of `bed_view.move_limits`, the per-printer lock, the displaced-Z marker, SIGTERM and + exceptions unwinding, the bed restored in a `finally`) through a grid of Zs, with the printer's light on; one + fresh frame per Z. Then, without touching the printer, the plate is FOLLOWED over the frames: + seed the plate polygon in a frame of known Z: `watch.bed.plate_mask` (= `roi`) drawn on the nearest + empty-bed reference, else that reference's own bed surface; with neither, the largest dark, + low-saturation region of the grid frame that has most of it (rough: the result says so). + follow from the seed outwards in both directions, frame to frame: the affine transform (ECC, coarse to + fine, correlation based so a light change does not move it) that carries the plate of the last + followed frame onto the next one, fitted on the plate pixels in view. The polygon moves with it, + so the part of the plate that has left the frame is still counted. + share visible area of the polygon in the frame / its whole area, per Z. + A plate that cannot be followed (too little of it in view, no texture, a correlation under ECC_MIN_CORR) ends + the chain in that direction: those Zs are listed as `unfollowed` and are not stored, so no view is invented. + +STORED (~/.local/share/3d-cli/bed/NAME/calibration.json, printer_ops.bed.Calibration): `views` [[Z, share]], + `plate_mask` (the polygon where most of the plate shows, 0..1 of the frame), `method`, the grid, the seed. + `bed_state.z_match` reads it when the printer has no camera calibration and no `watch.bed.roi`; + `watch.bed.views` overrides it. + +LIMITS: the model is affine (the perspective of a big move is followed approximately); the plate must show + texture; the share is relative to the plate's area at that Z; the grid step has to be small enough for the + plate to move a few tens of px between two Zs. `--json` has every number. +""" + +from __future__ import annotations + +import argparse +import json +import math +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import bed_state as B +import bed_view +from errors import UsageError +from printer_ops.bed import AUTO_VIEW_SETTLE_S, BedConfig, write_calibration +from printer_ops.bed_light import LightSession, write_block + +if TYPE_CHECKING: + import numpy as np + + from printer_ops.devices import Device + +DEFAULT_STEP_MM = 20.0 +MAX_POINTS = ( + 60 # frames of one calibration: each is a move, a settle wait and a camera frame +) +MIN_VISIBLE = ( + 0.05 # a plate with less of it in view than this cannot be followed any further +) +ECC_MIN_CORR = ( + 0.5 # correlation of the followed plate under which the step is not trusted +) +GROW_PX = 15 # the fine fit looks this far beyond where the plate is expected: a step moves it a few px +HEURISTIC_MIN_SHARE = ( + 0.05 # the largest dark region must cover this share of the frame to be a plate +) + + +@dataclass +class PlateAt: + z: float + share: float + polygon: list[tuple[float, float]] # px of the frame the plate is followed in + corr: float | None = None + + +@dataclass +class Tracked: + method: str # "plate_mask" | "reference" | "heuristic" + seed_z: float + seed_source: str + at: list[PlateAt] = field(default_factory=list) + unfollowed: list[dict[str, Any]] = field(default_factory=list) + + +# ---- geometry (pure python: a polygon is a list of (x, y)) --------------------------------------- + + +def area(poly: list[tuple[float, float]]) -> float: + """Shoelace area of a simple polygon.""" + n = len(poly) + return ( + abs( + sum( + poly[i][0] * poly[(i + 1) % n][1] - poly[(i + 1) % n][0] * poly[i][1] + for i in range(n) + ) + ) + / 2 + ) + + +def _clip_edge( + poly: list[tuple[float, float]], + inside: Callable[[tuple[float, float]], bool], + cut: Callable[[tuple[float, float], tuple[float, float]], tuple[float, float]], +) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for i, cur in enumerate(poly): + prev = poly[i - 1] + if inside(cur): + if not inside(prev): + out.append(cut(prev, cur)) + out.append(cur) + elif inside(prev): + out.append(cut(prev, cur)) + return out + + +def clip_to_frame( + poly: list[tuple[float, float]], shape: tuple[int, int] +) -> list[tuple[float, float]]: + """`poly` cut to the frame (Sutherland-Hodgman against the four edges: x 0..w-1, y 0..h-1).""" + h, w = shape + x1, y1 = float(w - 1), float(h - 1) + + def at_x( + x: float, + ) -> Callable[[tuple[float, float], tuple[float, float]], tuple[float, float]]: + return lambda a, b: (x, a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])) + + def at_y( + y: float, + ) -> Callable[[tuple[float, float], tuple[float, float]], tuple[float, float]]: + return lambda a, b: (a[0] + (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]), y) + + for inside, cut in ( + (lambda p: p[0] >= 0, at_x(0.0)), + (lambda p: p[0] <= x1, at_x(x1)), + (lambda p: p[1] >= 0, at_y(0.0)), + (lambda p: p[1] <= y1, at_y(y1)), + ): + if not poly: + break + poly = _clip_edge(poly, inside, cut) + return poly + + +def visible_share(poly: list[tuple[float, float]], shape: tuple[int, int]) -> float: + """The visible area of `poly` in a frame of `shape` over its whole area.""" + whole = area(poly) + if whole < 1e-6: + return 0.0 + clipped = clip_to_frame(poly, shape) + return min(1.0, area(clipped) / whole) if len(clipped) >= 3 else 0.0 + + +def to_px( + poly_norm: tuple[tuple[float, float], ...], shape: tuple[int, int] +) -> list[tuple[float, float]]: + h, w = shape + return [(x * (w - 1), y * (h - 1)) for x, y in poly_norm] + + +def to_norm( + poly: list[tuple[float, float]], shape: tuple[int, int] +) -> list[list[float]]: + h, w = shape + return [[round(x / (w - 1), 4), round(y / (h - 1), 4)] for x, y in poly] + + +def transform( + poly: list[tuple[float, float]], warp: np.ndarray +) -> list[tuple[float, float]]: + """`poly` carried by the 2x3 affine `warp`.""" + return [ + ( + float(warp[0, 0] * x + warp[0, 1] * y + warp[0, 2]), + float(warp[1, 0] * x + warp[1, 1] * y + warp[1, 2]), + ) + for x, y in poly + ] + + +# ---- following the plate ----------------------------------------------------------------------- + + +def _mask( + poly: list[tuple[float, float]], shape: tuple[int, int], grow_px: int = GROW_PX +) -> np.ndarray: + """Where the plate is expected in the frame, grown by `grow_px`: the pixels the fine fit looks at.""" + import cv2 + import numpy as np + + m = np.zeros(shape, np.uint8) + cv2.fillPoly(m, [np.round(np.array(poly, np.float32)).astype(np.int32)], 1) + return np.asarray(cv2.dilate(m, np.ones((2 * grow_px + 1,) * 2, np.uint8))) + + +def _ecc( + prev: np.ndarray, + cur: np.ndarray, + mask: np.ndarray | None, + warp: np.ndarray, + scale: int, +) -> tuple[np.ndarray, float]: + """One ECC fit at 1/`scale` size, from `warp` (full-size coordinates); the result is in full-size ones. + `mask` (valid pixels of `cur`) is optional: a tight mask makes the coarse fits fail to converge.""" + import cv2 + import numpy as np + + def small(a: np.ndarray, interp: int) -> np.ndarray: + if scale == 1: + return a + return np.asarray( + cv2.resize( + a, (a.shape[1] // scale, a.shape[0] // scale), interpolation=interp + ) + ) + + start = warp.copy() + start[:, 2] /= scale + stop = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 100, 1e-6) + corr, fitted = cv2.findTransformECC( + small(prev, cv2.INTER_AREA), + small(cur, cv2.INTER_AREA), + start, + cv2.MOTION_AFFINE, + stop, + small(np.ones(prev.shape, np.uint8) if mask is None else mask, cv2.INTER_NEAREST), + 5, + ) + fitted[:, 2] *= scale + return fitted, float(corr) + + +def follow( + prev: np.ndarray, cur: np.ndarray, poly: list[tuple[float, float]] +) -> tuple[np.ndarray, float] | str: + """The affine transform that carries the plate of `prev` (outlined by `poly`) onto `cur`, with its + correlation; or why the plate cannot be followed. Coarse to fine: the frames at 1/4 and 1/2 size find the + move of the picture (the plate is most of it), then the full-size fit looks only where that move puts the plate.""" + import cv2 + import numpy as np + + g1, g2 = ( + np.asarray(cv2.cvtColor(i, cv2.COLOR_BGR2GRAY), np.float32) for i in (prev, cur) + ) + warp = np.eye(2, 3, dtype=np.float32) + try: + for scale in (4, 2): + warp, _ = _ecc(g1, g2, None, warp, scale) + mask = _mask(transform(poly, warp), g1.shape) + if visible_share(transform(poly, warp), g1.shape) < MIN_VISIBLE: + return "too little of the plate is in the frame to follow it" + warp, corr = _ecc(g1, g2, mask, warp, 1) + except cv2.error: + return "the plate's picture does not converge onto the next frame's (no texture, or it moved too far)" + if corr < ECC_MIN_CORR: + return f"the plate matches the next frame only to {corr:.2f} (under {ECC_MIN_CORR})" + return warp, corr + + +def _unfollowed( + frames: list[tuple[float, np.ndarray]], why: str +) -> list[dict[str, Any]]: + return [{"z": z, "why": why} for z, _ in frames] + + +def _chain( + frames: list[tuple[float, np.ndarray]], + seed_img: np.ndarray, + seed_poly: list[tuple[float, float]], + out: Tracked, +) -> None: + """Follow the plate from the seed through `frames` (nearest Z first); record each Z, stop at the first the + plate cannot be followed to (the Zs beyond it are listed, not stored).""" + prev, poly = seed_img, seed_poly + for i, (z, img) in enumerate(frames): + step = follow(prev, img, poly) + if isinstance(step, str): + out.unfollowed.extend(_unfollowed(frames[i:], f"at Z {z:g}: {step}")) + return + warp, corr = step + poly = transform(poly, warp) + share = visible_share(poly, img.shape[:2]) + out.at.append(PlateAt(z, share, poly, corr)) + if share < MIN_VISIBLE: + out.unfollowed.extend( + _unfollowed(frames[i + 1 :], f"the plate has left the frame at Z {z:g}") + ) + return + prev = img + + +def track( + seed_img: np.ndarray, + seed_poly: list[tuple[float, float]], + seed_z: float, + frames: dict[float, np.ndarray], + method: str, + source: str, +) -> Tracked: + """Follow the plate of the seed frame over the grid `frames` (Z -> image), outwards in both directions.""" + out = Tracked(method, seed_z, source) + out.at.append( + PlateAt(seed_z, visible_share(seed_poly, seed_img.shape[:2]), seed_poly) + ) + below = sorted(((z, i) for z, i in frames.items() if z < seed_z), reverse=True) + above = sorted((z, i) for z, i in frames.items() if z >= seed_z) + for side in (above, below): + _chain(side, seed_img, seed_poly, out) + # one point per Z: a grid frame at the seed's own Z (followed from the seed) replaces the seed's point + out.at = sorted({p.z: p for p in out.at}.values(), key=lambda p: p.z) + return out + + +# ---- the seed ------------------------------------------------------------------------------------- + + +def heuristic_polygon(img: np.ndarray) -> list[tuple[float, float]] | None: + """The outline of the largest dark, low-saturation region of `img` (a black PEI plate against a room): rough, + and merged with a black frame it touches. None when no region is a twentieth of the frame.""" + import cv2 + import numpy as np + + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + dark = ((hsv[..., 1] < 80) & (hsv[..., 2] < 120)).astype(np.uint8) + opened = cv2.morphologyEx(dark, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)) + n, lab, stats, _ = cv2.connectedComponentsWithStats(opened) + if n < 2: + return None + best = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + if ( + stats[best, cv2.CC_STAT_AREA] + < HEURISTIC_MIN_SHARE * img.shape[0] * img.shape[1] + ): + return None + hull = cv2.convexHull(np.argwhere(lab == best)[:, ::-1].astype(np.int32)) + approx = cv2.approxPolyDP(hull, 0.02 * cv2.arcLength(hull, True), True) + return [(float(x), float(y)) for x, y in approx[:, 0, :]] + + +@dataclass +class Seed: + img: np.ndarray + z: float + polygon: list[tuple[float, float]] + method: str + source: str + + +def reference_seed(cfg: BedConfig, refs: list[B.Reference]) -> Seed | None: + """The plate in an empty-bed reference: `watch.bed.plate_mask`/`roi` drawn on it, else its bed surface.""" + import cv2 + import numpy as np + + plates = [r for r in refs if r.kind == "plate" and r.z is not None] + if not plates: + return None + ref = max(plates, key=lambda r: float(r.surface.mean())) + assert ref.z is not None + if cfg.roi is not None: + return Seed( + ref.image, + ref.z, + to_px(cfg.roi, ref.image.shape[:2]), + "plate_mask", + f"watch.bed.plate_mask on the reference at Z {ref.z:g}", + ) + contours, _ = cv2.findContours( + ref.surface.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + if not contours: + return None + big = max(contours, key=cv2.contourArea) + approx = cv2.approxPolyDP(big, 0.01 * cv2.arcLength(big, True), True) + return Seed( + ref.image, + ref.z, + [(float(x), float(y)) for x, y in approx[:, 0, :]], + "reference", + f"the bed surface of the reference at Z {ref.z:g}", + ) + + +def heuristic_seed(frames: dict[float, np.ndarray]) -> Seed | None: + """The grid frame with the largest dark region, outlined; the rough fallback without a reference.""" + best: Seed | None = None + for z, img in frames.items(): + poly = heuristic_polygon(img) + if poly is not None and (best is None or area(poly) > area(best.polygon)): + best = Seed( + img, + z, + poly, + "heuristic", + f"the largest dark low-saturation region of the frame at Z {z:g}", + ) + return best + + +def analyse(frames: dict[float, np.ndarray], seed: Seed | None) -> Tracked: + """The plate's visible share per Z over `frames` from `seed` (None: the heuristic seed). Pure: no printer.""" + seed = seed or heuristic_seed(frames) + if seed is None: + raise UsageError( + "no plate to start from: no empty-bed reference, no watch.bed.plate_mask, and no frame " + "shows a dark region of at least a twentieth of it", + command="printer bed", + remediation=[ + "Take a reference first: `3d printer bed reference NAME --capture`, or draw the " + "plate in watch.bed.plate_mask: [[x, y], ...] (0..1 of the frame)." + ], + ) + return track(seed.img, seed.polygon, seed.z, frames, seed.method, seed.source) + + +# ---- the grid ---------------------------------------------------------------------------------------- + + +def plan_grid( + limits: bed_view.Limits, + z_from: float | None, + z_to: float | None, + z_step: float | None, +) -> list[float]: + """The Zs to look from, ascending, within the clearance floor and the axis ceiling; UsageError otherwise.""" + lo = limits.z_floor if z_from is None else z_from + hi = limits.z_ceiling if z_to is None else z_to + step = DEFAULT_STEP_MM if z_step is None else z_step + if not all(math.isfinite(v) for v in (lo, hi, step)) or step <= 0: + raise UsageError( + "the Z grid needs finite --z-from/--z-to and a positive --z-step", + command="printer bed", + ) + if lo < limits.z_floor or hi > limits.z_ceiling: + raise UsageError( + f"Z {lo:g}..{hi:g} is outside what may be moved to: {limits.z_floor:g}..{limits.z_ceiling:g} " + f"(the tallest thing on the bed is {limits.part_mm:g} mm, {limits.part_source}; the ceiling " + "is the axis limit)", + command="printer bed", + remediation=[ + "Lower the floor with a smaller --part-height only if nothing stands on the bed." + ], + ) + if hi < lo: + raise UsageError( + f"--z-to {hi:g} is below --z-from {lo:g}", command="printer bed" + ) + n = int((hi - lo) / step + 1e-9) + 1 + if n > MAX_POINTS: + raise UsageError( + f"{n} Zs is too many (at most {MAX_POINTS}): raise --z-step", + command="printer bed", + ) + return [round(lo + i * step, 3) for i in range(n)] + + +# ---- the flow ------------------------------------------------------------------------------------------ + + +def _collect( + device: Device, + adapter: Any, + camera: Any, + m: bed_view.Motion, + limits: bed_view.Limits, + grid: list[float], + light: LightSession, + sleep: Callable[[float], None], + settle_s: float, +) -> tuple[dict[float, np.ndarray], dict[str, Any]]: + """Move through `grid`, one frame per Z; the bed is put back (and the marker cleared) however this ends.""" + frames: dict[float, np.ndarray] = {} + notes: dict[str, Any] = {"moved": [], "refused": None} + with bed_view.excursion(device, adapter, m, limits) as trip: + light.on() # before the first move: its settle overlaps the Z travel + for z in grid: + try: + trip.go(z) + except bed_view.MoveRefused as exc: + notes["refused"] = f"stopped at Z {z:g}: {exc}" + break + notes["moved"].append(z) + sleep(settle_s) + jpeg, _ = B._fresh_frame(device, camera, sleep) + frames[z] = B._decode_frame(device, jpeg) + notes["restore_error"] = trip.restore_error + return frames, notes + + +def _document( + device: Device, + tracked: Tracked, + grid: list[float], + shape: tuple[int, int], + now: float, +) -> dict[str, Any]: + best = max(tracked.at, key=lambda p: p.share) + return { + "printer": device.name, + "ts": now, + "method": tracked.method, + "seed": {"z": tracked.seed_z, "source": tracked.seed_source}, + "grid": grid, + "views": [[p.z, round(p.share, 3)] for p in tracked.at], + "plate_mask": to_norm(best.polygon, shape), + "plate_mask_z": best.z, + "unfollowed": tracked.unfollowed, + "corr": { + f"{p.z:g}": None if p.corr is None else round(p.corr, 3) for p in tracked.at + }, + } + + +def calibrate( + device: Device, + cfg: BedConfig, + *, + z_from: float | None = None, + z_to: float | None = None, + z_step: float | None = None, + part_height: float | None = None, + adapter: Any = None, + camera: Any = None, + now: float | None = None, + sleep: Callable[[float], None] = time.sleep, + settle_s: float | None = None, +) -> dict[str, Any]: + """Look at the plate from every Z of the grid and store how much of it shows at each. An idle, homed + printer only; the bed ends where it was found. The result is the stored document plus `light`, `view` + (what the bed did) and `path`.""" + from printer_ops import make_adapter + + adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) + camera = camera if camera is not None else B.camera_for(device, cfg) + m = bed_view.read_motion(adapter) + limits = bed_view._may_move(device, adapter, cfg, m, part_height) + if not isinstance(limits, bed_view.Limits): + raise UsageError( + f"{device.name}: not calibrated, nothing was moved: {limits}", + command="printer bed", + remediation=[ + "`bed calibrate` needs an idle (idle / complete / cancelled), homed, writable " + "printer whose part height is known (--part-height MM)." + ], + ) + grid = plan_grid(limits, z_from, z_to, z_step) + light = LightSession( + adapter, + cfg.light, + note="the calibration frames", + blocked=write_block(device), + sleep=sleep, + ) + with light: + frames, notes = _collect( + device, + adapter, + camera, + m, + limits, + grid, + light, + sleep, + AUTO_VIEW_SETTLE_S if settle_s is None else settle_s, + ) + if len(frames) < 2: + raise UsageError( + f"{device.name}: only {len(frames)} frame(s) taken ({notes['refused'] or 'no grid'}): " + "nothing was stored", + command="printer bed", + ) + tracked = analyse(frames, reference_seed(cfg, B.RefStore(device.name).load())) + if len(tracked.at) < 2: + raise UsageError( + f"{device.name}: the plate could not be followed over the frames " + f"({'; '.join(str(u['why']) for u in tracked.unfollowed[:2])}): nothing was stored", + command="printer bed", + remediation=[ + "Smaller --z-step, or a plate_mask / a reference to start from." + ], + ) + now = time.time() if now is None else now + doc = _document(device, tracked, grid, next(iter(frames.values())).shape[:2], now) + path = write_calibration(device.name, doc) + return { + **doc, + "path": str(path), + "light": light.report.to_dict(), + "view": {**notes, "z_before": m.z, "restored": notes["restore_error"] is None}, + } + + +def _text(device: Device, res: dict[str, Any]) -> str: + rows = "\n".join( + f" Z {z:6.1f} {share:>4.0%} of the plate in the frame" + for z, share in res["views"] + ) + lost_zs = ", ".join(f"{u['z']:g}" for u in res["unfollowed"]) + lost = f"\n not followed (not stored): {lost_zs}" if lost_zs else "" + rough = ( + "\n method heuristic: the plate was outlined from the largest dark region — check it" + if res["method"] == "heuristic" + else "" + ) + view = res["view"] + back = ( + "bed put back" + if view["restored"] + else f"BED NOT PUT BACK: {view['restore_error']}" + ) + stopped = f"\n {view['refused']}" if view.get("refused") else "" + light = (res.get("light") or {}).get("text") + return ( + f"{device.name}: calibrated from {res['seed']['source']} ({res['method']}); {back}\n{rows}{lost}{rough}" + f"{stopped}\n {res['path']}" + (f"\n light: {light}" if light else "") + ) + + +def cmd_calibrate(args: argparse.Namespace, device: Device, cfg: BedConfig) -> int: + """`bed calibrate NAME`: exit 0 when the views were stored (and the bed is back), 1 when the bed is not back.""" + res = calibrate( + device, + cfg, + z_from=args.z_from, + z_to=args.z_to, + z_step=args.z_step, + part_height=args.part_height, + ) + print(json.dumps(res, indent=1, default=str) if args.json else _text(device, res)) + return 0 if res["view"]["restored"] else 1 diff --git a/lib/bed_state.py b/lib/bed_state.py index 0387673c..2b661a0a 100644 --- a/lib/bed_state.py +++ b/lib/bed_state.py @@ -80,6 +80,7 @@ bed_config, clear_ack, evidence_dir, + read_calibration, read_state, refs_dir, state_path, @@ -383,6 +384,7 @@ class ZMatch: cam: CameraModel | None = None height_mm: float = 80.0 max_px: float = TH.match_px + views: tuple[tuple[float, float], ...] = () # (toolhead Z, share of the plate in the frame): learnt or declared def __call__(self, ref_z: float | None, z: float | None) -> bool: if ref_z is None or z is None: @@ -392,8 +394,21 @@ def __call__(self, ref_z: float | None, z: float | None) -> bool: return view_shift_px(self.cam, round(ref_z, 2), round(z, 2), self.height_mm) <= self.max_px def in_view(self, z: float | None) -> float | None: - """Share of the bed in the frame at `z`; None without a calibration or a Z.""" - return None if self.cam is None or z is None else bed_in_view(self.cam, round(z, 1)) + """Share of the bed in the frame at `z`: from `views` (`3d printer bed calibrate`, `watch.bed.views`; + None beyond the Zs they cover) else from the camera calibration; None without either or a Z.""" + if z is None: + return None + if self.views: + return _interp_view(self.views, z) + return None if self.cam is None else bed_in_view(self.cam, round(z, 1)) + + def picker_view(self, z: float) -> float | None: + """`bed_view`'s view(z): the share in the frame. Without a calibration or views the plate is in the + frame by definition (a roi polygon): 1.0. With views, a Z beyond them is not known to show it: 0.0.""" + seen = self.in_view(z) + if seen is not None: + return seen + return 0.0 if self.views else (1.0 if self.cam is None else None) def nearest_view(self, z: float, view_min: float) -> float | None: """The highest Z (5 mm steps) at or below `min(z, MAX_Z_MM)` where at least `view_min` of the bed is in view.""" @@ -402,8 +417,31 @@ def nearest_view(self, z: float, view_min: float) -> float | None: None) +def _interp_view(views: tuple[tuple[float, float], ...], z: float) -> float | None: + """The share of the plate in the frame at `z`, interpolated linearly between the Zs of `views`; None beyond + them (the camera is never guessed to see the plate where nobody looked).""" + if not views[0][0] <= z <= views[-1][0]: + return None + for (z0, f0), (z1, f1) in zip(views, views[1:], strict=False): + if z0 <= z <= z1: + return f0 if z1 == z0 else f0 + (f1 - f0) * (z - z0) / (z1 - z0) + return views[-1][1] + + +def _views_for(device: Device, cfg: BedConfig, cam: CameraModel | None) -> tuple[tuple[float, float], ...]: + """The Z views in force: `watch.bed.views` when the owner declared them, else what `bed calibrate` learnt + for a printer that has neither a camera calibration nor a `watch.bed.roi`.""" + if cfg.views: + return cfg.views + if cam is not None or cfg.roi is not None: + return () + learnt = read_calibration(device.name) + return () if learnt is None else learnt.views + + def z_match(device: Device, cfg: BedConfig) -> ZMatch: - return ZMatch(cfg.z_tol_mm, camera_model(device, cfg), cfg.volume_height_mm, thresholds_for(cfg).match_px) + cam = camera_model(device, cfg) + return ZMatch(cfg.z_tol_mm, cam, cfg.volume_height_mm, thresholds_for(cfg).match_px, _views_for(device, cfg, cam)) def out_of_view(match: ZMatch, z: float | None, th: Thresholds = TH) -> str | None: @@ -418,6 +456,11 @@ def out_of_view(match: ZMatch, z: float | None, th: Thresholds = TH) -> str | No f"frame, under {th.view_min:.0%}){where}") +def reference_files(d: Path) -> list[Path]: + """The reference meta files of a printer's data dir (calibration.json lies beside them: not a reference).""" + return sorted(p for p in d.glob("*.json") if p.name != "calibration.json") + + class RefStore: """The references of one printer, re-read when the directory changes.""" @@ -428,14 +471,14 @@ def __init__(self, printer: str) -> None: def signature(self) -> tuple[tuple[str, int], ...]: try: - return tuple(sorted((p.name, p.stat().st_mtime_ns) for p in self.dir.glob("*.json"))) + return tuple(sorted((p.name, p.stat().st_mtime_ns) for p in reference_files(self.dir))) except OSError: return () def load(self) -> list[Reference]: sig = self.signature() if sig != self._sig: - loaded = [_load_reference(p) for p in sorted(self.dir.glob("*.json"))] + loaded = [_load_reference(p) for p in reference_files(self.dir)] self._sig, self._refs = sig, [r for r in loaded if r is not None] return self._refs @@ -765,15 +808,18 @@ def read_printer(device: Device) -> tuple[str, float | None]: return _STATE.get(raw, STATE_UNKNOWN), z -def camera_for(device: Device) -> Any: - """The same snapshot source as `3d printer vision` (printer_ops.camera: `watch.vision.camera`, - else the printer model's default camera); a printer without one is refused.""" +def camera_for(device: Device, cfg: BedConfig | None = None) -> Any: + """The snapshot source of the bed check: `watch.bed.camera` (a URL) when set, else the same one as + `3d printer vision` (printer_ops.camera: `watch.vision.camera`, else the printer model's default camera); + a printer without one is refused.""" from errors import UsageError - from printer_ops.camera import open_camera, require_camera + from printer_ops.camera import SNAPSHOT_URL, CameraSpec, open_camera, require_camera if device.adapter != "moonraker": raise UsageError(f"{device.name}: no camera or Z access for adapter {device.adapter}", command="printer bed", remediation=["The bed check needs a Moonraker printer (adapter: moonraker)."]) + if cfg is not None and cfg.camera is not None: + return open_camera(device, CameraSpec(SNAPSHOT_URL, cfg.camera, False, (), f"{device.name}.watch.bed.camera")) return open_camera(device, require_camera(device, "printer bed")) @@ -1030,7 +1076,7 @@ def _run_monitor(device: Device, cfg: BedConfig, *, duration: float | None = Non busy_interval: float = 10.0) -> Monitor: name = device.name th = thresholds_for(cfg) if th is None else th - camera = camera if camera is not None else camera_for(device) + camera = camera if camera is not None else camera_for(device, cfg) store = RefStore(name) prev = read_state(name) or {} known_bed, known_plate = prev.get("known_bed", prev.get("bed")), prev.get("known_plate", prev.get("plate")) @@ -1087,7 +1133,7 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A return {**last, "source": "watch", "age_s": round(now - float(last["checked"]), 1)} if before_frame is not None: before_frame() - jpeg, frozen = _fresh_frame(device, camera, sleep) + jpeg, frozen = _fresh_frame(device, camera, sleep, cfg) img = _decode_frame(device, jpeg) ctx = bed_refs.JudgeContext(bed_refs.LIGHT_UNKNOWN if light_state is None else light_state(), cfg.plate, cfg.reference_max_age_days, now) @@ -1105,10 +1151,11 @@ def one_shot(device: Device, cfg: BedConfig, *, max_age: float | None, camera: A **v.info} -def _fresh_frame(device: Device, camera: Any, sleep: Callable[[float], None]) -> tuple[bytes, bool]: +def _fresh_frame(device: Device, camera: Any, sleep: Callable[[float], None], + cfg: BedConfig | None = None) -> tuple[bytes, bool]: """(a fresh camera frame, whether the camera looks frozen): a live camera never repeats a frame byte for byte, so the same frame over FROZEN_CHECK_S seconds is a frozen one (the newer frame is the one judged).""" - camera = camera if camera is not None else camera_for(device) + camera = camera if camera is not None else camera_for(device, cfg) first = jpeg = grab(camera) waited = 0.0 while jpeg == first and waited < FROZEN_CHECK_S: @@ -1139,7 +1186,7 @@ def status_view(device: Device, cfg: BedConfig, *, mode: str = "auto", max_age: from printer_ops import make_adapter adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) - camera = camera if camera is not None else camera_for(device) + camera = camera if camera is not None else camera_for(device, cfg) match = z_match(device, cfg) refs = RefStore(device.name).load() @@ -1154,7 +1201,7 @@ def judge(age: float | None) -> dict[str, Any]: before_frame=light.on, light_state=lambda: bed_refs.light_state(light.report.to_dict())) def view(z: float) -> float | None: - return 1.0 if match.cam is None else match.in_view(z) # a roi polygon is in the frame by definition + return match.picker_view(z) with light: # the light is back as it was found after the bed is back (restore order: Z, then light) if max_age is None: # a fresh frame is certain: the settle overlaps the Z move @@ -1219,7 +1266,7 @@ def capture_reference_best_view(device: Device, cfg: BedConfig, *, plate: bool, from printer_ops import make_adapter adapter = adapter if adapter is not None else make_adapter(device, timeout=10.0) - camera = camera if camera is not None else camera_for(device) + camera = camera if camera is not None else camera_for(device, cfg) match = z_match(device, cfg) light = LightSession(adapter, cfg.light, note="the reference frame", blocked=write_block(device), sleep=sleep) @@ -1233,7 +1280,7 @@ def take(_: float | None) -> dict[str, Any]: light_state=lambda: bed_refs.light_state(light.report.to_dict())) def view(z: float) -> float | None: - return 1.0 if match.cam is None else match.in_view(z) + return match.picker_view(z) with light: light.on() # before the move: the settle overlaps the Z travel @@ -1246,7 +1293,8 @@ def view(z: float) -> float | None: def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], tuple[str, float | None]], frame: bytes | None, frame_z: float | None, - before_frame: Callable[[], object] | None = None) -> tuple[float, bytes]: + before_frame: Callable[[], object] | None = None, + cfg: BedConfig | None = None) -> tuple[float, bytes]: from errors import UsageError if frame is not None: @@ -1264,7 +1312,7 @@ def _reference_frame(device: Device, camera: Any, printer_fn: Callable[[Device], remediation=["Retry when `3d printer status` reads the printer."]) if before_frame is not None: before_frame() - return z, grab(camera if camera is not None else camera_for(device)) + return z, grab(camera if camera is not None else camera_for(device, cfg)) def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: Any = None, @@ -1281,7 +1329,7 @@ def capture_reference(device: Device, cfg: BedConfig, *, plate: bool, camera: An from errors import UsageError now = time.time() if now is None else now - z, jpeg = _reference_frame(device, camera, printer_fn, frame, frame_z, before_frame) + z, jpeg = _reference_frame(device, camera, printer_fn, frame, frame_z, before_frame, cfg) match = z_match(device, cfg) hidden = out_of_view(match, z, thresholds_for(cfg)) if hidden is not None: # a reference there would read every low part as a clear bed @@ -1344,7 +1392,7 @@ def list_references(printer: str, cfg: BedConfig | None = None, now: float | Non now = time.time() if now is None else now max_days = None if cfg is None else cfg.reference_max_age_days out = [] - for p in sorted(refs_dir(printer).glob("*.json")): + for p in reference_files(refs_dir(printer)): try: meta = json.loads(p.read_text(encoding="utf-8")) if not isinstance(meta, dict) or meta.get("kind") not in ("plate", "noplate") \ @@ -1366,6 +1414,7 @@ def list_references(printer: str, cfg: BedConfig | None = None, now: float | Non 3d printer bed status NAME [--json] [--max-age S] [--best-view | --no-move] [--part-height MM] 3d printer bed reference NAME [--capture [--best-view] [--no-plate] [--from FRAME.jpg --z Z]] [--json] 3d printer bed reference NAME --refresh [--assume-empty "WHY"] [--part-height MM] [--json] +3d printer bed calibrate NAME [--z-from A --z-to B --z-step S] [--part-height MM] [--json] 3d printer bed monitor NAME [--duration S] Bed state from the printer's camera (issue #303): is the bed clear, is the build plate on. Needs one reference frame of the EMPTY bed WITH the plate per printer and resting Z, taken @@ -1402,6 +1451,16 @@ def list_references(printer: str, cfg: BedConfig | None = None, now: float | Non with the plate on in a good view with the light on (else it refuses, with the reason, and nothing is stored); --assume-empty "WHY" is the owner's word that the bed is empty: it replaces that check (WHY is kept in the reference and in the journal). + calibrate learn how much of the plate the camera sees at each Z, for a printer with neither a + camera calibration nor a `watch.bed.roi`: an IDLE printer's bed is moved in Z only (the + guarded move of `check`, within its floor and ceiling: --part-height MM, default the last + job's object_height; --z-from/--z-to default to those limits, --z-step to 20 mm), a frame + is taken at every Z of the grid with the light on, the plate is FOLLOWED from the empty-bed + reference (or `watch.bed.plate_mask`) frame to frame, its visible share per Z and its + polygon are stored in ~/.local/share/3d-cli/bed/NAME/calibration.json, and the bed is put + back. `check` / `status` / the auto view then know where the plate shows (`watch.bed.views` + overrides). Without a reference and a plate_mask the largest dark low-saturation region + of each frame is used: rough, check the result. Refused while the printer is not idle. monitor the loop `3d printer watch` runs per printer with `watch.bed.enabled: true`: JSON lines on stdout (events bed_clear, bed_occupied, plate_present, plate_missing; the warning bed_unknown when the first verdict after a job @@ -1410,7 +1469,12 @@ def list_references(printer: str, cfg: BedConfig | None = None, now: float | Non Config (fleet/printers.yaml or printer-devices.yaml, `.watch.bed`): enabled: true run the monitor in `3d printer watch` and the `bed` start gate roi: [[x, y], ...] bed polygon (0..1 of the frame) at the reference Z; default: the - camera calibration, else the whole frame + camera calibration, else the whole frame (`plate_mask` is the same key) + views: {70: 1.0, 200: 0.15} Z (mm) -> share of the plate in the frame there, for a camera without + a calibration; overrides what `bed calibrate` learnt (linear between the + Zs, unknown beyond them) + camera: http://HOST:8080/?action=snapshot the bed camera's snapshot URL (default + watch.vision.camera, else the printer model's camera) interval_s, stale_s, volume_height_mm z_tol_mm reference Z tolerance for a watch.bed.roi (uncalibrated) camera thresholds: {clear_max: 0.05, level: 30} per-printer overrides of the tuned pixel thresholds @@ -1498,6 +1562,8 @@ def list_references(printer: str, cfg: BedConfig | None = None, now: float | Non 3d printer bed reference u1 --capture 3d printer bed reference u1 --refresh # new reference after a confident clear check 3d printer bed reference u1 --refresh --assume-empty "plate wiped and empty, I looked" + 3d printer bed calibrate k1se --part-height 40 # learn the plate's view per Z + 3d printer bed calibrate k1se --z-from 40 --z-to 200 --z-step 10 --json 3d printer bed status u1 3d printer bed status u1 --no-move 3d printer bed status u1 --best-view --part-height 0 # the plate is empty: look at the whole of it @@ -1611,7 +1677,7 @@ def _allowed_flags(args: argparse.Namespace) -> set[str]: reference |= {"assume_empty", "part_height"} return {"check": {"json", "expect", "no_move", "part_height"}, "status": {"json", "max_age", "best_view", "no_move", "part_height"}, "monitor": {"duration"}, - "reference": reference}[args.sub] + "calibrate": {"json", "z_from", "z_to", "z_step", "part_height"}, "reference": reference}[args.sub] def _validate_refresh(args: argparse.Namespace) -> None: @@ -1660,7 +1726,7 @@ def main(argv: list[str]) -> int: from printer_ops.devices import get_device ap = argparse.ArgumentParser(prog="3d printer bed", add_help=False) - ap.add_argument("sub", choices=("check", "status", "reference", "monitor")) + ap.add_argument("sub", choices=("check", "status", "reference", "monitor", "calibrate")) ap.add_argument("name") ap.add_argument("--json", action="store_true") ap.add_argument("--max-age", type=float) @@ -1673,6 +1739,9 @@ def main(argv: list[str]) -> int: ap.add_argument("--no-move", action="store_true") ap.add_argument("--part-height", type=float) ap.add_argument("--expect", choices=("empty", "part")) + ap.add_argument("--z-from", dest="z_from", type=float) + ap.add_argument("--z-to", dest="z_to", type=float) + ap.add_argument("--z-step", dest="z_step", type=float) ap.add_argument("--refresh", action="store_true") ap.add_argument("--assume-empty", dest="assume_empty") ap.add_argument("-h", "--help", action="store_true") @@ -1684,11 +1753,15 @@ def main(argv: list[str]) -> int: device = get_device(args.name) cfg = bed_config(device) capturing = args.refresh or (args.capture and not args.from_file) - if args.sub in ("monitor", "status", "check") or (args.sub == "reference" and capturing): - camera_for(device) # a printer without a camera is refused here, before any printer request + if args.sub in ("monitor", "status", "check", "calibrate") or (args.sub == "reference" and capturing): + camera_for(device, cfg) # a printer without a camera is refused here, before any printer request if args.sub == "monitor": run_monitor(device, cfg, duration=args.duration) return 0 + if args.sub == "calibrate": + import bed_calibrate + + return bed_calibrate.cmd_calibrate(args, device, cfg) if args.sub == "reference": return _cmd_reference(args, device, cfg) if args.sub == "check": diff --git a/lib/bed_view.py b/lib/bed_view.py index 76fa831b..eb2c8220 100644 --- a/lib/bed_view.py +++ b/lib/bed_view.py @@ -662,6 +662,45 @@ def _moved_check(device: Any, adapter: Any, m: Motion, pick: ViewPick, limits: L return _annotate(verdict, {**base, **used}, _join(note, text)) -__all__ = ["Candidate", "Limits", "MoveRefused", "MoveVetoed", "Motion", "ViewPick", "auto_view", "guarded_z_move", - "move_limits", "no_write_access", "part_height", "pick_inspection_z", "read_motion", "recover_pending", "restore_z", - "view_in_progress"] +@dataclass +class Excursion: + """A bed that leaves where it was found for several Zs (`excursion`): `go(z)` moves it, `restore_error` is + None once it is back, else why not.""" + + device: Any + adapter: Any + start: Motion + limits: Limits + read: Callable[[Any], Motion] = read_motion + restore_error: str | None = None + + def go(self, z: float) -> None: + """Move the bed to toolhead Z `z` (G-code Z under the offset found at the start), guarded: MoveRefused + when the printer is no longer idle and homed, `z` is outside the limits, or the offset changed.""" + assert self.start.z is not None + guarded_z_move(self.adapter, to_gcode_z=z - self.start.offset, floor=self.limits.z_floor, + ceiling=self.limits.z_ceiling, target_toolhead_z=z, read=self.read) + + +@contextlib.contextmanager +def excursion(device: Any, adapter: Any, m: Motion, limits: Limits, *, read: Callable[[Any], Motion] = read_motion, + clock: Callable[[], float] = time.time) -> Iterator[Excursion]: + """The bed's trips to several Zs and back, the same safety net as `auto_view` has for one: the per-printer + lock (MoveRefused when another view check runs), the displaced-Z marker, SIGTERM unwinding like an + exception, and the restore to where the bed was found (`m`) in the `finally`, after any exception. The + result of the restore is `Excursion.restore_error` (None = back).""" + trip = Excursion(device, adapter, m, limits, read) + with view_lock(device.name) as held: + if not held: + raise MoveRefused("another view check of this printer is running") + with terminate_as_exit(): + _write_pending(device.name, m, clock()) + try: + yield trip + finally: + trip.restore_error = _restore_and_clear(device, adapter, m, read) + + +__all__ = ["Candidate", "Excursion", "Limits", "MoveRefused", "MoveVetoed", "Motion", "ViewPick", "auto_view", + "excursion", "guarded_z_move", "move_limits", "no_write_access", "part_height", "pick_inspection_z", + "read_motion", "recover_pending", "restore_z", "view_in_progress"] diff --git a/lib/commands/printer.py b/lib/commands/printer.py index e7bec171..c9a954a0 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -89,7 +89,7 @@ `vision fetch-model [--tier a|b|all]`, `vision fit-head ..`, `vision doctor` (read-only: does any service run --dry-run?) (see docs/commands/printer-vision.md) - bed check|status|reference|monitor NAME + bed check|status|reference|calibrate|monitor NAME bed state from the camera: clear/occupied, build plate present/missing; `check NAME` is THE look at the bed: it moves an idle bed in Z only to where the camera sees the plate, judges @@ -99,6 +99,8 @@ takes the empty-bed reference, `reference NAME --refresh [--assume-empty WHY]` replaces a stale one (confident clear check, or your word) + `calibrate NAME [--z-from A --z-to B --z-step S]` learns the + plate's visible share per Z (idle printer, bed moved in Z only) (see `3d printer bed --help`) subscribe --session ID [..] printer alerts go to that omp session (it looks at the frame, tells the user only if real); `unsubscribe`, diff --git a/lib/printer_ops/bed.py b/lib/printer_ops/bed.py index e4584610..67f600c4 100644 --- a/lib/printer_ops/bed.py +++ b/lib/printer_ops/bed.py @@ -76,9 +76,9 @@ AUTO_VIEW_SETTLE_S = 8.0 # after the move: the camera's own frame rewrite (~4 s) plus vibration BED_VIEW_EVENT = "bed_free" # the event of the one-shot `3d printer subscribe --once --on bed-free` -BED_KEYS = ("enabled", "interval_s", "roi", "volume_height_mm", "z_tol_mm", "stale_s", "clear_ack", "thresholds", - "auto_view", "auto_view_min", "auto_view_part_mm", "light", "plate", "reference_max_age_days", - "auto_reference") +BED_KEYS = ("enabled", "interval_s", "roi", "plate_mask", "volume_height_mm", "z_tol_mm", "stale_s", "clear_ack", + "thresholds", "auto_view", "auto_view_min", "auto_view_part_mm", "light", "plate", "reference_max_age_days", + "auto_reference", "views", "camera") # The per-printer overrides of bed_state.Thresholds (tuned on the U1 and K1 SE frames of tests/data/bed; # another camera may need other values): every field of that dataclass, a test keeps the two in step. @@ -119,6 +119,8 @@ class BedConfig: plate: str = "default" # the plate type in use: references are kept per plate type (bed_refs.py) reference_max_age_days: float = 30.0 # an older reference never makes a verdict `occupied` auto_reference: bool = False # refresh the reference after a confident clear check (bed_refresh.py) + views: tuple[tuple[float, float], ...] = () # (toolhead Z, share of the plate in the frame): the owner's override + camera: str | None = None # snapshot URL of the bed camera (else watch.vision.camera / the model's) def _num(value: Any, what: str, minimum: float) -> float: @@ -162,6 +164,51 @@ def _roi(value: Any, what: str) -> tuple[tuple[float, float], ...]: return tuple((float(x), float(y)) for x, y in value) +def _roi_config(raw: dict[str, Any], where: str) -> tuple[tuple[float, float], ...] | None: + """`roi`, or its alias `plate_mask`: the plate polygon (0..1 of the frame) at the reference Z.""" + if "roi" in raw and "plate_mask" in raw: + raise InvalidArgument(f"{where}.plate_mask", "given with roi", ["only one of roi and plate_mask: the same polygon"], + command="printer") + key = "roi" if "roi" in raw else "plate_mask" + return _roi(raw[key], f"{where}.{key}") if key in raw else None + + +def _views(value: Any, what: str) -> tuple[tuple[float, float], ...]: + """`views: {Z: share of the plate in the frame}`, sorted by Z.""" + if not isinstance(value, dict) or not value: + raise InvalidArgument(what, str(value), ["a mapping of toolhead Z (mm) to the share of the plate in the frame " + "there (0..1), e.g. {70: 1.0, 200: 0.15}"], command="printer") + out = [] + for key, share in value.items(): + z = _num(key if not isinstance(key, str) else _number_text(key, what), f"{what} Z", 0) + frac = _num(share, f"{what}[{key}]", 0) + if frac > 1: + raise InvalidArgument(f"{what}[{key}]", str(share), ["a share of the plate in the frame, 0..1"], + command="printer") + out.append((z, frac)) + return tuple(sorted(out)) + + +def _number_text(text: str, what: str) -> float: + try: + return float(text) + except ValueError: + raise InvalidArgument(what, text, ["a number of mm as the key"], command="printer") from None + + +def _camera_url(value: Any, what: str) -> str: + if not isinstance(value, str) or not value.strip() or any(ch in value for ch in " \n\r\t"): + raise InvalidArgument(what, str(value), ["a snapshot URL (http://HOST:PORT/?action=snapshot) or a path on the " + "printer's Moonraker"], command="printer") + return value.strip() + + +def _view_config(raw: dict[str, Any], where: str) -> dict[str, Any]: + """The keys of `watch.bed` about what the camera sees: the Z views and the camera URL.""" + return {"views": _views(raw["views"], f"{where}.views") if "views" in raw else (), + "camera": _camera_url(raw["camera"], f"{where}.camera") if "camera" in raw else None} + + def _ack_lines(raw: Any, where: str, device: Device) -> list[str]: lines = [raw] if isinstance(raw, str) else raw if (not isinstance(lines, list) or not lines @@ -268,7 +315,7 @@ def bed_config(device: Device) -> BedConfig: cfg = BedConfig( enabled=_flag(raw.get("enabled", False), f"{where}.enabled"), interval_s=_num(raw.get("interval_s", 1.0), f"{where}.interval_s", 0.2), - roi=_roi(raw["roi"], f"{where}.roi") if "roi" in raw else None, + roi=_roi_config(raw, where), volume_height_mm=_num(raw.get("volume_height_mm", 80.0), f"{where}.volume_height_mm", 0), z_tol_mm=_num(raw.get("z_tol_mm", 0.5), f"{where}.z_tol_mm", 0), stale_s=_num(raw.get("stale_s", 120.0), f"{where}.stale_s", 1), @@ -280,8 +327,9 @@ def bed_config(device: Device) -> BedConfig: if "auto_view_part_mm" in raw else None, light=_light(raw["light"], device) if "light" in raw else LightConfig(), **_reference_config(raw, where), + **_view_config(raw, where), ) - if cfg.enabled: + if cfg.enabled and cfg.camera is None: from printer_ops.camera import camera_spec spec = camera_spec(device) @@ -312,6 +360,47 @@ def evidence_dir() -> Path: return cache_dir() / "vision" / "bed" +@dataclass(frozen=True) +class Calibration: + """What `3d printer bed calibrate NAME` learnt (calibration.json next to the references).""" + + views: tuple[tuple[float, float], ...] # (toolhead Z, share of the plate in the frame), by Z + plate_mask: tuple[tuple[float, float], ...] | None # the plate polygon (0..1 of the frame) where most of it shows + ts: float + method: str + + +def calibration_path(printer: str) -> Path: + return refs_dir(printer) / "calibration.json" + + +def read_calibration(printer: str) -> Calibration | None: + """The learnt Z views of `printer`, or None (never calibrated, or the file is unusable: logged).""" + path = calibration_path(printer) + try: + doc = json.loads(path.read_text(encoding="utf-8")) + views = tuple(sorted((float(z), float(f)) for z, f in doc["views"])) + if len(views) < 2 or not all(math.isfinite(z) and math.isfinite(f) and 0.0 <= f <= 1.0 for z, f in views): + raise ValueError("needs at least two finite Z views with a share of 0..1") + mask = doc.get("plate_mask") + return Calibration(views, None if not mask else tuple((float(x), float(y)) for x, y in mask), + float(doc.get("ts", 0)), str(doc.get("method", ""))) + except OSError: + return None + except (ValueError, KeyError, TypeError) as exc: + _log(f"ignoring {path}: {exc}") + return None + + +def write_calibration(printer: str, doc: dict[str, Any]) -> Path: + path = calibration_path(printer) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(doc, indent=1, default=str), encoding="utf-8") + tmp.replace(path) + return path + + def read_state(printer: str) -> dict[str, Any] | None: try: data = json.loads(state_path(printer).read_text(encoding="utf-8")) @@ -516,6 +605,6 @@ def run_status(printer: str, *, move: str = "force", max_age: float | None = Non __all__ = ["AUTO_VIEW_CLEARANCE", "AUTO_VIEW_FEED", "AUTO_VIEW_MIN", "AUTO_VIEW_SETTLE_S", "AUTO_VIEW_Z_FLOOR", "AUTO_VIEW_Z_TOP_MARGIN", "BED_DEPS", "BED_TOOL", "BED_VIEW_EVENT", "KINDS", - "STATUS_MOVE_TIMEOUT_S", "STATUS_TIMEOUT_S", "AckConfig", "BedChild", "BedConfig", "bed_config", - "clear_ack", "event_from", "read_state", "refs_dir", "run_status", "state_path", "status_argv", - "transitions", "write_state"] + "STATUS_MOVE_TIMEOUT_S", "STATUS_TIMEOUT_S", "AckConfig", "BedChild", "BedConfig", "Calibration", + "bed_config", "calibration_path", "clear_ack", "event_from", "read_calibration", "read_state", "refs_dir", + "run_status", "state_path", "status_argv", "transitions", "write_calibration", "write_state"] diff --git a/tests/e2e/test_printer_bed_calibrate.py b/tests/e2e/test_printer_bed_calibrate.py new file mode 100644 index 00000000..dceffc21 --- /dev/null +++ b/tests/e2e/test_printer_bed_calibrate.py @@ -0,0 +1,191 @@ +"""`3d printer bed calibrate NAME` end to end through bin/3d against a loopback fake Moonraker (the printer is +idle and homed, its camera serves one fixed U1 frame, so every Z shows the plate in the same place: the plate +followed over the grid keeps its share and the point of the test is the CLI, the guarded Z-only moves and the file +it writes). The bed check's own geometry is tested in tests/test_bed_calibrate.py with a camera that moves.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from printer_fakes import FakeMoonraker + +from .test_printer_bed_workflows import HAND_ROI, jpeg, u1_after_job +from .workflow_helper import run_cli + +pytest.importorskip("numpy") +pytest.importorskip("cv2") + +RESUME = """ resume: + purge_macro: SM_PRINT_PREEXTRUDE_FILAMENT + purge_mm: 30 + purge_retract_mm: 0.5 + no_tower_prime_mm: 12 + clearance_mm: 5 + tower_margin_mm: 3 + filament_diameter_mm: 1.75 + min_print_temp: 170 + default_accel: 10000 + z_max_mm: 275 + start_commands: [] +""" + + +def homed_u1(state: str = "complete") -> FakeMoonraker: + moon = u1_after_job(state) + moon.status["toolhead"] = {"position": [5.0, 5.0, 200.0, 0.0], "homed_axes": "xyz"} + moon.status["gcode_move"] = {"gcode_position": [5.0, 5.0, 200.0, 0.0]} + return moon + + +def write_config( + tmp_path: Path, moon: FakeMoonraker, bed: str, *, resume: bool = True +) -> None: + d = tmp_path / "xdg-config" / "3d-cli" + d.mkdir(parents=True, exist_ok=True) + (d / "printer-devices.yaml").write_text( + f"""u1: + adapter: moonraker + host: 127.0.0.1 + port: {moon.port} + model: Snapmaker U1 + trusted_macros: [T0] + watch: + vision: {{keepalive: false}} + bed: {bed} +{RESUME if resume else ""}""", + encoding="utf-8", + ) + + +BED = f"{{enabled: true, plate_mask: {HAND_ROI}, light: {{enabled: false}}}}" + + +def calibration(tmp_path: Path) -> Path: + return next(tmp_path.glob("xdg-data/**/bed/u1/calibration.json")) + + +def test_calibrate_moves_the_bed_in_z_only_writes_the_views_and_leaves_the_references_alone( + tmp_path: Path, +) -> None: + moon = homed_u1() + with moon.serve(): + write_config(tmp_path, moon, BED) + assert ( + run_cli( + tmp_path, "printer", "bed", "reference", "u1", "--capture" + ).returncode + == 0 + ) + moon.camera = jpeg("u1_z200_clear_ref.jpg") + moon.scripts.clear() + out = run_cli( + tmp_path, "printer", "bed", "calibrate", "u1", "--part-height", "20", + "--z-from", "40", "--z-to", "100", "--z-step", "30", "--json", + ) # fmt: skip + scripts = [ + line + for sc in moon.scripts + for line in sc.splitlines() + if line.startswith("G") + ] + listed = run_cli(tmp_path, "printer", "bed", "reference", "u1") + + assert out.returncode == 0, out.stdout + out.stderr + res = json.loads(out.stdout) + assert res["method"] == "plate_mask" + assert res["grid"] == [40.0, 70.0, 100.0] + assert res["view"]["restored"] is True + # the grid, and the reference frame the plate was followed from (Z 200, where the printer rested) + assert [z for z, _ in res["views"]] == [40.0, 70.0, 100.0, 200.0] + assert min(share for _, share in res["views"]) > 0.9 + assert res["seed"]["z"] == 200.0 + stored = json.loads(calibration(tmp_path).read_text(encoding="utf-8")) + assert stored["views"] == res["views"] + assert len(stored["plate_mask"]) == 4 + assert [s for s in scripts if s.startswith("G1")] == [ + f"G1 Z{z:.3f} F600" for z in (40, 70, 100) + ] + assert "calibration.json" not in listed.stdout + assert "plate" in listed.stdout + + +def test_calibrate_is_refused_while_a_job_runs_and_nothing_is_moved_or_stored( + tmp_path: Path, +) -> None: + moon = homed_u1("printing") + with moon.serve(): + write_config(tmp_path, moon, BED) + out = run_cli( + tmp_path, "printer", "bed", "calibrate", "u1", "--part-height", "20" + ) + writes = [r.path for r in moon.writes()] + + assert ( + out.returncode == 2 + and "printing" in out.stderr + and "nothing was moved" in out.stderr + ) + assert writes == [] and list(tmp_path.glob("xdg-data/**/calibration.json")) == [] + + +def test_calibrate_without_the_part_height_or_with_a_grid_under_the_floor_moves_nothing( + tmp_path: Path, +) -> None: + moon = homed_u1() + with moon.serve(): + write_config(tmp_path, moon, BED) + no_height = run_cli(tmp_path, "printer", "bed", "calibrate", "u1") + too_low = run_cli( + tmp_path, + "printer", + "bed", + "calibrate", + "u1", + "--part-height", + "20", + "--z-from", + "20", + ) + no_resume = None + write_config(tmp_path, moon, BED, resume=False) + no_resume = run_cli( + tmp_path, "printer", "bed", "calibrate", "u1", "--part-height", "20" + ) + writes = [r.path for r in moon.writes()] + + assert ( + no_height.returncode == 2 + and "height of what may stand on the bed is unknown" in no_height.stderr + ) + assert too_low.returncode == 2 and "outside what may be moved to" in too_low.stderr + assert no_resume.returncode == 2 and "no resume.z_max_mm" in no_resume.stderr + assert writes == [] + + +def test_calibrate_flags_belong_to_calibrate_and_are_validated(tmp_path: Path) -> None: + moon = homed_u1() + with moon.serve(): + write_config(tmp_path, moon, BED) + stray = run_cli(tmp_path, "printer", "bed", "check", "u1", "--z-step", "5") + bad_step = run_cli( + tmp_path, + "printer", + "bed", + "calibrate", + "u1", + "--part-height", + "20", + "--z-step", + "0", + ) + helped = run_cli(tmp_path, "printer", "bed", "--help") + + assert stray.returncode == 2 and "does not take --z-step" in stray.stderr + assert bad_step.returncode == 2 and "positive --z-step" in bad_step.stderr + assert "bed calibrate NAME [--z-from A --z-to B --z-step S]" in helped.stdout + assert ( + "views: {70: 1.0, 200: 0.15}" in helped.stdout and "plate_mask" in helped.stdout + ) diff --git a/tests/test_bed_calibrate.py b/tests/test_bed_calibrate.py new file mode 100644 index 00000000..74b3d27e --- /dev/null +++ b/tests/test_bed_calibrate.py @@ -0,0 +1,600 @@ +"""`3d printer bed calibrate NAME` (lib/bed_calibrate.py, issue #723): how much of the plate the camera sees at +each Z, learnt on an idle printer by moving the bed in Z only, and used by the bed check for a printer without a +camera calibration. + +The camera is `SynthCamera`: it renders a textured plate that slides down the frame (and shrinks a little) as +the fake printer's bed Z rises, so the true visible share per Z is known. Real frames are used where they exist +(the K1 SE empty bed, shifted like a Z move). Nothing here touches a real printer or camera. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from errors import InvalidArgument, UsageError +from printer_ops import bed as pbed +from printer_ops.devices import Device +from test_bed_light import LightPrinter +from test_bed_state import K1_ROI, frame +from test_bed_view import RESUME, FakePrinter + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_calibrate as C # noqa: E402 (needs numpy + OpenCV) +import bed_state as B # noqa: E402 +import bed_view as V # noqa: E402 + +SHAPE = (270, 480) +Z0 = 40.0 # the Z the plate is fully in view at +NOW = 1_800_000_000.0 + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def device( + *, model: str | None = None, access: str = "full", resume: Any = RESUME +) -> Device: + """A Moonraker printer with no camera calibration (model None) and a Z axis that goes to 275.""" + return Device( + "k1", + "moonraker", + "127.0.0.1", + 7125, + model, + Path("test.yaml"), + access=access, + watch={}, + resume=resume, + ) + + +# ---- a plate that slides down the frame as the bed rises ------------------------------------------------------ + + +def plate_texture(w: int = 360, h: int = 220, seed: int = 1) -> Any: + rng = np.random.default_rng(seed) + n = rng.normal(0, 1, (h, w)).astype(np.float32) + t = ( + cv2.GaussianBlur(n, (0, 0), 2.0) * 40 + + cv2.GaussianBlur(n[::-1], (0, 0), 8.0) * 30 + ) + return cv2.cvtColor(np.clip(70 + t, 10, 200).astype(np.uint8), cv2.COLOR_GRAY2BGR) + + +def render( + tex: Any, z: float, *, strip: bool = True +) -> tuple[Any, list[tuple[float, float]]]: + """The frame at bed Z `z` and the plate's outline in it: 1 px down per mm of Z, 0.05 % smaller per mm.""" + h, w = tex.shape[:2] + s = 1 - 0.0005 * (z - Z0) + m = np.array([[s, 0, (SHAPE[1] - w * s) / 2], [0, s, 20 + (z - Z0)]], np.float32) + out = np.full((*SHAPE, 3), (150, 170, 190), np.uint8) + if strip: + out[:, :40] = (30, 30, 30) # a static dark machine part beside the plate + warped = cv2.warpAffine(tex, m, (SHAPE[1], SHAPE[0]), borderValue=(0, 0, 0)) + inside = ( + cv2.warpAffine( + np.full((h, w), 255, np.uint8), + m, + (SHAPE[1], SHAPE[0]), + flags=cv2.INTER_NEAREST, + ) + > 0 + ) + out[inside] = warped[inside] + corners = ((0, 0), (w - 1, 0), (w - 1, h - 1), (0, h - 1)) + return out, [ + (float(m[0, 0] * x + m[0, 2]), float(m[1, 1] * y + m[1, 2])) for x, y in corners + ] + + +def true_share(tex: Any, z: float) -> float: + return C.visible_share(render(tex, z)[1], SHAPE) + + +class SynthCamera: + """The camera of the fake printer: the frame depends on where its bed is.""" + + def __init__(self, printer: FakePrinter, tex: Any, *, strip: bool = True) -> None: + self.printer, self.tex, self.strip = printer, tex, strip + self.grabs = 0 + self.fail: BaseException | None = None + self.zs: list[float] = [] + + def keepalive(self) -> bool: + return False + + def grab(self) -> Any: + self.grabs += 1 + if self.fail is not None and self.grabs > 2: + raise self.fail + self.zs.append(self.printer.z) + img, _ = render(self.tex, self.printer.z, strip=self.strip) + jpeg = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 95])[1].tobytes() + com = ( + b"\xff\xfe" + (2 + 8).to_bytes(2, "big") + self.grabs.to_bytes(8, "big") + ) # never a frozen frame + return SimpleNamespace(jpeg=jpeg[:2] + com + jpeg[2:]) + + +def roi_cfg(tex: Any, **kw: Any) -> pbed.BedConfig: + poly = C.to_norm(render(tex, Z0)[1], SHAPE) + return pbed.BedConfig(enabled=True, roi=tuple((x, y) for x, y in poly), **kw) + + +def seeded_reference(tex: Any, dev: Device, cfg: pbed.BedConfig) -> None: + """An empty-bed reference at Z0 (the plate fully in view): the seed of the calibration.""" + jpeg = cv2.imencode(".jpg", render(tex, Z0)[0])[1].tobytes() + B.capture_reference(dev, cfg, plate=True, frame=jpeg, frame_z=Z0, now=NOW) + + +def run( + printer: FakePrinter, cam: SynthCamera, cfg: pbed.BedConfig, **kw: Any +) -> dict[str, Any]: + args: dict[str, Any] = { + "part_height": 20.0, + "z_from": 40.0, + "z_to": 200.0, + "z_step": 20.0, + } + args.update(kw) + return C.calibrate( + device(), + cfg, + adapter=printer, + camera=cam, + now=NOW, + sleep=lambda s: None, + settle_s=0.0, + **args, + ) + + +# ---- geometry -------------------------------------------------------------------------------------------------- + + +def test_the_visible_share_of_a_polygon_is_its_area_in_the_frame_over_its_whole_area() -> ( + None +): + inside = [(10.0, 10.0), (110.0, 10.0), (110.0, 110.0), (10.0, 110.0)] + assert C.visible_share(inside, SHAPE) == pytest.approx(1.0) + half = [ + (10.0, 169.0), + (110.0, 169.0), + (110.0, 369.0), + (10.0, 369.0), + ] # the frame ends at y 269 + assert C.visible_share(half, SHAPE) == pytest.approx(0.5, abs=0.01) + assert C.visible_share([(10.0, 400.0), (50.0, 400.0), (50.0, 450.0)], SHAPE) == 0.0 + huge = [ + (-500.0, -500.0), + (900.0, -500.0), + (900.0, 900.0), + (-500.0, 900.0), + ] # the frame is inside the plate + assert C.visible_share(huge, SHAPE) == pytest.approx(479 * 269 / (1400 * 1400)) + assert ( + C.visible_share([(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)], SHAPE) == 0.0 + ) # no area + + +def test_a_polygon_round_trips_between_pixels_and_the_unit_frame() -> None: + px = C.to_px(K1_ROI, SHAPE) + assert C.to_norm(px, SHAPE) == [[round(x, 4), round(y, 4)] for x, y in K1_ROI] + + +# ---- the Z grid ------------------------------------------------------------------------------------------------ + + +def limits(floor: float = 35.0, ceiling: float = 270.0) -> V.Limits: + return V.Limits(floor, ceiling, 20.0, "--part-height") + + +def test_the_default_grid_runs_from_the_clearance_floor_to_the_ceiling_in_20_mm_steps() -> ( + None +): + grid = C.plan_grid(limits(), None, None, None) + assert grid[0] == 35.0 and grid[-1] == 255.0 and len(grid) == 12 + assert C.plan_grid(limits(), 40.0, 100.0, 30.0) == [40.0, 70.0, 100.0] + + +@pytest.mark.parametrize( + ("args", "fragment"), + [ + ( + (30.0, None, None), + "outside what may be moved to", + ), # under the clearance floor + ((None, 280.0, None), "outside what may be moved to"), # over the axis ceiling + ((None, None, 0.0), "positive --z-step"), + ((None, None, float("nan")), "finite"), + ((100.0, 60.0, None), "below --z-from"), + ((35.0, 270.0, 1.0), "too many"), + ], +) +def test_a_grid_outside_the_limits_or_without_a_sane_step_is_refused( + args: tuple[Any, ...], fragment: str +) -> None: + with pytest.raises(UsageError, match=fragment): + C.plan_grid(limits(), *args) + + +# ---- following the plate ---------------------------------------------------------------------------------------- + + +def test_the_followed_plate_gives_the_true_visible_share_at_every_z() -> None: + tex = plate_texture() + zs = [Z0 + 20 * i for i in range(1, 10)] + frames = {z: render(tex, z)[0] for z in zs} + seed = C.Seed(render(tex, Z0)[0], Z0, render(tex, Z0)[1], "plate_mask", "test") + tracked = C.analyse(frames, seed) + got = {p.z: p.share for p in tracked.at} + assert set(got) >= {z for z in zs if z <= 200} + for z, share in got.items(): + assert share == pytest.approx(true_share(tex, z), abs=0.03), z + assert ( + got[Z0] > 0.95 > got[160.0] > got[200.0] + ) # the plate slides out of the frame with the bed's Z + + +def test_a_plate_that_cannot_be_followed_ends_the_chain_and_the_rest_is_listed_not_invented() -> ( + None +): + tex = plate_texture() + frames = {z: render(tex, z)[0] for z in (60.0, 80.0)} + frames[100.0] = np.full((*SHAPE, 3), 128, np.uint8) # a covered lens: no texture + frames[120.0] = render(tex, 120.0)[0] + seed = C.Seed(render(tex, Z0)[0], Z0, render(tex, Z0)[1], "plate_mask", "test") + tracked = C.analyse(frames, seed) + assert [p.z for p in tracked.at] == [Z0, 60.0, 80.0] + assert [u["z"] for u in tracked.unfollowed] == [100.0, 120.0] + assert "at Z 100" in str(tracked.unfollowed[0]["why"]) + + +def test_a_real_frame_shifted_like_a_z_move_is_followed_with_the_k1_plate_polygon() -> ( + None +): + """The K1 SE empty bed (tests/data/bed) with the plate polygon of its config, moved down like a bed that rises.""" + img = frame("k1_z125_clear_ref.jpg") + shape = img.shape[:2] + poly = C.to_px(K1_ROI, shape) + frames, truth = {}, {} + for z, ty in ((100.0, 10.0), (110.0, 25.0), (120.0, 40.0)): + m = np.float32([[1, 0, 0], [0, 1, ty]]) + frames[z] = cv2.warpAffine( + img, m, (shape[1], shape[0]), borderMode=cv2.BORDER_REPLICATE + ) + truth[z] = C.transform(poly, m) + tracked = C.analyse(frames, C.Seed(img, 90.0, poly, "plate_mask", "K1_ROI")) + assert [p.z for p in tracked.at] == [90.0, 100.0, 110.0, 120.0] + for p in tracked.at[1:]: + assert p.share == pytest.approx(C.visible_share(truth[p.z], shape), abs=0.03) + + +def test_without_a_seed_the_largest_dark_region_is_the_plate_and_the_result_says_it_is_rough() -> ( + None +): + tex = plate_texture() + frames = {z: render(tex, z, strip=False)[0] for z in (40.0, 60.0, 80.0, 100.0)} + poly = C.heuristic_polygon(frames[40.0]) + assert poly is not None and C.area(poly) > 0.5 * C.area(render(tex, Z0)[1]) + tracked = C.analyse(frames, None) + assert tracked.method == "heuristic" and "largest dark" in tracked.seed_source + shares = [p.share for p in tracked.at] + assert len(shares) == 4 and shares == sorted(shares, reverse=True) + + +def test_no_plate_to_start_from_is_refused_with_the_way_out() -> None: + blank = {z: np.full((*SHAPE, 3), 200, np.uint8) for z in (40.0, 60.0)} + with pytest.raises(UsageError, match="no plate to start from"): + C.analyse(blank, None) + + +# ---- the printer flow -------------------------------------------------------------------------------------------- + + +def calibrated() -> tuple[LightPrinter, SynthCamera, dict[str, Any], Any]: + """A calibration run from Z 200 over the grid 40..180, with the empty-bed reference as the seed.""" + tex = plate_texture() + cfg = roi_cfg(tex) + seeded_reference(tex, device(), cfg) + p = LightPrinter("complete", 200.0, lit=False) + cam = SynthCamera(p, tex) + return p, cam, run(p, cam, cfg, z_to=180.0), tex + + +def test_calibrate_stores_the_true_visible_share_per_z_where_the_check_reads_it() -> ( + None +): + _, cam, res, tex = calibrated() + assert res["method"] == "plate_mask" and res["view"]["restored"] is True + # one frame per Z (the check grabs twice: the second, newer one is judged) + assert list(dict.fromkeys(cam.zs)) == [ + 40.0, + 60.0, + 80.0, + 100.0, + 120.0, + 140.0, + 160.0, + 180.0, + ] + views = dict(res["views"]) + for z, share in views.items(): + assert share == pytest.approx(true_share(tex, z), abs=0.04), z + stored = pbed.read_calibration("k1") + assert stored is not None and stored.method == "plate_mask" + assert len(stored.plate_mask or ()) == 4 and [z for z, _ in stored.views] == sorted( + views + ) + # the file lies beside the references and is not one + assert [r["kind"] for r in B.list_references("k1")] == ["plate"] + assert [r.kind for r in B.RefStore("k1").load()] == ["plate"] + + +def test_calibrate_moves_only_in_z_puts_the_bed_and_the_light_back_and_leaves_no_marker() -> ( + None +): + p, _, res, _ = calibrated() + moves = [ + ln + for s in p.scripts + for ln in s.splitlines() + if ln.startswith(("G0", "G1", "G28")) + ] + assert not [ + m for m in moves if not m.startswith("G1 Z") + ] # no X/Y, no home, no macro + assert ( + moves[0] == "G1 Z40.000 F600" + and moves[-1] == "G1 Z200.000 F600" + and len(moves) == 9 + ) + assert p.z == 200.0 and p.lit is False and V._read_pending("k1") is None + assert res["light"]["switched"] is True and res["light"]["restored"] is True + + +def test_calibrate_refuses_everything_that_is_not_an_idle_homed_writable_printer_before_moving() -> ( + None +): + tex = plate_texture() + cfg = roi_cfg(tex) + for printer, fragment in ( + (FakePrinter("printing", 3.0), "printing"), + (FakePrinter("paused", 3.0), "paused"), + (FakePrinter("complete", 200.0, homed=""), "not homed"), + ): + cam = SynthCamera(printer, tex) + with pytest.raises(UsageError, match=fragment): + run(printer, cam, cfg) + assert printer.scripts == [] and cam.grabs == 0 + p = FakePrinter("complete", 200.0) + with pytest.raises(UsageError, match="read-only|no writes"): + C.calibrate( + device(access="read"), + cfg, + adapter=p, + camera=SynthCamera(p, tex), + part_height=20.0, + ) + assert p.scripts == [] + + +def test_calibrate_needs_the_part_height_and_stays_within_the_clearance() -> None: + tex = plate_texture() + cfg = roi_cfg(tex) + p = FakePrinter("complete", 200.0) + cam = SynthCamera(p, tex) + with pytest.raises( + UsageError, match="height of what may stand on the bed is unknown" + ): + run(p, cam, cfg, part_height=None) + with pytest.raises(UsageError, match="outside what may be moved to"): + run(p, cam, cfg, z_from=20.0) # the floor is the part height + 15 mm + assert p.scripts == [] and cam.grabs == 0 + + +def test_calibrate_puts_the_bed_back_when_the_camera_fails_half_way() -> None: + tex = plate_texture() + cfg = roi_cfg(tex) + seeded_reference(tex, device(), cfg) + p = FakePrinter("complete", 200.0) + cam = SynthCamera(p, tex) + cam.fail = OSError("camera went away") + with pytest.raises(OSError, match="went away"): + run(p, cam, cfg) + assert p.z == 200.0 and V._read_pending("k1") is None + assert pbed.read_calibration("k1") is None # nothing half-stored + + +def test_calibrate_stops_when_a_job_starts_and_stores_nothing_wrong() -> None: + tex = plate_texture() + cfg = roi_cfg(tex) + seeded_reference(tex, device(), cfg) + p = FakePrinter("complete", 200.0) + cam = SynthCamera(p, tex) + + def start_job(printer: FakePrinter) -> None: + if printer.z > 80.0: + printer.raw = "printing" + + p.on_script = start_job + res = run(p, cam, cfg) # the frames before the job are enough for a calibration + assert ( + "stopped at Z" in res["view"]["refused"] + and "printing" in res["view"]["refused"] + ) + assert max(z for z, _ in res["views"]) <= 100.0 + assert not any( + s.startswith("G1 Z200") for s in p.scripts + ) # a job owns the bed now: no restore move is sent + + +def test_calibrate_without_a_reference_or_plate_mask_uses_the_dark_region_and_says_so() -> ( + None +): + tex = plate_texture() + p = FakePrinter("complete", 200.0) + cam = SynthCamera(p, tex, strip=False) + res = run(p, cam, pbed.BedConfig(enabled=True), z_to=100.0) + assert res["method"] == "heuristic" and len(res["views"]) >= 3 + assert "check it" in C._text(device(), res) + + +def test_the_text_and_json_of_a_calibration() -> None: + tex = plate_texture() + cfg = roi_cfg(tex) + seeded_reference(tex, device(), cfg) + p = FakePrinter("complete", 200.0) + res = run(p, SynthCamera(p, tex), cfg, z_to=100.0) + text = C._text(device(), res) + assert ( + "calibrated from watch.bed.plate_mask on the reference at Z 40" in text + and "bed put back" in text + ) + assert ( + "Z 40.0 100%" in text + and "of the plate in the frame" in text + and "calibration.json" in text + ) + json.dumps(res, default=str) + + +# ---- what the check does with it ---------------------------------------------------------------------------------- + + +def write_views(views: list[list[float]]) -> None: + pbed.write_calibration( + "k1", {"ts": NOW, "method": "plate_mask", "views": views, "plate_mask": None} + ) + + +def test_a_learnt_calibration_tells_the_check_how_much_of_the_plate_shows_at_a_z() -> ( + None +): + cfg = pbed.BedConfig(enabled=True) + assert ( + B.z_match(device(), cfg).views == () + and B.z_match(device(), cfg).in_view(100.0) is None + ) + write_views([[40.0, 1.0], [100.0, 0.6], [200.0, 0.0]]) + match = B.z_match(device(), cfg) + assert match.in_view(70.0) == pytest.approx(0.8) and match.in_view( + 150.0 + ) == pytest.approx(0.3) + assert ( + match.in_view(30.0) is None and match.in_view(250.0) is None + ) # nobody looked there + # the view picker: unknown to be in view beyond the learnt Zs is 0, not "in the frame by definition" + assert ( + match.picker_view(70.0) == pytest.approx(0.8) + and match.picker_view(250.0) == 0.0 + ) + assert ( + B.ZMatch().picker_view(250.0) == 1.0 + ) # no views, no calibration: a roi polygon is in the frame + # the bed check now knows that Z 190 is a poor view of the plate + assert B.out_of_view(match, 199.0, B.thresholds_for(cfg)) is not None + assert B.out_of_view(match, 70.0, B.thresholds_for(cfg)) is None + + +def test_the_declared_views_override_the_learnt_ones_and_a_calibrated_camera_or_a_roi_ignores_them() -> ( + None +): + write_views([[40.0, 1.0], [200.0, 0.0]]) + declared = pbed.BedConfig(enabled=True, views=((50.0, 1.0), (150.0, 0.5))) + assert B.z_match(device(), declared).in_view(100.0) == pytest.approx(0.75) + with_roi = pbed.BedConfig(enabled=True, roi=((0.1, 0.1), (0.9, 0.1), (0.9, 0.9))) + assert ( + B.z_match(device(), with_roi).views == () + ) # a roi polygon is in the frame by definition + assert B.z_match(device(), pbed.BedConfig(enabled=True)).views != () + u1 = device(model="Snapmaker U1") + assert ( + B.z_match(u1, pbed.BedConfig(enabled=True)).views == () + ) # the bundled calibration knows better + assert B.z_match(u1, pbed.BedConfig(enabled=True)).in_view(200.0) is not None + + +def test_a_broken_calibration_file_is_ignored_not_fatal() -> None: + path = pbed.calibration_path("k1") + path.parent.mkdir(parents=True, exist_ok=True) + for text in ("{", '{"views": [[1, 2]]}', '{"views": [[40, 1.5], [60, 0.2]]}', "[]"): + path.write_text(text, encoding="utf-8") + assert pbed.read_calibration("k1") is None + assert B.z_match(device(), pbed.BedConfig(enabled=True)).views == () + + +# ---- the config --------------------------------------------------------------------------------------------------- + + +def dev_with(bed: dict[str, Any]) -> Device: + return Device( + "k1", + "moonraker", + "127.0.0.1", + 7125, + "Creality K1 SE", + Path("test.yaml"), + watch={"bed": bed}, + ) + + +def test_views_camera_and_plate_mask_are_read_and_validated() -> None: + cfg = pbed.bed_config( + dev_with( + { + "views": {"200": 0.15, 70: 1.0}, + "camera": "http://127.0.0.1:8080/?action=snapshot", + } + ) + ) + assert ( + cfg.views == ((70.0, 1.0), (200.0, 0.15)) + and cfg.camera == "http://127.0.0.1:8080/?action=snapshot" + ) + mask = [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9]] + assert pbed.bed_config(dev_with({"plate_mask": mask})).roi == tuple( + (x, y) for x, y in mask + ) + assert ( + pbed.bed_config(dev_with({"roi": mask})).roi + == pbed.bed_config(dev_with({"plate_mask": mask})).roi + ) + assert ( + pbed.bed_config( + dev_with({"enabled": True, "camera": "/server/files/camera/monitor.jpg"}) + ).enabled + is True + ) + bad_configs: list[dict[str, Any]] = [ + {"views": {}}, + {"views": [1, 2]}, + {"views": {"x": 1.0}}, + {"views": {70: 1.5}}, + {"views": {-5: 0.5}}, + {"camera": ""}, + {"camera": "http://a b"}, + {"camera": 7}, + {"plate_mask": mask, "roi": mask}, + {"plate_mask": [[0.1, 0.1]]}, + ] + for bad in bad_configs: + with pytest.raises(InvalidArgument): + pbed.bed_config(dev_with(bad)) diff --git a/tests/test_bed_state.py b/tests/test_bed_state.py index b7bf81bc..656e9e75 100644 --- a/tests/test_bed_state.py +++ b/tests/test_bed_state.py @@ -23,7 +23,7 @@ from errors import InvalidArgument from printer_fakes import FakeMoonraker, u1_status from printer_ops import bed as pbed -from printer_ops import preflight +import printer_ops.preflight as preflight from printer_ops.devices import Device np = pytest.importorskip("numpy") From 5ccdf51e2367b33cecb2dfd9ea39cd90c1e00d21 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:06:23 +0100 Subject: [PATCH 16/30] =?UTF-8?q?feat(bed):=20`bed=20check=20--assume-empt?= =?UTF-8?q?y=20"why"`=20=E2=80=94=20the=20owner's=20auditable=20word=20tha?= =?UTF-8?q?t=20the=20bed=20is=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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-.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

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 aT+a*x;nOdv+5-0n1>Qh`$x-_E>;C`=trQFZ literal 0 HcmV?d00001 diff --git a/tests/data/quality/u1_f1.jpg b/tests/data/quality/u1_f1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a34cfe848e24ef3130f68fa913fc39a3960a1fdb GIT binary patch literal 34590 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 Cy8UGU literal 0 HcmV?d00001 diff --git a/tests/data/quality/u1_f10.jpg b/tests/data/quality/u1_f10.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae42a93ae983de46bb0dd38a3d152828d87a2661 GIT binary patch literal 31130 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 literal 0 HcmV?d00001 diff --git a/tests/data/quality/u1_f12.jpg b/tests/data/quality/u1_f12.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c05f78cd7f8aad1435ca09f9fa65e7c31fb2d405 GIT binary patch literal 30815 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 HS@}Nzp2t)& literal 0 HcmV?d00001 diff --git a/tests/data/quality/u1_f7.jpg b/tests/data/quality/u1_f7.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ec9746a45bf59701e304fc4e6bb5a6e9684b5126 GIT binary patch literal 35216 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�k71c^X6e!-6y+O{RYU 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 str: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + (tmp_path / "config" / "3d-cli").mkdir(parents=True) + (tmp_path / "config" / "3d-cli" / "printer-devices.yaml").write_text( + DEVICES, encoding="utf-8" + ) + return "u1" + + +def read(name: str, u1: str, z: float = 0.6) -> dict: + return PQ.quality_numbers((DATA / name).read_bytes(), name=u1, z=z) + + +def whole(name: str) -> dict: + return PQ.quality_numbers((DATA / name).read_bytes(), name=None, z=0.0) + + +def test_clean_first_layer_vs_stringing_debris_separate_on_the_real_camera( + u1: str, +) -> None: + clean = read("u1_f5.jpg", u1) + assert clean["status"] == "ok" and clean["region_source"].startswith( + "plate surface of u1" + ) + assert clean["fleck_frac"] < 0.02 and clean["order"] > 0.8 + for name in ("u1_f7.jpg", "u1_f10.jpg", "u1_f12.jpg"): + bad = read(name, u1) + assert bad["fleck_frac"] > 2.4 * clean["fleck_frac"], name + assert bad["fleck_frac"] > 0.035 and bad["order"] < 0.75, name + + +def test_the_empty_plate_has_its_own_glints_which_is_why_presence_is_not_the_numbers_job( + u1: str, +) -> None: + """The bare textured plate sparkles (fleck_frac 0.1): the decision side only judges a plate with a part.""" + assert read("u1_f1.jpg", u1)["fleck_frac"] > 0.06 + + +def test_the_numbers_do_not_depend_on_the_camera_resolution( + u1: str, tmp_path: Path +) -> None: + img = cv2.imread(str(DATA / "u1_f7.jpg")) + big = cv2.resize(img, (1920, 1080), interpolation=cv2.INTER_LINEAR) + ok, buf = cv2.imencode(".jpg", big, [cv2.IMWRITE_JPEG_QUALITY, 90]) + assert ok + a, b = read("u1_f7.jpg", u1), PQ.quality_numbers(bytes(buf), name=u1, z=0.6) + assert ( + abs(a["fleck_frac"] - b["fleck_frac"]) < 0.03 + and abs(a["order"] - b["order"]) < 0.05 + ) + + +@pytest.mark.parametrize( + "name", ["photo_599472757.jpg", "photo_599472758.jpg", "photo_599472764.jpg"] +) +def test_phone_photos_of_the_failed_first_layer_read_far_below_a_clean_first_layer( + name: str, +) -> None: + """Whole-frame look (no camera calibration for a phone): the beads are a mess, `order` is far under the + first-layer floor, a clean first layer reads 0.83+.""" + n = whole(name) + assert n["status"] == "ok" and n["region_source"] == "whole frame" + assert n["order"] < Q.QualityConfig().first_layer_order + issue, level = Q.absolute_verdict(n, 0.2, Q.QualityConfig()) + assert issue == Q.FIRST_LAYER and level in (Q.WARNING, Q.SEVERE) + + +def synth(white_specks: int = 0, lines: bool = True, seed: int = 1) -> bytes: + """A 640x360 beige surface of parallel beads, optionally peppered with white specks.""" + rng = np.random.default_rng(seed) + img = np.full((360, 640, 3), (120, 165, 205), np.uint8) # BGR beige + if lines: + for y in range(0, 360, 9): + img[y : y + 3] = ( + 104, + 148, + 188, + ) # shallow gaps between beads: far under the fleck threshold + for _ in range(white_specks): + x, y = int(rng.integers(10, 630)), int(rng.integers(10, 350)) + cv2.circle(img, (x, y), int(rng.integers(1, 4)), (245, 245, 245), -1) + ok, buf = cv2.imencode(".png", img) + assert ok + return bytes(buf) + + +def test_synthetic_white_specks_raise_fleck_frac_and_lost_lines_lower_order() -> None: + clean = PQ.quality_numbers(synth(0), name=None, z=0.0) + specks = PQ.quality_numbers(synth(250), name=None, z=0.0) + mush = PQ.quality_numbers(synth(0, lines=False), name=None, z=0.0) + assert clean["fleck_frac"] < 0.005 and specks["fleck_frac"] > 0.015 + assert clean["order"] > 0.9 and specks["order"] < clean["order"] + assert mush["order"] < clean["order"], ( + "an even surface with no bead pattern has nothing to be ordered" + ) + + +def test_a_region_that_is_too_small_or_a_broken_frame_is_blind_never_clean() -> None: + tiny = PQ.quality_numbers( + synth(0), name=None, z=0.0, roi=[(0.1, 0.1), (0.12, 0.1), (0.12, 0.12)] + ) + assert tiny["status"] == "blind" and "under" in tiny["reason"] + broken = PQ.quality_numbers(b"not a jpeg", name=None, z=0.0) + assert broken["status"] == "blind" and "decodable" in broken["reason"] + + +def test_roi_parsing_names_the_bad_part() -> None: + assert PQ.parse_roi("0,0;1,0;1,1") == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)] + for bad in ("0,0;1,0", "0,0;2,0;1,1", "a,b;c,d;e,f"): + with pytest.raises(ValueError): + PQ.parse_roi(bad) + + +def test_the_annotated_frame_marks_the_debris_in_red_and_is_a_jpeg( + u1: str, tmp_path: Path +) -> None: + out = tmp_path / "a" / "marked.jpg" + n = PQ.quality_numbers( + (DATA / "u1_f7.jpg").read_bytes(), name=u1, z=0.6, annotated=out + ) + assert n["annotated"] == str(out) and out.read_bytes()[:2] == b"\xff\xd8" + marked = cv2.imread(str(out)) + assert (marked[..., 2].astype(int) - marked[..., 0].astype(int) > 150).sum() > 200 + + +def test_the_command_line_prints_one_json_object_and_help( + u1: str, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert ( + PQ.main( + ["check", u1, "--frame", str(DATA / "u1_f5.jpg"), "--z", "0.6", "--json"] + ) + == 0 + ) + doc = json.loads(capsys.readouterr().out) + assert doc["status"] == "ok" and { + "fleck_frac", + "fleck_per10k", + "order", + "luma", + "region_px", + } <= set(doc) + assert ( + PQ.main( + [ + "check", + u1, + "--frame", + str(DATA / "photo_599472757.jpg"), + "--roi", + "whole", + "--json", + ] + ) + == 0 + ) + assert json.loads(capsys.readouterr().out)["region_source"] == "whole frame" + assert ( + PQ.main(["--help"]) == 0 + and "print_quality.py check NAME" in capsys.readouterr().out + ) + assert PQ.main([]) == 1 + + +# ---- the table of the calibration, end to end through the judge -------------------------------- + + +def test_verdict_table_of_the_session_frames_through_the_judge(u1: str) -> None: + """f1 empty (reference, not judged); the first layer f5 four times = the baseline; then the debris frames in + the order they happened. Nothing before the debris, a WARNING on it, never above WARNING-severity events.""" + judge = Q.QualityJudge(Q.QualityConfig()) + rows = [] + sequence = [("u1_f1.jpg", False, 1)] + [("u1_f5.jpg", True, 1)] * 5 + sequence += [ + ("u1_f7.jpg", True, 3), + ("u1_f10.jpg", True, 3), + ("u1_f12.jpg", True, 4), + ("u1_f12.jpg", True, 4), + ] + for i, (name, present, layer) in enumerate(sequence): + v = judge.feed( + Q.QualityReading(60.0 * i, read(name, u1), layer=layer, present=present) + ) + rows.append((name, v.state, v.level, [f.kind for f in v.new])) + assert [r[1] for r in rows[:6]] == [ + "absent", + "learning", + "learning", + "learning", + "learning", + "ok", + ] + assert all(r[2] == "ok" for r in rows[:6]) + warned = [r for r in rows if r[3]] + assert [r[3] for r in warned] == [["quality_stringing_warning"]] + assert judge.report.worst is not None + assert judge.report.max_level == "warning" and judge.report.worst["layer"] == 3 + assert all(f["level"] in ("warning", "severe") for f in judge.report.alerts) diff --git a/tests/test_printer_quality.py b/tests/test_printer_quality.py new file mode 100644 index 00000000..30b1744e --- /dev/null +++ b/tests/test_printer_quality.py @@ -0,0 +1,317 @@ +"""printer_ops/quality.py — the decision half of the print-quality monitor, on plain numbers (no pixels). + +Numbers are what lib/print_quality.py reports for a frame: `fleck_frac` (white debris share) and `order` (bead +pattern regularity). The calibration frames are in tests/test_print_quality.py; here the STATE MACHINE is proved: +baseline from the job's own first layers, persistence, one alert per issue and level, never CRITICAL, clear, +trend, the first-layer floor, the report. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from errors import InvalidArgument +from printer_ops import quality as Q +from printer_ops.quality_report import render_markdown, summary_line, write_report + +CLEAN = { + "status": "ok", + "fleck_frac": 0.014, + "fleck_per10k": 9.0, + "order": 0.84, + "luma": 140.0, +} + + +def nums(fleck: float = 0.014, order: float = 0.84) -> dict[str, Any]: + return {**CLEAN, "fleck_frac": fleck, "order": order} + + +class Clock: + def __init__(self, step: float = 60.0) -> None: + self.t = 0.0 + self.step = step + + def next(self) -> float: + self.t += self.step + return self.t + + +def judge_with_baseline( + cfg: Q.QualityConfig | None = None, +) -> tuple[Q.QualityJudge, Clock]: + judge, clock = ( + Q.QualityJudge(cfg or Q.QualityConfig(), printer="u1", job="sheet.gcode"), + Clock(), + ) + for _ in range(4): + v = judge.feed(Q.QualityReading(clock.next(), nums(), layer=1)) + assert v.state == Q.LEARNING + assert judge.base is not None + return judge, clock + + +def feed( + judge: Q.QualityJudge, clock: Clock, n: int, **kw: float +) -> list[Q.QualityVerdict]: + return [ + judge.feed(Q.QualityReading(clock.next(), nums(**kw), layer=5, z=1.0)) + for _ in range(n) + ] + + +def test_the_first_readings_with_the_part_on_the_plate_are_the_baseline_and_are_never_judged() -> ( + None +): + judge = Q.QualityJudge(Q.QualityConfig()) + for _ in range(3): + assert ( + judge.feed(Q.QualityReading(0, nums(0.09, 0.84), layer=1)).state + == Q.LEARNING + ) + assert judge.base is None + v = judge.feed(Q.QualityReading(1, nums(0.09, 0.84), layer=1)) + assert ( + v.state == Q.LEARNING + and judge.base is not None + and judge.base.fleck == pytest.approx(0.09) + ) + + +def test_blind_and_absent_readings_are_counted_but_never_judged_and_never_clean() -> ( + None +): + judge = Q.QualityJudge() + assert ( + judge.feed( + Q.QualityReading(0, {"status": "blind", "reason": "plate out of view"}) + ).state + == Q.BLIND + ) + assert judge.feed(Q.QualityReading(1, nums(), present=False)).state == Q.ABSENT + assert ( + judge.report.skipped == {"blind": 1, "absent": 1} + and sum(judge.report.per_level.values()) == 0 + ) + assert judge.report.max_level == Q.OK + + +def test_debris_is_stringing_and_a_single_bad_frame_is_not_a_finding() -> None: + judge, clock = judge_with_baseline() + (one,) = feed(judge, clock, 1, fleck=0.08, order=0.80) + assert ( + one.raw == Q.SEVERE + and one.issue == Q.STRINGING + and one.level == Q.OK + and one.new == [] + ) + (clean,) = feed(judge, clock, 1) + assert clean.new == [] and clean.level == Q.OK + + +def test_two_bad_readings_warn_once_then_severe_once_and_never_again() -> None: + judge, clock = judge_with_baseline() + cfg = judge.cfg + verdicts = feed(judge, clock, 2, fleck=0.045, order=0.83) # warning level twice + assert [f.kind for v in verdicts for f in v.new] == ["quality_stringing_warning"] + sev = feed(judge, clock, 3, fleck=0.09, order=0.70) # severe three times + assert [f.kind for v in sev for f in v.new] == ["quality_stringing_severe"] + assert judge.level == Q.SEVERE + again = feed(judge, clock, 5, fleck=0.09, order=0.70) + assert [f for v in again for f in v.new] == [], "one alert per issue and level" + assert cfg.severe_confirm == 3 and judge.report.max_level == Q.SEVERE + assert [a["kind"] for a in judge.report.alerts] == [ + "quality_stringing_warning", + "quality_stringing_severe", + ] + + +def test_a_quality_finding_is_never_critical_and_maps_to_watch_severities() -> None: + assert set(Q.WATCH_SEVERITY.values()) == {"info", "warning"} + judge, clock = judge_with_baseline() + out = [f for v in feed(judge, clock, 6, fleck=0.1, order=0.6) for f in v.new] + assert out and all(f.severity in ("info", "warning") for f in out) + assert all("critical" not in f.severity for f in out) + + +def test_the_level_clears_after_clean_readings_and_may_warn_again_only_for_a_new_level() -> ( + None +): + judge, clock = judge_with_baseline() + feed(judge, clock, 3, fleck=0.045) + assert judge.level == Q.WARNING + cleared = feed(judge, clock, judge.cfg.clear_after) + assert cleared[-1].level == Q.OK and "back to normal" in " ".join(cleared[-1].notes) + again = feed(judge, clock, 3, fleck=0.045) + assert [f for v in again for f in v.new] == [], ( + "already told once for this issue and level" + ) + + +def test_pattern_loss_without_debris_is_texture() -> None: + judge, clock = judge_with_baseline() + verdicts = feed(judge, clock, 3, fleck=0.015, order=0.66) # order dropped 0.18 + kinds = [f.kind for v in verdicts for f in v.new] + assert kinds and kinds[0].startswith("quality_texture_") + assert "полосы" in [f.text for v in verdicts for f in v.new][0] + + +def test_the_first_layer_floor_needs_no_baseline_and_a_bad_first_layer_never_makes_the_baseline() -> ( + None +): + judge = Q.QualityJudge(Q.QualityConfig()) + verdicts = [ + judge.feed(Q.QualityReading(float(i), nums(0.02, 0.40), layer=1)) + for i in range(2) + ] + assert verdicts[0].issue == Q.FIRST_LAYER and verdicts[0].raw == Q.SEVERE + assert judge.base is None and judge.report.baseline is None + kinds = [f.kind for v in verdicts for f in v.new] + assert ( + kinds == ["quality_first_layer_warning"] + or kinds == ["quality_first_layer_severe"] + or kinds[0].startswith("quality_first_layer") + ) + assert "первый слой" in [f.text for v in verdicts for f in v.new][0] + + +def test_without_a_layer_number_z_decides_what_a_first_layer_is() -> None: + judge = Q.QualityJudge(Q.QualityConfig()) + assert ( + judge.feed(Q.QualityReading(0, nums(0.02, 0.5), z=0.3)).issue == Q.FIRST_LAYER + ) + assert judge.feed(Q.QualityReading(1, nums(0.02, 0.5), z=5.0)).issue is None + + +def test_a_rising_debris_trend_lifts_a_notice_to_a_warning() -> None: + judge, clock = judge_with_baseline( + Q.QualityConfig(notice_abs=0.02, warning_abs=0.09, severe_abs=0.2) + ) + verdicts = [] + for i in range(8): # 1.9x .. 2.5x the baseline: notice level by ratio, rising fast + v = judge.feed( + Q.QualityReading( + clock.next(), nums(fleck=0.021 + 0.003 * i), layer=5, z=1.0 + ) + ) + verdicts.append(v) + assert any( + v.trend is not None and v.trend >= judge.cfg.trend_rise for v in verdicts + ) + assert any("rising" in " ".join(v.notes) for v in verdicts) + + +def test_a_clean_reading_moves_the_baseline_a_debris_reading_does_not() -> None: + judge, clock = judge_with_baseline() + before = judge.base.fleck # type: ignore[union-attr] + feed(judge, clock, 3, fleck=0.016) + assert judge.base.fleck > before # type: ignore[union-attr] + grown = judge.base.fleck # type: ignore[union-attr] + feed(judge, clock, 3, fleck=0.09) + assert judge.base.fleck == grown # type: ignore[union-attr] + + +def test_the_notice_level_is_info_only_and_a_spotless_baseline_has_a_floor() -> None: + judge = Q.QualityJudge(Q.QualityConfig()) + for _ in range(4): + judge.feed(Q.QualityReading(0, nums(0.001), layer=1)) + clock = Clock() + # 0.031 is 31x a spotless baseline but only 2.6x its floor, and under the warning's absolute level + new = [ + f + for _ in range(3) + for f in judge.feed( + Q.QualityReading(clock.next(), nums(0.031), layer=5, z=1.0) + ).new + ] + assert [f.kind for f in new] == ["quality_stringing_notice"] + assert new[0].severity == "info" and judge.level == Q.NOTICE + + +def test_a_slowly_growing_debris_is_not_absorbed_into_the_baseline() -> None: + judge, clock = judge_with_baseline() + for i in range(60): + judge.feed( + Q.QualityReading( + clock.next(), nums(fleck=0.014 + 0.0008 * i), layer=5, z=1.0 + ) + ) + assert ( + judge.base is not None + and judge.base.fleck <= 0.014 * judge.cfg.adapt_fleck_cap + 1e-9 + ) + assert judge.level != Q.OK, ( + "0.062 after an hour of creeping is stringing, however slowly it came" + ) + + +def test_absolute_verdict_is_what_a_single_frame_can_say() -> None: + cfg = Q.QualityConfig() + assert Q.absolute_verdict(nums(0.014), 1.2, cfg) == (None, Q.OK) + assert Q.absolute_verdict(nums(0.04), 1.2, cfg) == (Q.STRINGING, Q.WARNING) + assert Q.absolute_verdict(nums(0.08), 1.2, cfg) == (Q.STRINGING, Q.SEVERE) + assert Q.absolute_verdict(nums(0.014, 0.5), 0.3, cfg) == (Q.FIRST_LAYER, Q.WARNING) + assert Q.absolute_verdict(nums(0.014, 0.5), 5.0, cfg) == (None, Q.OK), ( + "the floor is for first layers only" + ) + assert Q.absolute_verdict({"status": "blind"}, 1.0, cfg) == (None, Q.OK) + assert Q.absolute_verdict(nums(0.09, 0.84), 0.3, cfg) == (None, Q.OK), ( + "a bare first-layer plate sparkles" + ) + + +def test_owner_text_is_russian_short_and_says_what_to_change() -> None: + v = Q.QualityVerdict(Q.WARNING, fleck_ratio=3.2) + text = Q.owner_text(Q.STRINGING, Q.WARNING, v) + assert ( + "ниточки" in text and "3.2" in text and "Что менять" in text and len(text) < 330 + ) + assert Q.owner_text( + Q.TEXTURE, Q.SEVERE, Q.QualityVerdict(Q.SEVERE, order_drop=0.2) + ).startswith("усиливается") + + +def test_quality_config_from_devices_yaml() -> None: + assert ( + Q.quality_config(None) == Q.QualityConfig() + and Q.quality_config(False).enabled is False + ) + assert Q.quality_config({"baseline_n": 6, "warning_ratio": 2}).baseline_n == 6 + for bad in ({"nope": 1}, {"baseline_n": "x"}, {"enabled": 1.5}, [1]): + with pytest.raises(InvalidArgument): + Q.quality_config(bad) + + +def test_the_report_counts_everything_and_renders_for_a_human(tmp_path: Path) -> None: + judge, clock = judge_with_baseline() + feed(judge, clock, 2) + feed(judge, clock, 4, fleck=0.09, order=0.62) + judge.feed(Q.QualityReading(clock.next(), {"status": "blind", "reason": "x"})) + rep = judge.report + assert rep.readings == 11 and rep.skipped == {"learning": 4, "blind": 1} + assert rep.per_level["ok"] == 2 and rep.per_level["severe"] == 4 + assert rep.worst is not None and rep.worst["level"] == "severe" + assert "11 readings (6 judged, 5 not" in summary_line(rep) + md = render_markdown(rep) + assert ( + "| severe | 4 |" in md + and "Baseline (the job's own first layers, 4 readings)" in md + ) + frame = tmp_path / "frame.jpg" + frame.write_bytes(b"jpeg") + rep.worst["frame"] = str(frame) + folder = write_report(rep, tmp_path / "runs", "u1") + assert folder.name.endswith("-u1-monitor-report") + assert json.loads((folder / "report.json").read_text())["max_level"] == "severe" + assert (folder / "worst-frame.jpg").read_bytes() == b"jpeg" + + +def test_a_job_with_nothing_wrong_still_gets_an_honest_report() -> None: + judge, clock = judge_with_baseline() + feed(judge, clock, 5) + md = render_markdown(judge.report) + assert "Alerts sent: none." in md and "Worst reading: none" in md From b39c2d601552aa6a0ecaa21bf0d766b03c775c24 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:26:13 +0100 Subject: [PATCH 19/30] feat(printer): job watch runs the print-quality tier and writes the monitoring report (Refs #733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/-NAME- monitor-report/ gets report.json, report.md and worst-frame.jpg. --no-quality and .watch.quality (false or any QualityConfig field) control it. Tests replay the real U1 frames through a fixture scenario. --- docs/commands/printer.md | 38 +++- lib/commands/printer.py | 5 +- lib/printer_ops/jobwatch_quality.py | 175 ++++++++++++++++ lib/printer_ops/jobwatch_run.py | 76 ++++++- tests/test_printer_quality_watch.py | 308 ++++++++++++++++++++++++++++ 5 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 lib/printer_ops/jobwatch_quality.py create mode 100644 tests/test_printer_quality_watch.py diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 02f5a44e..b5bba60c 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -926,7 +926,7 @@ alerts an agent): this one *acts*, so it needs `--yes` — you authorise a pause ``` 3d printer watch NAME --job --yes [--on-fail auto|pause|cancel] [--gcode FILE.gcode] [--out DIR] [--interval S] [--checkpoints L1@25%,L1@50%,L1@100%,25%,50%,100%] [--dry-run | --tg-dry-run] - [--fixture SCENARIO.json] [--duration S] [--wait-start S] + [--fixture SCENARIO.json] [--duration S] [--wait-start S] [--no-quality] ``` Start it right after `3d printer start NAME FILE --yes` (or give `start` [`--watch`](#start---watch)); @@ -946,6 +946,8 @@ quarter, half and all of the first layer — the first-layer marks need `--gcode - *Signals.* The same Moonraker events as the always-on watch (filament runout, heater loss, bed off, exception, G-code error, homing lost), and the printer's own defect detection (`noodle` verdicts with p >= 0.8, read from klippy.log). +- *Print quality* ([below](#print-quality-monitor)). Is the print GOOD, not just still there: stringing + debris, a lost bead pattern (stripes, roughness), an uneven first layer. Warnings only, never a stop. **Actions on a critical finding**, in this order, each step runs even if an earlier one failed: @@ -959,6 +961,40 @@ quarter, half and all of the first layer — the first-layer marks need `--gcode A warning (part moved, nothing seen, a heater drift) is only a tg message with the frame; nothing is touched and nobody is called. + +**The print-quality monitor** (#733; `lib/print_quality.py` measures, `printer_ops/quality.py` decides) runs +on the same frame as the part check, in the plate region at the print's own Z. Why it exists: on 2026-11-05 a +10 h sheet of small tags stringed from layer 2 on — white thread debris all over the part — while the part +check (it only asks "is the part where it was") and the spaghetti model (no stringing class: p 0.0 on all +12 frames) stayed silent. + +| number | what it is | +|---|---| +| `fleck_frac` | share of the plate region covered by small bright **neutral** specks and strands (the top-hat of the minimum colour channel: high only where R, G and B are all high, i.e. white debris — not beige part, not golden specular grain) | +| `order` | structure-tensor coherence: 1 = every neighbourhood has one dominant bead direction, lower = debris, webs, broken or striped lines | + +The judge compares them with the **job's own first layers**: the first 4 readings with the part on the plate +(bed_part says so) are the baseline, never judged against anything but a floor; a clean reading drifts it a +little (bounded at 1.3x debris / 0.04 order), a bad one never does. Issues: `stringing` (debris against the +baseline AND an absolute level), `texture` (the pattern got lost with no debris: stripes, roughness, +under-/over-extrusion), `first_layer` (an absolute floor on `order` while layer <= 3). Levels: `notice` +(report only) < `warning` < `severe`; a level is reported when 2 of the last 4 readings reach it (3 of 4 +for severe), it is latched and clears after 6 clean readings. A debris trend (ratio to the baseline gained +per 10 min) lifts a notice to a warning. + +A quality finding is **never critical**: one `tg --photo FRAME ""` per issue and level (the frame has the debris marked in red), nobody is rung, the job is not +touched. Part gone, spaghetti and the noodle verdict still act as above. `--no-quality`, or +`.watch.quality: false`, turns it off; `.watch.quality: {baseline_n: 4, warning_ratio: 2.0, +warning_abs: 0.035, ...}` tunes any `QualityConfig` field (a wrong key is an error naming the valid ones). + +**The monitoring report.** When the watch ends — the job completed, a lost print stopped it, or `--duration` +ran out — `DIR/-NAME-monitor-report/` holds `report.json` (readings, counts per severity and per issue, +the alerts that were sent, the baseline, the worst reading, the timeline), `report.md` (the same for a human) +and `worst-frame.jpg`. A job with nothing wrong still gets one ("0 warnings in 41 judged readings"): silence +then means *checked and fine*, not *never ran*. Calibration, limits and the numbers on the real frames: +[print-quality-monitor.md](../research/print-quality-monitor.md). + **`--on-fail`.** `auto` (default): CANCEL when the part is gone, or on a camera finding in the first layers (layer <= 3); PAUSE for everything else (runout, heater, later spaghetti: resumable). `pause` / `cancel`: always that. Defaults can also be set in `.watch.job` (`on_fail`, diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 8f339679..7f74813d 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -76,7 +76,10 @@ CANCEL (--on-fail), save the frames + klippy lines to print/runs/, `tg call me --say`, tg with the frame. Start it right after `printer start`. Never moves an axis; --dry-run, - --fixture (see `3d printer watch --job --help`) + --fixture (see `3d printer watch --job --help`). Also the + PRINT-QUALITY monitor (stringing debris, lost bead pattern, + uneven first layer): tg --photo warnings only, never a stop, + and a monitor-report/ folder at the end; --no-quality supervise status|install|uninstall|quiet [NAME...|--all] the ALWAYS-ON supervision: one watch + one vision systemd user unit per owned printer from the fleet registry, running without a login, waiting diff --git a/lib/printer_ops/jobwatch_quality.py b/lib/printer_ops/jobwatch_quality.py new file mode 100644 index 00000000..79b40615 --- /dev/null +++ b/lib/printer_ops/jobwatch_quality.py @@ -0,0 +1,175 @@ +"""jobwatch_quality.py — the print-quality monitor inside `3d printer watch NAME --job`. + +One camera check of the job watch (jobwatch_run.JobWatch.check) already has the frame and the bed_part reading +(is the part on the plate). `QualityWatch.check` takes the same frame, asks lib/print_quality.py for its numbers +(a subprocess with numpy + OpenCV, like bed_part.py), feeds the pure `QualityJudge` (printer_ops/quality.py) and +returns the NON-critical jobwatch Findings it owes the owner: one per issue and level (warning, severe). A +`notice` is not a finding: it only counts in the report. A quality finding is never critical, so `decide` +answers `alert` for it: the owner gets `tg --photo FRAME ""` and the job is not touched. + +`finish` writes the monitoring report (quality_report.py) when the watch ends — also when it ends because a part +was lost: what the monitor saw until then is part of the evidence. + +INVARIANTS: read-only like the rest of the camera side; a tool failure is a `blind` reading (counted in the report), +never an exception that stops the watch. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from printer_ops.jobwatch import Finding, JobConfig, Observation +from printer_ops.quality import ( + NOTICE, + QualityConfig, + QualityFinding, + QualityJudge, + QualityReading, + QualityVerdict, +) +from printer_ops.quality_report import summary_line, write_report + +QualityTool = Callable[[list[str]], dict[str, Any]] +ROTATE = 4 # annotated frames kept (the evidence copies live in the alert's own folder) + + +def run_quality_tool(argv: list[str]) -> dict[str, Any]: + """print_quality.py through cli.pyrun (numpy + OpenCV): its JSON answer, or a blind reading saying why not.""" + from cli.pyrun import tool_argv + from printer_ops.bed import BED_DEPS + + try: + r = subprocess.run( + tool_argv(BED_DEPS, "print_quality.py", argv), + capture_output=True, + text=True, + timeout=120, + ) + if r.returncode != 0: + tail = (r.stderr or "")[-200:] + return { + "status": "blind", + "reason": f"the quality tool failed (exit {r.returncode}): {tail}", + } + return dict(json.loads(r.stdout)) + except (OSError, subprocess.SubprocessError, ValueError) as exc: + return {"status": "blind", "reason": f"the quality tool failed: {exc}"} + + +def part_present(o: Observation, cfg: JobConfig) -> bool: + """Something stands on the plate: bed_part says so (the same test as the part's `seen`); without a bed_part + reading (blind) the print is taken to have a part from layer 2 on.""" + part = o.part + if part is not None and part.status == "ok": + return part.changed >= cfg.present_min and part.blob_px >= cfg.present_blob_px + return (o.layer or 0) >= 2 + + +class QualityWatch: + def __init__( + self, + name: str, + run_dir: Path, + tool: QualityTool, + cfg: QualityConfig, + job_cfg: JobConfig, + job: str, + ) -> None: + self.name, self.dir, self.tool, self.job_cfg = name, run_dir, tool, job_cfg + self.judge = QualityJudge(cfg, printer=name, job=job) + self.n = 0 + self.last: QualityReading | None = None + self.last_verdict: QualityVerdict | None = None + + # -- one reading + + def check(self, jpeg: bytes, o: Observation) -> list[Finding]: + """Measure the frame, judge it, and return the findings to alert (warning / severe only).""" + if self.last is not None and o.now - self.last.now < self.judge.cfg.min_gap_s: + self.last_verdict = None # a recheck of the part check, seconds after the last look: nothing new to say + return [] + reading = self.read(jpeg, o) + v = self.judge.feed(reading) + self.last, self.last_verdict = reading, v + if v.worst: + self.pin(reading) + return [self.finding(f) for f in v.new if f.level != NOTICE] + + def read(self, jpeg: bytes, o: Observation) -> QualityReading: + self.n += 1 + frame = self.dir / "quality-frame.jpg" + annotated = self.dir / f"quality-annotated-{self.n % ROTATE}.jpg" + frame.write_bytes(jpeg) + argv = [ + "check", + self.name, + "--frame", + str(frame), + "--z", + str(o.z if o.z is not None else 0.0), + "--annotated", + str(annotated), + "--json", + ] + numbers = self.tool(argv) + return QualityReading( + o.now, + numbers, + layer=o.layer, + z=o.z, + progress=o.progress, + present=part_present(o, self.job_cfg), + frame=str(frame), + annotated=str(annotated) if numbers.get("annotated") else None, + ) + + def pin(self, r: QualityReading) -> None: + """Keep the worst frame: the rotating files are overwritten by the next readings.""" + src = r.annotated or r.frame + if src and Path(src).is_file(): + dst = self.dir / "quality-worst.jpg" + shutil.copyfile(src, dst) + self.judge.pin_worst(str(dst)) + + @staticmethod + def finding(f: QualityFinding) -> Finding: + return Finding(f.kind, False, f.text, dict(f.data)) + + def alert_files(self) -> dict[str, str | None]: + """The frames to send with the alert: the one with the debris marked first, then the plain one.""" + r = self.last + if r is None: + return {} + return {"frame.quality.jpg": r.annotated, "frame.raw.jpg": r.frame} + + # -- the end + + def note(self) -> str | None: + """The log line for the last reading (None: nothing worth saying).""" + v, r = self.last_verdict, self.last + if v is None or r is None: + return None + n = r.numbers + if v.state in ("blind", "absent"): + return f"quality {v.state}: {'; '.join(v.notes)}" + return ( + f"quality {v.state} (level {v.level}): fleck {n.get('fleck_frac')} order {n.get('order')}" + + ( + f" ratio {v.fleck_ratio} drop {v.order_drop}" + if v.fleck_ratio is not None + else "" + ) + ) + + def finish(self, out_dir: Path) -> tuple[Path | None, str]: + """Write the monitoring report; -> (its folder, the one-line summary). No readings: no report.""" + if self.judge.report.readings == 0: + return None, "print-quality monitor: no camera reading was taken" + return write_report(self.judge.report, out_dir, self.name), summary_line( + self.judge.report + ) diff --git a/lib/printer_ops/jobwatch_run.py b/lib/printer_ops/jobwatch_run.py index 63a85a10..fc212ce1 100644 --- a/lib/printer_ops/jobwatch_run.py +++ b/lib/printer_ops/jobwatch_run.py @@ -9,6 +9,8 @@ the printer's own signals; on a lost print it stops the job (PAUSE or CANCEL, see `--on-fail`), saves the frames and the klippy.log lines into `--out`, rings `tg call me --say ...` and sends the frame with `tg`. Decisions are in jobwatch.py, the stop/alert steps in jobwatch_actions.py, the readings in jobwatch_sources.py. +The print-QUALITY monitor (stringing debris, a lost bead pattern, an uneven first layer: warnings only, never a +stop) runs on the same frames (jobwatch_quality.py) and writes its monitoring report when the watch ends. NEVER: starts a print, moves an axis, resumes, sends G-code. The camera sees the plate at the print's own Z, so nothing is moved to look. Exit codes: 0 the job ended and nothing was lost, 3 a lost print was found (the stop @@ -46,9 +48,11 @@ parse_checkpoints, ) from printer_ops.jobwatch_actions import Bound, Responder, bind, find_tg +from printer_ops.jobwatch_quality import QualityWatch, run_quality_tool +from printer_ops.quality import QualityConfig, quality_config USAGE = """3d printer watch NAME --job --yes [--on-fail auto|pause|cancel] [--gcode FILE.gcode] [--out DIR] - [--interval S] [--checkpoints L1@25%,L1@50%,L1@100%,25%,50%,100%] [--dry-run | --tg-dry-run] + [--interval S] [--checkpoints L1@25%,L1@50%,L1@100%,25%,50%,100%] [--dry-run | --tg-dry-run] [--no-quality] [--fixture SCENARIO.json] [--duration S] [--wait-start S] Watch ONE running print and ABORT IT when it is lost. Start it right after `3d printer start NAME FILE --yes` (or give `start` --watch): it follows that job until it ends. Unlike the always-on `3d printer watch NAME` @@ -63,6 +67,16 @@ camera says `blind` — never "gone". signals the same Moonraker events as the always-on watch (filament runout, heater loss, bed off, exception, G-code error, homing lost) and the printer's own defect detection (`noodle` verdicts, p >= 0.8). + quality the PRINT-QUALITY monitor (printer_ops/quality.py, lib/print_quality.py): white stringing debris, + a lost bead pattern (stripes, roughness, under-/over-extrusion) and an uneven first layer, judged + against the job's OWN first layers (the first 4 readings with the part on the plate are the + reference). It sends `tg --photo FRAME ""` once per issue and level (warning, then + severe) and NEVER pauses or cancels: a bad surface is the owner's call. A `notice` is only + written to the report. When the job ends (or the watch stops) the MONITORING REPORT is written + to DIR/-NAME-monitor-report/ (report.json, report.md, worst-frame.jpg): readings per + severity and issue, the alerts, the baseline, the worst frame. `--no-quality` or + `.watch.quality: false` turns it off; `.watch.quality: {key: value}` tunes it + (the QualityConfig fields). On a critical finding: the job is stopped, the frames + finding.json + the klippy.log lines of the job are saved to DIR/-NAME-jobwatch/ (default ./print/runs), `tg call me --say ""` rings the owner, then `tg --photo FRAME ""` sends the picture. A warning (part moved, nothing seen, a heater drift) is a tg @@ -77,6 +91,7 @@ --checkpoints LIST extra checks (see above); `--checkpoints none` for the periodic one only --dry-run nothing is sent to the printer or the phone: the stop and both tg commands are printed --tg-dry-run the job is really stopped, the tg commands are only printed + --no-quality skip the print-quality monitor (and its report) --fixture FILE no network at all: the printer and the camera come from a scenario file with saved frames (implies --dry-run for the phone; a fixture printer records the stop it is given) --duration S stop watching after S seconds (default: until the job ends) @@ -99,6 +114,8 @@ "candidate.png", "track.json", "candidate-track.json", + "quality-frame.jpg", + "quality-worst.jpg", ) @@ -118,6 +135,7 @@ class Args: fixture: str | None duration: float | None wait_start: float | None + no_quality: bool = False class _Parser(argparse.ArgumentParser): @@ -145,6 +163,7 @@ def parse_args(argv: list[str]) -> Args: p.add_argument(flag, type=float) p.add_argument("--dry-run", action="store_true") p.add_argument("--tg-dry-run", action="store_true") + p.add_argument("--no-quality", action="store_true") a = p.parse_args(argv) return Args( a.name, @@ -161,6 +180,7 @@ def parse_args(argv: list[str]) -> Args: a.fixture, a.duration, a.wait_start, + a.no_quality, ) @@ -415,6 +435,7 @@ def __init__( clock: Callable[[], float], sleep: Callable[[float], None], echo: Callable[[str], None] = print, + quality: QualityWatch | None = None, ) -> None: self.source, self.camera, self.responder, self.cfg, self.tracker = ( source, @@ -425,6 +446,7 @@ def __init__( ) self.clock, self.sleep, self.echo = clock, sleep, echo self.judge = JobJudge(cfg) + self.quality = quality self.next_check = 0.0 self.recheck_at: float | None = None self.ended = 0 @@ -448,6 +470,7 @@ def due(self, o: Observation, now: float) -> bool: def check(self, o: Observation, now: float) -> Verdict: why = None + jpeg: bytes | None = None try: jpeg = self.source.grab() why = self.camera.ensure_baseline(jpeg, o) @@ -457,6 +480,8 @@ def check(self, o: Observation, now: float) -> Verdict: o.noodles = self.source.noodles() v = self.judge.step(o) self.camera.learn(v) + if self.quality is not None and jpeg is not None: + v.findings.extend(self.quality.check(jpeg, o)) first = o.layer is not None and o.layer <= 1 self.next_check = now + ( self.cfg.first_layer_interval_s if first else self.cfg.interval_s @@ -476,6 +501,8 @@ def remember(self, o: Observation, v: Verdict) -> None: ) + "".join(f"; {x}" for x in v.notes) ) + if self.quality is not None and (note := self.quality.note()): + self.say(note) if o.part is not None and o.part.frame: self.last_files = { "frame.annotated.jpg": o.part.annotated, @@ -535,11 +562,19 @@ def handle(self, findings: list[Finding], o: Observation) -> int | None: "alert", fresh, layer=o.layer, - files=self.last_files, + files=self.alert_files(fresh), extra={"observations": self.history, "action": "alert"}, ) return None + def alert_files(self, fresh: list[Finding]) -> dict[str, str | None]: + """The frames sent with an alert: the quality picture (debris marked) for a quality alert.""" + if self.quality is not None and all( + f.kind.startswith("quality_") for f in fresh + ): + return {**self.quality.alert_files(), **self.last_files} + return self.last_files + def wait_for_job(self, deadline: float) -> bool: while self.clock() < deadline: o = self.poll(self.clock()) @@ -549,6 +584,20 @@ def wait_for_job(self, deadline: float) -> bool: return False def run(self, duration: float | None) -> int: + """Watch until the job ends; the monitoring report is written whatever way the watch ends.""" + try: + return self._run(duration) + finally: + self.report() + + def report(self) -> None: + if self.quality is None: + return + folder, line = self.quality.finish(self.cfg.out_dir) + if folder is not None: + self.say(f"{line} -> {folder}") + + def _run(self, duration: float | None) -> int: start = self.clock() if not self.wait_for_job(start + self.cfg.wait_start_s): self.say("no job is printing: nothing to watch") @@ -708,11 +757,14 @@ def run(argv: list[str]) -> int: args, dict((device.watch or {}).get("job") or {}), default_out_dir(args.name) ) tracker = CheckpointTracker(cfg.checkpoints, first_layer_range(args.gcode)) + quality = quality_config((device.watch or {}).get("quality")) with one_watch_per_printer(args.name): - return _run_watch(args, cfg, tracker) + return _run_watch(args, cfg, tracker, quality) -def _run_watch(args: Args, cfg: JobConfig, tracker: CheckpointTracker) -> int: +def _run_watch( + args: Args, cfg: JobConfig, tracker: CheckpointTracker, quality_cfg: QualityConfig +) -> int: from cli.paths import state_dir run_dir = state_dir() / "jobwatch" / args.name @@ -729,7 +781,21 @@ def _run_watch(args: Args, cfg: JobConfig, tracker: CheckpointTracker) -> int: ): # a new job starts from nothing: no baseline or footprint of the last one (run_dir / name).unlink(missing_ok=True) camera = CameraCheck(args.name, run_dir, run_part_tool, cfg) - watch = JobWatch(source, camera, responder, cfg, tracker, clock=now, sleep=sleep) + quality = ( + None + if args.no_quality or not quality_cfg.enabled + else QualityWatch( + args.name, + run_dir, + run_quality_tool, + quality_cfg, + cfg, + responder.bound.filename, + ) + ) + watch = JobWatch( + source, camera, responder, cfg, tracker, clock=now, sleep=sleep, quality=quality + ) print( f"watching {args.name}: {responder.bound.filename} (job {responder.bound.job_id or '?'}), every " f"{cfg.interval_s:g} s, on-fail {cfg.on_fail}, evidence -> {cfg.out_dir}" diff --git a/tests/test_printer_quality_watch.py b/tests/test_printer_quality_watch.py new file mode 100644 index 00000000..0e0404cf --- /dev/null +++ b/tests/test_printer_quality_watch.py @@ -0,0 +1,308 @@ +"""`3d printer watch NAME --job`: the print-quality tier on REAL U1 frames (tests/data/quality). + +The frames are the camera of the Snapmaker U1 during the 10 h SIMBOL sheet job of 2026-11-05, downscaled to 640 px: +`u1_f1` the empty plate, `u1_f5` the clean first layer, `u1_f7` / `u1_f10` / `u1_f12` the stringing debris of layers +2-4 that NOTHING flagged on the day (the spaghetti model scored 0.0 on all twelve frames, the job watch only asks +whether the part is still there). The printer is a fixture scenario on a virtual clock; the real `tg` is refused +under pytest and the fixture forces its dry run, so nothing here touches a printer, a camera or a phone. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from printer_ops import jobwatch_run as R + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import bed_part # noqa: E402 (needs numpy + OpenCV) +import print_quality # noqa: E402 + +DATA = Path(__file__).parent / "data" / "quality" +DEVICES = """u1: + adapter: moonraker + host: 127.0.0.1 + port: 1 + model: Snapmaker U1 + watch: + vision: {snapshot: "http://127.0.0.1:1/?action=snapshot"} +""" + + +@pytest.fixture(autouse=True) +def xdg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for var, sub in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ): + monkeypatch.setenv(var, str(tmp_path / sub)) + cfg = tmp_path / "config" / "3d-cli" + cfg.mkdir(parents=True) + (cfg / "printer-devices.yaml").write_text(DEVICES, encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + R, + "run_part_tool", + lambda argv: bed_part.run_check(bed_part.build_parser().parse_args(argv)), + ) + monkeypatch.setattr( + R, + "run_quality_tool", + lambda argv: print_quality.run_check( + print_quality.build_parser().parse_args(argv) + ), + ) + return tmp_path + + +def at(t: float, frame: str, layer: int) -> dict[str, Any]: + return { + "t": t, + "state": "printing", + "layer": layer, + "total_layer": 100, + "progress": layer / 100, + "z": 0.2 + 0.2 * layer, + "frame": frame, + } + + +def end(t: float) -> dict[str, Any]: + return { + "t": t, + "state": "complete", + "layer": 100, + "progress": 1.0, + "frame": "u1_f1.jpg", + } + + +# the first layer is on the plate (the empty plate is the job's baseline frame), then debris grows on layers 2-4 +HEALTHY = [ + at(0, "u1_f1.jpg", 1), + *[at(15 * i, "u1_f5.jpg", 1) for i in range(1, 6)], + at(120, "u1_f5.jpg", 2), +] +STRINGY = [ + at(180, "u1_f7.jpg", 3), + at(240, "u1_f10.jpg", 3), + at(300, "u1_f12.jpg", 4), + at(360, "u1_f12.jpg", 4), +] + + +def watch( + tmp_path: Path, + steps: list[dict[str, Any]], + *flags: str, + capsys: pytest.CaptureFixture[str], +) -> tuple[int, str, list[dict[str, Any]]]: + for name in {s["frame"] for s in steps}: + (tmp_path / name).write_bytes((DATA / name).read_bytes()) + scenario = tmp_path / "scenario.json" + scenario.write_text( + json.dumps({"printer": "u1", "file": "sheet.gcode", "steps": steps}), + encoding="utf-8", + ) + code = R.run( + [ + "u1", + "--job", + "--fixture", + str(scenario), + "--out", + str(tmp_path / "runs"), + "--duration", + "1500", + "--checkpoints", + "none", + *flags, + ] + ) + out = capsys.readouterr().out + outcomes = [ + json.loads(line)["jobwatch"] + for line in out.splitlines() + if line.startswith('{"jobwatch"') + ] + return code, out, outcomes + + +def would_run(out: str) -> list[list[str]]: + return [ + json.loads(line)["would_run"] + for line in out.splitlines() + if line.startswith('{"would_run"') + ] + + +def quality_only(outcomes: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The job watch's own part check (bed_part) also reads these frames: the toolhead and the light differ from + frame to frame, so it may add a `part_moved` WARNING of its own. This file is about the quality tier.""" + return [o for o in outcomes if o["findings"][0]["kind"].startswith("quality_")] + + +def report_dir(tmp_path: Path) -> Path: + (d,) = (tmp_path / "runs").glob("*-u1-monitor-report") + return d + + +def test_stringing_debris_on_real_frames_is_warned_once_and_never_stops_the_job( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, outcomes = watch( + tmp_path, [*HEALTHY, *STRINGY, end(500)], "--tg-dry-run", capsys=capsys + ) + assert code == 0, "a quality finding never fails the watch" + (alert,) = quality_only(outcomes) + assert alert["findings"][0]["kind"] == "quality_stringing_warning" + assert alert["action"] == "alert" + assert all( + s["name"] not in ("pause", "cancel", "call") + for o in outcomes + for s in o["steps"] + ) + msgs = [m for m in would_run(out) if "ниточки" in m[-1]] + assert ( + len(msgs) == 1 + and msgs[0][:3] == ["tg", "--tag", "problem"] + and "--photo" in msgs[0] + ) + assert all(m[:2] != ["tg", "call"] for m in would_run(out)), ( + "the owner is not rung for a bad surface" + ) + text = msgs[0][-1] + assert "белые ниточки" in text and "Что менять" in text and "температур" in text + assert "Evidence:" in text # the owner can find the frame + + +def test_the_clean_first_layer_raises_nothing_and_the_report_says_it_was_checked( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, outcomes = watch( + tmp_path, + [*HEALTHY, at(300, "u1_f5.jpg", 4), end(400)], + "--tg-dry-run", + capsys=capsys, + ) + assert code == 0 and quality_only(outcomes) == [] + doc = json.loads((report_dir(tmp_path) / "report.json").read_text()) + assert ( + doc["max_level"] == "ok" + and doc["per_level"]["warning"] == 0 + and doc["per_level"]["severe"] == 0 + ) + assert doc["judged"] >= 1 and doc["baseline"]["n"] == 4 + assert doc["skipped"]["absent"] >= 1, ( + "the empty plate of the first frame is not judged" + ) + assert "print-quality monitor:" in out and "monitor-report" in out + + +def test_the_monitoring_report_counts_severities_and_keeps_the_worst_frame( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + watch(tmp_path, [*HEALTHY, *STRINGY, end(500)], "--tg-dry-run", capsys=capsys) + d = report_dir(tmp_path) + doc = json.loads((d / "report.json").read_text()) + assert doc["max_level"] == "warning" + assert ( + doc["per_level"]["severe"] >= 1 + and doc["per_level"]["warning"] >= 2 + and doc["per_issue"]["stringing"] >= 3 + ) + assert [a["kind"] for a in doc["alerts"]] == ["quality_stringing_warning"] + assert ( + doc["worst"]["issue"] == "stringing" + and doc["worst"]["level"] == "severe" + and doc["worst"]["layer"] == 3 + ) + assert (d / "worst-frame.jpg").is_file() and ( + d / "worst-frame.jpg" + ).stat().st_size > 1000 + md = (d / "report.md").read_text() + assert ( + "| severe |" in md + and "Alerts (one per issue and level" in md + and "worst-frame.jpg" in md + ) + + +def test_a_quality_alert_ships_the_frame_with_the_debris_marked( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _, _, outcomes = watch( + tmp_path, [*HEALTHY, *STRINGY, end(500)], "--tg-dry-run", capsys=capsys + ) + d = Path(quality_only(outcomes)[0]["evidence_dir"]) + assert (d / "frame.quality.jpg").is_file() and (d / "frame.raw.jpg").is_file() + marked = cv2.imread(str(d / "frame.quality.jpg")) + assert ( + marked[..., 2].astype(int) - marked[..., 0].astype(int) > 150 + ).sum() > 200, "red fleck pixels drawn in" + + +def test_no_quality_turns_the_tier_and_its_report_off( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + code, out, outcomes = watch( + tmp_path, + [*HEALTHY, *STRINGY, end(500)], + "--tg-dry-run", + "--no-quality", + capsys=capsys, + ) + assert code == 0 and quality_only(outcomes) == [] + assert not list((tmp_path / "runs").glob("*monitor-report")) + assert "print-quality monitor" not in out + + +def test_the_report_is_written_even_when_a_lost_part_ends_the_watch( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """What the monitor saw until the part came off is part of the evidence.""" + steps = [ + *HEALTHY, + at(180, "u1_f7.jpg", 3), + at(240, "u1_f10.jpg", 3), + at(300, "u1_f1.jpg", 20), + at(360, "u1_f1.jpg", 21), + ] + code, _, _ = watch(tmp_path, steps, "--tg-dry-run", capsys=capsys) + assert code == 3 + doc = json.loads((report_dir(tmp_path) / "report.json").read_text()) + assert doc["readings"] >= 5 + + +def test_a_watch_quality_block_tunes_the_thresholds_and_a_bad_key_is_refused( + tmp_path: Path, capsys: pytest.CaptureFixture[str], xdg: Path +) -> None: + from errors import InvalidArgument + + devices = xdg / "config" / "3d-cli" / "printer-devices.yaml" + unreachable = "{warning_ratio: 99, severe_ratio: 99, notice_ratio: 99, warning_drop: 9, severe_drop: 9, notice_drop: 9}" + devices.write_text(DEVICES + f" quality: {unreachable}\n", encoding="utf-8") + code, _, outcomes = watch( + tmp_path, [*HEALTHY, *STRINGY, end(500)], "--tg-dry-run", capsys=capsys + ) + assert code == 0 and quality_only(outcomes) == [], ( + "thresholds nobody can reach: nothing alerts" + ) + devices.write_text(DEVICES + " quality: {no_such_key: 1}\n", encoding="utf-8") + with pytest.raises(InvalidArgument, match="no_such_key"): + R.run( + [ + "u1", + "--job", + "--fixture", + str(tmp_path / "scenario.json"), + "--wait-start", + "5", + ] + ) From f77bb3fe0f89e6e21b5322e1712b6299d9ea31eb Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:27:15 +0100 Subject: [PATCH 20/30] feat(vision): printer vision runs the print-quality tier (Refs #733) 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__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. --- docs/architecture/lib-map.md | 1 + docs/commands/printer-vision.md | 34 ++++- lib/commands/printer.py | 3 +- lib/printer_ops/vision_cycle.py | 8 +- lib/vision_detect.py | 55 ++++++- lib/vision_quality.py | 232 +++++++++++++++++++++++++++++ tests/test_printer_vision_cycle.py | 19 +++ tests/test_vision_quality.py | 149 ++++++++++++++++++ 8 files changed, 492 insertions(+), 9 deletions(-) create mode 100644 lib/vision_quality.py create mode 100644 tests/test_vision_quality.py diff --git a/docs/architecture/lib-map.md b/docs/architecture/lib-map.md index 89ee282e..6a562fb0 100644 --- a/docs/architecture/lib-map.md +++ b/docs/architecture/lib-map.md @@ -334,6 +334,7 @@ Every current root Python module is listed here so future additions are intentio - `bed_photo.py` — the table photo of a finished print (#579): plan a lift of the bed to a viewing Z (`--lift MM` or `auto` from the camera calibration, within `bed_view.move_limits`), `bed_view.guarded_z_move`, a fresh camera frame, restore in a `finally`; dry run by default. Backs `3d photo capture`; the camera/printer are injected so tests never touch a printer. - `bed_part.py` — the pixel half of `3d printer watch NAME --job` (#723): the changed share of the plate region against the job's empty-plate baseline (homography-warped to the current Z with a camera calibration; without one the plate is FOLLOWED on the picture — a chained ECC affine transform saved with every healthy footprint, `--track`/`--save-track`, so the baseline survives the bed's drift), and how much of the part's learnt footprint is still changed / how much changed beyond it; `blind` (never "gone") when the plate is out of view, cannot be followed, or the baseline cannot stand. Stateless, read-only; decisions live in `printer_ops/jobwatch.py`. - `print_quality.py` — the pixel half of the print-quality monitor (#733): in the plate region of a frame (640 px wide) `fleck_frac` (small bright NEUTRAL specks and strands — the top-hat of the minimum colour channel — i.e. stringing debris), `order` (energy-weighted structure-tensor coherence: how regular the bead pattern is) and `luma`; `blind` when the plate is out of view. Stateless, read-only; run by `printer_ops/jobwatch_quality.py` as a subprocess and imported by `vision_quality.py`. Decisions live in `printer_ops/quality.py`; calibration in `docs/research/print-quality-monitor.md`. +- `vision_quality.py` — the print-quality tier of `3d printer vision` (#733): `QualityTier` measures each frame (`print_quality.py`), judges it with `printer_ops/quality.py` against the print's own first layers and returns `Finding`s (`quality__`, warning/severe -> a WARNING event with the marked frame, notice -> info; never critical, never a pause); `--once` reports the absolute verdict of one frame; writes the per-print monitoring report when the print ends. - `lib/geometry/mesh_metrics.py` — pinned-convention 3D shape metrics (Chamfer, F-score@tau, Hausdorff, normal consistency, volumetric IoU) between two meshes; backs `3d metrics geometry`. - `perceptual_metrics.py` — perceptual/semantic image metrics (PSNR, LPIPS, CLIP-sim) with explicit senses and graceful degradation; backs `3d metrics perceptual`. - `metrics.py` — compatibility wrapper re-exporting `registries.metrics`. diff --git a/docs/commands/printer-vision.md b/docs/commands/printer-vision.md index 7417be04..4b38056e 100644 --- a/docs/commands/printer-vision.md +++ b/docs/commands/printer-vision.md @@ -9,7 +9,7 @@ from the device's `watch:` block in `printer-devices.yaml`). ```text 3d printer vision NAME [--interval S] [--once] [--save OUT.jpg] [--dry-run] [--min-severity LEVEL] [--snapshot URL|PATH] [--no-keepalive] [--no-npu] [--evidence DIR] [--verbose] - [--no-verify] [--no-geometry] [--scene] [--no-detach] [--model | --no-model] + [--no-verify] [--no-geometry] [--scene] [--no-detach] [--no-quality] [--model | --no-model] 3d printer vision eval --pos PATH... --neg PATH... [--every N] [--warmup N] [--interval S] [--lead-in N] [--printer NAME] [--no-verify] [--scene] [--no-detach] [--json] [--model [--model-class CLASS]] @@ -37,6 +37,11 @@ from the device's `watch:` block in `printer-devices.yaml`). - `--no-detach` — skip the part-detached check (`vision_part_detached`, below); same as `detach: false` in the device's `watch: vision:` block, which can also hold a mapping that tunes it. With `--once` the report adds `detach_gap` (0..1, null = the check abstained) and `part_detached` (gap at or above the absolute threshold). +- `--no-quality` — skip the print-quality tier (below) and its per-print monitoring report; same as + `watch.quality: false`. With `--once` the report adds a `quality` block: the numbers + (`fleck_frac`, `order`), the ABSOLUTE verdict of that one frame (`level`, `issue`, `basis`) — a single + frame has no job baseline, so it is a hint to look at the frame — and `findings` lists + `quality__`. Without the print position (Z) it says `status: blind` and why. - `--scene` — also run the bed-change detectors (`vision_foreign_object`, `vision_part_moved` — against a 3-12 min reference and, for slow drift, a 15-60 min one — and `vision_no_growth`). Off by default: they are still noisy (see [printer-vision-tuning.md](../research/printer-vision-tuning.md)). Same as `scene: true` in the @@ -95,6 +100,9 @@ from the device's `watch:` block in `printer-devices.yaml`). | `vision_part_detached` | warning, then critical | the printed part looks lifted/peeled off the plate: bare build plate shows where the near wall of the part should be (details below); on by default with the camera calibration; never pauses | | `vision_model_spaghetti` | warning | tier (b): the whole-frame model scores loose filament >= 0.5 on 3 model runs (30 s apart) in a row; a second opinion next to `vision_spaghetti`, never a pause | | `vision_model_part_detached`, `vision_model_empty_bed` | warning | tier (b), **off by default** (no skill zero-shot): only with a fitted head (`fit-head`) or a `threshold` in `watch.vision.model`; `vision_model_empty_bed` is only judged once the print is at least `min_z` (10 mm) high | +| `quality_stringing_warning`, `quality_stringing_severe` | warning | the print-quality tier: white stringing debris on the plate, against the print's own first layers (below); a WARNING event with the marked frame, once per issue and level, **never a pause** | +| `quality_texture_*`, `quality_first_layer_*` | warning | the bead pattern got lost with no debris (stripes, roughness, under/over-extrusion) / an uneven first layer (absolute floor) | +| `quality__notice` | info | the first sign of the same; only subscribers that asked for info | | `vision_foreign_object`, `vision_part_moved` | warning | only with `--scene`: something new on the bed outside the parts / a part's silhouette changed | | `vision_no_growth` | info | only with `--scene`: the newest layers of a part look empty (under- or no extrusion) | | `printer_noodle_verdict` | warning | the U1's own NPU detector logs a defect label (table below) at or above 0.83 (once per episode, 5 min quiet ends it) | @@ -336,6 +344,25 @@ an `auto_pause` event in `printer-agents/NAME/events.jsonl` and the journal. printing and logs each verdict to klippy.log; vision tails that log over HTTP Range requests. Moonraker does not expose the detector itself (`camera.detect_capture` → -32601). +## Print quality (stringing, pattern loss, first layer) + +Tiers (a) and (b) look for FAILURES. None of them knows stringing: the Obico model scored p 0.0 / q 0.0 on all +12 frames of the 2026-11-05 sheet job whose layers 2-4 were covered in white thread debris, so nothing was +flagged until the owner saw the part. The quality tier (`lib/vision_quality.py`, `lib/print_quality.py`, +`printer_ops/quality.py`) measures each frame in the plate region at the print's Z (`fleck_frac`: white +specks and strands; `order`: regularity of the bead pattern) and judges it against the print's OWN first +layers: the first frame of the print is the empty-plate reference (a part stands once a frame differs from +it; past the first layers it always does), the first 4 readings with a part are the baseline. Levels notice +< warning < severe, 2 of the last 4 readings to report one, one WARNING event with the marked frame +(`tg --photo FRAME ""` through the dispatcher) per issue and level — a second level inside the +dispatcher's cooldown folds into its `+N more` line — and **never a pause**. When the print ends +(or another starts) `/--monitor-report/` gets `report.json`, `report.md` and +`worst-frame.jpg`; one stderr line says where. It needs the camera calibration or `watch.bed.roi` for the plate +region and the print context (Z) from Moonraker. Start the supervisor at the beginning of a job: a print adopted +mid-way has no first layers to learn from. Numbers, calibration and limits: +[print-quality-monitor.md](../research/print-quality-monitor.md). The job watch +([`printer watch --job`](printer.md#print-quality-monitor)) runs the same tier on its own frames. + ## Three tiers 1. **(a) cheap checks, every frame.** The pipeline above: stale/dark camera, Obico boxes + CLIP @@ -427,6 +454,11 @@ u1: head: ~/.cache/3d-cli/vision/models/tier-b-head.json # default; from `fit-head` ``` +`watch.quality` (a sibling of `vision:`, shared with `printer watch --job`) tunes the print-quality tier: +`quality: false` turns it off, `quality: {baseline_n: 4, warning_ratio: 2.0, warning_abs: 0.035, severe_ratio: +3.5, severe_abs: 0.06, warning_drop: 0.10, first_layer_order: 0.6, ...}` any `QualityConfig` field +(`printer_ops/quality.py`); a wrong key is an error naming the valid ones. + The block is per device, so every Moonraker printer in the fleet (U1, K1 SE, others) gets its own camera `snapshot`, thresholds and `crop`; nothing in tier (b) is U1-specific. diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 7f74813d..eb0d6402 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -87,7 +87,8 @@ when a printing/answering printer is unsupervised (see `3d printer supervise --help`) vision NAME [options] camera failure detection (spaghetti, detached part, stale/dark camera) - into the same event sinks as watch; also + and print quality (stringing, lost bead pattern, uneven first layer: + warnings only, --no-quality) into the same event sinks as watch; also `vision eval --pos P.. --neg N..`, `vision record NAME DIR [--timeout S]`, `vision fetch-model [--tier a|b|all]`, `vision fit-head ..`, `vision doctor` (read-only: does any service run --dry-run?) diff --git a/lib/printer_ops/vision_cycle.py b/lib/printer_ops/vision_cycle.py index 9d9181e0..d04a8fcb 100644 --- a/lib/printer_ops/vision_cycle.py +++ b/lib/printer_ops/vision_cycle.py @@ -132,11 +132,13 @@ def __init__( log: Callable[[str], None] = _stderr, force_frame: bool = False, after_keepalive: Callable[[], None] | None = None, + on_print_end: Callable[[], None] | None = None, ) -> None: """`force_frame`: grab a frame even when nothing prints or the printer is silent (`--once`); `after_keepalive`: called when a keepalive was sent in such a cycle (`--once` waits for the first fresh frame of a sleeping camera). `quiet` / `clear_quiet` default to the printer's - intentional-off marker (printer_ops.intentional_off).""" + intentional-off marker (printer_ops.intentional_off). `on_print_end`: called once when a print that + was supervised ends (the printer left printing/paused): the per-print quality report is written there.""" self.printer = printer self.adapter = adapter self.camera = camera @@ -156,6 +158,7 @@ def __init__( self.log = log self.force_frame = force_frame self.after_keepalive = after_keepalive + self.on_print_end = on_print_end self.reachable: bool | None = None # None until the first poll self.unreachable_since: float | None = None self.last_poll_ok: float | None = None @@ -223,7 +226,10 @@ def _track_job( self, state: str, filename: str | None, new_print: bool, now: float ) -> None: if state not in (PRINTING, PAUSED): + ended = self.job_file is not None self.job_file = self.job_started = None + if ended and self.on_print_end is not None: + self.on_print_end() elif new_print or self.job_started is None or filename != self.job_file: self.job_file, self.job_started = filename, now diff --git a/lib/vision_detect.py b/lib/vision_detect.py index 834fda4e..a18a823f 100644 --- a/lib/vision_detect.py +++ b/lib/vision_detect.py @@ -246,7 +246,11 @@ def __init__( geometry: PrintGeometry | None = None, model: Any = None, fixed_bed: Any = None, + quality: Any = None, ) -> None: + self.quality = ( + quality # vision_quality.QualityTier: print-quality Findings, never p/q + ) self.fixed_bed = ( fixed_bed # vision_detach.FixedBedDetach: uncalibrated part-detached check ) @@ -267,6 +271,13 @@ def reset_print(self) -> None: self.model.reset_print() if self.fixed_bed is not None: self.fixed_bed.reset() + if self.quality is not None: + self.quality.reset_print() # writes the report of the print that just ended first + + def finish_print(self) -> None: + """The print ended: write its monitoring report (no-op without the quality tier).""" + if self.quality is not None: + self.quality.finish() def _allowed(self, b: Box) -> bool: cx, cy = (b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2 @@ -282,6 +293,8 @@ def score_bgr(self, bgr: np.ndarray) -> FrameScore: kept = [b for b in s.boxes if self._allowed(b)] if self.model is not None: findings = findings + self.model.update(bgr, self.context, self._now()) + if self.quality is not None: + findings = findings + self.quality.update(bgr, self.context, self._now()) s = FrameScore( p=float(sum(b.conf for b in kept)), boxes=kept, @@ -617,6 +630,7 @@ def build_scorer( detach: bool = True, model: bool | None = False, bed_roi: Any = None, + quality: Any = None, ) -> FrameScorer: """The per-frame pipeline from a device's `watch: vision:` settings: `verify: false` turns stage 2 off, `ignore:` lists polygons (normalised [x, y] points) whose boxes are dropped, @@ -628,7 +642,9 @@ def build_scorer( (or `detach=False`, --no-detach) turns it off, `detach: {gap_abs: 0.3, ...}` tunes it. `model`: tier (b), the whole-frame vision model (vision_model.py), driven by `watch.vision.model` — False (default) = not built, True = build it (downloads the weights if - needed), None = build it only when its weights are already cached.""" + needed), None = build it only when its weights are already cached. + `quality`: the print-quality tier (vision_quality.QualityTier; built by run_live from + `watch.quality`), which only ever adds Findings.""" from errors import UsageError polys = _ignore_polygons(cfg) @@ -655,7 +671,13 @@ def build_scorer( tier_b = build_tier(cfg.get("model"), flag=model) return FrameScorer( - ObicoScorer(ensure_model()), verifier, polys, geometry, tier_b, fixed_bed + ObicoScorer(ensure_model()), + verifier, + polys, + geometry, + tier_b, + fixed_bed, + quality, ) @@ -772,6 +794,8 @@ def _once_report( if score is not None: report.update(_score_fields(score)) report.update(_detach_fields(scorer)) + if scorer.quality is not None: + report["quality"] = scorer.quality.once_report() if scorer.model is not None and scorer.model.last_scores: report["model"] = scorer.model.report() report["trend"] = round(monitor.tracker.adjusted, 3) @@ -810,6 +834,7 @@ def run_live(args: argparse.Namespace) -> int: from printer_ops.vision_config import monitor_limits, npu_enabled from printer_ops.vision_cycle import VisionCycle from printer_ops.vision_status import VisionStatus + from vision_quality import build_quality device = get_device(args.printer) camera = _device_camera(device, args) @@ -822,6 +847,9 @@ def run_live(args: argparse.Namespace) -> int: ) cfg = _vision_cfg(device) cam = _calibration(device, args) + evidence = ( + Path(args.evidence) if args.evidence else cache_dir() / "vision" / "evidence" + ) scorer = build_scorer( cfg, verify=not args.no_verify, @@ -830,13 +858,21 @@ def run_live(args: argparse.Namespace) -> int: detach=not args.no_detach, model=args.model, bed_roi=((getattr(device, "watch", None) or {}).get("bed") or {}).get("roi"), + quality=build_quality( + device, evidence, once=args.once, disabled=args.no_quality + ), ) - context_tap = ContextTap(device.host, device.port) if scorer.geometry else None - evidence = ( - Path(args.evidence) if args.evidence else cache_dir() / "vision" / "evidence" + # the print position (Z) feeds the camera masks and the quality tier's plate region + context_tap = ( + ContextTap(device.host, device.port) + if scorer.geometry or scorer.quality + else None ) monitor = VisionMonitor( - device.name, scorer, annotate=annotate, evidence_dir=evidence, + device.name, + scorer, + annotate=annotate, + evidence_dir=evidence, limits=monitor_limits(cfg), ) status = ( @@ -857,6 +893,7 @@ def set_context(ctx: Any) -> None: camera=camera, monitor=monitor, reset_print=scorer.reset_print, + on_print_end=scorer.finish_print, interval_s=args.interval, context_tap=context_tap, set_context=set_context, @@ -1532,6 +1569,12 @@ def main(argv: list[str]) -> int: live.add_argument("--no-geometry", action="store_true") live.add_argument("--scene", action="store_true") live.add_argument("--no-detach", action="store_true") + live.add_argument( + "--no-quality", + action="store_true", + help="skip the print-quality tier (stringing debris, lost bead pattern, uneven first " + "layer) and its per-print monitoring report; default follows watch.quality", + ) live.add_argument( "--model", action=argparse.BooleanOptionalAction, diff --git a/lib/vision_quality.py b/lib/vision_quality.py new file mode 100644 index 00000000..b822b391 --- /dev/null +++ b/lib/vision_quality.py @@ -0,0 +1,232 @@ +"""vision_quality.py — the print-quality tier of `3d printer vision`: is the print GOOD, not just alive? + +Tiers (a) and (b) of the vision stack look for FAILURES (spaghetti, a part off the plate, a foreign object). +None of them knows stringing: on 2026-11-05 the Obico model scored p 0.0 / q 0.0 on all 12 frames of a job that +stringed from layer 2 on (docs/research/print-quality-monitor.md). This tier measures the frame the way the job +watch does (lib/print_quality.py: white debris, bead-pattern order) and judges it with the same pure state +machine (printer_ops/quality.py: the job's OWN first layers are the baseline, persistence, one alert per issue +and level, a debris trend), then hands the result to VisionMonitor as `Finding`s: + + quality__ warning / severe -> a WARNING event: tg --photo FRAME "" via the + watch dispatcher, once per issue and level; NEVER a pause (only CRITICAL events + can reach the auto-pauser, and this tier cannot produce one); + notice -> an INFO event (subscribers that asked for info), report only otherwise. + +PRESENCE: during the first layers (Z <= `first_layer_z`) the print's first frame is the empty-plate reference and + the part stands on the plate once a frame differs from it (the same colour difference as the bed check); past + them a part is always there. A print adopted mid-way has no empty reference: judged from its first layer-above + frame on, its baseline is whatever it looks like then. Needs the camera + position context (`ContextTap`: the print's Z and the plate region at that Z); without it the tier is silent + and says why in `--once`. + +`once`: a one-frame look (`printer vision NAME --once`) has no history: it reports the numbers and the ABSOLUTE + verdict only (printer_ops.quality.absolute_verdict) and says so; a quality finding there is a hint to look at + the frame, not a confirmed one. + +REPORT: `finish()` writes the per-print monitoring report (printer_ops/quality_report.py) into the evidence folder + when the print ends or another one starts. + +INVARIANTS: read-only; heavy imports (numpy / OpenCV, bed_state) are lazy; a measurement error is a `blind` + reading in the report, never an exception in the vision loop. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +LIB = Path(__file__).resolve().parent +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +from printer_ops.quality import ( # noqa: E402 + NOTICE, + OK, + QualityConfig, + QualityFinding, + QualityJudge, + QualityReading, + absolute_verdict, + owner_text, + quality_config, +) +from printer_ops.quality_report import summary_line, write_report # noqa: E402 +from printer_ops.vision import Finding, PrintContext # noqa: E402 + +if TYPE_CHECKING: + import numpy as np + +PRESENT_PROC_W = 480 # the width the bed check compares at (bed_state.PROC_W) + + +class QualityTier: + def __init__( + self, + device: Any, + cfg: QualityConfig, + evidence_dir: Path | None = None, + *, + once: bool = False, + ) -> None: + self.device, self.cfg, self.evidence_dir, self.once = ( + device, + cfg, + evidence_dir, + once, + ) + self.judge = QualityJudge(cfg, printer=device.name) + self.base: tuple[np.ndarray, float] | None = ( + None # the first frame of the print at 480 px, its Z + ) + self.last: dict[str, Any] = {} + + # -- print boundaries + + def reset_print(self) -> None: + """A new print: the report of the one before is written first, then everything starts over.""" + self.finish() + self.judge = QualityJudge(self.cfg, printer=self.device.name) + self.base = None + self.last = {} + + def finish(self) -> Path | None: + """Write the monitoring report of the print that just ended (no readings: nothing to write).""" + report = self.judge.report + if report.readings == 0 or self.evidence_dir is None: + return None + folder = write_report(report, self.evidence_dir, self.device.name) + print( + f"{self.device.name}: {summary_line(report)} -> {folder}", + file=sys.stderr, + flush=True, + ) + self.judge = QualityJudge(self.cfg, printer=self.device.name) + return folder + + # -- one frame + + def update( + self, bgr: np.ndarray, ctx: PrintContext | None, now: float + ) -> list[Finding]: + """Measure, judge, and return the Findings that are owed to the owner right now.""" + if ctx is None: + self.last = { + "status": "blind", + "reason": "no print position context (the plate region needs the Z)", + } + return [] + import print_quality as PQ + + img = PQ.prepare(bgr) + shape = (img.shape[0], img.shape[1]) + region = PQ.shrink(PQ.device_region(self.device, ctx.z, shape)) + numbers, flecks = PQ.measure( + img, region, f"plate surface of {self.device.name} at Z {ctx.z:g}" + ) + self.last = dict(numbers) + if numbers["status"] != "ok": + self.judge.feed(QualityReading(now, numbers, z=ctx.z)) + return [] + label = f"fleck {numbers['fleck_frac']:.3f} order {numbers['order']:.2f}" + image = PQ.annotated_jpeg(img, region, flecks, label) + if self.once: + return self._once(numbers, ctx, image) + reading = QualityReading( + now, numbers, z=ctx.z, present=self._present(img, ctx.z) + ) + v = self.judge.feed(reading) + self.last.update( + state=v.state, + level=v.level, + issue=v.issue, + fleck_ratio=v.fleck_ratio, + order_drop=v.order_drop, + ) + if v.worst: + self._pin(image) + return [self._finding(f, image) for f in v.new] + + def _once( + self, numbers: dict[str, Any], ctx: PrintContext, image: bytes + ) -> list[Finding]: + issue, level = absolute_verdict(numbers, ctx.z, self.cfg) + self.last.update( + basis="absolute: one frame, no job baseline", level=level, issue=issue + ) + if issue is None or level in (OK, NOTICE): + return [] + from printer_ops.quality import QualityVerdict + + text = owner_text(issue, level, QualityVerdict(level, issue=issue)) + data = {"issue": issue, "level": level, "numbers": numbers, "basis": "absolute"} + return [ + self._finding( + QualityFinding(f"quality_{issue}_{level}", issue, level, text, data), + image, + ) + ] + + def _present(self, img: np.ndarray, z: float) -> bool: + """The part stands on the plate. Past the first layers it certainly does; during them the frame must differ + from the print's first (empty-plate) frame.""" + import cv2 + + if z > self.cfg.first_layer_z: + return True + w = PRESENT_PROC_W + small = cv2.resize( + img, + (w, max(1, round(img.shape[0] * w / img.shape[1]))), + interpolation=cv2.INTER_AREA, + ) + if self.base is None: + self.base = (small, z) + return False + return self._differs(self.base[0], small, z) + + def _differs(self, base: np.ndarray, small: np.ndarray, z: float) -> bool: + import bed_part as BP + import bed_state as B + + cfg = B.bed_config(self.device) + region = BP.plate_region(self.device, cfg, z, small.shape[:2]) + th = B.thresholds_for(cfg) + d = B.difference( + base, small, region, th.level, th.detrend_px, th.shift_px, th.open_px + ) + return d.frac >= PRESENT_MIN and d.blob * int(region.sum()) >= PRESENT_BLOB_PX + + # -- helpers + + def _pin(self, image: bytes) -> None: + if self.evidence_dir is None: + return + self.evidence_dir.mkdir(parents=True, exist_ok=True) + path = self.evidence_dir / f"{self.device.name}-quality-worst.jpg" + path.write_bytes(image) + self.judge.pin_worst(str(path)) + + @staticmethod + def _finding(f: QualityFinding, image: bytes) -> Finding: + return Finding(f.kind, f.severity, f.text, dict(f.data), image) + + def once_report(self) -> dict[str, Any]: + """The `quality` block of `vision --once`: the numbers, the verdict and what it is based on.""" + return {k: v for k, v in self.last.items() if k != "annotated"} + + +PRESENT_MIN = 0.004 # the changed share of the plate that says "something stands there" (jobwatch.JobConfig.present_min) +PRESENT_BLOB_PX = ( + 150 # ... and the largest changed region, px at 480 (JobConfig.present_blob_px) +) + + +def build_quality( + device: Any, evidence_dir: Path | None, *, once: bool, disabled: bool +) -> QualityTier | None: + """The tier of a printer from `.watch.quality` (False = off); None when it is off.""" + cfg = quality_config((getattr(device, "watch", None) or {}).get("quality")) + if disabled or not cfg.enabled: + return None + return QualityTier(device, cfg, evidence_dir, once=once) diff --git a/tests/test_printer_vision_cycle.py b/tests/test_printer_vision_cycle.py index 3537dfdf..b5ccff32 100644 --- a/tests/test_printer_vision_cycle.py +++ b/tests/test_printer_vision_cycle.py @@ -651,6 +651,7 @@ class _FakeScorer: geometry = None fixed_bed = None model = None + quality = None context = None def __init__(self) -> None: @@ -659,6 +660,9 @@ def __init__(self) -> None: def reset_print(self) -> None: self.resets += 1 + def finish_print(self) -> None: + pass + def __call__(self, jpeg: bytes) -> FrameScore: return FrameScore( 0.25, [Box(0.1, 0.2, 0.3, 0.4, 0.5)], 77.0, probs=[0.4], q=0.1 @@ -725,6 +729,7 @@ def _live_args(**over: Any) -> argparse.Namespace: "no_verify": True, "scene": False, "no_detach": True, + "no_quality": True, "model": False, "evidence": None, "dry_run": False, @@ -833,3 +838,17 @@ def fake_sleep(seconds: float) -> None: ) doc = read_status("u1") assert doc is not None and doc["reachable"] is False and doc["job"] is None + + +def test_a_finished_print_calls_on_print_end_once(rig: Rig) -> None: + """The per-print quality report is written when the printer leaves printing/paused, once.""" + ends: list[int] = [] + rig.cycle.on_print_end = lambda: ends.append(1) + rig.print_file() + rig.tick() + rig.tick() + assert ends == [] + rig.printer.state, rig.printer.filename = "standby", None + rig.tick() + rig.tick() + assert ends == [1] diff --git a/tests/test_vision_quality.py b/tests/test_vision_quality.py new file mode 100644 index 00000000..e7367ffe --- /dev/null +++ b/tests/test_vision_quality.py @@ -0,0 +1,149 @@ +"""vision_quality.py — the print-quality tier of `3d printer vision` on REAL U1 frames (tests/data/quality). + +`u1_f1` is the empty plate (the print's first frame), `u1_f5` the clean first layer, `u1_f7` / `u1_f10` / `u1_f12` +the stringing debris of layers 2-4 that nothing flagged on 2026-11-05. The tier is fed the way FrameScorer feeds +it: a decoded frame, the print context (Z), a clock. No printer, no camera, no model, no network. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from printer_ops.vision import PrintContext + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") +import vision_quality as VQ # noqa: E402 + +DATA = Path(__file__).parent / "data" / "quality" +DEVICES = """u1: + adapter: moonraker + host: 127.0.0.1 + port: 1 + model: Snapmaker U1 +""" + + +@pytest.fixture +def device(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from printer_ops.devices import get_device + + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + cfg = tmp_path / "config" / "3d-cli" + cfg.mkdir(parents=True) + (cfg / "printer-devices.yaml").write_text(DEVICES, encoding="utf-8") + return get_device("u1") + + +def frame(name: str) -> Any: + return cv2.imread(str(DATA / name)) + + +def ctx(z: float) -> PrintContext: + return PrintContext("sheet.gcode", z, 100.0, 100.0) + + +def feed_first_layer(tier: VQ.QualityTier) -> None: + """The empty plate, then four readings of the clean first layer: the job's baseline.""" + now = 1000.0 + assert tier.update(frame("u1_f1.jpg"), ctx(0.2), now) == [] + for i in range(1, 5): + assert tier.update(frame("u1_f5.jpg"), ctx(0.2), now + 15 * i) == [] + assert tier.judge.base is not None and tier.judge.base.n == 4 + + +def test_stringing_on_real_frames_becomes_one_warning_finding_with_the_marked_frame( + device, tmp_path: Path +) -> None: + tier = VQ.QualityTier(device, VQ.QualityConfig(), tmp_path / "evidence") + feed_first_layer(tier) + found = [] + for i, name in enumerate(("u1_f7.jpg", "u1_f10.jpg", "u1_f12.jpg", "u1_f12.jpg")): + found += tier.update(frame(name), ctx(1.0), 1200.0 + 60 * i) + (f,) = found + assert f.kind == "quality_stringing_warning" and f.severity == "warning" + assert "белые ниточки" in f.text and "Что менять" in f.text + assert ( + f.image is not None + and cv2.imdecode(np.frombuffer(f.image, np.uint8), cv2.IMREAD_COLOR) is not None + ) + assert (tmp_path / "evidence" / "u1-quality-worst.jpg").is_file() + + +def test_the_clean_first_layer_and_the_empty_plate_raise_nothing(device) -> None: + tier = VQ.QualityTier(device, VQ.QualityConfig()) + feed_first_layer(tier) + for i in range(5): + assert tier.update(frame("u1_f5.jpg"), ctx(0.4), 1100.0 + 15 * i) == [] + assert ( + tier.judge.report.per_level["warning"] == 0 + and tier.judge.report.per_level["severe"] == 0 + ) + assert tier.judge.report.skipped.get("absent") == 1, ( + "the empty first frame is the reference, not a reading" + ) + + +def test_no_print_context_is_blind_not_clean(device) -> None: + tier = VQ.QualityTier(device, VQ.QualityConfig()) + assert tier.update(frame("u1_f12.jpg"), None, 0.0) == [] + assert ( + tier.once_report()["status"] == "blind" and "Z" in tier.once_report()["reason"] + ) + + +def test_a_new_print_writes_the_report_of_the_one_before( + device, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + tier = VQ.QualityTier(device, VQ.QualityConfig(), tmp_path / "evidence") + feed_first_layer(tier) + for i, name in enumerate(("u1_f7.jpg", "u1_f10.jpg", "u1_f12.jpg")): + tier.update(frame(name), ctx(1.0), 1200.0 + 60 * i) + tier.reset_print() + (d,) = (tmp_path / "evidence").glob("*-u1-monitor-report") + doc = json.loads((d / "report.json").read_text()) + assert ( + doc["max_level"] == "warning" + and doc["per_issue"]["stringing"] >= 2 + and (d / "worst-frame.jpg").is_file() + ) + assert "print-quality monitor:" in capsys.readouterr().err + assert tier.judge.report.readings == 0 and tier.base is None, ( + "the next print starts from nothing" + ) + tier.finish() + assert len(list((tmp_path / "evidence").glob("*-u1-monitor-report"))) == 1, ( + "an empty print writes no report" + ) + + +@pytest.mark.parametrize( + ("name", "verdict"), + [ + ("u1_f5.jpg", None), + ("u1_f7.jpg", "severe"), + ("u1_f10.jpg", "warning"), + ("u1_f12.jpg", "warning"), + ], +) +def test_once_gives_the_absolute_verdict_of_one_frame( + device, name: str, verdict: str | None +) -> None: + """`printer vision NAME --once`: no job history, so only the absolute levels; it says so.""" + tier = VQ.QualityTier(device, VQ.QualityConfig(), once=True) + found = tier.update(frame(name), ctx(1.2), 0.0) + report = tier.once_report() + assert "absolute" in report["basis"] + assert [f.kind for f in found] == ( + [] if verdict is None else [f"quality_stringing_{verdict}"] + ) + assert report["fleck_frac"] > 0 + + +def test_a_disabled_tier_is_not_built(device) -> None: + assert VQ.build_quality(device, None, once=False, disabled=True) is None + assert VQ.build_quality(device, None, once=False, disabled=False) is not None From ea27db3ece7bd4e03941470e4cf1becc60a57b3c Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:39:01 +0100 Subject: [PATCH 21/30] fix(printer): the first-layer floor is on only for a calibrated camera; 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. --- docs/commands/printer-vision.md | 2 +- docs/commands/printer.md | 2 +- docs/research/print-quality-monitor.md | 3 ++ lib/printer_ops/jobwatch_run.py | 4 +- lib/printer_ops/quality.py | 50 ++++++++++------- lib/vision_quality.py | 5 +- tests/test_print_quality.py | 7 +-- tests/test_printer_jobwatch.py | 5 +- tests/test_printer_quality.py | 30 +++++++++-- tests/test_vision_quality.py | 74 +++++++++++++++++++++++++- 10 files changed, 150 insertions(+), 32 deletions(-) diff --git a/docs/commands/printer-vision.md b/docs/commands/printer-vision.md index 4b38056e..206c9d03 100644 --- a/docs/commands/printer-vision.md +++ b/docs/commands/printer-vision.md @@ -457,7 +457,7 @@ u1: `watch.quality` (a sibling of `vision:`, shared with `printer watch --job`) tunes the print-quality tier: `quality: false` turns it off, `quality: {baseline_n: 4, warning_ratio: 2.0, warning_abs: 0.035, severe_ratio: 3.5, severe_abs: 0.06, warning_drop: 0.10, first_layer_order: 0.6, ...}` any `QualityConfig` field -(`printer_ops/quality.py`); a wrong key is an error naming the valid ones. +(`printer_ops/quality.py`); a wrong key is an error naming the valid ones. The first-layer floor (`first_layer_order`, 0 = off) is on by default only for a Snapmaker U1, the camera it was calibrated on. The block is per device, so every Moonraker printer in the fleet (U1, K1 SE, others) gets its own camera `snapshot`, thresholds and `crop`; nothing in tier (b) is U1-specific. diff --git a/docs/commands/printer.md b/docs/commands/printer.md index b5bba60c..2a740bbe 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -977,7 +977,7 @@ The judge compares them with the **job's own first layers**: the first 4 reading (bed_part says so) are the baseline, never judged against anything but a floor; a clean reading drifts it a little (bounded at 1.3x debris / 0.04 order), a bad one never does. Issues: `stringing` (debris against the baseline AND an absolute level), `texture` (the pattern got lost with no debris: stripes, roughness, -under-/over-extrusion), `first_layer` (an absolute floor on `order` while layer <= 3). Levels: `notice` +under-/over-extrusion), `first_layer` (an absolute floor on `order` while layer <= 3; ON by default only for the U1, whose camera it was calibrated on — `first_layer_order: 0.6` in `watch.quality` turns it on elsewhere, a K1 SE view of a part reads 0.5 normally). Levels: `notice` (report only) < `warning` < `severe`; a level is reported when 2 of the last 4 readings reach it (3 of 4 for severe), it is latched and clears after 6 clean readings. A debris trend (ratio to the baseline gained per 10 min) lifts a notice to a warning. diff --git a/docs/research/print-quality-monitor.md b/docs/research/print-quality-monitor.md index eec12e5b..a3512635 100644 --- a/docs/research/print-quality-monitor.md +++ b/docs/research/print-quality-monitor.md @@ -87,6 +87,9 @@ not separate measurements. `order` (pattern regularity) and, for a lifted part, the existing `part_moved` (footprint). No frame of a *known* under-extruded layer exists, so `texture` is calibrated for "no false alarm on clean frames", not for recall. A bowed corner is not told apart from other disorder on one frame. +* **The first-layer floor is per camera.** An absolute `order` floor (0.6) separates the U1's clean first layer (0.83+) from + the stringy phone photos (0.33-0.53), but a K1 SE view of a pasted part reads 0.5 normally (the job-watch fixtures of PR #727), so + the floor is on by default only for the Snapmaker U1 profile (`MODEL_PROFILES`) and a `watch.quality` key elsewhere. * **Baseline = the job's first layers.** A job whose first layers are already stringy teaches the monitor that this is normal; the absolute levels and the first-layer floor still apply. A supervisor started mid-print learns from what it sees then. diff --git a/lib/printer_ops/jobwatch_run.py b/lib/printer_ops/jobwatch_run.py index fc212ce1..a2e16d5d 100644 --- a/lib/printer_ops/jobwatch_run.py +++ b/lib/printer_ops/jobwatch_run.py @@ -757,7 +757,9 @@ def run(argv: list[str]) -> int: args, dict((device.watch or {}).get("job") or {}), default_out_dir(args.name) ) tracker = CheckpointTracker(cfg.checkpoints, first_layer_range(args.gcode)) - quality = quality_config((device.watch or {}).get("quality")) + quality = quality_config( + (device.watch or {}).get("quality"), getattr(device, "model", None) + ) with one_watch_per_printer(args.name): return _run_watch(args, cfg, tracker, quality) diff --git a/lib/printer_ops/quality.py b/lib/printer_ops/quality.py index 495346bb..8d81be52 100644 --- a/lib/printer_ops/quality.py +++ b/lib/printer_ops/quality.py @@ -88,9 +88,10 @@ class QualityConfig: # first layer: an absolute floor (no baseline can exist before the first layer is there) first_layer_max: int = 3 first_layer_z: float = 0.8 # without a layer number: Z up to this is "first layers" - first_layer_order: float = ( - 0.6 # clean beads read 0.83-0.85, a stringy first layer 0.33-0.53 - ) + # `order` under this in a first layer is `first_layer` warning (0 = off). Clean beads on the U1 camera read + # 0.83-0.85, a stringy first layer 0.33-0.53 — but another camera/scene has its own normal (a K1 SE view of + # a part reads 0.5), so the floor is ON only for a calibrated camera (MODEL_PROFILES) or when configured. + first_layer_order: float = 0.0 first_layer_severe: float = 0.45 window: int = 4 confirm: int = 2 @@ -107,17 +108,37 @@ class QualityConfig: timeline: int = 200 # readings kept in the report -def quality_config(settings: Any) -> QualityConfig: +def first_layer_floor(order: float, cfg: QualityConfig) -> str: + """The absolute first-layer level of an `order` reading (`ok` when the floor is off).""" + if cfg.first_layer_order <= 0: + return OK + if order < min(cfg.first_layer_severe, cfg.first_layer_order): + return SEVERE + return WARNING if order < cfg.first_layer_order else OK + + +# Per camera model, what was calibrated beyond the generic defaults (docs/research/print-quality-monitor.md). +MODEL_PROFILES: dict[str, dict[str, Any]] = {"snapmaker u1": {"first_layer_order": 0.6}} + + +def profile_for(model: str | None) -> dict[str, Any]: + """The calibrated defaults of a printer model (`device.model`, matched case-insensitively), else none.""" + text = (model or "").strip().lower() + return dict(next((v for k, v in MODEL_PROFILES.items() if k in text), {})) + + +def quality_config(settings: Any, model: str | None = None) -> QualityConfig: """`.watch.quality` (a mapping of QualityConfig fields; `false` turns the monitor off) -> config. Unknown keys and values of the wrong type are an InvalidArgument naming the accepted keys.""" from dataclasses import fields, replace from errors import InvalidArgument - if settings is None or settings is True: - return QualityConfig() if settings is False: return QualityConfig(enabled=False) + base = replace(QualityConfig(), **profile_for(model)) + if settings is None or settings is True: + return base defaults = {f.name: getattr(QualityConfig(), f.name) for f in fields(QualityConfig)} accepted = sorted(defaults) if not isinstance(settings, dict): @@ -135,7 +156,7 @@ def quality_config(settings: Any) -> QualityConfig: raise InvalidArgument( f"watch.quality.{key}", repr(value), accepted, command="printer watch" ) - return replace(QualityConfig(), **settings) + return replace(base, **settings) @dataclass @@ -317,10 +338,7 @@ def _floor_level(self, r: QualityReading, first: bool) -> str: """The first-layer absolute floor on `order` (needs no baseline).""" if not first: return OK - order = float(r.numbers["order"]) - if order < self.cfg.first_layer_severe: - return SEVERE - return WARNING if order < self.cfg.first_layer_order else OK + return first_layer_floor(float(r.numbers["order"]), self.cfg) def _learning(self, r: QualityReading, floor: str) -> QualityVerdict: # A reading that already fails the first-layer floor is judged, but never makes the reference. @@ -548,15 +566,7 @@ def absolute_verdict( if numbers.get("status") != "ok": return None, OK if z is not None and z <= cfg.first_layer_z: - order = float(numbers["order"]) - floor = ( - SEVERE - if order < cfg.first_layer_severe - else WARNING - if order < cfg.first_layer_order - else OK - ) - return _pick(OK, OK, floor) + return _pick(OK, OK, first_layer_floor(float(numbers["order"]), cfg)) debris = _level_of( float(numbers["fleck_frac"]), (cfg.notice_abs, cfg.warning_abs, cfg.severe_abs) ) diff --git a/lib/vision_quality.py b/lib/vision_quality.py index b822b391..fbf41099 100644 --- a/lib/vision_quality.py +++ b/lib/vision_quality.py @@ -226,7 +226,10 @@ def build_quality( device: Any, evidence_dir: Path | None, *, once: bool, disabled: bool ) -> QualityTier | None: """The tier of a printer from `.watch.quality` (False = off); None when it is off.""" - cfg = quality_config((getattr(device, "watch", None) or {}).get("quality")) + cfg = quality_config( + (getattr(device, "watch", None) or {}).get("quality"), + getattr(device, "model", None), + ) if disabled or not cfg.enabled: return None return QualityTier(device, cfg, evidence_dir, once=once) diff --git a/tests/test_print_quality.py b/tests/test_print_quality.py index 5e65cef8..860725b9 100644 --- a/tests/test_print_quality.py +++ b/tests/test_print_quality.py @@ -93,8 +93,9 @@ def test_phone_photos_of_the_failed_first_layer_read_far_below_a_clean_first_lay first-layer floor, a clean first layer reads 0.83+.""" n = whole(name) assert n["status"] == "ok" and n["region_source"] == "whole frame" - assert n["order"] < Q.QualityConfig().first_layer_order - issue, level = Q.absolute_verdict(n, 0.2, Q.QualityConfig()) + cfg = Q.quality_config(None, "Snapmaker U1") + assert n["order"] < cfg.first_layer_order + issue, level = Q.absolute_verdict(n, 0.2, cfg) assert issue == Q.FIRST_LAYER and level in (Q.WARNING, Q.SEVERE) @@ -201,7 +202,7 @@ def test_the_command_line_prints_one_json_object_and_help( def test_verdict_table_of_the_session_frames_through_the_judge(u1: str) -> None: """f1 empty (reference, not judged); the first layer f5 four times = the baseline; then the debris frames in the order they happened. Nothing before the debris, a WARNING on it, never above WARNING-severity events.""" - judge = Q.QualityJudge(Q.QualityConfig()) + judge = Q.QualityJudge(Q.quality_config(None, "Snapmaker U1")) rows = [] sequence = [("u1_f1.jpg", False, 1)] + [("u1_f5.jpg", True, 1)] * 5 sequence += [ diff --git a/tests/test_printer_jobwatch.py b/tests/test_printer_jobwatch.py index 0385eb70..541a92a2 100644 --- a/tests/test_printer_jobwatch.py +++ b/tests/test_printer_jobwatch.py @@ -36,6 +36,7 @@ port: 1 model: Creality K1 SE watch: + quality: false vision: {snapshot: "http://127.0.0.1:1/?action=snapshot"} bed: enabled: true @@ -439,7 +440,9 @@ def test_a_camera_that_jumps_beyond_what_can_be_followed_is_blind_never_gone( assert "the plate cannot be followed on the picture" in out -def pixel_check(tmp_path: Path, frame: str, z: float, chained: bool = False) -> dict[str, Any]: +def pixel_check( + tmp_path: Path, frame: str, z: float, chained: bool = False +) -> dict[str, Any]: """bed_part.py on one frame against DATA/empty.jpg (Z 0.2); `chained`: start from the files of the last call.""" argv = [ *("check", "k1se", "--json", "--baseline", str(DATA / "empty.jpg")), diff --git a/tests/test_printer_quality.py b/tests/test_printer_quality.py index 30b1744e..3cd2c373 100644 --- a/tests/test_printer_quality.py +++ b/tests/test_printer_quality.py @@ -18,6 +18,9 @@ from printer_ops import quality as Q from printer_ops.quality_report import render_markdown, summary_line, write_report +U1 = Q.quality_config( + None, "Snapmaker U1" +) # the calibrated camera: the first-layer floor is on CLEAN = { "status": "ok", "fleck_frac": 0.014, @@ -163,7 +166,7 @@ def test_pattern_loss_without_debris_is_texture() -> None: def test_the_first_layer_floor_needs_no_baseline_and_a_bad_first_layer_never_makes_the_baseline() -> ( None ): - judge = Q.QualityJudge(Q.QualityConfig()) + judge = Q.QualityJudge(U1) verdicts = [ judge.feed(Q.QualityReading(float(i), nums(0.02, 0.40), layer=1)) for i in range(2) @@ -180,7 +183,7 @@ def test_the_first_layer_floor_needs_no_baseline_and_a_bad_first_layer_never_mak def test_without_a_layer_number_z_decides_what_a_first_layer_is() -> None: - judge = Q.QualityJudge(Q.QualityConfig()) + judge = Q.QualityJudge(U1) assert ( judge.feed(Q.QualityReading(0, nums(0.02, 0.5), z=0.3)).issue == Q.FIRST_LAYER ) @@ -250,7 +253,7 @@ def test_a_slowly_growing_debris_is_not_absorbed_into_the_baseline() -> None: def test_absolute_verdict_is_what_a_single_frame_can_say() -> None: - cfg = Q.QualityConfig() + cfg = U1 assert Q.absolute_verdict(nums(0.014), 1.2, cfg) == (None, Q.OK) assert Q.absolute_verdict(nums(0.04), 1.2, cfg) == (Q.STRINGING, Q.WARNING) assert Q.absolute_verdict(nums(0.08), 1.2, cfg) == (Q.STRINGING, Q.SEVERE) @@ -275,6 +278,27 @@ def test_owner_text_is_russian_short_and_says_what_to_change() -> None: ).startswith("усиливается") +def test_the_first_layer_floor_is_on_only_for_a_calibrated_camera_or_when_configured() -> ( + None +): + assert Q.QualityConfig().first_layer_order == 0.0 and U1.first_layer_order == 0.6 + assert Q.quality_config(None, "Creality K1 SE").first_layer_order == 0.0 + assert ( + Q.quality_config({"first_layer_order": 0.5}, "Creality K1 SE").first_layer_order + == 0.5 + ) + assert ( + Q.quality_config( + {"first_layer_order": 0.0}, "snapmaker u1 (2026)" + ).first_layer_order + == 0.0 + ) + off = Q.QualityJudge(Q.QualityConfig()) + assert off.feed(Q.QualityReading(0, nums(0.02, 0.2), layer=1)).issue is None, ( + "a part that reads 0.2 is normal on some cameras" + ) + + def test_quality_config_from_devices_yaml() -> None: assert ( Q.quality_config(None) == Q.QualityConfig() diff --git a/tests/test_vision_quality.py b/tests/test_vision_quality.py index e7367ffe..6b82dbfe 100644 --- a/tests/test_vision_quality.py +++ b/tests/test_vision_quality.py @@ -9,7 +9,7 @@ import json from pathlib import Path -from typing import Any +from typing import Any, cast import pytest @@ -147,3 +147,75 @@ def test_once_gives_the_absolute_verdict_of_one_frame( def test_a_disabled_tier_is_not_built(device) -> None: assert VQ.build_quality(device, None, once=False, disabled=True) is None assert VQ.build_quality(device, None, once=False, disabled=False) is not None + + +class _NoSpaghetti: + """Stage 1 as it behaved on the day: no box on any frame (p 0.0).""" + + def score_bgr(self, bgr: Any) -> Any: + from printer_ops.vision import FrameScore + + return FrameScore(p=0.0, boxes=[], luma=70.0) + + +def test_the_frame_scorer_adds_the_quality_findings_next_to_a_silent_spaghetti_model( + device, tmp_path: Path +) -> None: + """The 2026-11-05 situation: p 0.0 / q 0.0 / no boxes — and now the same frame still says stringing.""" + import vision_detect as VD + + tier = VQ.QualityTier(device, VQ.QualityConfig(), tmp_path, once=True) + scorer = VD.FrameScorer(cast(Any, _NoSpaghetti()), quality=tier) + scorer.context = ctx(1.2) + score = scorer((DATA / "u1_f7.jpg").read_bytes()) + assert score.p == 0.0 and score.boxes == [] + assert [f.kind for f in score.findings] == ["quality_stringing_severe"] + scorer.context = ctx(1.2) + assert scorer((DATA / "u1_f5.jpg").read_bytes()).findings == [] + + +def test_once_report_carries_the_quality_block(device, tmp_path: Path) -> None: + import argparse + from types import SimpleNamespace + + import vision_detect as VD + + tier = VQ.QualityTier(device, VQ.QualityConfig(), tmp_path, once=True) + scorer = VD.FrameScorer(cast(Any, _NoSpaghetti()), quality=tier) + scorer.context = ctx(1.2) + score = scorer((DATA / "u1_f12.jpg").read_bytes()) + rep = SimpleNamespace( + frame=None, + score=score, + events=[], + as_json=lambda name: {"printer": name}, + ) + monitor = SimpleNamespace(tracker=SimpleNamespace(adjusted=0.0)) + out = VD._once_report(argparse.Namespace(save=None), scorer, monitor, rep, "u1") + assert out["findings"] == ["quality_stringing_warning"] + assert out["quality"]["level"] == "warning" and out["quality"]["fleck_frac"] > 0.035 + assert "absolute" in out["quality"]["basis"] + + +def test_the_scorer_resets_and_finishes_the_tier_with_the_print( + device, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + import vision_detect as VD + + tier = VQ.QualityTier(device, VQ.QualityConfig(), tmp_path) + scorer = VD.FrameScorer(cast(Any, _NoSpaghetti()), quality=tier) + for i in range(5): + scorer.context = ctx(0.2) + scorer.now = 100.0 + 15 * i + scorer(frame_jpeg("u1_f1.jpg" if i == 0 else "u1_f5.jpg")) + scorer.finish_print() + assert len(list(tmp_path.glob("*-u1-monitor-report"))) == 1 + assert "print-quality monitor:" in capsys.readouterr().err + scorer.finish_print() + assert len(list(tmp_path.glob("*-u1-monitor-report"))) == 1, ( + "ending twice writes one report" + ) + + +def frame_jpeg(name: str) -> bytes: + return (DATA / name).read_bytes() From d35e2cf2d6e2bbfa56dd575c14125e78659eb3c9 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:42:20 +0100 Subject: [PATCH 22/30] fix(printer): per-model quality defaults come from a data file, not a 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. --- docs/research/print-quality-monitor.md | 2 +- lib/data/vision/quality_profiles.json | 4 ++++ lib/printer_ops/quality.py | 24 ++++++++++++++++++------ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 lib/data/vision/quality_profiles.json diff --git a/docs/research/print-quality-monitor.md b/docs/research/print-quality-monitor.md index a3512635..40b5be6c 100644 --- a/docs/research/print-quality-monitor.md +++ b/docs/research/print-quality-monitor.md @@ -89,7 +89,7 @@ not separate measurements. A bowed corner is not told apart from other disorder on one frame. * **The first-layer floor is per camera.** An absolute `order` floor (0.6) separates the U1's clean first layer (0.83+) from the stringy phone photos (0.33-0.53), but a K1 SE view of a pasted part reads 0.5 normally (the job-watch fixtures of PR #727), so - the floor is on by default only for the Snapmaker U1 profile (`MODEL_PROFILES`) and a `watch.quality` key elsewhere. + the floor is on by default only for the Snapmaker U1 profile (`lib/data/vision/quality_profiles.json`) and a `watch.quality` key elsewhere. * **Baseline = the job's first layers.** A job whose first layers are already stringy teaches the monitor that this is normal; the absolute levels and the first-layer floor still apply. A supervisor started mid-print learns from what it sees then. diff --git a/lib/data/vision/quality_profiles.json b/lib/data/vision/quality_profiles.json new file mode 100644 index 00000000..cd955c74 --- /dev/null +++ b/lib/data/vision/quality_profiles.json @@ -0,0 +1,4 @@ +{ + "_doc": "Print-quality monitor (lib/printer_ops/quality.py): what was CALIBRATED beyond the generic QualityConfig defaults, per printer model. A model matches when its name, lowercased and stripped to letters and digits, ends with the key (Snapmaker U1 -> ...u1). Same keys as `.watch.quality`; the printer's own watch.quality wins. See docs/research/print-quality-monitor.md.", + "u1": {"first_layer_order": 0.6} +} diff --git a/lib/printer_ops/quality.py b/lib/printer_ops/quality.py index 8d81be52..39dc9757 100644 --- a/lib/printer_ops/quality.py +++ b/lib/printer_ops/quality.py @@ -37,8 +37,11 @@ class it knows), and the job watch (jobwatch.py) only asks "is the part still wh from __future__ import annotations +import json +import re from collections import deque from dataclasses import dataclass, field +from pathlib import Path from typing import Any # issues @@ -90,7 +93,7 @@ class QualityConfig: first_layer_z: float = 0.8 # without a layer number: Z up to this is "first layers" # `order` under this in a first layer is `first_layer` warning (0 = off). Clean beads on the U1 camera read # 0.83-0.85, a stringy first layer 0.33-0.53 — but another camera/scene has its own normal (a K1 SE view of - # a part reads 0.5), so the floor is ON only for a calibrated camera (MODEL_PROFILES) or when configured. + # a part reads 0.5), so the floor is ON only f. first_layer_order: float = 0.0 first_layer_severe: float = 0.45 window: int = 4 @@ -117,14 +120,23 @@ def first_layer_floor(order: float, cfg: QualityConfig) -> str: return WARNING if order < cfg.first_layer_order else OK -# Per camera model, what was calibrated beyond the generic defaults (docs/research/print-quality-monitor.md). -MODEL_PROFILES: dict[str, dict[str, Any]] = {"snapmaker u1": {"first_layer_order": 0.6}} +PROFILES_FILE = ( + Path(__file__).resolve().parents[1] / "data" / "vision" / "quality_profiles.json" +) def profile_for(model: str | None) -> dict[str, Any]: - """The calibrated defaults of a printer model (`device.model`, matched case-insensitively), else none.""" - text = (model or "").strip().lower() - return dict(next((v for k, v in MODEL_PROFILES.items() if k in text), {})) + """The calibrated defaults of a printer model (`device.model`) from lib/data/vision/quality_profiles.json + (matched on the model name stripped to lowercase letters and digits, by its ending), else none.""" + key = re.sub(r"[^a-z0-9]", "", (model or "").lower()) + if not key: + return {} + profiles = json.loads(PROFILES_FILE.read_text(encoding="utf-8")) + hit: dict[str, Any] = next( + (v for k, v in profiles.items() if not k.startswith("_") and key.endswith(k)), + {}, + ) + return dict(hit) def quality_config(settings: Any, model: str | None = None) -> QualityConfig: From 47f2d6151021806ecbdc3e4e7f7dd23efc224bab Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:43:11 +0100 Subject: [PATCH 23/30] feat(printer): checked macro rules and Moonraker macro discovery 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) --- lib/printer_ops/base.py | 13 ++ lib/printer_ops/macros.py | 269 +++++++++++++++++++++++++++++++++++ lib/printer_ops/moonraker.py | 63 +++++--- 3 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 lib/printer_ops/macros.py diff --git a/lib/printer_ops/base.py b/lib/printer_ops/base.py index 9fae875d..120098e1 100644 --- a/lib/printer_ops/base.py +++ b/lib/printer_ops/base.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from printer_ops.devices import Device + from printer_ops.macros import MacroCatalog from urllib.parse import urlsplit from errors import InvalidArgument, ThreeDError @@ -252,6 +253,18 @@ def run_gcode(self, script: str, *, timeout: float = 120.0) -> None: once the printer ran it, waiting at most `timeout` seconds.""" raise self._unsupported("run_gcode") + def gcode_request(self, script: str) -> str: + """The request `run_gcode(script)` sends, as one line (`POST `), for `--dry-run`. Sends nothing.""" + raise self._unsupported("macros and aliases (no G-code channel)") + + def macro_catalog(self) -> MacroCatalog: + """The macros and extended commands the printer defines. Read-only.""" + raise self._unsupported("macros and aliases (no G-code channel)") + + def catalog_requests(self) -> list[str]: + """The requests `macro_catalog()` sends, one line each (`GET `), for `macro list --dry-run`.""" + raise self._unsupported("macros and aliases (no G-code channel)") + def set_head_filament(self, index: int, filament: HeadFilament, *, force_official: bool = False) -> HeadWrite: """Make head `index` show `filament` on the printer, verified by reading it back. diff --git a/lib/printer_ops/macros.py b/lib/printer_ops/macros.py new file mode 100644 index 00000000..f9403d1a --- /dev/null +++ b/lib/printer_ops/macros.py @@ -0,0 +1,269 @@ +"""macros.py — what `3d printer macro run` and the alias subcommands may send to a Klipper printer. + +WHAT: the pure rules between a user's words and one G-code script: a macro name and its + `KEY=VALUE` parameters are checked character by character (`build_line`), a command is put + into a risk class (`classify`), and `assess` turns a script into the gates it must pass + (`--yes`, an idle printer). `MacroCatalog` is what the printer says it defines + (Moonraker `/printer/objects/list` + `/printer/gcode/help`). + +WHY: Moonraker's `/printer/gcode/script` runs ANY text, newlines and `;` included, so one + unchecked value could append `M112` or a whole second command. Nothing here is free text: + a name is an identifier, a value is a short token from a fixed alphabet, and the emergency + stop is never sent (the printer's own button is the way to stop, not a remote script). + Issue https://github.com/alex-mextner/3d-cli/issues/718. + +RISK CLASSES (`classify`): `inert` (reads, messages: any time), `job` (pause/resume/cancel + style: allowed while a job runs), `heating`, `motion` (everything not known, because a + macro body is not checked), `dangerous` (restarts, config writes, kinematics). heating, + motion and dangerous need an idle printer; dangerous and any macro outside the printer's + `trusted_macros` also need `--yes`. + +INVARIANTS: stdlib only; nothing here touches the network. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Final + +from errors import InvalidArgument +from printer_ops.base import PrinterRefused + +INERT, JOB, HEATING, MOTION, DANGEROUS = ( + "inert", + "job", + "heating", + "motion", + "dangerous", +) +KINDS: Final = (INERT, JOB, HEATING, MOTION, DANGEROUS) +IDLE_KINDS: Final = frozenset({HEATING, MOTION, DANGEROUS}) + +# Never sent, whatever the flags: the emergency stop is the printer's own button. +DENIED: Final = frozenset({"M112", "EMERGENCY_STOP"}) +_JOB: Final = frozenset( + { + "PAUSE", + "RESUME", + "CANCEL_PRINT", + "CLEAR_PAUSE", + "M600", + "M220", + "M221", + "SET_PAUSE_NEXT_LAYER", + "SET_PAUSE_AT_LAYER", + } +) +_INERT: Final = frozenset( + { + "M105", + "M114", + "M115", + "M117", + "GET_POSITION", + "STATUS", + "HELP", + "RESPOND", + "SET_GCODE_VARIABLE_QUERY", + } +) +_INERT_PREFIXES: Final = ("QUERY_", "GET_") +_HEATING: Final = frozenset( + { + "M104", + "M109", + "M140", + "M141", + "M190", + "M191", + "SET_HEATER_TEMPERATURE", + "TURN_OFF_HEATERS", + "TEMPERATURE_WAIT", + "SET_TEMPERATURE_FAN_TARGET", + "SET_PURIFIER_MODE", + } +) +_DANGEROUS: Final = frozenset( + { + "FIRMWARE_RESTART", + "RESTART", + "SAVE_CONFIG", + "SDCARD_RESET_FILE", + "SET_KINEMATIC_POSITION", + "FORCE_MOVE", + "M999", + "M84", + "M18", + "SHUTDOWN", + "SHAPER_CALIBRATE", + "PID_CALIBRATE", + "PROBE_CALIBRATE", + "Z_OFFSET_APPLY_PROBE", + "Z_OFFSET_APPLY_ENDSTOP", + } +) + +_NAME: Final = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}") +_NUMERIC_CODE: Final = re.compile( + r"[GMT][0-9]+" +) # G28, M104, T0: standard codes, not macros +# A value is one short token: no space, quote, `;`, `{`, `%`, newline. `,` and `:` carry lists/pairs. +_VALUE: Final = re.compile(r"[A-Za-z0-9_.,:+/-]{1,64}") +_WORD: Final = re.compile(r"[A-Za-z0-9_]+") + + +def is_numeric_code(name: str) -> bool: + """`G28`, `M104`, `T2`: a standard G-code, which only a printer-defined macro of that name may override.""" + return _NUMERIC_CODE.fullmatch(name.upper()) is not None + + +def check_name(name: str) -> str: + """The upper-cased command name; InvalidArgument unless it is a plain identifier, PrinterRefused when denied.""" + if not _NAME.fullmatch(name): + raise InvalidArgument( + "macro", + repr(name), + ["an identifier: letters, digits, _ (starts with a letter or _)"], + command="printer", + extra="Newlines, spaces, `;` and quotes are never sent.", + ) + upper = name.upper() + refuse_denied(upper, "macro name") + return upper + + +def refuse_denied(token: str, what: str) -> None: + for word in _WORD.findall(token.upper()): + if word in DENIED: + raise PrinterRefused( + f"{what} contains {word}: 3d never sends the emergency stop", + command="printer", + remediation=[ + "Stop the printer with its own emergency-stop control (touchscreen, Fluidd, power)." + ], + ) + + +def check_value(key: str, value: str) -> str: + if not _VALUE.fullmatch(value): + raise InvalidArgument( + key, + repr(value), + ["1-64 characters from A-Z a-z 0-9 _ . , : + / -"], + command="printer", + extra="No spaces, quotes, `;`, braces or newlines: a value is one token.", + ) + refuse_denied(value, f"value of {key}") + return value + + +def build_line(name: str, params: list[str]) -> str: + """`NAME KEY=VALUE ...` from a macro name and `KEY=VALUE` words; every character is validated here.""" + out = [check_name(name)] + seen: set[str] = set() + for word in params: + key, sep, value = word.partition("=") + if not sep or not _NAME.fullmatch(key): + raise InvalidArgument( + "parameter", + repr(word), + ["KEY=VALUE, e.g. EXTRUDER=0"], + command="printer", + ) + key = key.upper() + if key in seen: + raise InvalidArgument( + "parameter", key, ["each key once"], command="printer" + ) + seen.add(key) + out.append(f"{key}={check_value(key, value)}") + return " ".join(out) + + +def command_of(line: str) -> str: + """First word of a G-code line, upper-cased (`G28 X` -> `G28`).""" + return line.split(None, 1)[0].upper() if line.strip() else "" + + +def classify(name: str) -> str: + n = name.upper() + if n in _DANGEROUS: + return DANGEROUS + if n in _JOB: + return JOB + if n in _HEATING: + return HEATING + if n in _INERT or n.startswith(_INERT_PREFIXES): + return INERT + return MOTION + + +@dataclass(frozen=True) +class Assessment: + kind: str # the strictest class among the lines + needs_idle: bool + yes_reasons: tuple[str, ...] # why `--yes` is needed; empty = not needed + + +_RANK: Final = {INERT: 0, JOB: 1, HEATING: 2, MOTION: 3, DANGEROUS: 4} + + +def assess( + lines: list[str], + trusted: frozenset[str], + *, + curated: bool, + kind: str | None = None, + entry_yes: bool = False, +) -> Assessment: + """Gates for a script. `curated` lines come from the alias table (the printer's own table or the + user's `aliases:`): they are vetted, so only a dangerous class or `yes: true` asks for --yes. + A `macro run` line (not curated) outside `trusted` asks for --yes. `kind` is an alias's declared class; + the strictest of it and each line's class wins.""" + worst = kind if kind in _RANK else INERT + reasons: list[str] = [] + for line in lines: + cmd = command_of(line) + refuse_denied(cmd, "command") + cls = classify(cmd) + if _RANK[cls] > _RANK[worst]: + worst = cls + if cls == DANGEROUS: + reasons.append( + f"{cmd} is dangerous (restart, config write or kinematics change)" + ) + elif not curated and cmd not in trusted: + reasons.append(f"{cmd} is not in this printer's trusted_macros") + if entry_yes: + reasons.append("this alias is declared to need confirmation") + return Assessment(worst, worst in IDLE_KINDS, tuple(dict.fromkeys(reasons))) + + +@dataclass(frozen=True) +class MacroCatalog: + """What a printer defines: `macros` = [gcode_macro X] names, `commands` = every extended command + with its help text (macros included).""" + + macros: frozenset[str] + commands: dict[str, str] = field(default_factory=dict, hash=False) + + def knows(self, name: str) -> bool: + n = name.upper() + return n in self.macros or n in self.commands + + def describe(self, name: str) -> str: + return self.commands.get(name.upper(), "") + + +def parse_catalog(objects: object, help_text: object) -> MacroCatalog: + """Moonraker's `objects/list` result (`{"objects": [...]}`) and `gcode/help` result (`{CMD: text}`).""" + names = objects.get("objects") if isinstance(objects, dict) else None + if not isinstance(names, list) or not isinstance(help_text, dict): + raise ValueError("objects/list or gcode/help has an unexpected shape") + macros = frozenset( + str(n).split(" ", 1)[1].strip().upper() + for n in names + if isinstance(n, str) and n.lower().startswith("gcode_macro ") and " " in n + ) + return MacroCatalog(macros, {str(k).upper(): str(v) for k, v in help_text.items()}) diff --git a/lib/printer_ops/moonraker.py b/lib/printer_ops/moonraker.py index 5ddb18c7..bc734594 100644 --- a/lib/printer_ops/moonraker.py +++ b/lib/printer_ops/moonraker.py @@ -71,7 +71,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from pathlib import Path -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from urllib.parse import quote, urlsplit from printer_ops.base import ( @@ -104,6 +104,9 @@ ) from printer_ops.move_bounds import Limits +if TYPE_CHECKING: + from printer_ops.macros import MacroCatalog + _STATE = { "standby": IDLE, "printing": PRINTING, @@ -114,6 +117,8 @@ } _HEATERS = ("extruder", "extruder1", "extruder2", "extruder3", "heater_bed") _T = TypeVar("_T") +# Macro discovery: the `gcode_macro NAME` objects and every extended command with its help text. +_CATALOG_PATHS = ("/printer/objects/list", "/printer/gcode/help") _QUERY = ( "webhooks&print_stats&virtual_sdcard&display_status&" + "&".join(_HEATERS) @@ -209,22 +214,17 @@ def _differs(after: Slot, want: HeadFilament) -> list[str]: ] -def parse_status(status: dict[str, Any]) -> PrinterStatus: - """Moonraker `objects/query` status dict -> normalized PrinterStatus.""" - hooks = status.get("webhooks") or {} - ps = status.get("print_stats") or {} +def _job_state(hooks: dict[str, Any], ps: dict[str, Any]) -> tuple[str, str, Any]: + """(normalized state, raw state, message) of the job: Klipper not ready (startup/shutdown/error) + means nothing about the job can be trusted.""" klippy = hooks.get("state", "ready") raw_state = str(ps.get("state", "")) or "?" if klippy != "ready": - # Klipper not ready (startup/shutdown/error): nothing about the job can be trusted. - state, raw_state = UNKNOWN, f"klippy:{klippy}" - message = hooks.get("state_message") - else: - state = _STATE.get(raw_state, UNKNOWN) - message = ps.get("message") or None - progress = (status.get("virtual_sdcard") or {}).get("progress") - if progress is None: - progress = (status.get("display_status") or {}).get("progress") + return UNKNOWN, f"klippy:{klippy}", hooks.get("state_message") + return _STATE.get(raw_state, UNKNOWN), raw_state, ps.get("message") or None + + +def _temps(status: dict[str, Any]) -> dict[str, tuple[float | None, float | None]]: temps: dict[str, tuple[float | None, float | None]] = {} for h in _HEATERS: obj = status.get(h) @@ -233,12 +233,22 @@ def parse_status(status: dict[str, Any]) -> PrinterStatus: obj.get("temperature"), obj.get("target"), ) + return temps + + +def parse_status(status: dict[str, Any]) -> PrinterStatus: + """Moonraker `objects/query` status dict -> normalized PrinterStatus.""" + ps = status.get("print_stats") or {} + state, raw_state, message = _job_state(status.get("webhooks") or {}, ps) + progress = (status.get("virtual_sdcard") or {}).get("progress") + if progress is None: + progress = (status.get("display_status") or {}).get("progress") return PrinterStatus( state=state, raw_state=raw_state, filename=ps.get("filename") or None, progress=float(progress) if progress is not None else None, - temps=temps, + temps=_temps(status), message=message, slots=parse_slots(status.get("print_task_config") or {}), raw=status, @@ -577,10 +587,31 @@ def run_gcode(self, script: str, *, timeout: float = 120.0) -> None: STEPPER_BUZZ takes ~10 s), so the read timeout is at least `timeout`.""" saved, self.timeout = self.timeout, max(self.timeout, timeout) try: - self._post("/printer/gcode/script?script=" + quote(script, safe="")) + self._post(self._script_path(script)) finally: self.timeout = saved + @staticmethod + def _script_path(script: str) -> str: + return "/printer/gcode/script?script=" + quote(script, safe="") + + def gcode_request(self, script: str) -> str: + return f"POST {self.base}{self._script_path(script)}" + + def catalog_requests(self) -> list[str]: + return [f"GET {self.base}{path}" for path in _CATALOG_PATHS] + + def macro_catalog(self) -> MacroCatalog: + """`gcode_macro NAME` objects (`/printer/objects/list`) and every extended command with its help text + (`/printer/gcode/help`, macros included). Read-only.""" + from printer_ops.macros import parse_catalog + + objects, help_text = (self._get(path) for path in _CATALOG_PATHS) + try: + return parse_catalog(objects, help_text) + except ValueError as exc: + raise self._comm_error("macro discovery", exc) from exc + def file_metadata(self, filename: str) -> dict[str, Any]: """Moonraker's parsed metadata of a stored G-code file (`object_height`, `layer_count`, ...); read-only.""" result = self._get("/server/files/metadata?filename=" + quote(filename, safe="")) From bbfc609b24453fa8703090ae9cb26409e13a5845 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:01:48 +0100 Subject: [PATCH 24/30] feat(printer): per-adapter, per-model, per-printer alias table 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) --- lib/data/printer-aliases.yaml | 179 ++++++++++++ lib/printer_ops/aliases.py | 529 ++++++++++++++++++++++++++++++++++ 2 files changed, 708 insertions(+) create mode 100644 lib/data/printer-aliases.yaml create mode 100644 lib/printer_ops/aliases.py diff --git a/lib/data/printer-aliases.yaml b/lib/data/printer-aliases.yaml new file mode 100644 index 00000000..aa884fb1 --- /dev/null +++ b/lib/data/printer-aliases.yaml @@ -0,0 +1,179 @@ +# printer-aliases.yaml — the `3d printer -n NAME ` shortcuts, per adapter and per printer model. +# +# Read by lib/printer_ops/aliases.py (schema checked at load). Issue #718. An alias is a name for +# G-code lines that THIS kind of printer understands, so what exists differs per firmware: +# a missing alias is reported as "not defined for ", never guessed. +# +# LAYERS (later wins per alias name, whole entry replaced): +# 1. adapters. the protocol's own commands (stock Klipper for `moonraker`) +# 2. models[].aliases every `contains` (case-insensitive, in the printer's `model:`) that matches +# 3. the printer's own entries in ~/.config/3d-cli/printer-aliases.yaml, then ./printer-aliases.yaml: +# `NAME: {alias: entry, ...}` (same entry schema; the project file wins per alias) +# An entry `unsupported: ` (or `null` in a printer's own entries) removes an alias from that +# layer on, with the reason shown to the user. +# +# ENTRY: +# summary one line for `--help` +# kind inert | job | heating | motion | dangerous (lib/printer_ops/macros.py: job = allowed while +# a job runs; heating/motion/dangerous need an idle printer; dangerous also needs --yes) +# yes true: the alias needs --yes (extra confirmation) +# timeout seconds Moonraker may take to answer (default 120) +# args ordered mapping, filled from positional words, `--name VALUE` or `NAME=VALUE`: +# type axes X, Y, Z letters, any order -> "X Y" +# type head T1..Tn (the fleet heads); also {name}_extruder, {name}_suffix ("" for T1, "1" for T2) +# type int | float min / max +# type choice choices: {name: {placeholder: value}}; the extra values fill unset placeholders +# optional: true | default: VALUE; a line whose placeholder has no value is left out +# gcode lines sent as ONE script; {arg} placeholders are replaced by validated values only +# +# Sources of the printer-specific entries (nothing was run on a real printer): +# Snapmaker U1: https://github.com/Snapmaker/u1-klipper lava/fluidd.cfg, lava/printer.cfg, +# klippy/extras/filament_feed.py, klippy/extras/purifier.py, klippy/kinematics/extruder.py +# Creality K1 SE: https://github.com/CrealityOfficial/K1_Series_Klipper +# config/K1_SE_CR4CU220812S12/gcode_macro.cfg +# Stock Klipper: https://www.klipper3d.org/G-Codes.html + +adapters: + moonraker: + home: + summary: "home all axes (G28), or only the given ones - `home X Y`" + kind: motion + timeout: 300 + args: + axes: {type: axes, optional: true, default: ""} + gcode: ["G28 {axes}"] + level: + summary: "bed mesh (BED_MESH_CALIBRATE)" + kind: motion + timeout: 600 + gcode: [BED_MESH_CALIBRATE] + qgl: + summary: "quad gantry level (QUAD_GANTRY_LEVEL), only on printers that define it" + kind: motion + timeout: 300 + gcode: [QUAD_GANTRY_LEVEL] + z-tilt: + summary: "Z tilt adjust (Z_TILT_ADJUST), only on printers that define it" + kind: motion + timeout: 300 + gcode: [Z_TILT_ADJUST] + pause: + summary: "pause the running print (PAUSE)" + kind: job + gcode: [PAUSE] + resume: + summary: "resume a paused print (RESUME)" + kind: job + gcode: [RESUME] + preheat: + summary: "heat to a material's typical temperatures, or `--nozzle C` / `--bed C` (`--head T2`)" + kind: heating + args: + material: + type: choice + optional: true + choices: + PLA: {nozzle: 210, bed: 60} + PETG: {nozzle: 235, bed: 80} + ABS: {nozzle: 250, bed: 100} + ASA: {nozzle: 255, bed: 100} + TPU: {nozzle: 225, bed: 50} + nozzle: {type: float, min: 0, max: 300, optional: true} + bed: {type: float, min: 0, max: 120, optional: true} + head: {type: head, default: T1} + gcode: + - SET_HEATER_TEMPERATURE HEATER={head_extruder} TARGET={nozzle} + - SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET={bed} + cooldown: + summary: "switch every heater off (TURN_OFF_HEATERS)" + kind: heating + gcode: [TURN_OFF_HEATERS] + load: + summary: "load filament with the printer's LOAD_FILAMENT macro (a Fluidd/Mainsail convention, not core Klipper)" + kind: motion + timeout: 300 + gcode: [LOAD_FILAMENT] + unload: + summary: "unload filament with the printer's UNLOAD_FILAMENT macro (a Fluidd/Mainsail convention, not core Klipper)" + kind: motion + timeout: 300 + gcode: [UNLOAD_FILAMENT] + +models: + - contains: snapmaker u1 + aliases: + level-heated: + summary: "heat the bed, soak, then bed mesh (AUTO_BED_MESH_CALIBRATE; `--bed C`, default 55; waits 2 min)" + kind: motion + timeout: 900 + args: + bed: {type: float, min: 30, max: 100, default: 55} + gcode: ["AUTO_BED_MESH_CALIBRATE TEMP={bed}"] + clean-nozzle: + summary: "rough nozzle clean at the discard position (ROUGHLY_CLEAN_NOZZLE)" + kind: motion + timeout: 300 + gcode: [ROUGHLY_CLEAN_NOZZLE] + load: + summary: "feed the filament of head T1..T4 (AUTO_FEEDING EXTRUDER=n LOAD=1)" + kind: motion + timeout: 600 + args: + slot: {type: head} + gcode: ["AUTO_FEEDING EXTRUDER={slot} LOAD=1"] + unload: + summary: "unload the filament of head T1..T4 (AUTO_FEEDING UNLOAD=1, stages prepare then doing)" + kind: motion + timeout: 600 + args: + slot: {type: head} + gcode: + - AUTO_FEEDING EXTRUDER={slot} UNLOAD=1 STAGE=prepare + - AUTO_FEEDING EXTRUDER={slot} UNLOAD=1 STAGE=doing + park: + summary: "park a head in its dock (PARK_EXTRUDER, PARK_EXTRUDER1..; default T1)" + kind: motion + timeout: 120 + args: + head: {type: head, default: T1} + gcode: ["PARK_EXTRUDER{head_suffix}"] + idle-position: + summary: "move a head to the idle XY position (MOVE_TO_XY_IDLE_POSITION_EXTRUDER; default T1)" + kind: motion + timeout: 120 + args: + head: {type: head, default: T1} + gcode: ["MOVE_TO_XY_IDLE_POSITION_EXTRUDER{head_suffix}"] + purifier: + summary: "chamber purifier mode idle|cool|preheat|hot (SET_PURIFIER_MODE MODE=0..3)" + kind: heating + args: + mode: + type: choice + choices: + idle: {code: 0} + cool: {code: 1} + preheat: {code: 2} + hot: {code: 3} + gcode: ["SET_PURIFIER_MODE MODE={code}"] + - contains: creality k1 + aliases: + level: + summary: "heat and bed mesh with the stock G29 macro (it heats the bed and nozzle itself)" + kind: motion + timeout: 900 + gcode: [G29] + load: + summary: "load filament (stock LOAD_MATERIAL: heats to the default temperature, feeds 80 mm)" + kind: motion + timeout: 300 + gcode: [LOAD_MATERIAL] + unload: + summary: "unload filament (stock QUIT_MATERIAL: heats, purges and retracts)" + kind: motion + timeout: 300 + gcode: [QUIT_MATERIAL] + qgl: + unsupported: "the K1 SE stock printer.cfg defines no quad_gantry_level (bed mesh only: `level`)" + z-tilt: + unsupported: "the K1 SE stock printer.cfg defines no z_tilt (bed mesh only: `level`)" diff --git a/lib/printer_ops/aliases.py b/lib/printer_ops/aliases.py new file mode 100644 index 00000000..d206537c --- /dev/null +++ b/lib/printer_ops/aliases.py @@ -0,0 +1,529 @@ +"""aliases.py — the `3d printer -n NAME ` registry: per adapter, per model, per printer. + +WHAT: loads lib/data/printer-aliases.yaml (the built-in table), merges the layers adapter -> + matching models -> the printer's own aliases (`printer-aliases.yaml`, user then project), and + binds the words a user typed to the alias's `args`, producing the G-code lines to send. + +WHY: "home", "load filament", "park" are the same intent on every printer but a different + command on each firmware (Klipper `G28`, Snapmaker U1 `AUTO_FEEDING EXTRUDER=n LOAD=1`, + Creality K1 `LOAD_MATERIAL`). The mapping is data, overridable per printer, and an alias a + model does not have is reported as such — never guessed. The table schema is in the data + file's header. Issue https://github.com/alex-mextner/3d-cli/issues/718. + +PER-PRINTER FILES (later wins per alias name, whole entry replaced): + ~/.config/3d-cli/printer-aliases.yaml, then ./printer-aliases.yaml: + u1: # the printer's fleet / printer-devices.yaml name + home: {summary: ..., kind: motion, gcode: ["G28 X Y"]} + park: null # removed for this printer + cooldown: {unsupported: "why"} # removed, reason shown + +INVARIANTS: stdlib + lazy pyyaml. Every value reaching a G-code line went through a typed + check here (axes letters, a fleet head label, a bounded number, a listed choice) or the + table itself; there is no free-text argument. +""" + +from __future__ import annotations + +import functools +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Final + +from errors import InvalidArgument, MissingDependency, ThreeDError, UsageError +from printer_ops import macros + +DATA_FILE: Final = ( + Path(__file__).resolve().parent.parent / "data" / "printer-aliases.yaml" +) +OVERRIDES_FILENAME: Final = "printer-aliases.yaml" +TYPES: Final = ("axes", "head", "int", "float", "choice") +_ENTRY_KEYS: Final = frozenset( + {"summary", "kind", "yes", "timeout", "args", "gcode", "unsupported"} +) +_ARG_KEYS: Final = frozenset({"type", "optional", "default", "min", "max", "choices"}) +_ALIAS_NAME: Final = re.compile(r"[a-z][a-z0-9-]*") +_PLACEHOLDER: Final = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") +_HEAD_LABEL: Final = re.compile(r"T[0-9]+", re.IGNORECASE) + + +class AliasConfigError(ThreeDError): + """The alias table (built-in or a printer's own file) breaks its schema. Exit 2.""" + + exit_code = 2 + + +@dataclass(frozen=True) +class HeadInfo: + index: int + extruder: str # Klipper object name: extruder, extruder1, ... + usable: bool + reason: str | None + + +@dataclass(frozen=True) +class ArgSpec: + name: str + type: str + optional: bool = False + default: str | None = None + minimum: float | None = None + maximum: float | None = None + choices: dict[str, dict[str, str]] = field(default_factory=dict, hash=False) + + +@dataclass(frozen=True) +class AliasSpec: + name: str + summary: str + kind: str + gcode: tuple[str, ...] + source: str # which layer defined it, for messages + args: tuple[ArgSpec, ...] = () + yes: bool = False + timeout: float = 120.0 + unsupported: str | None = None # set: the alias does NOT exist here; this is why + + +def _yaml() -> Any: + try: + import yaml + except ImportError as exc: # pragma: no cover - pyyaml is a core dependency + raise MissingDependency( + "pyyaml", + install="uv sync", + degrades="printer aliases cannot be read", + command="printer", + ) from exc + return yaml + + +def _bad(where: str, why: str) -> AliasConfigError: + return AliasConfigError( + f"{where}: {why}", + command="printer", + remediation=[f"Schema: the header of {DATA_FILE}"], + ) + + +def _number(value: Any, where: str, key: str) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + ): + raise _bad(where, f"`{key}` must be a number, got {value!r}") + return float(value) + + +def _text(value: Any) -> str: + """A scalar of the YAML as the text a G-code line gets (210.0 -> 210).""" + return f"{value:g}" if isinstance(value, float) else str(value) + + +# ---- parsing one entry ---------------------------------------------------------------------- + + +def _choices(spec: dict[str, Any], where: str) -> dict[str, dict[str, str]]: + if spec["type"] != "choice": + if "choices" in spec: + raise _bad(where, "`choices` is for type choice only") + return {} + raw = spec.get("choices") + if not isinstance(raw, dict) or not raw: + raise _bad(where, "a choice needs a non-empty `choices` mapping") + out: dict[str, dict[str, str]] = {} + for label, fills in raw.items(): + if not isinstance(fills, dict): + raise _bad(where, f"choice {label!r} must map placeholder -> value") + out[str(label)] = {str(k): _text(v) for k, v in fills.items()} + return out + + +def _arg(name: str, spec: Any, where: str) -> ArgSpec: + where = f"{where} arg {name}" + if ( + not isinstance(spec, dict) + or set(spec) - _ARG_KEYS + or spec.get("type") not in TYPES + ): + raise _bad( + where, + f"must be a mapping with type in {', '.join(TYPES)} and keys {', '.join(sorted(_ARG_KEYS))}", + ) + lo = _number(spec["min"], where, "min") if "min" in spec else None + hi = _number(spec["max"], where, "max") if "max" in spec else None + default = _text(spec["default"]) if "default" in spec else None + optional = bool(spec.get("optional", False)) or default is not None + return ArgSpec(name, spec["type"], optional, default, lo, hi, _choices(spec, where)) + + +def _placeholders(arg: ArgSpec) -> set[str]: + if arg.type == "head": + return {arg.name, f"{arg.name}_extruder", f"{arg.name}_suffix"} + if arg.type == "choice": + return {arg.name, *(k for fills in arg.choices.values() for k in fills)} + return {arg.name} + + +def _removed(name: str, spec: Any, source: str, where: str) -> AliasSpec | None: + """`None` / `{unsupported: why}`: the alias is removed from this layer on; None when `spec` is a real entry.""" + if spec is None: + return AliasSpec( + name, "", macros.INERT, (), source, unsupported="disabled for this printer" + ) + if not isinstance(spec, dict) or set(spec) - _ENTRY_KEYS: + raise _bad( + where, f"must be a mapping with keys {', '.join(sorted(_ENTRY_KEYS))}" + ) + if "unsupported" not in spec: + return None + reason = spec["unsupported"] + if len(spec) != 1 or not isinstance(reason, str) or not reason.strip(): + raise _bad(where, "`unsupported` takes a reason and no other key") + return AliasSpec(name, "", macros.INERT, (), source, unsupported=reason.strip()) + + +def _gcode_lines( + spec: dict[str, Any], args: tuple[ArgSpec, ...], where: str +) -> tuple[str, ...]: + gcode = spec.get("gcode") + if ( + not isinstance(gcode, list) + or not gcode + or not all(isinstance(g, str) and g.strip() for g in gcode) + ): + raise _bad(where, "`gcode` must be a non-empty list of lines") + known = {p for a in args for p in _placeholders(a)} + for line in gcode: + macros.refuse_denied(line, f"{where} gcode") + unknown = set(_PLACEHOLDER.findall(line)) - known + if unknown: + raise _bad( + where, + f"gcode line {line!r} uses {', '.join(sorted(unknown))}, which no arg provides", + ) + if re.search(r"[;\n\r]", line): + raise _bad(where, f"gcode line {line!r} must be one command without `;`") + return tuple(g.strip() for g in gcode) + + +def parse_entry(name: str, spec: Any, source: str) -> AliasSpec: + """One alias entry (`None` or `{unsupported: why}` = removed) -> AliasSpec; every schema problem is reported.""" + where = f"{source} alias {name}" + if not _ALIAS_NAME.fullmatch(name): + raise _bad(where, "an alias name is lower-case letters, digits and -") + removed = _removed(name, spec, source, where) + if removed is not None: + return removed + if spec.get("kind") not in macros.KINDS: + raise _bad(where, f"`kind` must be one of {', '.join(macros.KINDS)}") + summary = spec.get("summary") + if not isinstance(summary, str) or not summary.strip(): + raise _bad(where, "`summary` is required (it is the --help text)") + raw_args = spec.get("args") or {} + if not isinstance(raw_args, dict): + raise _bad(where, "`args` must be a mapping") + args = tuple(_arg(str(k), v, where) for k, v in raw_args.items()) + timeout = _number(spec["timeout"], where, "timeout") if "timeout" in spec else 120.0 + if not 0 < timeout <= 3600: + raise _bad(where, "`timeout` is seconds, 1-3600") + return AliasSpec( + name, + summary.strip(), + str(spec["kind"]), + _gcode_lines(spec, args, where), + source, + args, + bool(spec.get("yes", False)), + timeout, + ) + + +def parse_entries(value: Any, source: str) -> dict[str, AliasSpec]: + if value is None: + return {} + if not isinstance(value, dict): + raise _bad( + source, + "must be a mapping of alias name -> entry (schema: lib/data/printer-aliases.yaml)", + ) + return {str(k): parse_entry(str(k), v, source) for k, v in value.items()} + + +# ---- the layers ----------------------------------------------------------------------------- + + +def _model_layer(item: Any) -> tuple[str, dict[str, AliasSpec]]: + if ( + not isinstance(item, dict) + or set(item) != {"contains", "aliases"} + or not isinstance(item["contains"], str) + ): + raise _bad(str(DATA_FILE), "each model is {contains: TEXT, aliases: {...}}") + return item["contains"].lower(), parse_entries( + item["aliases"], f"model {item['contains']}" + ) + + +@functools.cache +def _table() -> tuple[ + dict[str, dict[str, AliasSpec]], tuple[tuple[str, dict[str, AliasSpec]], ...] +]: + data = _yaml().safe_load(DATA_FILE.read_text(encoding="utf-8")) + if not isinstance(data, dict) or set(data) - {"adapters", "models"}: + raise _bad(str(DATA_FILE), "top level must have `adapters` and `models` only") + adapters = { + str(a): parse_entries(entries, f"adapter {a}") + for a, entries in (data.get("adapters") or {}).items() + } + return adapters, tuple(_model_layer(item) for item in data.get("models") or []) + + +def builtin_names() -> set[str]: + """Every alias name any built-in layer defines (they become `3d printer` subcommands).""" + return {n for _, entries in builtin_layers() for n in entries} + + +def builtin_layers() -> list[tuple[str, dict[str, AliasSpec]]]: + """(layer label, entries) of the whole built-in table, adapters first.""" + adapters, models = _table() + return [ + *((f"adapter {a}", e) for a, e in adapters.items()), + *((f"model {c}", e) for c, e in models), + ] + + +def overrides_for(name: str, cwd: Path | None = None) -> dict[str, AliasSpec]: + """The aliases printer `name` has of its own: user file, then project file (later wins per alias).""" + from cli.paths import config_dir + + merged: dict[str, AliasSpec] = {} + for path in ( + config_dir() / OVERRIDES_FILENAME, + (cwd or Path.cwd()) / OVERRIDES_FILENAME, + ): + if not path.is_file(): + continue + try: + data = _yaml().safe_load(path.read_text(encoding="utf-8")) + except _yaml().YAMLError as exc: + raise AliasConfigError( + f"{path}: invalid YAML: {exc}", command="printer" + ) from exc + if data is not None and not isinstance(data, dict): + raise _bad(str(path), "top level must map a printer name -> its aliases") + merged.update(parse_entries((data or {}).get(name), f"{path} {name}")) + return merged + + +def resolve( + adapter: str, model: str | None, overrides: Mapping[str, AliasSpec] | None = None +) -> dict[str, AliasSpec]: + """Aliases of one printer, unsupported ones included (their `unsupported` says why).""" + adapters, models = _table() + merged: dict[str, AliasSpec] = dict(adapters.get(adapter, {})) + text = (model or "").lower() + for contains, entries in models: + if contains in text: + merged.update(entries) + merged.update(overrides or {}) + return merged + + +def supported(table: Mapping[str, AliasSpec]) -> dict[str, AliasSpec]: + return {n: s for n, s in sorted(table.items()) if s.unsupported is None} + + +# ---- binding the words of a call ------------------------------------------------------------ + + +@dataclass(frozen=True) +class Bound: + lines: tuple[str, ...] + + +def _scan(words: list[str], names: list[str]) -> tuple[dict[str, str], list[str]]: + """Words -> (named values, positional words): `--name V`, `--name=V`, `NAME=V`; the rest is positional.""" + named: dict[str, str] = {} + positional: list[str] = [] + it = iter(words) + for w in it: + key, value = _named(w, names, it) + if key is None: + positional.append(w) + continue + if key not in names: + raise UsageError( + f"unknown argument {key!r}", + command="printer", + remediation=[f"Accepted: {', '.join(names) or 'none'}"], + ) + if key in named: + raise UsageError(f"argument {key!r} given twice", command="printer") + named[key] = value + return named, positional + + +def _named(word: str, names: list[str], rest: Any) -> tuple[str | None, str]: + """(argument name, value) when `word` names an argument (consuming the next word for `--name V`).""" + if word.startswith("--"): + key, sep, value = word[2:].partition("=") + if not sep: + value = next(rest, "") + if not value: + raise UsageError(f"{word} needs a value", command="printer") + return key.lower(), value + key, sep, value = word.partition("=") + return (key.lower(), value) if sep and key.lower() in names else (None, "") + + +def _split(words: list[str], spec: AliasSpec) -> dict[str, str]: + """Words -> {arg name: raw text}: named ones first, positional ones fill the rest in `args` order.""" + names = [a.name for a in spec.args] + out, positional = _scan(words, names) + free = [n for n in names if n not in out] + if len(positional) > len(free): + raise UsageError( + f"`{spec.name}` takes {len(names)} argument(s) ({', '.join(names) or 'none'}), " + f"got extra {positional[len(free) :]}", + command="printer", + ) + out.update(zip(free, positional, strict=False)) + return out + + +def _number_arg(arg: ArgSpec, text: str) -> str: + try: + v = float(text) if arg.type == "float" else float(int(text)) + except ValueError: + raise InvalidArgument( + arg.name, + text, + [f"a {'number' if arg.type == 'float' else 'whole number'}"], + command="printer", + ) from None + below = arg.minimum is not None and v < arg.minimum + above = arg.maximum is not None and v > arg.maximum + if not math.isfinite(v) or below or above: + lo = "-inf" if arg.minimum is None else f"{arg.minimum:g}" + hi = "inf" if arg.maximum is None else f"{arg.maximum:g}" + raise InvalidArgument(arg.name, text, [f"{lo} .. {hi}"], command="printer") + return f"{v:g}" + + +def _head_values( + arg: ArgSpec, text: str, heads: Mapping[str, HeadInfo], alias: str +) -> dict[str, str]: + label = text.upper() + if not _HEAD_LABEL.fullmatch(text): + raise InvalidArgument( + arg.name, + text, + ["a head label: " + ", ".join(sorted(heads))], + command="printer", + extra="Heads are named by their label (T1..), not by a 0- or 1-based number.", + ) + info = heads.get(label) + if info is None: + raise InvalidArgument( + arg.name, + text, + sorted(heads), + command="printer", + extra=f"`{alias}` knows only the heads listed for this printer in fleet/printers.yaml.", + ) + if not info.usable: + from printer_ops.base import PrinterRefused + + raise PrinterRefused( + f"head {label} is marked unusable: {info.reason or 'no reason recorded'}", + command="printer", + ) + suffix = str(info.index) if info.index else "" + return { + arg.name: str(info.index), + f"{arg.name}_extruder": info.extruder, + f"{arg.name}_suffix": suffix, + } + + +def _axes(text: str) -> str: + letters = list(text.upper()) + if ( + not letters + or any(c not in "XYZ" for c in letters) + or len(set(letters)) != len(letters) + ): + raise InvalidArgument( + "axes", + text, + ["X, Y, Z in any order, each once, e.g. XY"], + command="printer", + ) + return " ".join(letters) + + +def _typed( + arg: ArgSpec, text: str, heads: Mapping[str, HeadInfo], alias: str +) -> dict[str, str]: + """The placeholder values one argument contributes, every one checked against its type.""" + if arg.type == "axes": + return {arg.name: _axes(text) if text else ""} + if arg.type == "head": + return _head_values(arg, text, heads, alias) + if arg.type == "choice": + match = {k.upper(): k for k in arg.choices}.get(text.upper()) + if match is None: + raise InvalidArgument( + arg.name, text, sorted(arg.choices), command="printer" + ) + return {arg.name: match} + return {arg.name: _number_arg(arg, text)} + + +def _collect( + spec: AliasSpec, words: list[str], heads: Mapping[str, HeadInfo] +) -> dict[str, str]: + """Placeholder -> checked value for everything the call provided; a choice's fills only where unset.""" + raw = _split(words, spec) + values: dict[str, str] = {} + fills: dict[str, str] = {} + for arg in spec.args: + text = raw.get(arg.name, arg.default) + if text is None: + if not arg.optional: + raise UsageError( + f"`{spec.name}` needs {arg.name}", + command="printer", + remediation=[f"3d printer {spec.name} --help"], + ) + continue + values.update(_typed(arg, text, heads, spec.name)) + if arg.type == "choice": + fills.update(arg.choices[values[arg.name]]) + by_name = {a.name: a for a in spec.args} + for key, fill in fills.items(): + if key not in values: + values[key] = _number_arg(by_name[key], fill) if key in by_name else fill + return values + + +def bind(spec: AliasSpec, words: list[str], heads: Mapping[str, HeadInfo]) -> Bound: + """Typed values from `words` -> the alias's G-code lines. A line whose placeholder has no value is left out; + no line left is a usage error (e.g. `preheat` without a material, --nozzle or --bed).""" + values = _collect(spec, words, heads) + lines = [ + _PLACEHOLDER.sub(lambda m: values[m.group(1)], line).strip() + for line in spec.gcode + if set(_PLACEHOLDER.findall(line)) <= set(values) + ] + if not lines: + raise UsageError( + f"`{spec.name}` needs at least one of: {', '.join(a.name for a in spec.args)}", + command="printer", + remediation=[f"3d printer {spec.name} --help"], + ) + return Bound(tuple(lines)) From a343c2100111a856d667671b1ea623d6570839ed Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:03:36 +0100 Subject: [PATCH 25/30] feat(printer): `3d printer macro list|run`, `aliases` and the per-model 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) --- docs/commands/printer.md | 95 +++ lib/commands/printer.py | 62 +- lib/printer_ops/alias_cli.py | 230 ++++++ lib/printer_ops/macro_cli.py | 368 +++++++++ tests/e2e/test_printer_macros_workflows.py | 172 ++++ tests/printer_macro_fakes.py | 76 ++ tests/test_printer_macros.py | 861 +++++++++++++++++++++ 7 files changed, 1857 insertions(+), 7 deletions(-) create mode 100644 lib/printer_ops/alias_cli.py create mode 100644 lib/printer_ops/macro_cli.py create mode 100644 tests/e2e/test_printer_macros_workflows.py create mode 100644 tests/printer_macro_fakes.py create mode 100644 tests/test_printer_macros.py diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 4ed17330..8772c38a 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -39,6 +39,10 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). 3d printer todo dismiss ID --reason TEXT | snooze ID [--until DATE] [--reason TEXT] | reopen ID 3d printer todo import [FILE...] [--dry-run] [--json] 3d printer -n PRINTER issues|todo list|add|done|dismiss|snooze|reopen|import … +3d printer -n PRINTER macro list [--offline | --dry-run] [--all] [--json] +3d printer -n PRINTER macro run MACRO [KEY=VALUE ...] [--yes] [--dry-run] [--offline] [--timeout S] +3d printer -n PRINTER aliases [--json] +3d printer -n PRINTER ALIAS [args] [--yes] [--dry-run] [--offline] [--timeout S] # home, level, load, preheat, ... ``` | Subcommand | What | @@ -57,6 +61,8 @@ Epic: [#90](https://github.com/alex-mextner/3d-cli/issues/90). | `subscribe` / `unsubscribe` / `subscriptions` | Route printer alerts to an AI agent first: it looks at the frame and tells the user only if the failure is real — see [printer-subscribe.md](printer-subscribe.md). | | `agent list` / `stop` / `handoff` | The temporary per-printer omp agent (tmux) that gets the warning+ alerts no live subscriber took — see [printer-subscribe.md#the-temporary-printer-agent](printer-subscribe.md#the-temporary-printer-agent). | | `doctor [NAME\|--all]` | Diagnose the printer's setup (Klippy, Moonraker, Spoolman link and CORS, active spool, camera, watch services, ...) and with `--fix` repair what is safe — the same command as `3d printers doctor`, see [printers-doctor.md](printers-doctor.md). | +| `macro list\|run` | The macros a Klipper printer defines, and ONE macro run with typed `KEY=VALUE` parameters — see [Macros and aliases](#macros-and-aliases-3d-printer-n-name-macro-home-load-). | +| `aliases`, `home`, `level`, `load`, `preheat`, ... | Short names for the usual operations, mapped per adapter, model and printer to the real macros — see [Macros and aliases](#macros-and-aliases-3d-printer-n-name-macro-home-load-). | | `todo …` (alias `issues`) | The to-do list of each printer: needed calibrations, due maintenance, repairs of open faults, manual tasks — see [To-do list](#to-do-list-3d-printer-todo). A printer's problems are tasks of this list, not GitHub tickets. | Options: @@ -340,6 +346,95 @@ Telegram approval). With `--printer A --axis-limits B` only A's list counts. print). The preflight overhangs gate counts uncarried runs up to it as bridges; `--bridge-max` overrides it, and without either the limit is 15 mm. The gate's line names where the limit came from. +## Macros and aliases (`3d printer -n NAME macro`, `home`, `load`, ...) + +Issue [#718](https://github.com/alex-mextner/3d-cli/issues/718). Two ways to run what the printer's own +firmware knows, both through the adapter's G-code channel (`moonraker`: `POST /printer/gcode/script`). +The `creality-stock` adapter has no G-code channel (ws:9999 only starts and stops a file): every command +below refuses it with "does not support macros and aliases". The printer is named with `-n NAME`, like `todo`. + +### `macro list` and `macro run` + +``` +3d printer -n u1 macro list # what the printer defines; * = in its trusted_macros +3d printer -n u1 macro list --offline --json # registry data only (trusted_macros + alias targets); nothing contacted +3d printer -n u1 macro list --dry-run # print the two GET requests, send none +3d printer -n u1 macro run SM_PRINT_AUTO_FEED EXTRUDER=1 --yes --dry-run +3d printer -n k1se macro run PAUSE +``` + +`macro list` reads `GET /printer/objects/list` (the `gcode_macro NAME` objects) and `GET /printer/gcode/help` +(every extended command with its description); `_private` macros need `--all`. `macro run` prints the exact +line that is sent (`gcode:` block) and the request on `--dry-run`. Gates, in this order, nothing is sent before +all passed: + +1. **Typed tokens.** The name is an identifier (`[A-Za-z_][A-Za-z0-9_]*`, upper-cased); each value is one token of + `A-Za-z0-9_.,:+/-` (at most 64 characters). A newline, `;`, space, quote, `{`, `%`, `#` or `*` is refused as an + invalid argument, so a value can never append a second command. +2. **No emergency stop.** `M112` and `EMERGENCY_STOP`, as a name or inside a value, are refused whatever the flags: + the printer's own stop control is the way to stop it. +3. **Access.** Only `access: full` printers; `read-only` and `offline` (never contacted) refuse, also with `--dry-run`. +4. **Defined on the printer.** The name must appear in the printer's `gcode/help`. A standard code (`G28`, `M104`, `T0`) + is run only if the printer defines a macro of that name (the K1's `G29`) or through an alias. +5. **`--yes`** for a macro outside the printer's `trusted_macros` and for dangerous commands (`FIRMWARE_RESTART`, `RESTART`, + `SAVE_CONFIG`, `FORCE_MOVE`, `M84`, calibrations that write config, ...), trusted or not. +6. **An idle printer** (state re-read now; printing, paused, busy and unreadable all refuse) for motion, heating and + unknown macros. `PAUSE`, `RESUME`, `CANCEL_PRINT`, `M600`, `M220`, `M221` and the `SET_PAUSE_*` pair are allowed during a job. + +A macro body is the printer's own config and is not checked, so any macro that is not known to be harmless is treated +as motion. `--offline` prints the script from registry data without contacting the printer (it cannot check 4 and 6). + +**There is no raw `gcode` passthrough.** `3d printer gcode SCRIPT` does not exist: `/printer/gcode/script` runs any text, newlines +and `;` included, the model of this tool is that an agent never sends a free-text line to hardware, and a stock G-code +(`G1`, `M104`) cannot be tied to a trusted list. A command the table lacks is added as a named, typed alias in the +printer's own alias file (below) or run as a macro the printer defines. + +### Aliases + +`3d printer -n NAME ALIAS [args] [--yes] [--dry-run] [--offline]`. An alias is a table entry (`kind`, a `summary`, typed +`args`, `gcode` lines); its lines are vetted, so only `dangerous` ones and entries marked `yes: true` ask for `--yes`; the +idle-printer rule (6) and the definition check (4) still apply. The table is +[`lib/data/printer-aliases.yaml`](../../lib/data/printer-aliases.yaml) (schema in its header), layered per adapter, then +every model whose name contains the entry's `contains` text, then the printer's own entries in +`~/.config/3d-cli/printer-aliases.yaml` and `./printer-aliases.yaml` (the project file wins per alias): + +```yaml +# ~/.config/3d-cli/printer-aliases.yaml or ./printer-aliases.yaml: printer name -> its aliases +u1: + home: {summary: home X and Y only, kind: motion, gcode: ["G28 X Y"]} # replaces the table's home + nozzle-wipe: {summary: wipe the nozzle, kind: motion, gcode: [ROUGHLY_CLEAN_NOZZLE]} # a new alias + park: null # removed for this printer + cooldown: {unsupported: "close the chamber door first"} # removed, with the reason shown +``` + +An alias that a model does not have says so (`alias park is not defined for k1se ...`, with the aliases it does have); +it is never guessed. `3d printer -n NAME aliases` lists what each alias sends on that printer and which are unsupported; +`3d printer ALIAS --help` shows one. + +| Alias | Sends | Where | +|---|---|---| +| `home [XYZ]` | `G28` / `G28 X Y` | every `moonraker` printer | +| `level` | `BED_MESH_CALIBRATE`; Creality K1: the stock `G29` (heats, probes) | `moonraker`; K1 family | +| `qgl`, `z-tilt` | `QUAD_GANTRY_LEVEL`, `Z_TILT_ADJUST` | `moonraker`, refused with a reason on the K1 SE (its stock `printer.cfg` defines neither) and when the firmware does not define the command | +| `pause`, `resume` | `PAUSE`, `RESUME` (allowed during a job) | `moonraker` | +| `preheat MATERIAL` / `--nozzle C` / `--bed C` / `--head T2` | `SET_HEATER_TEMPERATURE` for the nozzle of the head (fleet `heads:`, default T1) and `heater_bed`; PLA 210/60, PETG 235/80, ABS 250/100, ASA 255/100, TPU 225/50 (nozzle/bed, typical values), nozzle 0-300, bed 0-120 | `moonraker` | +| `cooldown` | `TURN_OFF_HEATERS` | `moonraker` | +| `load`, `unload` | generic: `LOAD_FILAMENT`, `UNLOAD_FILAMENT` (Fluidd/Mainsail convention, refused when the printer has no such macro); K1: `LOAD_MATERIAL`, `QUIT_MATERIAL`; U1: `load T1..T4` = `AUTO_FEEDING EXTRUDER=n LOAD=1`, `unload T1..T4` = `AUTO_FEEDING EXTRUDER=n UNLOAD=1 STAGE=prepare` then `STAGE=doing` | per adapter/model | +| `level-heated [--bed C]` | `AUTO_BED_MESH_CALIBRATE TEMP=C` (heats the bed to 55 by default, soaks 2 min, meshes) | Snapmaker U1 | +| `clean-nozzle` | `ROUGHLY_CLEAN_NOZZLE` | Snapmaker U1 | +| `park [T1..T4]`, `idle-position [T1..T4]` | `PARK_EXTRUDER[n]`, `MOVE_TO_XY_IDLE_POSITION_EXTRUDER[n]` | Snapmaker U1 | +| `purifier idle\|cool\|preheat\|hot` | `SET_PURIFIER_MODE MODE=0..3` | Snapmaker U1 | + +U1 facts come from the firmware config of [Snapmaker/u1-klipper](https://github.com/Snapmaker/u1-klipper) +(`lava/fluidd.cfg`, `lava/printer.cfg`, `klippy/extras/filament_feed.py`, `purifier.py`, `kinematics/extruder.py`); the +K1 SE ones from [CrealityOfficial/K1_Series_Klipper](https://github.com/CrealityOfficial/K1_Series_Klipper) +`config/K1_SE_CR4CU220812S12/gcode_macro.cfg`. None of them was run on a real printer by the author: the tests use +the loopback fake Moonraker only. Head arguments are the fleet labels (`T1`..), never a bare number, and an unusable +head (fleet `usable: false`) is refused with its reason. + +Models without an adapter here (Bambu Lab, Prusa) have no aliases: they are `offline` in the fleet and every command above +refuses them; a Bambu/Prusa adapter would bring its own `adapters:` layer in the table. + ## Adapters | Adapter | Printers | Transport | diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 55563bd2..7319ccd0 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -1,4 +1,4 @@ -"""3d printer — drive configured physical printers: list, status, upload, start, cancel, watch, vision, bed, subscribe, agent, todo. +"""3d printer — drive configured physical printers: list, status, upload, start, cancel, macro, aliases, watch, vision, bed, subscribe, agent, todo. WHAT: talks to the printers named in printer-devices.yaml through a protocol adapter — `moonraker` (Klipper/Moonraker, Snapmaker U1) or `creality-stock` (Creality K1 family on @@ -17,6 +17,9 @@ approval, printer_ops/force_hatch.py); - a failed/timed-out start is reported as UNKNOWN and never resent automatically. +MACROS AND ALIASES (`-n NAME macro list|run`, `aliases`, `home`, `load`, ...): printer_ops/macro_cli.py and alias_cli.py; + a macro is a checked passthrough, an alias a per-adapter/model/printer table entry (issue #718). + INVARIANTS: stdlib-only at import time (registry contract); printer_ops is imported lazily. """ from __future__ import annotations @@ -99,8 +102,35 @@ maintenance, repairs, manual tasks (3d-api; offline a JSONL buffer — see `3d printer todo --help`). A printer's problems are tasks of this list, not GitHub tickets. - -n, --name NAME the printer for `todo`/`issues`: - `3d printer -n u1 issues list` + macro list|run MACRO [KEY=VALUE ...] [--yes] [--dry-run] [--offline] + the macros the printer defines / run ONE macro (Klipper: any + gcode_macro or extended command) with typed KEY=VALUE params; + the exact line sent is printed. Names are identifiers, values + one safe token; M112/EMERGENCY_STOP never; read-only/offline + printers refuse; a macro outside the printer's trusted_macros + and dangerous ones need --yes; motion/heating ones need an + idle printer (PAUSE/RESUME/CANCEL_PRINT style do not). + There is no raw `gcode` passthrough (see docs/commands/printer.md) + (see `3d printer macro --help`) + aliases [--json] what each alias below sends on this printer (-n NAME) or per + adapter/model; `3d printer ALIAS --help` for one + -n, --name NAME the printer for `todo`/`issues`, `macro`, `aliases` and the aliases: + `3d printer -n u1 issues list`, `3d printer -n u1 home` + (own aliases per printer: ~/.config/3d-cli/printer-aliases.yaml, ./printer-aliases.yaml) + +Aliases (thin subcommands, need -n NAME; each maps per adapter/model to real macros and says so +when a model has none: `3d printer -n NAME ALIAS [args] [--yes] [--dry-run] [--offline]`): + home [XYZ] home all axes, or only X/Y/Z (G28) + level bed mesh (BED_MESH_CALIBRATE; Creality K1: stock G29) + qgl | z-tilt quad gantry level / Z tilt adjust (printers that define them) + pause | resume PAUSE / RESUME (allowed while a job runs) + preheat MATERIAL | --nozzle C --bed C [--head T2] + PLA|PETG|ABS|ASA|TPU typical temperatures (SET_HEATER_TEMPERATURE) + cooldown TURN_OFF_HEATERS + load T1..T4 | unload T1..T4 filament in/out: U1 AUTO_FEEDING per head; K1 LOAD_MATERIAL / + QUIT_MATERIAL (no head); other Klipper LOAD_FILAMENT / UNLOAD_FILAMENT + Snapmaker U1 only: level-heated [--bed C], clean-nozzle, park [T1..T4], idle-position [T1..T4], + purifier idle|cool|preheat|hot Options: --json list/status as JSON (status: normalized state + raw firmware payload) @@ -150,6 +180,11 @@ 3d printer todo list u1 3d printer -n k1se issues add --title "X belt squeaks" --type inspect""" +# Subcommands that take the printer as a positional NAME: `-n` is refused for them. +_NAME_AS_POSITIONAL = frozenset({ + "list", "status", "upload", "start", "cancel", "evidence", "watch", "supervise", "vision", "guard", "bed", + "subscribe", "unsubscribe", "subscriptions", "agent", "doctor", +}) _VALUE = {"--secs", "--as", "--source-3mf", "--bridge-max"} _FLAGS = {"--json", "--yes", "--force", "--no-bed-move"} @@ -499,8 +534,8 @@ def run(argv: list[str]) -> int: if sub in ("-h", "--help", "help"): print(USAGE) return 0 - if name is not None and sub not in ("todo", "issues"): - raise UsageError(f"-n/--name selects the printer for `todo`/`issues`; `{sub}` takes the printer as NAME", + if name is not None and sub in _NAME_AS_POSITIONAL: + raise UsageError(f"-n/--name selects the printer for `todo`/`issues`, `macro`, `aliases` and the aliases; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) if sub == "list": return _cmd_list(rest) @@ -550,9 +585,22 @@ def run(argv: list[str]) -> int: from printops.todo_cli import run as todo return todo(rest, printer=name) + if sub == "macro": + from printer_ops.macro_cli import run_macro + + return run_macro(name, rest) + if sub == "aliases": + from printer_ops.alias_cli import run_aliases + + return run_aliases(name, rest) + from printer_ops.alias_cli import run_alias + + aliased = run_alias(name, sub, rest) + if aliased is not None: + return aliased raise UsageError(f"unknown subcommand '{sub}'", command="printer", remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor or todo (issues) — see `3d printer --help`."]) + "subscriptions, agent, doctor, todo (issues), macro, aliases or an alias (home, level, load, ...) — see `3d printer --help`."]) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: @@ -574,7 +622,7 @@ def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: COMMAND = Command( name="printer", group="SLICING", - summary="drive configured printers: list / status / upload / start --yes / cancel --yes / evidence / watch / vision / subscribe / agent / todo", + summary="drive configured printers: list / status / upload / start --yes / cancel --yes / macro / aliases (home, level, load, ...) / evidence / watch / vision / subscribe / agent / todo", usage=USAGE, run=run, ) diff --git a/lib/printer_ops/alias_cli.py b/lib/printer_ops/alias_cli.py new file mode 100644 index 00000000..a3aebb01 --- /dev/null +++ b/lib/printer_ops/alias_cli.py @@ -0,0 +1,230 @@ +"""alias_cli.py — `3d printer -n NAME aliases` and the alias subcommands (home, level, load, preheat, park, ...). + +WHAT: the CLI side of printer_ops/aliases.py: lists what each alias sends on a printer (or per adapter/model), + and runs one alias through the same gates as `macro run` (printer_ops/macro_cli.py `execute`: typed values, + `access: full`, a G-code channel, defined on the printer, `--yes`, an idle printer; `--dry-run`, `--offline`). + +WHY: an alias a printer model lacks must say so and list what it has — never guess a macro name — and every alias + must show the exact G-code it sends. Issue https://github.com/alex-mextner/3d-cli/issues/718. + +INVARIANTS: stdlib-only at import time; imported lazily by `commands/printer.py`. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from errors import UsageError +from printer_ops import aliases, macros +from printer_ops.macro_cli import ( + FLAGS, + VALUE_OPTS, + adapter_of, + device_of, + execute, + heads_of, + require_full_access, + split_opts, + timeout_of, +) + +if TYPE_CHECKING: + from printer_ops.devices import Device + +ALIASES_USAGE = """3d printer [-n NAME] aliases [--json] + With -n NAME: the aliases that printer has (adapter + model + its own printer-aliases.yaml entries), what each + sends, and which are not supported on it and why. Without -n: the whole built-in table + (lib/data/printer-aliases.yaml).""" + + +def _wants_help(argv: list[str]) -> bool: + return any(a in ("-h", "--help") for a in argv) + + +def _alias_line(spec: aliases.AliasSpec) -> str: + args = " ".join(f"[{a.name}]" if a.optional else f"<{a.name}>" for a in spec.args) + return f"{spec.name} {args}".rstrip() + + +def _arg_line(a: aliases.ArgSpec) -> str: + choices = f" {', '.join(a.choices)}" if a.choices else "" + limits = ( + f" {a.minimum:g}..{a.maximum:g}" + if a.minimum is not None and a.maximum is not None + else "" + ) + default = f" (default {a.default})" if a.default else "" + return f" {a.name}: {a.type}{choices}{limits}{default}" + + +def _detail(spec: aliases.AliasSpec) -> str: + if spec.unsupported is not None: + return f" {spec.name}: NOT supported - {spec.unsupported}" + confirm = ", needs --yes" if spec.yes or spec.kind == macros.DANGEROUS else "" + return "\n".join( + [ + f" {_alias_line(spec)}", + f" {spec.summary}", + f" sends: {'; '.join(spec.gcode)} [{spec.kind}{confirm}] (from {spec.source})", + *(_arg_line(a) for a in spec.args), + ] + ) + + +def _as_json(spec: aliases.AliasSpec) -> dict[str, Any]: + return { + "summary": spec.summary, + "kind": spec.kind, + "gcode": list(spec.gcode), + "source": spec.source, + "unsupported": spec.unsupported, + } + + +def _list_builtin(as_json: bool) -> int: + layers = aliases.builtin_layers() + if as_json: + print( + json.dumps( + {label: {n: _as_json(s) for n, s in e.items()} for label, e in layers}, + indent=2, + ) + ) + return 0 + for label, entries in layers: + print(f"{label}:") + for spec in entries.values(): + print(_detail(spec)) + return 0 + + +def _list_printer(name: str, as_json: bool) -> int: + device = device_of(name, "aliases") + table = aliases.resolve( + device.adapter, device.model, aliases.overrides_for(device.name) + ) + if as_json: + print(json.dumps({n: _as_json(s) for n, s in sorted(table.items())}, indent=2)) + return 0 + print( + f"{device.name} ({device.model or device.adapter}, adapter {device.adapter}):" + ) + ordered = sorted( + table.values(), key=lambda s: s.unsupported is not None + ) # the usable ones first + for spec in ordered: + print(_detail(spec)) + return 0 + + +def run_aliases(name: str | None, argv: list[str]) -> int: + """`aliases [--json]`: the aliases of printer NAME (resolved), or the whole built-in table.""" + if _wants_help(argv): + print(ALIASES_USAGE) + return 0 + words, opts = split_opts(argv, frozenset({"--json"})) + if words: + raise UsageError( + f"`aliases` takes no arguments, got {words}", command="printer" + ) + return ( + _list_builtin("--json" in opts) + if name is None + else _list_printer(name, "--json" in opts) + ) + + +def _print_builtin_help(sub: str) -> int: + for label, entries in aliases.builtin_layers(): + if sub in entries: + print(f"{label}:") + print(_detail(entries[sub])) + return 0 + + +def _no_printer(sub: str, argv: list[str]) -> int | None: + """An alias word without -n NAME: its built-in help, or a usage error; None when `sub` is no alias at all.""" + if sub not in aliases.builtin_names(): + return None + if _wants_help(argv): + return _print_builtin_help(sub) + raise UsageError( + f"`{sub}` is an alias and needs the printer: 3d printer -n NAME {sub} ...", + command="printer", + remediation=["3d printer aliases # what each alias sends, per model"], + ) + + +def _unknown_alias( + name: str, + sub: str, + model: str | None, + adapter: str, + table: dict[str, aliases.AliasSpec], +) -> UsageError: + return UsageError( + f"alias `{sub}` is not defined for {name} ({model or 'no model'}, adapter {adapter})", + command="printer", + remediation=[ + f"Defined for it: {', '.join(aliases.supported(table)) or 'none'}", + f"Run a macro it defines: `3d printer -n {name} macro list`", + f"Or add `{name}: {{{sub}: {{summary: ..., kind: motion, gcode: [...]}}}}` to " + f"./{aliases.OVERRIDES_FILENAME} (or the user config dir's)", + ], + ) + + +def _lookup(device: Device, sub: str, argv: list[str]) -> aliases.AliasSpec | None: + """The alias to run; None when `--help` was printed instead. Raises for what the printer cannot do.""" + helping = _wants_help(argv) + if not helping: + require_full_access(device, f"alias {sub}") + adapter_of( + device + ) # an adapter without a G-code channel says so before any "alias not defined" + table = aliases.resolve( + device.adapter, device.model, aliases.overrides_for(device.name) + ) + spec = table.get(sub) + if spec is None: + raise _unknown_alias(device.name, sub, device.model, device.adapter, table) + if spec.unsupported is not None: + raise UsageError( + f"alias `{sub}` is not supported on {device.name} ({device.model or device.adapter}): " + f"{spec.unsupported}", + command="printer", + remediation=[ + f"Defined for it: {', '.join(aliases.supported(table)) or 'none'}" + ], + ) + if helping: + print( + f"3d printer -n {device.name} {_alias_line(spec)} [--yes] [--dry-run] [--offline] [--timeout S]" + ) + print(_detail(spec)) + return None + return spec + + +def run_alias(name: str | None, sub: str, argv: list[str]) -> int | None: + """Subcommand `sub` as an alias of printer `name`; None when `sub` is no alias at all (the caller reports it).""" + if name is None: + return _no_printer(sub, argv) + device = device_of(name, sub) + spec = _lookup(device, sub, argv) + if spec is None: + return 0 + words, opts = split_opts(argv, FLAGS, VALUE_OPTS) + bound = aliases.bind(spec, words, heads_of(device)) + return execute( + device, + list(bound.lines), + what=f"alias {sub}", + again=" ".join([sub, *words]), + kind=spec.kind, + curated=True, + entry_yes=spec.yes, + timeout=timeout_of(opts, spec.timeout), + opts=opts, + ) diff --git a/lib/printer_ops/macro_cli.py b/lib/printer_ops/macro_cli.py new file mode 100644 index 00000000..f9f1e37a --- /dev/null +++ b/lib/printer_ops/macro_cli.py @@ -0,0 +1,368 @@ +"""macro_cli.py — `3d printer -n NAME macro list|run`, (the shared gates `execute`, `device_of`, `adapter_of`, `heads_of` also serve printer_ops/alias_cli.py). + +WHAT: the CLI side of printer_ops/macros.py (what may be sent) and printer_ops/aliases.py (the per-adapter, + per-model, per-printer shortcut table): discovery of the macros a Klipper printer defines, a checked + passthrough of one macro with `KEY=VALUE` parameters, and the aliases that expand to G-code lines. + +SAFETY, in the order it is enforced (nothing is sent before every step passed): + 1. names/values are typed tokens (macros.build_line, aliases.bind): no newline, `;`, quote, M112; + 2. the printer must be `access: full` (read-only and offline fleet printers refuse; offline are never contacted); + 3. the adapter must have a G-code channel (Moonraker; creality-stock has none); + 4. the command must be defined on the printer (`/printer/gcode/help`); a standard G/M code only through an alias; + 5. `--yes` for a macro outside `trusted_macros`, for dangerous ones and for aliases that ask for it; + 6. an idle printer for motion/heating/dangerous (state re-read here; pause/resume/cancel style is exempt). + `--dry-run` runs 1-6 (reads only) and prints the exact request instead of sending it; `--offline` contacts + nothing and prints the script from registry data only. + +INVARIANTS: stdlib-only at import time; printer_ops pieces are imported lazily by the caller. +Issue https://github.com/alex-mextner/3d-cli/issues/718. +""" + +from __future__ import annotations + +import difflib +import json +from typing import TYPE_CHECKING, Any + +from errors import InvalidArgument, UsageError +from printer_ops import aliases, macros +from printer_ops.base import BUSY_STATES, PrinterRefused + +if TYPE_CHECKING: + from printer_ops.base import Adapter + from printer_ops.devices import Device + +FLAGS = frozenset({"--yes", "--dry-run", "--offline", "--json", "--all"}) +VALUE_OPTS = frozenset({"--timeout"}) + +MACRO_USAGE = """3d printer -n NAME macro ... + list [--offline] [--dry-run] [--all] [--json] + the macros NAME defines (Moonraker: /printer/objects/list + /printer/gcode/help), + with trust (* = in its trusted_macros), risk class and description; `_private` + ones with --all. --offline: registry data only (trusted_macros + alias targets), + nothing is contacted. --dry-run: print the requests, send none. + run MACRO [KEY=VALUE ...] [--yes] [--dry-run] [--offline] [--timeout S] + run ONE macro: the line `MACRO KEY=VALUE ...` is printed exactly as sent. + Refused: names that are not identifiers, values with anything but A-Za-z0-9_.,:+/-, + M112/EMERGENCY_STOP anywhere, read-only/offline printers, G/M codes that the + printer does not define as macros, names the printer does not define. + --yes: needed for a macro outside the printer's trusted_macros and for dangerous + ones (FIRMWARE_RESTART, RESTART, SAVE_CONFIG, ...). Motion/heating/unknown macros + need an idle printer; PAUSE/RESUME/CANCEL_PRINT-style ones do not. + --dry-run: checks everything (reads only), prints the request, sends nothing. + +Examples: + 3d printer -n u1 macro list + 3d printer -n u1 macro list --offline --json + 3d printer -n u1 macro run SM_PRINT_AUTO_FEED EXTRUDER=1 --yes --dry-run + 3d printer -n k1se macro run PAUSE + 3d printer -n u1 macro run MOVE_TO_XY_IDLE_POSITION_EXTRUDER X_OFFSET=3.5 --yes""" + + +def split_opts( + argv: list[str], flags: frozenset[str], values: frozenset[str] = frozenset() +) -> tuple[list[str], dict[str, str]]: + """Pull known option words out of argv; everything else (macro/alias words, `--nozzle 200`) stays in order.""" + rest: list[str] = [] + opts: dict[str, str] = {} + i = 0 + while i < len(argv): + a = argv[i] + if a in flags: + opts[a] = "1" + elif a in values: + if i + 1 >= len(argv): + raise UsageError(f"{a} needs a value", command="printer") + opts[a] = argv[i + 1] + i += 1 + else: + rest.append(a) + i += 1 + return rest, opts + + +def timeout_of(opts: dict[str, str], default: float) -> float: + if "--timeout" not in opts: + return default + try: + value = float(opts["--timeout"]) + except ValueError: + value = -1.0 + if not 0 < value <= 3600: + raise InvalidArgument( + "--timeout", opts["--timeout"], ["seconds, 1-3600"], command="printer" + ) + return value + + +def device_of(name: str | None, sub: str) -> Device: + from printer_ops.devices import get_device + + if name is None: + raise UsageError( + f"`{sub}` needs the printer: 3d printer -n NAME {sub} ...", + command="printer", + remediation=["`3d printer list` shows the configured printers."], + ) + return get_device(name, offline_ok=True) + + +def heads_of(device: Device) -> dict[str, aliases.HeadInfo]: + """Heads of the printer by label (fleet/printers.yaml); a printer the fleet does not describe has T1 only.""" + from printer_ops.moonraker import extruder_name + from printops.fleet import load_fleet + + fleet = load_fleet() + entry = fleet.printers.get(device.name) if fleet is not None else None + if entry is None or not entry.heads: + return {"T1": aliases.HeadInfo(0, "extruder", True, None)} + return { + h.label.upper(): aliases.HeadInfo( + h.index, h.extruder or extruder_name(h.index), h.usable, h.reason + ) + for h in entry.heads + } + + +def adapter_of(device: Device) -> Adapter: + from printer_ops import make_adapter + + adapter = make_adapter(device) + adapter.gcode_request( + "" + ) # UnsupportedOperation when the adapter has no G-code channel (creality-stock) + return adapter + + +def _check_defined( + device: Device, lines: list[str], catalog: macros.MacroCatalog, *, curated: bool +) -> None: + for line in lines: + cmd = macros.command_of(line) + if cmd in device.trusted_macros: + continue + if macros.is_numeric_code(cmd): + if curated or cmd in catalog.macros: + continue + raise PrinterRefused( + f"{cmd} is a standard G-code, not a macro {device.name} defines: `macro run` sends macros only", + command="printer", + remediation=[ + "Use an alias for the usual ones (`3d printer aliases`), or define the macro in the " + "printer's config." + ], + ) + if not catalog.knows(cmd): + near = difflib.get_close_matches( + cmd, sorted(catalog.commands.keys() | catalog.macros), n=3 + ) + raise PrinterRefused( + f"{device.name} does not define {cmd}", + command="printer", + remediation=[ + f"Did you mean: {', '.join(near)}" + if near + else f"`3d printer -n {device.name} macro list` shows what it defines." + ], + ) + + +def require_full_access(device: Device, what: str) -> None: + """Only `access: full` printers take a write; read-only ones may still be read, offline ones are never contacted.""" + if device.access == "full": + return + hint = (f"Only read it: 3d printer status {device.name}" if device.access == "read-only" + else "3d never contacts an offline fleet printer; run its jobs by hand.") + raise PrinterRefused(f"{device.name} is {device.access} in {device.source}: {what} refused", + command="printer", remediation=[hint]) + + +def execute( + device: Device, + lines: list[str], + *, + what: str, + again: str, + kind: str | None, + curated: bool, + entry_yes: bool, + timeout: float, + opts: dict[str, str], +) -> int: + require_full_access(device, what) + verdict = macros.assess( + lines, device.trusted_macros, curated=curated, kind=kind, entry_yes=entry_yes + ) + script = "\n".join(lines) + adapter = adapter_of(device) + dry, offline = "--dry-run" in opts, "--offline" in opts + header = f"{device.name} ({device.adapter} {device.host}:{device.port}): {what}" + if not offline: + _check_defined(device, lines, adapter.macro_catalog(), curated=curated) + need_yes = bool(verdict.yes_reasons) and "--yes" not in opts + if need_yes and not (dry or offline): + raise UsageError( + f"{what} needs --yes: {'; '.join(verdict.yes_reasons)}", + command="printer", + remediation=[ + f"Confirm explicitly: 3d printer -n {device.name} {again} --yes" + ], + ) + if verdict.needs_idle and not offline: + st = adapter.status() + if st.state in BUSY_STATES: + raise PrinterRefused( + f"{device.name} is {st.state} [{st.raw_state}]: {what} is {verdict.kind} and needs an idle printer", + command="printer", + remediation=[ + "Wait for the job to end; pause/resume/cancel style macros (PAUSE, RESUME, CANCEL_PRINT) " + "are allowed while it runs." + ], + ) + print(header) + print("gcode:") + for line in lines: + print(f" {line}") + if need_yes: + print(f"would need --yes: {'; '.join(verdict.yes_reasons)}") + if offline: + print( + f"{adapter.gcode_request(script)}\noffline: nothing was contacted; the printer's idle state and its " + "macro list were not checked" + ) + return 0 + if dry: + print(f"{adapter.gcode_request(script)}\ndry-run: nothing was sent") + return 0 + adapter.run_gcode(script, timeout=timeout) + print(f"sent to {device.name}; the printer answered ok") + return 0 + + +def _registry_rows(device: Device) -> list[dict[str, Any]]: + """Macros known without contacting the printer: its trusted_macros and the commands of its aliases.""" + rows: dict[str, dict[str, Any]] = {} + for n in sorted(device.trusted_macros): + rows[n] = { + "name": n, + "trusted": True, + "kind": macros.classify(n), + "description": "", + "source": "trusted_macros", + } + table = aliases.supported( + aliases.resolve(device.adapter, device.model, aliases.overrides_for(device.name)) + ) + for alias, spec in table.items(): + for line in spec.gcode: + cmd = macros.command_of(line) + if cmd and "{" not in cmd and cmd not in rows: + rows[cmd] = { + "name": cmd, + "trusted": False, + "kind": macros.classify(cmd), + "description": "", + "source": f"alias {alias}", + } + return sorted(rows.values(), key=lambda r: str(r["name"])) + + +def _print_rows(rows: list[dict[str, Any]], as_json: bool, title: str) -> None: + if as_json: + print(json.dumps(rows, indent=2)) + return + print(title) + width = max((len(str(r["name"])) for r in rows), default=4) + print(f" {'NAME':<{width}} T {'KIND':<9} DESCRIPTION") + for r in rows: + print( + f" {r['name']:<{width}} {'*' if r['trusted'] else ' '} {r['kind']:<9} {r['description'] or r.get('source', '')}" + ) + + +def _macro_list(device: Device, opts: dict[str, str]) -> int: + as_json = "--json" in opts + if device.access == "offline" or "--offline" in opts: + rows = _registry_rows(device) + _print_rows( + rows, + as_json, + f"{device.name}: {len(rows)} macros known from the registry (nothing contacted; " + "T * = in trusted_macros)", + ) + return 0 + adapter = adapter_of(device) + if "--dry-run" in opts: + print("\n".join(adapter.catalog_requests())) + print("dry-run: nothing was sent") + return 0 + catalog = adapter.macro_catalog() + names = sorted( + catalog.macros + if "--all" in opts + else {m for m in catalog.macros if not m.startswith("_")} + ) + rows = [ + { + "name": n, + "trusted": n in device.trusted_macros, + "kind": macros.classify(n), + "description": catalog.describe(n), + "source": "printer", + } + for n in names + ] + hidden = len(catalog.macros) - len(names) + note = f", {hidden} private (_NAME) hidden: --all" if hidden else "" + _print_rows( + rows, + as_json, + f"{device.name}: {len(rows)} macros defined by the printer{note} (T * = in trusted_macros)", + ) + return 0 + + +def _macro_run(device: Device, words: list[str], opts: dict[str, str]) -> int: + if not words: + raise UsageError( + "`macro run` needs the macro name", + command="printer", + remediation=["3d printer macro --help"], + ) + line = macros.build_line(words[0], words[1:]) + return execute( + device, + [line], + what=f"macro run {line}", + again=f"macro run {line}", + kind=None, + curated=False, + entry_yes=False, + timeout=timeout_of(opts, 120.0), + opts=opts, + ) + + +def run_macro(name: str | None, argv: list[str]) -> int: + if not argv or argv[0] in ("-h", "--help", "help"): + print(MACRO_USAGE) + return 0 if argv else 1 + sub, rest = argv[0], argv[1:] + if sub not in ("list", "run"): + raise UsageError( + f"unknown macro subcommand '{sub}'", + command="printer", + remediation=["Use list or run."], + ) + if any(a in ("-h", "--help") for a in rest): + print(MACRO_USAGE) + return 0 + words, opts = split_opts(rest, FLAGS, VALUE_OPTS) + device = device_of(name, f"macro {sub}") + if sub == "list": + if words: + raise UsageError( + f"`macro list` takes no arguments, got {words}", command="printer" + ) + return _macro_list(device, opts) + return _macro_run(device, words, opts) diff --git a/tests/e2e/test_printer_macros_workflows.py b/tests/e2e/test_printer_macros_workflows.py new file mode 100644 index 00000000..6bf01f83 --- /dev/null +++ b/tests/e2e/test_printer_macros_workflows.py @@ -0,0 +1,172 @@ +"""Real `bin/3d printer -n NAME macro|aliases|ALIAS` runs against the loopback fake Moonraker. + +The fleet is a copy of the repo's fleet/printers.yaml (`u1`, `k1se`) with the address swapped for the +fake's (tests/printer_fakes.py `write_fleet`); no real printer is reachable. Issue #718. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from printer_fakes import FakeMoonraker, u1_status +from printer_macro_fakes import FakeMoonrakerMacros, write_fleet + +from .workflow_helper import run_cli + +U1_MACROS = { + n: n + for n in ( + "PAUSE", + "RESUME", + "M600", + "AUTO_FEEDING", + "SM_PRINT_AUTO_FEED", + "ROUGHLY_CLEAN_NOZZLE", + "BED_MESH_CALIBRATE", + ) +} +U1_COMMANDS = { + n: n for n in ("PARK_EXTRUDER", "SET_HEATER_TEMPERATURE", "TURN_OFF_HEATERS") +} + + +def test_a_user_lists_macros_then_runs_one_and_a_dry_run_sends_nothing( + tmp_path: Path, +) -> None: + u1 = FakeMoonrakerMacros(macros=dict(U1_MACROS), commands=dict(U1_COMMANDS)) + with u1.serve(): + env = {"THREED_FLEET": str(write_fleet(tmp_path, u1=u1))} + listed = run_cli( + tmp_path, "printer", "-n", "u1", "macro", "list", env_extra=env + ) + dry = run_cli( + tmp_path, + "printer", + "-n", + "u1", + "macro", + "run", + "AUTO_FEEDING", + "EXTRUDER=1", + "--yes", + "--dry-run", + env_extra=env, + ) + writes_after_dry = u1.writes() + ran = run_cli( + tmp_path, + "printer", + "-n", + "u1", + "macro", + "run", + "SM_PRINT_AUTO_FEED", + "EXTRUDER=1", + env_extra=env, + ) + + assert listed.returncode == 0, listed.stderr + assert ( + "u1: 7 macros defined by the printer" in listed.stdout + and "SM_PRINT_AUTO_FEED" in listed.stdout + ) + assert dry.returncode == 0, dry.stderr + assert ( + "AUTO_FEEDING EXTRUDER=1" in dry.stdout + and "script=AUTO_FEEDING%20EXTRUDER%3D1" in dry.stdout + ) + assert writes_after_dry == [] + assert ran.returncode == 0, ran.stderr + assert u1.scripts == ["SM_PRINT_AUTO_FEED EXTRUDER=1"] + + +def test_a_user_runs_aliases_and_gets_a_clear_refusal_for_a_busy_printer_and_for_injection( + tmp_path: Path, +) -> None: + u1 = FakeMoonrakerMacros(macros=dict(U1_MACROS), commands=dict(U1_COMMANDS)) + with u1.serve(): + env = {"THREED_FLEET": str(write_fleet(tmp_path, u1=u1))} + load = run_cli(tmp_path, "printer", "-n", "u1", "load", "T2", env_extra=env) + injected = run_cli( + tmp_path, + "printer", + "-n", + "u1", + "macro", + "run", + "AUTO_FEEDING", + "EXTRUDER=1;M112", + "--yes", + env_extra=env, + ) + emergency = run_cli( + tmp_path, "printer", "-n", "u1", "macro", "run", "M112", env_extra=env + ) + scripts_before = list(u1.scripts) + u1.status = u1_status("printing", "part.gcode", 0.3) + busy = run_cli(tmp_path, "printer", "-n", "u1", "park", env_extra=env) + pause = run_cli(tmp_path, "printer", "-n", "u1", "pause", env_extra=env) + + assert load.returncode == 0, load.stderr + assert scripts_before == ["AUTO_FEEDING EXTRUDER=1 LOAD=1"] + assert injected.returncode == 2 and "EXTRUDER" in injected.stderr + assert ( + emergency.returncode == 1 + and "3d never sends the emergency stop" in emergency.stderr + ) + assert ( + busy.returncode == 1 + and "is printing" in busy.stderr + and "needs an idle printer" in busy.stderr + ) + assert pause.returncode == 0, pause.stderr + assert u1.scripts == [*scripts_before, "PAUSE"] + + +def test_read_only_and_offline_printers_are_refused_and_never_contacted( + tmp_path: Path, +) -> None: + k1 = FakeMoonrakerMacros(macros={"PAUSE": "p"}) + with k1.serve(): + env = { + "THREED_FLEET": str( + write_fleet(tmp_path, access={"k1se": "read-only"}, k1se=k1) + ) + } + read_only = run_cli(tmp_path, "printer", "-n", "k1se", "home", env_extra=env) + offline = run_cli( + tmp_path, "printer", "-n", "p1s", "macro", "run", "M620", env_extra=env + ) + requests = list(k1.requests) + + assert read_only.returncode == 1 and "is read-only" in read_only.stderr + assert offline.returncode == 1 and "is offline" in offline.stderr + assert requests == [] + + +def test_aliases_listing_and_help_per_model(tmp_path: Path) -> None: + k1 = FakeMoonraker() + with k1.serve(): + env = {"THREED_FLEET": str(write_fleet(tmp_path, k1se=k1))} + unknown = run_cli( + tmp_path, "printer", "-n", "k1se", "purifier", "hot", env_extra=env + ) + table = run_cli( + tmp_path, "printer", "-n", "k1se", "aliases", "--json", env_extra=env + ) + helped = run_cli(tmp_path, "printer", "preheat", "--help", env_extra=env) + top = run_cli(tmp_path, "printer", "--help", env_extra=env) + + assert ( + unknown.returncode == 2 + and "alias `purifier` is not defined for k1se" in unknown.stderr + ) + assert json.loads(table.stdout)["load"]["gcode"] == ["LOAD_MATERIAL"] + assert helped.returncode == 0 and "SET_HEATER_TEMPERATURE" in helped.stdout + assert ( + top.returncode == 0 + and "macro list|run" in top.stdout + and "level-heated" in top.stdout + ) + assert k1.requests == [] diff --git a/tests/printer_macro_fakes.py b/tests/printer_macro_fakes.py new file mode 100644 index 00000000..0af186e0 --- /dev/null +++ b/tests/printer_macro_fakes.py @@ -0,0 +1,76 @@ +"""Fakes for `3d printer macro|aliases|ALIAS` tests: a fake Moonraker that also answers macro discovery, and a +fleet file whose contactable printers point at loopback fakes (never at a real address). Issue #718.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from printer_fakes import FakeMoonraker, Recorded + +REPO = Path(__file__).resolve().parent.parent + + +@dataclass +class FakeMoonrakerMacros(FakeMoonraker): + """FakeMoonraker + `/printer/objects/list` and `/printer/gcode/help` (Moonraker's macro discovery).""" + + macros: dict[str, str] = field( + default_factory=dict + ) # `[gcode_macro NAME]` -> description + commands: dict[str, str] = field( + default_factory=dict + ) # other extended commands -> help text + + def handler(self) -> type[BaseHTTPRequestHandler]: + fake = self + base = super().handler() + + class H(base): # type: ignore[valid-type, misc] + def do_GET(self) -> None: + u = urlsplit(self.path) + if u.path == "/printer/objects/list": + fake.requests.append(Recorded("GET", u.path, parse_qs(u.query))) + objects = [ + "webhooks", + "print_stats", + "toolhead", + "extruder", + "heater_bed", + *(f"gcode_macro {m}" for m in fake.macros), + ] + self._reply({"result": {"objects": objects}}) # type: ignore[attr-defined] + elif u.path == "/printer/gcode/help": + fake.requests.append(Recorded("GET", u.path, parse_qs(u.query))) + self._reply({"result": {**fake.commands, **fake.macros}}) # type: ignore[attr-defined] + else: + super().do_GET() + + return H + + +def write_fleet( + tmp_path: Path, access: dict[str, str] | None = None, **fakes: FakeMoonraker +) -> Path: + """A copy of the repo's fleet/printers.yaml whose contactable printers are the named fakes (`u1=moon`): their + host/port point at the loopback fake and every other contactable entry is dropped, so no test can reach a real + address. Offline fleet printers stay as they are (never contacted); `access` overrides the access per printer.""" + import yaml + + data = yaml.safe_load( + (REPO / "fleet" / "printers.yaml").read_text(encoding="utf-8") + ) + for name in list(data): + if data[name].get("access") == "offline": + continue + if name not in fakes: + del data[name] + continue + data[name].update(host="127.0.0.1", port=fakes[name].port) + if access and name in access: + data[name]["access"] = access[name] + path = tmp_path / "fleet.yaml" + path.write_text(yaml.safe_dump(data), encoding="utf-8") + return path diff --git a/tests/test_printer_macros.py b/tests/test_printer_macros.py new file mode 100644 index 00000000..673f2a8a --- /dev/null +++ b/tests/test_printer_macros.py @@ -0,0 +1,861 @@ +"""`3d printer -n NAME macro list|run`, `aliases` and the alias subcommands (home, load, park, ...). + +Every printer is a loopback fake Moonraker (tests/printer_fakes.py) built from the REAL fleet entries of +`u1` and `k1se` (fleet/printers.yaml with the address swapped for the fake's); a refused command is proven +by the fake having received NO write (often no request at all). Issue #718. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +import yaml +from printer_fakes import FakeCreality, FakeMoonraker, u1_status, write_devices +from printer_macro_fakes import FakeMoonrakerMacros, write_fleet + +from commands.printer import USAGE, run +from errors import InvalidArgument, ThreeDError, UsageError +from printer_ops import aliases, macros +from printer_ops.base import PrinterRefused, UnsupportedOperation + +REPO = Path(__file__).resolve().parent.parent + +U1_MACROS = { + name: f"{name} macro" + for name in ( + "PAUSE", + "RESUME", + "CANCEL_PRINT", + "M600", + "T0", + "T1", + "BED_MESH_CALIBRATE", + "ROUGHLY_CLEAN_NOZZLE", + "AUTO_FEEDING", + "SM_PRINT_AUTO_FEED", + "AUTO_BED_MESH_CALIBRATE", + "SM_PRINT_FLOW_CALIBRATE", + "_CLIENT_VARIABLE", + ) +} +U1_COMMANDS = { + name: f"{name} command" + for name in ( + "SET_HEATER_TEMPERATURE", + "TURN_OFF_HEATERS", + "PARK_EXTRUDER", + "PARK_EXTRUDER1", + "PARK_EXTRUDER2", + "MOVE_TO_XY_IDLE_POSITION_EXTRUDER", + "SET_PURIFIER_MODE", + "FIRMWARE_RESTART", + "GET_POSITION", + "BED_MESH_CLEAR", + ) +} +K1_MACROS = { + name: f"{name} macro" + for name in ( + "PAUSE", + "RESUME", + "LOAD_MATERIAL", + "QUIT_MATERIAL", + "G29", + "START_PRINT", + "END_PRINT", + ) +} +K1_COMMANDS = { + name: f"{name} command" for name in ("SET_HEATER_TEMPERATURE", "TURN_OFF_HEATERS") +} + + +class Shop: + """The two shop printers as fakes, plus a way to run `3d printer ...` and read what it printed.""" + + def __init__( + self, u1: FakeMoonraker, k1se: FakeMoonraker, capsys: pytest.CaptureFixture[str] + ) -> None: + self.u1, self.k1se, self.capsys = u1, k1se, capsys + + def out(self, *argv: str) -> str: + assert run(list(argv)) == 0 + return self.capsys.readouterr().out + + def fails(self, exc: type[ThreeDError], *argv: str) -> ThreeDError: + with pytest.raises(exc) as info: + run(list(argv)) + return info.value + + +@pytest.fixture() +def shop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> Iterator[Shop]: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + u1 = FakeMoonrakerMacros(macros=dict(U1_MACROS), commands=dict(U1_COMMANDS)) + k1 = FakeMoonrakerMacros(macros=dict(K1_MACROS), commands=dict(K1_COMMANDS)) + with u1.serve(), k1.serve(): + monkeypatch.setenv("THREED_FLEET", str(write_fleet(tmp_path, u1=u1, k1se=k1))) + yield Shop(u1, k1, capsys) + + +def use_fleet(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, **kw: Any) -> None: + monkeypatch.setenv("THREED_FLEET", str(write_fleet(tmp_path, **kw))) + + +# ---- macro list ----------------------------------------------------------------------------- + + +def test_macro_list_reads_the_printer_and_marks_trusted_macros(shop: Shop) -> None: + text = shop.out("-n", "u1", "macro", "list") + + assert "u1: 12 macros defined by the printer, 1 private (_NAME) hidden" in text + assert any( + line.split()[:2] == ["SM_PRINT_AUTO_FEED", "*"] for line in text.splitlines() + ) # in trusted_macros + assert any( + line.split()[:1] == ["AUTO_FEEDING"] and "*" not in line.split()[:2] + for line in text.splitlines() + ) + assert "_CLIENT_VARIABLE" not in text + assert [r.path for r in shop.u1.requests] == [ + "/printer/objects/list", + "/printer/gcode/help", + ] + assert shop.u1.writes() == [] + + +def test_macro_list_all_json_includes_private_macros(shop: Shop) -> None: + rows = json.loads(shop.out("-n", "k1se", "macro", "list", "--all", "--json")) + + assert {r["name"] for r in rows} == set(K1_MACROS) + start = next(r for r in rows if r["name"] == "START_PRINT") + assert start["trusted"] is True and start["description"] == "START_PRINT macro" + assert next(r for r in rows if r["name"] == "LOAD_MATERIAL")["trusted"] is False + + +def test_macro_list_offline_uses_registry_data_and_contacts_nothing(shop: Shop) -> None: + text = shop.out("-n", "u1", "macro", "list", "--offline", "--json") + names = {r["name"] for r in json.loads(text)} + + assert { + "SM_PRINT_AUTO_FEED", + "DETECT_BED_PLATE", + } <= names # trusted_macros of fleet/printers.yaml + assert {"AUTO_FEEDING", "ROUGHLY_CLEAN_NOZZLE", "G28"} <= names # alias targets + assert shop.u1.requests == [] + + +def test_macro_list_dry_run_prints_the_requests_and_sends_none(shop: Shop) -> None: + text = shop.out("-n", "u1", "macro", "list", "--dry-run") + + assert f"GET http://127.0.0.1:{shop.u1.port}/printer/objects/list" in text + assert f"GET http://127.0.0.1:{shop.u1.port}/printer/gcode/help" in text + assert "dry-run: nothing was sent" in text + assert shop.u1.requests == [] + + +def test_macro_list_of_an_offline_fleet_printer_uses_the_registry(shop: Shop) -> None: + text = shop.out("-n", "p1s", "macro", "list") + + assert ( + "macros known from the registry (nothing contacted" in text and "M620" in text + ) + assert shop.u1.requests == [] and shop.k1se.requests == [] + + +# ---- macro run ------------------------------------------------------------------------------ + + +def test_macro_run_passes_params_and_prints_exactly_the_line_sent(shop: Shop) -> None: + text = shop.out("-n", "u1", "macro", "run", "sm_print_auto_feed", "extruder=1") + + assert shop.u1.scripts == ["SM_PRINT_AUTO_FEED EXTRUDER=1"] + assert "gcode:\n SM_PRINT_AUTO_FEED EXTRUDER=1\n" in text + assert "sent to u1" in text + + +def test_macro_run_dry_run_prints_the_request_and_sends_nothing(shop: Shop) -> None: + text = shop.out( + "-n", "u1", "macro", "run", "SM_PRINT_AUTO_FEED", "EXTRUDER=1", "--dry-run" + ) + + assert ( + f"POST http://127.0.0.1:{shop.u1.port}/printer/gcode/script?script=SM_PRINT_AUTO_FEED%20EXTRUDER%3D1" + in text + ) + assert "dry-run: nothing was sent" in text + assert shop.u1.scripts == [] and shop.u1.writes() == [] + + +def test_macro_outside_trusted_macros_needs_yes(shop: Shop) -> None: + err = shop.fails( + UsageError, "-n", "u1", "macro", "run", "AUTO_FEEDING", "EXTRUDER=0" + ) + dry = shop.out( + "-n", "u1", "macro", "run", "AUTO_FEEDING", "EXTRUDER=0", "--dry-run" + ) + assert shop.u1.writes() == [] + + shop.out("-n", "u1", "macro", "run", "AUTO_FEEDING", "EXTRUDER=0", "--yes") + + assert ( + "needs --yes" in err.message + and "AUTO_FEEDING is not in this printer's trusted_macros" in err.message + ) + assert "would need --yes" in dry + assert shop.u1.scripts == ["AUTO_FEEDING EXTRUDER=0"] + + +def test_dangerous_macro_needs_yes_even_when_trusted( + shop: Shop, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = tmp_path / "xdg" / "3d-cli" + cfg.mkdir(parents=True) + (cfg / "printer-devices.yaml").write_text( + f"u1: {{adapter: moonraker, host: 127.0.0.1, port: {shop.u1.port}, model: Snapmaker U1, " + "trusted_macros: [FIRMWARE_RESTART]}\n", + encoding="utf-8", + ) + + err = shop.fails(UsageError, "-n", "u1", "macro", "run", "FIRMWARE_RESTART") + + assert "FIRMWARE_RESTART is dangerous" in err.message + assert shop.u1.writes() == [] + + +def test_motion_macro_is_refused_while_printing_but_pause_style_is_not( + shop: Shop, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + shop.u1.status = u1_status("printing", "part.gcode", 0.4) + + err = shop.fails( + PrinterRefused, + "-n", + "u1", + "macro", + "run", + "AUTO_FEEDING", + "EXTRUDER=0", + "--yes", + ) + dry = shop.fails( + PrinterRefused, + "-n", + "u1", + "macro", + "run", + "AUTO_FEEDING", + "EXTRUDER=0", + "--yes", + "--dry-run", + ) + assert shop.u1.writes() == [] + shop.out( + "-n", "u1", "macro", "run", "PAUSE", "--yes" + ) # PAUSE is a job macro, but not in this printer's trusted list + shop.out( + "-n", "u1", "macro", "run", "M600" + ) # trusted + job class: no --yes, no idle printer needed + + for e in (err, dry): + assert "is printing" in e.message and "needs an idle printer" in e.message + assert shop.u1.scripts == ["PAUSE", "M600"] + + +def test_paused_and_unreadable_printers_count_as_busy(shop: Shop) -> None: + shop.u1.status = u1_status("paused", "part.gcode", 0.4) + assert ( + "is paused" + in shop.fails( + PrinterRefused, "-n", "u1", "macro", "run", "AUTO_FEEDING", "--yes" + ).message + ) + shop.u1.status = u1_status("error") + shop.u1.status["webhooks"]["state"] = ( + "shutdown" # Klippy shut down: the state reads unknown + ) + assert shop.fails(PrinterRefused, "-n", "u1", "home").message.endswith( + "needs an idle printer" + ) + assert shop.u1.writes() == [] + + +def test_read_only_printer_refuses_every_write_but_can_be_listed( + shop: Shop, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + use_fleet(monkeypatch, tmp_path, access={"u1": "read-only"}, u1=shop.u1) + + run_err = shop.fails( + PrinterRefused, "-n", "u1", "macro", "run", "SM_PRINT_AUTO_FEED", "EXTRUDER=0" + ) + alias_err = shop.fails(PrinterRefused, "-n", "u1", "home") + dry_err = shop.fails(PrinterRefused, "-n", "u1", "home", "--dry-run") + listed = shop.out("-n", "u1", "macro", "list") + + for e in (run_err, alias_err, dry_err): + assert "is read-only" in e.message and "refused" in e.message + assert shop.u1.writes() == [] and "SM_PRINT_AUTO_FEED" in listed + + +def test_offline_fleet_printer_refuses_run_and_aliases_and_is_never_contacted( + shop: Shop, +) -> None: + errs = [ + shop.fails(PrinterRefused, "-n", "p1s", *cmd) + for cmd in (("macro", "run", "M620"), ("home",), ("pause", "--dry-run")) + ] + + assert all("is offline" in e.message for e in errs) + assert shop.u1.requests == [] and shop.k1se.requests == [] + + +def test_macro_names_and_values_that_could_inject_a_second_command_are_rejected_before_any_contact( + shop: Shop, +) -> None: + bad_names = [ + "PAUSE\nM112", + "PAUSE;M112", + "PAUSE M112", + "G1 X1 Y1", + "", + "1PAUSE", + "PAUSE\r", + "SM-PRINT", + "ÅÄÖ", + "A" * 65, + ] + bad_values = [ + "1;M112", + "1\nM112", + "a b", + "'x'", + '"x"', + "{1}", + "%25", + "1#c", + "1*3", + "", + "x" * 65, + ] + for name in bad_names: + with pytest.raises(InvalidArgument): + run(["-n", "u1", "macro", "run", name]) + for value in bad_values: + with pytest.raises(InvalidArgument): + run(["-n", "u1", "macro", "run", "SM_PRINT_AUTO_FEED", f"EXTRUDER={value}"]) + for word in ("EXTRUDER", "=1", "1EXTRUDER=1", "EXT RUDER=1"): + with pytest.raises(InvalidArgument): + run(["-n", "u1", "macro", "run", "SM_PRINT_AUTO_FEED", word]) + with pytest.raises(InvalidArgument): + run( + [ + "-n", + "u1", + "macro", + "run", + "SM_PRINT_AUTO_FEED", + "EXTRUDER=1", + "extruder=2", + ] + ) # a key twice + + assert shop.u1.requests == [] + + +@pytest.mark.parametrize( + "argv", + [ + ["macro", "run", "M112"], + ["macro", "run", "m112"], + ["macro", "run", "EMERGENCY_STOP"], + ["macro", "run", "SM_PRINT_AUTO_FEED", "EXTRUDER=M112"], + ["macro", "run", "SM_PRINT_AUTO_FEED", "MSG=a,m112"], + ["macro", "run", "SM_PRINT_AUTO_FEED", "MSG=EMERGENCY_STOP", "--yes"], + ], +) +def test_the_emergency_stop_is_never_sent_whatever_the_flags( + shop: Shop, argv: list[str] +) -> None: + err = shop.fails(PrinterRefused, "-n", "u1", *argv, "--yes") + + assert "3d never sends the emergency stop" in err.message + assert shop.u1.requests == [] + + +def test_a_standard_gcode_is_not_a_macro(shop: Shop) -> None: + err = shop.fails(PrinterRefused, "-n", "u1", "macro", "run", "G28", "--yes") + shop.fails(PrinterRefused, "-n", "u1", "macro", "run", "M104", "S=200", "--yes") + + assert "standard G-code, not a macro u1 defines" in err.message + assert shop.u1.writes() == [] + + +def test_a_gcode_macro_that_overrides_a_standard_code_runs_as_a_macro( + shop: Shop, +) -> None: + shop.out( + "-n", "k1se", "macro", "run", "G29", "BED_TEMP=60", "--yes" + ) # the K1's stock bed-mesh macro + + assert shop.k1se.scripts == ["G29 BED_TEMP=60"] + + +def test_an_undefined_name_is_refused_with_a_hint(shop: Shop) -> None: + err = shop.fails(PrinterRefused, "-n", "u1", "macro", "run", "PAUSEE", "--yes") + + assert err.message == "u1 does not define PAUSEE" and "PAUSE" in err.remediation[0] + assert shop.u1.writes() == [] + + +def test_offline_run_prints_the_script_and_contacts_nothing(shop: Shop) -> None: + text = shop.out( + "-n", "u1", "macro", "run", "AUTO_FEEDING", "EXTRUDER=0", "--offline" + ) + + assert ( + "AUTO_FEEDING EXTRUDER=0" in text and "offline: nothing was contacted" in text + ) + assert shop.u1.requests == [] + + +def test_there_is_no_raw_gcode_passthrough(shop: Shop) -> None: + err = shop.fails(UsageError, "-n", "u1", "gcode", "G28") + + assert "unknown subcommand 'gcode'" in err.message or "alias `gcode`" in err.message + assert shop.u1.requests == [] + + +# ---- aliases -------------------------------------------------------------------------------- + + +def test_home_sends_g28_or_only_the_named_axes(shop: Shop) -> None: + shop.out("-n", "k1se", "home") + shop.out("-n", "k1se", "home", "xy") + shop.out("-n", "k1se", "home", "--axes", "Z") + + assert shop.k1se.scripts == ["G28", "G28 X Y", "G28 Z"] + for bad in ("XX", "A", "X Y"): + with pytest.raises(ThreeDError): + run(["-n", "k1se", "home", bad]) + assert len(shop.k1se.scripts) == 3 + + +def test_alias_dry_run_checks_everything_and_sends_nothing(shop: Shop) -> None: + text = shop.out("-n", "k1se", "level", "--dry-run") + + assert "alias level" in text and " G29\n" in text + assert ( + f"POST http://127.0.0.1:{shop.k1se.port}/printer/gcode/script?script=G29" + in text + ) + assert shop.k1se.writes() == [] + assert [r.path for r in shop.k1se.requests] == [ + "/printer/objects/list", + "/printer/gcode/help", + "/printer/objects/query", + ] # reads only: macros, then the idle check + + +def test_preheat_material_and_explicit_temperatures(shop: Shop) -> None: + shop.out("-n", "k1se", "preheat", "PETG") + shop.out("-n", "k1se", "preheat", "--nozzle", "200") + shop.out("-n", "k1se", "preheat", "pla", "--bed", "55.5") + shop.out("-n", "u1", "preheat", "ABS", "--head", "T2") + + assert shop.k1se.scripts == [ + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=235\nSET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=80", + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=200", + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=210\nSET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=55.5", + ] + assert shop.u1.scripts == [ + "SET_HEATER_TEMPERATURE HEATER=extruder1 TARGET=250\nSET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=100" + ] + + +@pytest.mark.parametrize( + "argv", + [ + ["preheat"], + ["preheat", "NYLON"], + ["preheat", "--nozzle", "999"], + ["preheat", "--bed", "-1"], + ["preheat", "--nozzle", "hot"], + ["preheat", "--nozzle", "nan"], + ["preheat", "PLA", "--head", "T9"], + ["preheat", "PLA", "--head", "2"], + ["preheat", "PLA", "extra", "words"], + ["preheat", "--colour", "red"], + ["preheat", "--nozzle", "200; M112"], + ], +) +def test_preheat_rejects_bad_arguments_before_contacting_the_printer( + shop: Shop, argv: list[str] +) -> None: + with pytest.raises(ThreeDError): + run(["-n", "k1se", *argv]) + + assert shop.k1se.requests == [] + + +def test_cooldown_pause_resume_alias_scripts(shop: Shop) -> None: + shop.out("-n", "k1se", "cooldown") + shop.out("-n", "k1se", "pause") + shop.out("-n", "k1se", "resume") + + assert shop.k1se.scripts == ["TURN_OFF_HEATERS", "PAUSE", "RESUME"] + + +def test_pause_and_resume_aliases_work_while_a_job_runs_but_cooldown_does_not( + shop: Shop, +) -> None: + shop.k1se.status = u1_status("printing", "part.gcode", 0.4) + + shop.out("-n", "k1se", "pause") + err = shop.fails(PrinterRefused, "-n", "k1se", "cooldown") + + assert shop.k1se.scripts == ["PAUSE"] and "needs an idle printer" in err.message + + +def test_u1_specific_aliases_map_to_the_firmware_macros(shop: Shop) -> None: + shop.out("-n", "u1", "load", "T2") + shop.out("-n", "u1", "unload", "t1") + shop.out("-n", "u1", "clean-nozzle") + shop.out("-n", "u1", "park") + shop.out("-n", "u1", "park", "T3") + shop.out("-n", "u1", "purifier", "hot") + shop.out("-n", "u1", "level-heated") + shop.out("-n", "u1", "level-heated", "--bed", "60") + shop.out("-n", "u1", "level") + + assert shop.u1.scripts == [ + "AUTO_FEEDING EXTRUDER=1 LOAD=1", + "AUTO_FEEDING EXTRUDER=0 UNLOAD=1 STAGE=prepare\nAUTO_FEEDING EXTRUDER=0 UNLOAD=1 STAGE=doing", + "ROUGHLY_CLEAN_NOZZLE", + "PARK_EXTRUDER", + "PARK_EXTRUDER2", + "SET_PURIFIER_MODE MODE=3", + "AUTO_BED_MESH_CALIBRATE TEMP=55", + "AUTO_BED_MESH_CALIBRATE TEMP=60", + "BED_MESH_CALIBRATE", + ] + + +def test_u1_head_arguments_follow_the_fleet_heads(shop: Shop) -> None: + for bad in ( + ["load"], + ["load", "T5"], + ["load", "2"], + ["load", "0"], + ["park", "T0"], + ["purifier", "turbo"], + ): + with pytest.raises(ThreeDError): + run(["-n", "u1", *bad]) + + assert shop.u1.requests == [] + + +def test_an_unusable_head_is_refused_with_its_reason( + shop: Shop, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + data = yaml.safe_load((tmp_path / "fleet.yaml").read_text(encoding="utf-8")) + data["u1"]["heads"][1].update(usable=False, reason="clogged") + (tmp_path / "fleet.yaml").write_text(yaml.safe_dump(data), encoding="utf-8") + + err = shop.fails(PrinterRefused, "-n", "u1", "load", "T2") + + assert ( + "head T2 is marked unusable: clogged" in err.message and shop.u1.requests == [] + ) + + +def test_k1se_filament_aliases_use_the_stock_creality_macros(shop: Shop) -> None: + shop.out("-n", "k1se", "load") + shop.out("-n", "k1se", "unload") + with pytest.raises(UsageError): + run(["-n", "k1se", "load", "T1"]) # the K1 macro has no head argument + + assert shop.k1se.scripts == ["LOAD_MATERIAL", "QUIT_MATERIAL"] + + +@pytest.mark.parametrize( + ("printer", "alias", "words"), + [ + ("k1se", "clean-nozzle", "the stock K1 SE config has no nozzle-clean macro"), + ("k1se", "park", "park"), + ("k1se", "purifier", "purifier"), + ], +) +def test_an_alias_a_model_does_not_have_says_so_and_lists_what_it_has( + shop: Shop, printer: str, alias: str, words: str +) -> None: + err = shop.fails(UsageError, "-n", printer, alias) + + assert ( + f"alias `{alias}` is not defined for k1se (Creality K1 SE, adapter moonraker)" + in err.message + ) + assert ( + "Defined for it: " in err.remediation[0] + and "home" in err.remediation[0] + and "park" not in err.remediation[0] + ) + assert shop.k1se.requests == [] + + +def test_an_alias_a_model_marks_unsupported_gives_the_reason(shop: Shop) -> None: + err = shop.fails(UsageError, "-n", "k1se", "qgl") + + assert ( + "not supported on k1se" in err.message + and "defines no quad_gantry_level" in err.message + ) + assert shop.k1se.requests == [] + + +def test_an_alias_whose_macro_the_firmware_lacks_is_refused_not_guessed( + shop: Shop, +) -> None: + err = shop.fails( + PrinterRefused, "-n", "u1", "qgl" + ) # generic Klipper alias; the U1 defines no such command + + assert err.message == "u1 does not define QUAD_GANTRY_LEVEL" + assert shop.u1.writes() == [] + + +def test_alias_help_and_listing_name_what_each_alias_sends(shop: Shop) -> None: + one = shop.out("-n", "u1", "unload", "--help") + table = json.loads(shop.out("-n", "u1", "aliases", "--json")) + k1 = shop.out("-n", "k1se", "aliases") + generic = shop.out("home", "--help") + builtin = shop.out("aliases") + + assert ( + "AUTO_FEEDING EXTRUDER={slot} UNLOAD=1 STAGE=prepare; AUTO_FEEDING EXTRUDER={slot} UNLOAD=1 STAGE=doing" + in one + ) + assert ( + table["load"]["gcode"] == ["AUTO_FEEDING EXTRUDER={slot} LOAD=1"] + and table["qgl"]["unsupported"] is None + ) + assert ( + "level" in k1 + and "G29" in k1 + and "qgl: NOT supported - " in k1 + and "park" not in k1 + ) + assert ( + "adapter moonraker" in generic + and "model snapmaker u1" not in generic.split("home")[0] + ) + assert "model snapmaker u1:" in builtin and "model creality k1:" in builtin + assert shop.u1.requests == [] + + +def test_an_alias_needs_the_printer_and_name_is_not_accepted_for_positional_subcommands( + shop: Shop, +) -> None: + needs = shop.fails(UsageError, "home") + named = shop.fails(UsageError, "-n", "u1", "status", "u1") + unknown = shop.fails(UsageError, "frobnicate") + unknown_on_printer = shop.fails(UsageError, "-n", "u1", "frobnicate") + + assert "is an alias and needs the printer: 3d printer -n NAME home" in needs.message + assert "takes the printer as NAME" in named.message + assert "unknown subcommand 'frobnicate'" in unknown.message + assert "alias `frobnicate` is not defined for u1" in unknown_on_printer.message + + +def test_a_printers_own_aliases_extend_replace_and_remove_the_table( + shop: Shop, tmp_path: Path +) -> None: + cfg = tmp_path / "xdg" / "3d-cli" + cfg.mkdir(parents=True) + (cfg / "printer-aliases.yaml").write_text( + yaml.safe_dump( + { + "u1": { + "home": {"summary": "home XY only", "kind": "motion", "gcode": ["G28 X Y"]}, + "nozzle-wipe": {"summary": "wipe", "kind": "motion", "gcode": ["ROUGHLY_CLEAN_NOZZLE"]}, + "park": None, + "cooldown": {"unsupported": "the chamber heater needs a manual step first"}, + }, + "k1se": {"home": {"summary": "k1 home", "kind": "motion", "gcode": ["G28 Z"]}}, + } + ), + encoding="utf-8", + ) + # the project file wins over the user file, per alias + (tmp_path / "printer-aliases.yaml").write_text( + yaml.safe_dump({"u1": {"home": {"summary": "project home", "kind": "motion", "gcode": ["G28 Y"]}}}), + encoding="utf-8", + ) + + shop.out("-n", "u1", "home") + shop.out("-n", "u1", "nozzle-wipe") + shop.out("-n", "k1se", "home") + parked = shop.fails(UsageError, "-n", "u1", "park") + cooled = shop.fails(UsageError, "-n", "u1", "cooldown") + + assert shop.u1.scripts == ["G28 Y", "ROUGHLY_CLEAN_NOZZLE"] and shop.k1se.scripts == ["G28 Z"] + assert "disabled for this printer" in parked.message and "chamber heater" in cooled.message + + +def test_a_broken_printer_aliases_file_is_a_config_error(shop: Shop, tmp_path: Path) -> None: + (tmp_path / "printer-aliases.yaml").write_text("u1: [not, a, mapping]\n", encoding="utf-8") + + err = shop.fails(ThreeDError, "-n", "u1", "aliases") + + assert err.exit_code == 2 and "must be a mapping of alias name" in err.message + assert shop.u1.requests == [] + + +@pytest.mark.parametrize( + ("entry", "why"), + [ + ({"summary": "x", "kind": "fast", "gcode": ["G28"]}, "kind"), + ({"summary": "x", "kind": "motion", "gcode": []}, "gcode"), + ( + {"summary": "x", "kind": "motion", "gcode": ["G28 {nope}"]}, + "no arg provides", + ), + ({"summary": "x", "kind": "motion", "gcode": ["G28; M84"]}, "without `;`"), + ({"summary": "x", "kind": "motion", "gcode": ["M112"]}, "emergency stop"), + ( + { + "summary": "x", + "kind": "motion", + "gcode": ["G28"], + "args": {"a": {"type": "text"}}, + }, + "type", + ), + ( + {"summary": "x", "kind": "motion", "gcode": ["G28"], "timeout": 99999}, + "timeout", + ), + ({"summary": "x", "kind": "motion", "gcode": ["G28"], "extra": 1}, "keys"), + ({"kind": "motion", "gcode": ["G28"]}, "summary"), + ], +) +def test_a_broken_alias_entry_is_a_config_error( + entry: dict[str, Any], why: str +) -> None: + with pytest.raises(ThreeDError) as info: + aliases.parse_entries({"mine": entry}, "printer-aliases.yaml u1") + + assert why in info.value.message + + +def test_the_builtin_table_parses_and_every_template_is_valid() -> None: + names = aliases.builtin_names() + + assert { + "home", + "level", + "clean-nozzle", + "load", + "unload", + "preheat", + "cooldown", + "pause", + "resume", + "park", + } <= names + for _label, entries in aliases.builtin_layers(): + for spec in entries.values(): + assert spec.unsupported or spec.kind in macros.KINDS + + +def test_a_creality_stock_printer_has_no_gcode_channel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(tmp_path) + k1 = FakeCreality() + with k1.serve(): + write_devices(tmp_path / "xdg", k1=k1) + for argv in ( + ["macro", "run", "PAUSE", "--yes"], + ["pause"], + ["macro", "list"], + ["load"], + ): + with pytest.raises(UnsupportedOperation) as info: + run(["-n", "k1se", *argv]) + assert ( + "does not support macros and aliases (no G-code channel)" + in info.value.message + ) + + assert k1.writes() == [] + capsys.readouterr() + + +# ---- the rules in isolation ------------------------------------------------------------------ + + +def test_classification_and_gates() -> None: + trusted = frozenset({"PAUSE", "START_PRINT"}) + + assert [ + macros.classify(n) + for n in ( + "pause", + "G28", + "SET_HEATER_TEMPERATURE", + "FIRMWARE_RESTART", + "QUERY_ENDSTOPS", + ) + ] == [macros.JOB, macros.MOTION, macros.HEATING, macros.DANGEROUS, macros.INERT] + job = macros.assess(["PAUSE"], trusted, curated=False) + other = macros.assess(["AUTO_FEEDING EXTRUDER=0"], trusted, curated=False) + curated = macros.assess( + ["AUTO_FEEDING EXTRUDER=0"], trusted, curated=True, kind=macros.MOTION + ) + restart = macros.assess(["FIRMWARE_RESTART"], trusted, curated=True) + assert (job.needs_idle, job.yes_reasons) == (False, ()) + assert other.needs_idle and other.yes_reasons == ( + "AUTO_FEEDING is not in this printer's trusted_macros", + ) + assert curated.needs_idle and curated.yes_reasons == () + assert restart.kind == macros.DANGEROUS and restart.yes_reasons + + +def test_parse_catalog_reads_moonraker_replies_and_rejects_other_shapes() -> None: + cat = macros.parse_catalog( + {"objects": ["webhooks", "gcode_macro Pause", "gcode_macro _x y"]}, + {"G29": "bed", "pause": "p"}, + ) + + assert ( + cat.macros == {"PAUSE", "_X Y"} + and cat.knows("g29") + and cat.describe("PAUSE") == "p" + ) + with pytest.raises(ValueError, match="unexpected shape"): + macros.parse_catalog([], {}) + + +def test_usage_text_names_every_builtin_alias() -> None: + for name in aliases.builtin_names(): + assert name in USAGE, f"`3d printer --help` does not mention the alias {name}" + assert "macro list|run" in USAGE and "aliases [--json]" in USAGE + + +def test_docs_name_every_builtin_alias_and_the_gcode_decision() -> None: + doc = (REPO / "docs" / "commands" / "printer.md").read_text(encoding="utf-8") + + for name in aliases.builtin_names(): + assert f"`{name}" in doc, ( + f"docs/commands/printer.md does not document the alias {name}" + ) + assert "no raw `gcode`" in doc and "3d printer -n u1 macro run" in doc From 3402218eaace2852c62d2c4ebc280d1ff6789a03 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:07:42 +0100 Subject: [PATCH 26/30] fix(printer): the U1 has no quad_gantry_level / z_tilt: qgl and z-tilt 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) --- docs/commands/printer.md | 2 +- lib/data/printer-aliases.yaml | 4 ++++ tests/test_printer_macros.py | 27 ++++++++++++++++----------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/commands/printer.md b/docs/commands/printer.md index 8772c38a..f1e6afb1 100644 --- a/docs/commands/printer.md +++ b/docs/commands/printer.md @@ -415,7 +415,7 @@ it is never guessed. `3d printer -n NAME aliases` lists what each alias sends on |---|---|---| | `home [XYZ]` | `G28` / `G28 X Y` | every `moonraker` printer | | `level` | `BED_MESH_CALIBRATE`; Creality K1: the stock `G29` (heats, probes) | `moonraker`; K1 family | -| `qgl`, `z-tilt` | `QUAD_GANTRY_LEVEL`, `Z_TILT_ADJUST` | `moonraker`, refused with a reason on the K1 SE (its stock `printer.cfg` defines neither) and when the firmware does not define the command | +| `qgl`, `z-tilt` | `QUAD_GANTRY_LEVEL`, `Z_TILT_ADJUST` | `moonraker`; refused with a reason on the U1 and the K1 SE (their stock `printer.cfg` define neither) and, on any printer, when the firmware does not define the command | | `pause`, `resume` | `PAUSE`, `RESUME` (allowed during a job) | `moonraker` | | `preheat MATERIAL` / `--nozzle C` / `--bed C` / `--head T2` | `SET_HEATER_TEMPERATURE` for the nozzle of the head (fleet `heads:`, default T1) and `heater_bed`; PLA 210/60, PETG 235/80, ABS 250/100, ASA 255/100, TPU 225/50 (nozzle/bed, typical values), nozzle 0-300, bed 0-120 | `moonraker` | | `cooldown` | `TURN_OFF_HEATERS` | `moonraker` | diff --git a/lib/data/printer-aliases.yaml b/lib/data/printer-aliases.yaml index aa884fb1..3b44aacb 100644 --- a/lib/data/printer-aliases.yaml +++ b/lib/data/printer-aliases.yaml @@ -156,6 +156,10 @@ models: preheat: {code: 2} hot: {code: 3} gcode: ["SET_PURIFIER_MODE MODE={code}"] + qgl: + unsupported: "the U1 printer.cfg (Snapmaker/u1-klipper lava/printer.cfg) defines no quad_gantry_level" + z-tilt: + unsupported: "the U1 printer.cfg (Snapmaker/u1-klipper lava/printer.cfg) defines no z_tilt" - contains: creality k1 aliases: level: diff --git a/tests/test_printer_macros.py b/tests/test_printer_macros.py index 673f2a8a..5b9b8582 100644 --- a/tests/test_printer_macros.py +++ b/tests/test_printer_macros.py @@ -14,7 +14,7 @@ import pytest import yaml -from printer_fakes import FakeCreality, FakeMoonraker, u1_status, write_devices +from printer_fakes import FakeCreality, u1_status, write_devices from printer_macro_fakes import FakeMoonrakerMacros, write_fleet from commands.printer import USAGE, run @@ -78,7 +78,7 @@ class Shop: """The two shop printers as fakes, plus a way to run `3d printer ...` and read what it printed.""" def __init__( - self, u1: FakeMoonraker, k1se: FakeMoonraker, capsys: pytest.CaptureFixture[str] + self, u1: FakeMoonrakerMacros, k1se: FakeMoonrakerMacros, capsys: pytest.CaptureFixture[str] ) -> None: self.u1, self.k1se, self.capsys = u1, k1se, capsys @@ -620,15 +620,20 @@ def test_an_alias_a_model_marks_unsupported_gives_the_reason(shop: Shop) -> None assert shop.k1se.requests == [] -def test_an_alias_whose_macro_the_firmware_lacks_is_refused_not_guessed( - shop: Shop, -) -> None: - err = shop.fails( - PrinterRefused, "-n", "u1", "qgl" - ) # generic Klipper alias; the U1 defines no such command +def test_an_alias_whose_macro_the_firmware_lacks_is_refused_not_guessed(shop: Shop) -> None: + del shop.k1se.macros["LOAD_MATERIAL"] # a K1 without the stock Creality macro - assert err.message == "u1 does not define QUAD_GANTRY_LEVEL" - assert shop.u1.writes() == [] + err = shop.fails(PrinterRefused, "-n", "k1se", "load") + + assert err.message == "k1se does not define LOAD_MATERIAL" + assert shop.k1se.writes() == [] + + +def test_u1_has_no_gantry_to_level(shop: Shop) -> None: + err = shop.fails(UsageError, "-n", "u1", "qgl") + + assert "not supported on u1" in err.message and "defines no quad_gantry_level" in err.message + assert shop.u1.requests == [] def test_alias_help_and_listing_name_what_each_alias_sends(shop: Shop) -> None: @@ -644,7 +649,7 @@ def test_alias_help_and_listing_name_what_each_alias_sends(shop: Shop) -> None: ) assert ( table["load"]["gcode"] == ["AUTO_FEEDING EXTRUDER={slot} LOAD=1"] - and table["qgl"]["unsupported"] is None + and table["qgl"]["unsupported"] is not None ) assert ( "level" in k1 From a1d0e988717df4f63ad49fe48d9ff5f99d896e9a Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:25:34 +0100 Subject: [PATCH 27/30] refactor(printer): split list/start/run of lib/commands/printer.py into 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) --- lib/commands/printer.py | 353 ++++++++++++++++++++++++++-------------- 1 file changed, 235 insertions(+), 118 deletions(-) diff --git a/lib/commands/printer.py b/lib/commands/printer.py index 7319ccd0..aa612405 100644 --- a/lib/commands/printer.py +++ b/lib/commands/printer.py @@ -30,6 +30,7 @@ from errors import InputNotFound, UsageError if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path from printer_ops.base import Adapter, PrinterStatus @@ -231,32 +232,81 @@ def _adapter(name: str, opts: dict[str, str], *, write: bool = False) -> Adapter return adapter_by_name(name, secs=opts.get("--secs")) -def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: - """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" +def _fleet_facts(printer: FleetPrinter | None) -> dict[str, Any]: + """The fleet-declared columns of a `printer list` row (empty facts when the printer is not in the fleet).""" from dataclasses import asdict + if printer is None: + return {"owner": None, "fleet": None, "heads": [], "ams": None, "enclosure": None, + "chamber_heater": None, "plates": None} + return { + "owner": printer.owner, + "fleet": str(printer.source), + "heads": [asdict(h) for h in printer.heads], + "ams": [asdict(u) for u in printer.ams] if printer.ams is not None else None, + "enclosure": printer.enclosure, + "chamber_heater": printer.chamber_heater, + "plates": list(printer.plates) if printer.plates is not None else None, + } + + +def _device_facts(device: Device | None) -> dict[str, Any]: + """The device-derived columns of a `printer list` row (the device that would be contacted).""" + if device is None: + return {"adapter": None, "address": None, "settings": None} + return {"adapter": device.adapter, "address": f"{device.host}:{device.port}", "settings": str(device.source)} + + +def _list_row(name: str, printer: FleetPrinter | None, device: Device | None) -> dict[str, Any]: + """One `printer list` entry: fleet facts (owner, topology) + the device that would be contacted.""" + fleet = _fleet_facts(printer) + dev = _device_facts(device) return { "name": name, "model": (device.model if device else None) or (printer.model if printer else None), - "owner": printer.owner if printer else None, + "owner": fleet["owner"], "access": device.access if device else printer.access if printer else None, - "adapter": device.adapter if device else None, - "address": f"{device.host}:{device.port}" if device else None, - "settings": str(device.source) if device else None, - "fleet": str(printer.source) if printer else None, - "heads": [asdict(h) for h in printer.heads] if printer else [], - "ams": [asdict(u) for u in printer.ams] if printer and printer.ams is not None else None, - "enclosure": printer.enclosure if printer else None, - "chamber_heater": printer.chamber_heater if printer else None, - "plates": list(printer.plates) if printer and printer.plates is not None else None, + "adapter": dev["adapter"], + "address": dev["address"], + "settings": dev["settings"], + "fleet": fleet["fleet"], + "heads": fleet["heads"], + "ams": fleet["ams"], + "enclosure": fleet["enclosure"], + "chamber_heater": fleet["chamber_heater"], + "plates": fleet["plates"], } +def _print_no_printers() -> None: + """The `printer list` hint when nothing is configured: where we looked and an example.""" + from printer_ops.devices import EXAMPLE, search_paths + from printops.fleet import fleet_path + + print("No printers configured. Looked in:") + for p in [fleet_path(), *search_paths()]: + print(f" {p}") + print("\nCreate one of them, e.g.:\n" + EXAMPLE) + + +def _print_list_row(r: dict[str, Any]) -> None: + """One human-readable `printer list` line plus its unusable-head and settings notes.""" + address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" + heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" + print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " + f"{address:<31} {heads}") + for h in r["heads"]: + if not h["usable"]: + print(f" {h['label']}: {h['reason']}") + if r["settings"] and r["settings"] != r["fleet"]: + print(f" device settings: {r['settings']}") + + def _cmd_list(argv: list[str]) -> int: import json - from printer_ops.devices import EXAMPLE, load_devices, search_paths - from printops.fleet import fleet_path, load_fleet + from printer_ops.devices import load_devices + from printops.fleet import load_fleet _, opts = _parse(argv, {"--json"}, 0, "list") fleet = load_fleet() @@ -267,22 +317,11 @@ def _cmd_list(argv: list[str]) -> int: print(json.dumps(rows, indent=1, ensure_ascii=False)) return 0 if not rows: - print("No printers configured. Looked in:") - for p in [fleet_path(), *search_paths()]: - print(f" {p}") - print("\nCreate one of them, e.g.:\n" + EXAMPLE) + _print_no_printers() return 0 print(f" {'NAME':<13} {'OWNER':<12} {'ACCESS':<9} {'MODEL':<20} {'ADDRESS':<31} HEADS") for r in rows: - address = f"{r['adapter']} {r['address']}" if r["adapter"] else "- (never contacted)" - heads = " | ".join(h["label"] + ("" if h["usable"] else " UNUSABLE") for h in r["heads"]) or "-" - print(f" {r['name']:<13} {r['owner'] or '-':<12} {r['access']:<9} {r['model'] or '-':<20} " - f"{address:<31} {heads}") - for h in r["heads"]: - if not h["usable"]: - print(f" {h['label']}: {h['reason']}") - if r["settings"] and r["settings"] != r["fleet"]: - print(f" device settings: {r['settings']}") + _print_list_row(r) if fleet is not None: print(f"\nfleet: {fleet.path}") return 0 @@ -386,20 +425,11 @@ def _cmd_start(argv: list[str]) -> int: return _start_snapshot(name, file, original, local, source, opts) -def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, - opts: dict[str, str]) -> int: - from printer_ops.base import UNKNOWN, PrinterCommError, PrinterRefused, file_sha256, safe_remote_name - from printer_ops.render import render_status - from printer_ops import bridge_max_for - from printer_ops.preflight import FAIL, run_preflight - from printer_ops.printops_checks import context_from_options - - remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode - size = local.stat().st_size - adapter = _adapter(name, opts, write=True) +def _require_stored_file(adapter: Adapter, name: str, file: str, remote: str, size: int, + opts: dict[str, str]) -> None: + """Refuse unless the printer holds `remote` with exactly the local size.""" + from printer_ops.base import PrinterRefused - st = adapter.status() - _refuse_if_busy(name, st, "before preflight") stored = adapter.list_files(expect=(remote, size)).get(remote) if stored is None or stored.size != size: got = "absent" if stored is None else f"{stored.size} B" @@ -408,6 +438,12 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=[f"Upload it first: 3d printer upload {name} {file}" + (f" --as {opts['--as']}" if "--as" in opts else "")], ) + + +def _verify_remote_identity(adapter: Adapter, name: str, file: str, remote: str, local: Path) -> str: + """Check the stored file is the local one; returns the local sha256.""" + from printer_ops.base import PrinterRefused, file_sha256 + # Identity of what will be printed with what is checked below: the read-back hash where # the firmware can return the file, else the hash in the name plus the size. remote_hash = adapter.remote_sha256(remote) @@ -420,11 +456,19 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P ) identity = "read-back sha256 matches" if remote_hash else "content hash in the file name + size" print(f"remote file {remote}: {identity}") + return local_hash + + +def _preflight_report(name: str, adapter: Adapter, st: PrinterStatus, original: Path, local: Path, + source: Path | None, opts: dict[str, str]) -> Any: + """Run and print the preflight on the snapshot; the report is relabelled with the original path.""" + from printer_ops import bridge_max_for + from printer_ops.preflight import check_bed, run_preflight + from printer_ops.printops_checks import context_from_options limits, limits_source = adapter.axis_limits() bridge, bridge_source = bridge_max_for(opts.get("--bridge-max"), adapter.device, command="printer") # the snapshot lives in a temp dir: NAME.3mf and NAME.slice-warnings.json sit next to the original - from printer_ops.preflight import check_bed # plus the PrintOps material gates of `3d print-preflight --printer NAME` (live 3d-api state # when configured, else fleet facts only) @@ -437,18 +481,34 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P else None) report.file = f"{original} (snapshot)" print(report.render()) - if report.overall == FAIL: - if "--force" not in opts: - raise PrinterRefused("preflight FAIL; start refused", command="printer", - remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " - "file an issue and fix it).", - "Forcing needs a one-time Telegram approval, not a flag: see --force in " - "`3d printer --help`."]) - from printer_ops.force_hatch import require_force_approval - - forced = ", ".join(c.name for c in report.checks if c.status == FAIL) - approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) - print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + return report + + +def _require_preflight_pass(report: Any, name: str, original: Path, local_hash: str, + opts: dict[str, str]) -> None: + """Refuse on a preflight FAIL unless --force is given and the owner approves it.""" + from printer_ops.base import PrinterRefused + from printer_ops.preflight import FAIL + + if report.overall != FAIL: + return + if "--force" not in opts: + raise PrinterRefused("preflight FAIL; start refused", command="printer", + remediation=["Fix the cause of each FAIL line above (a gate's false positive is a bug: " + "file an issue and fix it).", + "Forcing needs a one-time Telegram approval, not a flag: see --force in " + "`3d printer --help`."]) + from printer_ops.force_hatch import require_force_approval + + forced = ", ".join(c.name for c in report.checks if c.status == FAIL) + approval = require_force_approval(printer=name, file=str(original), sha256=local_hash, checks=report.checks) + print(f"FORCED: starting despite preflight FAIL ({forced}); owner approved on Telegram ({approval})") + + +def _start_and_report(adapter: Adapter, name: str, remote: str) -> None: + """Send the start and print the state after it; any unreadable outcome is reported as UNKNOWN.""" + from printer_ops.base import UNKNOWN, PrinterCommError + from printer_ops.render import render_status _refuse_if_busy(name, adapter.status(), "state changed during preflight") dont_resend = [f"Do NOT resend blindly. Check: 3d printer status {name}"] @@ -475,6 +535,23 @@ def _start_snapshot(name: str, file: str, original: Path, local: Path, source: P command="printer", remediation=dont_resend, ) print(render_status(name, adapter, after)) + + +def _start_snapshot(name: str, file: str, original: Path, local: Path, source: Path | None, + opts: dict[str, str]) -> int: + from printer_ops.base import safe_remote_name + + remote = safe_remote_name(local, opts.get("--as")) # content-addressed: -.gcode + size = local.stat().st_size + adapter = _adapter(name, opts, write=True) + + st = adapter.status() + _refuse_if_busy(name, st, "before preflight") + _require_stored_file(adapter, name, file, remote, size, opts) + local_hash = _verify_remote_identity(adapter, name, file, remote, local) + report = _preflight_report(name, adapter, st, original, local, source, opts) + _require_preflight_pass(report, name, original, local_hash, opts) + _start_and_report(adapter, name, remote) return 0 @@ -522,6 +599,106 @@ def _cmd_bed(argv: list[str]) -> int: return exec_tool(BED_DEPS, BED_TOOL, argv) +def _run_evidence(rest: list[str], name: str | None) -> int: + from printer_ops.evidence import run as evidence + + return evidence(rest) + + +def _run_watch(rest: list[str], name: str | None) -> int: + from printer_ops.watch_run import run as watch + + return watch(rest) + + +def _run_supervise(rest: list[str], name: str | None) -> int: + from printer_ops.supervise import run as supervise + + return supervise(rest) + + +def _run_guard(rest: list[str], name: str | None) -> int: + from printer_ops.guard import run as guard + + return guard(rest, vision_deps=VISION_DEPS) + + +def _run_agent(rest: list[str], name: str | None) -> int: + from printer_ops.printer_agent import run as agent + + return agent(rest) + + +def _run_doctor(rest: list[str], name: str | None) -> int: + from printers_cli import dispatch + + return dispatch(["doctor", *rest]) + + +def _run_todo(rest: list[str], name: str | None) -> int: + from printops.todo_cli import run as todo + + return todo(rest, printer=name) + + +def _run_macro(rest: list[str], name: str | None) -> int: + from printer_ops.macro_cli import run_macro + + return run_macro(name, rest) + + +def _run_aliases(rest: list[str], name: str | None) -> int: + from printer_ops.alias_cli import run_aliases + + return run_aliases(name, rest) + + +def _run_alias(sub: str, rest: list[str], name: str | None) -> int: + """A subcommand that is none of the above: an alias of printer NAME (home, load, ...), else unknown.""" + from printer_ops.alias_cli import run_alias + + aliased = run_alias(name, sub, rest) + if aliased is not None: + return aliased + raise UsageError(f"unknown subcommand '{sub}'", command="printer", + remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " + "subscriptions, agent, doctor, todo (issues), macro, aliases or an alias (home, level, load, ...) — see `3d printer --help`."]) + + +def _run_subscriptions(sub: str) -> Callable[[list[str], str | None], int]: + """Handler for subscribe/unsubscribe/subscriptions: the module takes the subcommand word too.""" + def handler(rest: list[str], name: str | None) -> int: + from printer_ops.subscriptions import run as subscriptions + + return subscriptions(sub, rest) + return handler + + +# subcommand -> handler(rest, -n/--name value). Add new subcommands here. +_SUBCOMMANDS: dict[str, Callable[[list[str], str | None], int]] = { + "list": lambda rest, name: _cmd_list(rest), + "status": lambda rest, name: _cmd_status(rest), + "upload": lambda rest, name: _cmd_upload(rest), + "start": lambda rest, name: _cmd_start(rest), + "cancel": lambda rest, name: _cmd_cancel(rest), + "evidence": _run_evidence, + "watch": _run_watch, + "supervise": _run_supervise, + "vision": lambda rest, name: _cmd_vision(rest), + "guard": _run_guard, + "bed": lambda rest, name: _cmd_bed(rest), + "subscribe": _run_subscriptions("subscribe"), + "unsubscribe": _run_subscriptions("unsubscribe"), + "subscriptions": _run_subscriptions("subscriptions"), + "agent": _run_agent, + "doctor": _run_doctor, + "todo": _run_todo, + "issues": _run_todo, + "macro": _run_macro, + "aliases": _run_aliases, +} + + def run(argv: list[str]) -> int: if not argv: print(USAGE) @@ -537,70 +714,10 @@ def run(argv: list[str]) -> int: if name is not None and sub in _NAME_AS_POSITIONAL: raise UsageError(f"-n/--name selects the printer for `todo`/`issues`, `macro`, `aliases` and the aliases; `{sub}` takes the printer as NAME", command="printer", remediation=[f"3d printer {sub} {name} …"]) - if sub == "list": - return _cmd_list(rest) - if sub == "status": - return _cmd_status(rest) - if sub == "upload": - return _cmd_upload(rest) - if sub == "start": - return _cmd_start(rest) - if sub == "cancel": - return _cmd_cancel(rest) - if sub == "evidence": - from printer_ops.evidence import run as evidence - - return evidence(rest) - if sub == "watch": - from printer_ops.watch_run import run as watch - - return watch(rest) - - if sub == "supervise": - from printer_ops.supervise import run as supervise - - return supervise(rest) - - if sub == "vision": - return _cmd_vision(rest) - if sub == "guard": - from printer_ops.guard import run as guard - - return guard(rest, vision_deps=VISION_DEPS) - if sub == "bed": - return _cmd_bed(rest) - if sub in ("subscribe", "unsubscribe", "subscriptions"): - from printer_ops.subscriptions import run as subscriptions - - return subscriptions(sub, rest) - if sub == "agent": - from printer_ops.printer_agent import run as agent - - return agent(rest) - if sub == "doctor": - from printers_cli import dispatch - - return dispatch(["doctor", *rest]) - if sub in ("todo", "issues"): - from printops.todo_cli import run as todo - - return todo(rest, printer=name) - if sub == "macro": - from printer_ops.macro_cli import run_macro - - return run_macro(name, rest) - if sub == "aliases": - from printer_ops.alias_cli import run_aliases - - return run_aliases(name, rest) - from printer_ops.alias_cli import run_alias - - aliased = run_alias(name, sub, rest) - if aliased is not None: - return aliased - raise UsageError(f"unknown subcommand '{sub}'", command="printer", - remediation=["Use list, status, upload, start, cancel, evidence, watch, supervise, vision, guard, bed, subscribe, unsubscribe, " - "subscriptions, agent, doctor, todo (issues), macro, aliases or an alias (home, level, load, ...) — see `3d printer --help`."]) + handler = _SUBCOMMANDS.get(sub) + if handler is None: + return _run_alias(sub, rest, name) + return handler(rest, name) def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: @@ -622,7 +739,7 @@ def _printer_name(argv: list[str]) -> tuple[str | None, list[str]]: COMMAND = Command( name="printer", group="SLICING", - summary="drive configured printers: list / status / upload / start --yes / cancel --yes / macro / aliases (home, level, load, ...) / evidence / watch / vision / subscribe / agent / todo", + summary="drive configured printers: list / status / upload / start --yes / cancel --yes / evidence / watch / vision / subscribe / agent / todo", usage=USAGE, run=run, ) From 4f501dd46079eb13bedd0b658c9c5c1982c724e2 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:15:30 +0100 Subject: [PATCH 28/30] test(thumbnails): user-workflow docstrings for the three e2e stories CI's story check names (Refs #722) --- tests/e2e/test_thumbnail_layers_workflows.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/test_thumbnail_layers_workflows.py b/tests/e2e/test_thumbnail_layers_workflows.py index b25ffef6..c9bc3a20 100644 --- a/tests/e2e/test_thumbnail_layers_workflows.py +++ b/tests/e2e/test_thumbnail_layers_workflows.py @@ -70,6 +70,7 @@ def test_slice_output_has_every_thumbnail_without_a_separate_step( def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None: + """A user previews a slice with --dry-run: no G-code is written and no thumbnail is drawn or announced.""" model = tmp_path / "part.stl" model.write_text("solid x\nendsolid x\n") dry = run_cli( @@ -86,6 +87,8 @@ def test_slice_dry_run_keeps_nothing_and_renders_nothing(tmp_path: Path) -> None def test_slice_of_a_3mf_project_asks_the_slicer_for_the_sizes_and_leaves_the_input_alone( tmp_path: Path, ) -> None: + """A user slices a project 3MF: the slicer is asked for the thumbnail sizes through a temporary copy + and the 3MF on disk stays byte for byte as it was.""" project = plate_project(tmp_path / "p.3mf") before = project.read_bytes() slicer = tmp_path / "spy-slicer" @@ -159,6 +162,7 @@ def test_upload_adds_the_missing_thumbnails_and_the_printer_receives_them( def test_upload_of_a_complete_gcode_is_untouched_and_silent(tmp_path: Path) -> None: + """A user uploads a G-code that already has its previews: the file is not touched, nothing is announced.""" gcode = complete(tmp_path / "part.gcode") before = gcode.read_bytes() moon = FakeMoonraker() @@ -225,6 +229,7 @@ def test_gcode_thumbnails_add_updates_the_plate_pictures_of_a_project( def test_help_documents_the_layers(tmp_path: Path) -> None: + """A user reads the three --help pages and finds where thumbnails are added and how --fix does it.""" thumbs = run_cli(tmp_path, "gcode-thumbnails", "--help") flight = run_cli(tmp_path, "print-preflight", "--help") printer = run_cli(tmp_path, "printer", "--help") From 659302f5523a3969630cbce6224a25549526285c Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:26:43 +0100 Subject: [PATCH 29/30] test(printer): user-workflow docstrings for two colour e2e stories (Refs #728) --- tests/e2e/test_print_preflight_colour_workflows.py | 2 ++ tests/e2e/test_printer_colour_workflows.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tests/e2e/test_print_preflight_colour_workflows.py b/tests/e2e/test_print_preflight_colour_workflows.py index 1d3be299..b2c9285b 100644 --- a/tests/e2e/test_print_preflight_colour_workflows.py +++ b/tests/e2e/test_print_preflight_colour_workflows.py @@ -58,6 +58,8 @@ def test_preflight_accepts_the_same_beige_and_prints_the_distance( def test_preflight_still_fails_a_wrong_material_and_a_different_colour( tmp_path: Path, ) -> None: + """A PLA file on a PETG head and a blue file on a beige head still FAIL; `--fix` refuses to rewrite across + another colour and leaves the file as it was.""" moon = FakeMoonraker() load_beige_petg(moon) pla = tmp_path / "pla.gcode" diff --git a/tests/e2e/test_printer_colour_workflows.py b/tests/e2e/test_printer_colour_workflows.py index 57f5360f..c7fe05b9 100644 --- a/tests/e2e/test_printer_colour_workflows.py +++ b/tests/e2e/test_printer_colour_workflows.py @@ -117,6 +117,7 @@ def test_printer_slot_set_dry_run_then_write_aligns_the_printer_with_the_file( def test_slot_set_refuses_an_offline_printer_and_a_missing_head(tmp_path: Path) -> None: + """The owner tries `slot set` on a read-only printer and on a head that is not there: both are refused.""" moon = FakeMoonraker() with moon.serve(): env = { From 282392ccb5fad5ee2f08be7abad3bc78c3ac50d5 Mon Sep 17 00:00:00 2001 From: alex-mextner <111590326+alex-mextner@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:36:37 +0100 Subject: [PATCH 30/30] fix(test): printops maint e2e used fixed dates that aged 'ok' into 'soon' on 2026-11-05; make them relative (Refs #718) --- tests/e2e/test_printops_maint_workflows.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/e2e/test_printops_maint_workflows.py b/tests/e2e/test_printops_maint_workflows.py index 7758d9bb..8f88c111 100644 --- a/tests/e2e/test_printops_maint_workflows.py +++ b/tests/e2e/test_printops_maint_workflows.py @@ -4,6 +4,8 @@ from __future__ import annotations import json +import time +from datetime import date, timedelta from pathlib import Path from typing import Any @@ -45,11 +47,11 @@ def _history(url: str) -> Any: - """Moonraker GET /server/history/*: 2240 m in total, 120 m in the last two weeks.""" + """Moonraker GET /server/history/*: 2240 m in total, 120 m five days ago (after the maintenance logged ten days ago).""" if url.endswith("/server/history/totals"): return {"result": {"job_totals": {"total_jobs": 88.0, "total_print_time": 1191293.4, "total_filament_used": 2240300.0}}} - return {"result": {"count": 1, "jobs": [{"start_time": 1791900000.0, "print_duration": 7200.0, + return {"result": {"count": 1, "jobs": [{"start_time": time.time() - 5 * 86400, "print_duration": 7200.0, "filament_used": 120000.0}]}} @@ -99,7 +101,7 @@ def test_user_tracks_a_broken_head_a_nozzle_swap_and_due_maintenance(maint: Any) assert "[days ?/30, filament_m 2240.3/1000; never logged]" in out assert "done? 3d printops maint log u1 --rule u1-linear-shafts" in out - code, out, err = maint("log", "u1", "--rule", "u1-linear-shafts", "--at", "2026-10-10", + code, out, err = maint("log", "u1", "--rule", "u1-linear-shafts", "--at", (date.today() - timedelta(days=10)).isoformat(), # relative dates: fixed ones age "ok" into "soon" "--notes", "IPA + white lithium grease") assert code == 0, err assert "logged #2 kinematics lube on u1" in out and "(rule u1-linear-shafts)" in out @@ -120,8 +122,8 @@ def test_user_tracks_a_broken_head_a_nozzle_swap_and_due_maintenance(maint: Any) assert code == 0, err lines = out.splitlines() assert lines[0].startswith("#3 ") and "u1/T2 ptfe replace" in lines[0] and "invalidated: ptfe" in lines[0] - assert "u1/T3 nozzle replace" in lines[1] and "invalidated: nozzle_swap" in lines[1] - assert "kinematics lube" in lines[2] and "rule u1-linear-shafts" in lines[2] + assert "kinematics lube" in lines[1] and "rule u1-linear-shafts" in lines[1] # ten days ago, after the 2026-10-14 swap + assert "u1/T3 nozzle replace" in lines[2] and "invalidated: nozzle_swap" in lines[2] code, out, err = maint("fault", "open", "u1/T1", "--symptom", "grinding", "--component", "extruder") assert code == 0, err