diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 160870b..ecf5976 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,7 +74,7 @@ jobs:
- name: Pytest with coverage
run: |
uv pip install '.[test]'
- python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=86
+ python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=90
- name: Syntax smoke
# Auto-discovers bambu_cli/**/*.py (see scripts/syntax_smoke.py).
run: python scripts/syntax_smoke.py
@@ -176,8 +176,8 @@ jobs:
#
# Pinned to 3.12 on purpose. The generator needs 3.10+ to evaluate the
# contracts' `X | None` annotations, and pydantic is only installed
- # above that floor (see the marker in pyproject). The package itself
- # still runs on 3.9 — that leg is covered by the test matrix.
+ # above that floor (see the marker in pyproject). The package requires
+ # Python >=3.10 — that floor is covered by the test matrix.
run: uv run --python 3.12 --with pydantic python scripts/gen_schemas.py --check
- name: layer boundaries (blocking)
# Directories alone never held: protocols/, slicer/ and download/ already
diff --git a/AGENTS.md b/AGENTS.md
index 660189e..4d33c18 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -57,14 +57,13 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** (
| `tui/` | Textual full-screen UI (`plate tui`), optional `[tui]` extra. A front-end over `interactive/core.py` — **not** an agent surface (see "human only" above) |
| `tlspin.py` | The single `verify_cert_fingerprint` used by mqtt, ftps, and camera (fail-closed; B.5) |
| `netsafety.py` | SSRF / private-IP guards for download targets |
-| `printables.py` | Printables model resolution (GraphQL + HTML fallback) |
| `ams.py` | AMS tray parsing and material matching |
| `utils.py` | `emit_json` / `emit_json_error` envelopes and shared output helpers |
| `config.py` | Config load/apply, timeouts, fingerprints |
| `context.py` | `Settings` / `RuntimeContext` process context |
| `logging_utils.py` | Process logger proxy; tests use `set_logger` / patch `_BACKEND` |
| `constants.py` | Exit codes, file-type tables, safety limits (immutable) |
-| `protocols/` | Low-level FTPS, MQTT, and camera clients used by `BambuPrinter` (`camera.py` moved here — it is a TLS transport sharing `tlspin`) |
+| `protocols/` | Low-level FTPS, MQTT, and camera clients used by `BambuPrinter` (`camera.py` lives here because it is a TLS transport sharing `tlspin`) |
| `errors.py` | `BambuError` hierarchy + `abort()` (domain never calls `sys.exit`) |
**Layer boundaries are enforced (blocking CI):** `scripts/check_layers.py` assigns every module a rank and rejects any import that goes *upward*, plus any import between the three sibling adapters. Deferred (function-local) imports count — they break the import cycle, not the dependency.
@@ -74,7 +73,7 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** (
50 commands/ interactive/ tui/ 45 job/ 40 download/ setup_cmd/
35 printer.py 30 protocols/ | slicer/ | printables/ <- MUST NOT import each other
25 cliparse.py 20 utils config context netsafety ams
-10 constants errors paths logging_utils argutils jsonio tlspin fsutil
+10 constants errors paths logging_utils argutils jsonio tlspin fsutil contracts
```
The rule exists because directories alone never held it: `protocols/`, `slicer/` and `download/` were already separate packages and still drifted — `slicer/output.py` imported a **private FTPS helper** to delete a partial file, so a change to Bambu transport code silently changed slicer behavior. If you need a helper in two adapters, push it down to rank 10 (that is what `fsutil.py` is for); do not import sideways.
@@ -104,8 +103,8 @@ When adding tests, follow [docs/test-backlog.md](docs/test-backlog.md) and the q
### Known architecture debt (honest)
-- **`protocols/mqtt.py` is a facade** over `mqtt_tls` / `mqtt_cmd` / `mqtt_print` / `mqtt_monitor` / `mqtt_session`. The old ~880 LOC hotspot was split; keep new MQTT logic in those siblings, not the facade.
-- B.4 (cli extraction → paths/jsonio/argutils) and B.5 (single `verify_cert_fingerprint` in tlspin.py) both landed; see [docs/quality-roadmap.md](docs/quality-roadmap.md) for the current gap list.
+- **`protocols/mqtt.py` is a facade** over `mqtt_tls` / `mqtt_cmd` / `mqtt_print` / `mqtt_monitor` / `mqtt_session`. Keep new MQTT logic in those siblings, not the facade.
+- For the current gap list, see [docs/quality-roadmap.md](docs/quality-roadmap.md).
## Camera snapshots for agents
@@ -135,7 +134,7 @@ Published on PyPI as `platecli`; the installed command is `plate`.
| Gate | Command / note |
|------|----------------|
| Default tests | `uv run python -m pytest tests/ -q -m "not live"` — never contacts a printer |
-| Coverage (CI) | `--cov-fail-under=86` (2026-08-13, PR #119: Linux 90.99% / Windows 90.68% / macOS passing; matrix 3.10/3.12/3.14; A+ target **92%** — see roadmap) |
+| Coverage (CI) | `--cov-fail-under=90` (A+ target **92%** — see roadmap) |
| Lint | `uvx ruff check bambu_cli` + `uvx ruff format --check bambu_cli` |
| Types | `uvx mypy -p bambu_cli` |
| Security lint | `uvx bandit -c pyproject.toml -r bambu_cli -ll` |
@@ -159,4 +158,4 @@ Full threat model: [SECURITY.md](SECURITY.md).
- Prefer `access_code_file` over inline `access_code`.
- Downloads block private/loopback targets unless `--allow-private-ips` (CLI-only, not sticky config).
- Destructive/physical actions need `--confirm` and exit `5` without it: `print`, `stop`, `pause`, `resume`, `delete`, `gcode`. `job` / `send` without `--confirm` still uploads and exits `0` with `"status": "uploaded_not_printed"` — only the print step is withheld. `light` is deliberately exempt (no motion/thermal/material effect). `--confirm` is a deliberate-action gate, not an authorization boundary — anything that can run `plate` can pass it.
-- Camera Docker streamer (when used) publishes via `camera_port`, now loopback-only by default (`127.0.0.1:1985:1984`); the feed is unauthenticated, so only expose it on the LAN (`0.0.0.0:...`) deliberately (see SECURITY.md).
+- Camera Docker streamer (when used) publishes via `camera_port`, loopback-only by default (`127.0.0.1:1985:1984`); the feed is unauthenticated, so only expose it on the LAN (`0.0.0.0:...`) deliberately (see SECURITY.md).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d09f3f..82ef340 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,176 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
+### Printer safety
+
+- **A1 and A1 mini can be sliced.** Every A1/A1 mini slice failed inside
+ OrcaSlicer ("Relative extruder addressing requires resetting the extruder
+ position at each layer"): Bambu's A1 profiles omit the per-layer `G92 E0`
+ reset and rely on an exemption OrcaSlicer's command line does not apply.
+ The machine profile handed to OrcaSlicer now appends `G92 E0` to the
+ layer-change G-code when no reset is present (a counter reset with relative
+ extrusion; nothing moves). X1/P1 profiles already carry it and are unchanged.
+- **P1S, X1 and X1E slice with the Standard profile they asked for.** These
+ models have no process profiles of their own and borrow the X1C ones. The
+ search took the first file in directory order whose name merely contained
+ the model code, which with the real profiles was `0.20mm Bambu Support W @BBL
+ X1C` — a profile for printing with support material — for every standard
+ slice ("X1" also matched X1C/X1E files, "A1" A1 mini ones). It now requires
+ an exact `@BBL ` name or a `compatible_printers` entry and prefers the
+ requested quality ("Standard", "Fine", ...).
+- **No more slicing for a guessed printer.** An unrecognised `model` in
+ config.json (including the A1 mini's own product name, "A1 mini") used to
+ fall back to the P1P profile — a 256 mm bed — without a warning, as did a
+ missing machine profile for the configured model or nozzle. `slice`, `job`
+ and `send` now refuse with a config error (exit 1) that names the model and
+ the supported list. "A1 mini" and "X1 Carbon" are accepted as spellings of
+ `A1M` / `X1C`. The process-profile search no longer borrows another model's
+ profile either.
+- `setup` no longer turns an unknown model into `P1P`: the interactive prompt
+ re-asks (and has no default when discovery found no model), and
+ non-interactive setup requires `--model`. `config validate` / `preflight`
+ report a new `printer-model` check.
+- **Slice overrides can no longer get around the temperature limits.** The
+ 0-350 °C nozzle / 0-150 °C bed check did not understand OrcaSlicer's own
+ comma-list syntax, so `--set-filament nozzle_temperature=400,220` produced
+ `M109 S400`; every temperature value is now split and checked, and one that
+ is not a plain ASCII decimal is refused (`3_5_0`, `1e2` and non-ASCII digits
+ read differently in OrcaSlicer than in the check). G-code and script settings
+ (`filament_start_gcode`, `machine_start_gcode`, `post_process`, ...) and
+ printer (machine) settings such as `printable_area` — which OrcaSlicer honoured
+ when sent through `--set` — are refused outright, and `slice --list-settings`
+ no longer lists the G-code ones. Other temperature keys (chamber,
+ vitrification, range limits) are bounded too.
+- **A printer command is sent once, never repeated.** When the MQTT
+ acknowledgement arrived after the timeout, `gcode`, `stop`, `pause`, `resume`
+ and `light` published the same command again on each retry (up to three
+ times) and then reported `"sent": false` — so `plate gcode "G1 E50"` could
+ extrude 150 mm and invite a fourth try. Retries now happen only while nothing
+ has been published; a sent-but-unacknowledged command exits 6 with
+ `"sent": true, "acknowledged": false` and `next_command: ["status", "--json"]`.
+ The TUI's long-lived connection had the same flaw and is fixed too. A PUBACK
+ that arrives while the connection is being closed still counts as an
+ acknowledgement.
+- **Slices now use the filament's and profile's real settings.** Three problems
+ stacked up, found together (all measured in real OrcaSlicer G-code):
+ `--nozzle-temp` / `--bed-temp` defaulted to 220 / 60 and overwrote every
+ filament, so `--filament PETG` or `ABS` printed at PLA temperatures; the
+ process and filament profiles handed to OrcaSlicer kept an `inherits`
+ reference it cannot resolve from a temp file, so every inherited value fell
+ back to a generic default (PETG came out as `filament_type = PLA` at 200 °C,
+ acceleration 500 instead of 10000, no elephant-foot compensation); and the
+ printer's default plate (Textured PEI) was never applied, so OrcaSlicer
+ sliced for the Cool Plate. Both profiles are now flattened like the machine
+ profile already was, the temperature flags default to the filament profile,
+ and `curr_bed_type` comes from the printer definition (`--set
+ curr_bed_type=...` still overrides it). PETG / PLA / ABS now slice at
+ 255/70, 220/55 and 270/90 °C. Expect different (Bambu-default) speeds and
+ print-time estimates than before.
+
+### Fixed
+
+- `status --monitor` exited 0 with no output when the printer refused the
+ connection (wrong access code), so a script could not tell it from success.
+ It now fails with exit 2 (`failed_step: mqtt`) on a refused connection, on a
+ socket/TLS error (previously an "Unexpected error", exit 5), and when the
+ printer never answers within the MQTT timeout (previously it waited forever).
+- A download blocked by the private-address guard (for example
+ `http://localhost/...`) was reported as a retryable network error (exit 2,
+ a nested `` message, no hint). It is now a refusal: exit
+ 5, `failed_step: validate`, a message naming the addresses and suggesting
+ `--allow-private-ips`, and `blocked_addresses` in the JSON envelope. The
+ branch meant to do this matched on text that only ever reached a log line,
+ and its two tests injected that text; they now drive the real error.
+- A ZIP with damaged compressed data (intact headers) crashed `job` and
+ `download` with "Unexpected error" and a traceback (exit 5), losing the job
+ summary. It is now an extract failure: exit 3, `failed_step: extract`.
+- Timeouts are validated. `--network-timeout`, `--slicer-timeout`,
+ `--command-timeout`, `--upload-timeout` and `--scan-timeout` accepted `-5`,
+ `0`, `nan` and `inf` (with `nan`/`inf` a slice could never time out); they now
+ require a positive, finite number of seconds (exit 5 otherwise). A bad
+ `*_timeout` value in config.json is ignored with a warning instead of
+ crashing before the command runs, and `config validate` reports it under a
+ new `timeouts` check.
+- The print command always named `Metadata/plate_1.gcode`, so a pre-sliced
+ 3MF whose only sliced plate was another one failed on the printer, and a
+ 3MF with no sliced plate at all was uploaded and sent to print. `job`/`send`
+ now read which plates are sliced: plate 1, or the only sliced plate, is
+ printed (with a warning when there are several); an unsliced 3MF is refused
+ before upload. `print`, `job` and `send` take `--plate N`, and the JSON
+ reports `plate`. Time estimates for a multi-plate 3MF are for the printed
+ plate, not the last one.
+- Slicing no longer litters the output folder. OrcaSlicer also writes
+ `plate_1.gcode` and `result.json` into its output directory, which was the
+ user's folder — by default the model's own — so every slice left both
+ behind and overwrote same-named files. OrcaSlicer now writes into a private
+ temporary directory and only the sliced 3MF is moved out.
+- `setup --migrate-access-code` with both `access_code` and `access_code_file`
+ deleted the inline code without looking at the file. When the file did not
+ exist, or held a placeholder, that left no usable access code. A missing file
+ now receives the inline code; an unreadable or placeholder file keeps the
+ inline code and reports an error.
+- `snapshot --json` via the Docker streamer reported `"docker_container":
+ "bambu_camera"` whatever `camera_container_name` was set to; it now reports
+ the container actually used.
+- `upload` (and `job`/`send`, which upload through it) refuses to replace the
+ file the printer is printing right now: an upload deletes the old file on
+ the printer first, and re-running a job on the same model mid-print would
+ pull the file out from under the running print. What the firmware does in
+ that case has not been verified on hardware; this check is the code-level
+ safeguard. It asks the printer for its current job first (exit 4 if that is
+ the same file); if the printer cannot be asked, the upload goes ahead.
+- Printing a plain `.gcode` file is now flagged as unverified: `print` sends
+ every file with `project_file`, which names a plate inside a 3MF, and it has
+ not been confirmed that Bambu firmware starts a plain `.gcode` that way (or
+ through the documented `gcode_file` command, which a community report says
+ does not start custom files). `print` warns before sending one; the open
+ question is listed in docs/live-printer-smoke.md.
+- `plate setup --` now updates that value in an existing config, as the
+ manual and troubleshooting guide already described (`--printer-ip` after a
+ DHCP change, `--access-code-env` after rotating the LAN code,
+ `--profiles-dir`/`--orca-slicer`, `--cert-fingerprint` to re-pin). Before,
+ a partial non-interactive setup failed on "missing required values", and the
+ path and pin flags alone started the interactive wizard and ignored the
+ flag. Values not passed are kept; the access code, pin, address and model
+ only when `--serial` is unchanged.
+
+### Security
+
+- **`"camera_allow_streamer": "false"` no longer enables the unpinned camera
+ streamer.** The key was read with `bool()`, so any non-empty string —
+ including `"false"` — turned on the Docker streamer that ignores
+ `cert_fingerprint`. Camera switches now use the same strict reader as
+ `insecure_tls`: JSON booleans, 0/1 and true/false spellings are honoured, and an
+ unreadable value falls to the safe side (streamer off, `camera_direct_only`
+ on) with a warning.
+- **SSRF filter hardening**: `netsafety` now unwraps IPv4-mapped IPv6,
+ deprecated IPv4-compatible IPv6 (`::/96`), 6to4, and NAT64 (`64:ff9b::/96`)
+ addresses and checks the embedded IPv4, and explicitly rejects IPv4/IPv6
+ multicast (`224.0.0.0/4`, `ff00::/8`) and deprecated site-local IPv6
+ (`fec0::/10`), which Python stdlib `ipaddress` treats as global. Public hosts
+ reached through NAT64 on IPv6-only networks stay reachable.
+- **FTP remote-path validation (defense in depth)**: `BambuPrinter` now rejects
+ remote paths containing CR, LF, or NUL before any `STOR`, `RETR`, `DELE`, or
+ `NLST`, returning a clean failure. `ftplib` already refuses CR/LF on the
+ control channel; this adds the NUL check and avoids an unhandled `ValueError`.
+- **URL validation & DoS prevention**: download URL validators now reject
+ whitespace and ASCII control characters up front, and `downloader` catches
+ low-level `http.client.HTTPException` to prevent unhandled tracebacks.
+- **Filename sanitization**: remote and local download filename sanitizers now
+ strip and reject ASCII DEL (`\x7f`).
+
+### Changed
+
+- README: the "Print something" first-run steps (which start with installing
+ OrcaSlicer) now come before the "Try it in 30 seconds" simulation section,
+ and that section says plainly that `plate --sim status` needs neither a
+ printer nor OrcaSlicer while a real print needs both. No CLI behaviour
+ changed; the empty-`HOME` first-run contract is now pinned by tests:
+ `plate preflight` reports the missing config, OrcaSlicer binary, and BBL
+ profiles as three separate checks, `plate setup --sim` without a TTY prints
+ the non-interactive `--printer-ip`/`--serial`/`--access-code-file` hint
+ instead of a traceback, and `plate --sim status` still exits `0`.
+
## [0.5.1] - 2026-08-28
### Fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5042c37..a65a5d4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -26,7 +26,7 @@ uv run python -m pytest tests/ -q -m "not live"
# Match CI hardness (ResourceWarning as error + coverage floor)
uv run python -W error::ResourceWarning -m pytest tests/ -m "not live" \
- --cov=bambu_cli --cov-report=term-missing --cov-fail-under=86
+ --cov=bambu_cli --cov-report=term-missing --cov-fail-under=90
# Smokes used in CI — all of them (see the "lint" job in .github/workflows/ci.yml).
# These are NOT part of pytest; a green suite says nothing about them.
diff --git a/LICENSE b/LICENSE
index c02be18..7bf9de0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 platecli contributors
+Copyright (c) 2026 Dylan Reed
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index b83eb0b..ff09ce9 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,15 @@
[](https://pypi.org/project/platecli/)
[](https://pepy.tech/projects/platecli)
[](https://opensource.org/licenses/MIT)
+
+[](#tested-and-hardened)
+[](#tested-and-hardened)
-[Install](#install) · [Try it in 30 seconds](#try-it-in-30-seconds) · [Print something](#print-something) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents)
+[Install](#install) · [Print something](#print-something) · [Try it without a printer](#try-it-in-30-seconds) · [Quality](#tested-and-hardened) · [User guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) · [Troubleshooting](https://github.com/DLANSAMA/platecli/blob/main/docs/troubleshooting.md) · [For AI agents](#built-for-ai-agents)
+
+Built and maintained by **[Dylan Reed](https://github.com/DLANSAMA)** · CI on Linux, macOS, and Windows across Python 3.10–3.14
@@ -28,6 +35,13 @@ model URL or file → download → slice (OrcaSlicer) → upload → pri
one command: plate go
```
+**Who it's for**
+
+- **Makers who live in a terminal** — print without opening a slicer GUI or signing into Bambu's cloud.
+- **Homelab and print-farm tinkerers** — every step is a scriptable subcommand with real exit codes.
+- **AI agents and automation** — `--json` output against [published JSON Schemas](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/), plus a `--sim` fake printer so a pipeline can be built and tested with no hardware.
+- **Anyone on a locked-down network** — nothing leaves your LAN; no account, no telemetry, no daemon.
+
**Supports:** any Bambu Lab printer with LAN mode — P1P, P1S, X1C, X1E, A1, A1 Mini. **Hardware-tested on the P1 series (P1P/P1S) only.** The rest speak the same LAN protocols and are expected to work, but are unverified on real hardware — treat them as best-effort and please [open an issue](https://github.com/DLANSAMA/platecli/issues) with what you hit. One caveat: `plate snapshot` grabs the camera directly (no extra software) on P1/A1-class printers. X1-series cameras need a locally-running Docker streamer, and that path is opt-in (`camera_allow_streamer` or `--allow-camera-streamer`) because the streamer does not honour `cert_fingerprint`.
## Install
@@ -52,30 +66,6 @@ pip install platecli
Previously published on PyPI as `bambu-local-cli` (yanked). The project is now `platecli`; the installed command is `plate`.
-## Try it in 30 seconds
-
-No printer needed — simulation mode fakes one so you can kick the tires right away:
-
-```bash
-plate --sim status
-```
-
-```
-🖨️ Bambu Printer Status
- State: IDLE
- Bed: 25°C / 0°C
- Nozzle: 25°C / 0°C
- Fan: 0 | WiFi: -42dBm
- AMS:
- Unit 0 (humidity 5, 26.0°C)
- ▶ Slot 0: PLA #F2F2F2 | 90%
- Slot 1: PETG #0A0AC8 | 60%
- Slot 2: empty
- Slot 3: TPU #000000 | 45%
-```
-
-Timestamps and log-level prefixes trimmed for brevity.
-
## Print something
Four steps, no flags to learn:
@@ -129,6 +119,30 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr
+## Try it in 30 seconds
+
+No printer yet, or OrcaSlicer not installed yet? Simulation mode fakes a printer so you can kick the tires right away — it needs neither. (A real print still needs both; see [Print something](#print-something).)
+
+```bash
+plate --sim status
+```
+
+```
+🖨️ Bambu Printer Status
+ State: IDLE
+ Bed: 25°C / 0°C
+ Nozzle: 25°C / 0°C
+ Fan: 0 | WiFi: -42dBm
+ AMS:
+ Unit 0 (humidity 5, 26.0°C)
+ ▶ Slot 0: PLA #F2F2F2 | 90%
+ Slot 1: PETG #0A0AC8 | 60%
+ Slot 2: empty
+ Slot 3: TPU #000000 | 45%
+```
+
+Timestamps and log-level prefixes trimmed for brevity.
+
## Why platecli
- **One command, whole pipeline** — `plate go` asks the questions; `plate job --confirm` takes flags. Both download, slice, upload, and print in one shot; or run `download` / `slice` / `upload` / `print` individually.
@@ -139,6 +153,28 @@ It is a front-end, not new machinery: it slices and builds the `job` request thr
- **Fixes itself findable** — `plate doctor` checks network, FTPS, and MQTT health and tells you exactly what's wrong.
- **Hardened where it counts** — TLS certificate pinning, SSRF-guarded downloads, and size-capped ZIP extraction.
+## Tested and hardened
+
+The numbers in the badges are measured on this repo, not aspirational:
+
+| | |
+|---|---|
+| **Tests** | 1,535 passing (`pytest -m "not live"`, v0.5.1) — unit, contract, property-based (Hypothesis), and Textual pilot tests |
+| **Coverage** | **91.1%** line+branch across the package; CI fails the build below **86%** |
+| **Platforms** | Linux, macOS, and Windows in CI, on Python 3.10 / 3.12 / 3.14 |
+| **Blocking gates** | `ruff`, `ruff format`, `mypy` (whole package, `check_untyped_defs`), `bandit`, `pip-audit`, plus layer-boundary and JSON-schema-drift checks |
+| **Mutation testing** | Nightly, scoped to the safety-critical modules (download validation, SSRF guards, slicer options, job payload) |
+| **Packaging** | Every CI run installs the built wheel *and* the sdist and runs the CLI from them |
+
+Coverage is deliberately honest — `except Exception` / `pass` / `continue` are **not**
+excluded from the report (see `[tool.coverage.report]` in `pyproject.toml`), so the
+number is not inflated by hiding error paths.
+
+Security posture is documented rather than implied: TLS certificate pinning for the
+printer connection, SSRF-guarded model downloads, size-capped archive extraction, and
+a written threat model with its known limitations in
+[SECURITY.md](https://github.com/DLANSAMA/platecli/blob/main/SECURITY.md).
+
## How it compares
The cloud-free Bambu ecosystem is in good shape, and for many people one of
@@ -183,7 +219,7 @@ Every command emits machine-readable `--json` output backed by published [JSON S
## Support & expectations
-platecli is maintained by one person in their spare time. Bug reports and pull requests are genuinely welcome — [open an issue](https://github.com/DLANSAMA/platecli/issues) with your `plate doctor` output attached and I'll get to it when I can. There is no response-time guarantee, and feature requests may sit or be declined to keep the tool small and local-only. If you need something faster than that, fork it — it's MIT.
+platecli is built and maintained by one person — [Dylan Reed](https://github.com/DLANSAMA) — in my spare time. Bug reports and pull requests are genuinely welcome — [open an issue](https://github.com/DLANSAMA/platecli/issues) with your `plate doctor` output attached and I'll get to it when I can. There is no response-time guarantee, and feature requests may sit or be declined to keep the tool small and local-only. If you need something faster than that, fork it — it's MIT.
## Status & disclaimer
@@ -191,9 +227,21 @@ platecli is maintained by one person in their spare time. Bug reports and pull r
> **Disclaimer:** platecli is an unofficial, community-developed tool. It is not affiliated with, endorsed by, or supported by Bambu Lab. "Bambu Lab" and product names are trademarks of their respective owners, used here only to describe compatibility. The printer protocols (MQTT/FTPS) are reverse-engineered; a firmware update may break functionality without warning — run `plate doctor` after printer updates.
+## Author
+
+**Dylan Reed** — [github.com/DLANSAMA](https://github.com/DLANSAMA) ·
+
+Design, implementation, test suite, and release engineering. For anything about
+the project itself, an [issue](https://github.com/DLANSAMA/platecli/issues) or a
+[discussion](https://github.com/DLANSAMA/platecli/discussions) is the fastest
+route, and security reports have their own channel in
+[SECURITY.md](https://github.com/DLANSAMA/platecli/blob/main/SECURITY.md).
+Email is open for work enquiries. Pull requests are welcome — see
+[CONTRIBUTING.md](https://github.com/DLANSAMA/platecli/blob/main/CONTRIBUTING.md).
+
## License
-MIT — Use freely, modify as needed.
+MIT © 2026 Dylan Reed — use freely, modify as needed. See [LICENSE](https://github.com/DLANSAMA/platecli/blob/main/LICENSE).
---
diff --git a/bambu_cli/ams.py b/bambu_cli/ams.py
index 8553861..3775fba 100644
--- a/bambu_cli/ams.py
+++ b/bambu_cli/ams.py
@@ -6,6 +6,13 @@
filament is loaded where and to build a correct ``--ams-mapping`` argument.
"""
+# Bambu firmware reports two sentinel values in ``tray_now`` that are not AMS
+# slot indices. They are distinct states and must not be conflated: 254 means
+# the printer is feeding from the external spool (vt_tray), 255 means nothing
+# is loaded at all.
+EXTERNAL_SPOOL_TRAY_ID = 254
+NOTHING_LOADED_TRAY_ID = 255
+
def _to_int(value, default=None):
try:
@@ -38,8 +45,9 @@ def _normalize_color(raw):
def parse_ams(status):
"""Normalize the AMS section of a printer status payload.
- Returns ``None`` when the payload carries no AMS data (printers without an
- AMS, or a simulated status). Otherwise returns::
+ Returns ``None`` when the payload carries no AMS data and no external spool
+ (printers without an AMS or loaded spool, or a simulated status).
+ Otherwise returns::
{
"active_tray": , # absolute tray index currently loaded
@@ -51,6 +59,10 @@ def parse_ams(status):
...
]}
],
+ "external_tray": {
+ "slot": 254, "type": "PLA", "color": "FFFFFF",
+ "remain": None, "empty": False, "active": False,
+ } | None,
}
``active`` marks the tray whose absolute index (``unit_id * 4 + slot``)
@@ -59,49 +71,87 @@ def parse_ams(status):
"""
if not isinstance(status, dict):
return None
+
ams_block = status.get("ams")
- if not isinstance(ams_block, dict):
- return None
- units_raw = ams_block.get("ams")
- if not isinstance(units_raw, list) or not units_raw:
+ units_raw = ams_block.get("ams") if isinstance(ams_block, dict) else None
+
+ # Resolve raw tray_now before sentinel normalization
+ raw_tray_now = None
+ if isinstance(ams_block, dict):
+ raw_tray_now = _to_int(ams_block.get("tray_now"))
+ if raw_tray_now is None:
+ raw_tray_now = _to_int(status.get("tray_now"))
+
+ # Resolve vt_tray (external spool / virtual tray)
+ vt_tray_raw = status.get("vt_tray")
+ if vt_tray_raw is None and isinstance(ams_block, dict):
+ vt_tray_raw = ams_block.get("vt_tray")
+ if vt_tray_raw is None and isinstance(status.get("print"), dict):
+ vt_tray_raw = status["print"].get("vt_tray")
+
+ external_tray = None
+ if isinstance(vt_tray_raw, dict) and any(vt_tray_raw.values()):
+ # _to_int returns `default` for missing AND unparseable input, so the
+ # default alone covers every case -- no None check needed after it.
+ vt_slot = _to_int(vt_tray_raw.get("id"), default=EXTERNAL_SPOOL_TRAY_ID)
+ vt_type = vt_tray_raw.get("tray_type") or None
+ vt_color = _normalize_color(vt_tray_raw.get("tray_color"))
+ vt_remain = _to_int(vt_tray_raw.get("remain"))
+ # 254 and 255 are BOTH sentinels, but they do not mean the same thing:
+ # 254 = printing from the external spool, 255 = nothing loaded at all.
+ # Only 254 marks the external spool active. Treating 255 as active
+ # showed a "▶" next to the external spool on an idle printer with no
+ # filament loaded, and put a false active filament in `status --json`.
+ is_vt_active = raw_tray_now == EXTERNAL_SPOOL_TRAY_ID
+ external_tray = {
+ "slot": vt_slot,
+ "type": vt_type,
+ "color": vt_color,
+ "remain": vt_remain,
+ "empty": not vt_type,
+ "active": is_vt_active,
+ }
+
+ if (not isinstance(units_raw, list) or not units_raw) and external_tray is None:
return None
- active_tray = _to_int(ams_block.get("tray_now"))
+ active_tray = raw_tray_now
# Bambu firmware reports tray_now 254/255 as a sentinel for the external
# spool / nothing loaded, not a real AMS slot index. Normalize those to None
# so no tray is falsely marked active and consumers don't present an
# external-spool state as an AMS detection.
- if active_tray in (254, 255):
+ if active_tray in (EXTERNAL_SPOOL_TRAY_ID, NOTHING_LOADED_TRAY_ID):
active_tray = None
units = []
- for unit_raw in units_raw:
- if not isinstance(unit_raw, dict):
- continue
- unit_id = _to_int(unit_raw.get("id"), default=0) or 0
- trays = []
- for tray_raw in unit_raw.get("tray") or []:
- if not isinstance(tray_raw, dict):
+ if isinstance(units_raw, list):
+ for unit_raw in units_raw:
+ if not isinstance(unit_raw, dict):
continue
- slot = _to_int(tray_raw.get("id"), default=0) or 0
- ftype = tray_raw.get("tray_type") or None
- absolute = unit_id * 4 + slot
- trays.append(
+ unit_id = _to_int(unit_raw.get("id"), default=0) or 0
+ trays = []
+ for tray_raw in unit_raw.get("tray") or []:
+ if not isinstance(tray_raw, dict):
+ continue
+ slot = _to_int(tray_raw.get("id"), default=0) or 0
+ ftype = tray_raw.get("tray_type") or None
+ absolute = unit_id * 4 + slot
+ trays.append(
+ {
+ "slot": slot,
+ "type": ftype,
+ "color": _normalize_color(tray_raw.get("tray_color")),
+ "remain": _to_int(tray_raw.get("remain")),
+ "empty": not ftype,
+ "active": active_tray is not None and absolute == active_tray,
+ }
+ )
+ units.append(
{
- "slot": slot,
- "type": ftype,
- "color": _normalize_color(tray_raw.get("tray_color")),
- "remain": _to_int(tray_raw.get("remain")),
- "empty": not ftype,
- "active": active_tray is not None and absolute == active_tray,
+ "id": unit_id,
+ "humidity": _to_int(unit_raw.get("humidity")),
+ "temp": _to_float(unit_raw.get("temp")),
+ "trays": trays,
}
)
- units.append(
- {
- "id": unit_id,
- "humidity": _to_int(unit_raw.get("humidity")),
- "temp": _to_float(unit_raw.get("temp")),
- "trays": trays,
- }
- )
- return {"active_tray": active_tray, "units": units}
+ return {"active_tray": active_tray, "units": units, "external_tray": external_tray}
diff --git a/bambu_cli/argutils.py b/bambu_cli/argutils.py
index 32f2ee0..e338232 100644
--- a/bambu_cli/argutils.py
+++ b/bambu_cli/argutils.py
@@ -38,5 +38,17 @@ def exit_code_from_system_exit(exc, default=EXIT_COMMAND_ERROR):
def setup_args_provided(args):
return any(
namespace_get(args, attr) is not None
- for attr in ("printer_ip", "serial", "access_code", "access_code_env", "access_code_file", "model", "nozzle")
+ for attr in (
+ "printer_ip",
+ "serial",
+ "access_code",
+ "access_code_env",
+ "access_code_file",
+ "model",
+ "nozzle",
+ # These alone used to start the interactive wizard, which ignored them.
+ "orca_slicer",
+ "profiles_dir",
+ "cert_fingerprint",
+ )
)
diff --git a/bambu_cli/cli.py b/bambu_cli/cli.py
index 03c58a0..2f92e3b 100644
--- a/bambu_cli/cli.py
+++ b/bambu_cli/cli.py
@@ -1,5 +1,5 @@
+import ipaddress
import logging
-import socket
import sys
import bambu_cli.printer as _printer # noqa: F401 — registers the RuntimeContext printer factory
@@ -251,12 +251,22 @@ def _handle_interrupt(interrupt_args, command_name):
logger.error(message)
sys.exit(EXIT_CONFIG_ERROR)
try:
- socket.getaddrinfo(printer_ip, None)
- except socket.gaierror:
- message = f"Invalid printer_ip or hostname in config: {printer_ip}"
- write_error_envelope(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
- logger.error(message)
- sys.exit(EXIT_CONFIG_ERROR)
+ ipaddress.ip_address(printer_ip)
+ except ValueError:
+ # Not an IP literal, so it has to resolve. _try_resolve_ip is the
+ # only bounded lookup available: it returns None on failure *or*
+ # timeout. Do not "confirm" its answer with a second
+ # socket.getaddrinfo — that call takes no timeout, so on a
+ # blackholed DNS server it blocks for the OS resolver's full retry
+ # budget, after the configured timeout has already elapsed.
+ from bambu_cli.config import get_network_timeout
+
+ timeout = get_network_timeout(args)
+ if utils._try_resolve_ip(printer_ip, timeout=timeout) is None:
+ message = f"Invalid printer_ip or hostname in config: {printer_ip}"
+ write_error_envelope(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
+ logger.error(message)
+ sys.exit(EXIT_CONFIG_ERROR)
_handler = _resolve_command(args.cmd)
if _handler is not None:
diff --git a/bambu_cli/cliparse.py b/bambu_cli/cliparse.py
index 17b4f08..2eff89b 100644
--- a/bambu_cli/cliparse.py
+++ b/bambu_cli/cliparse.py
@@ -23,6 +23,35 @@
from bambu_cli.utils import emit_json
+def positive_seconds(value):
+ """argparse type for a timeout: a positive, finite number of seconds.
+
+ ``float`` alone accepted ``-5``, ``0``, ``nan`` and ``inf``; ``nan``/``inf``
+ meant a slice could never time out and a negative value failed deep inside
+ a socket call instead of at the command line.
+ """
+ import math
+
+ try:
+ seconds = float(value)
+ except (TypeError, ValueError):
+ raise argparse.ArgumentTypeError(f"must be a positive number of seconds (got {value!r})") from None
+ if not math.isfinite(seconds) or seconds <= 0:
+ raise argparse.ArgumentTypeError(f"must be a positive number of seconds (got {value!r})")
+ return seconds
+
+
+def positive_int(value):
+ """argparse type for a 1-based index such as a plate number."""
+ try:
+ number = int(value)
+ except (TypeError, ValueError):
+ raise argparse.ArgumentTypeError(f"must be a positive whole number (got {value!r})") from None
+ if number < 1:
+ raise argparse.ArgumentTypeError(f"must be a positive whole number (got {value!r})")
+ return number
+
+
class _SilentArgumentParser(argparse.ArgumentParser):
"""A parser whose error()/exit() never terminate the process.
@@ -176,6 +205,11 @@ def _add_job_arguments(parser):
"--dry-run", action="store_true", help="No-side-effect validation; skip download/slice/upload/print"
)
parser.add_argument("--upload-only", action="store_true", help="Upload the printable but do not start the print")
+ parser.add_argument(
+ "--plate",
+ type=positive_int,
+ help="Plate of a multi-plate 3MF to print (default: for job/send, the only sliced plate or plate 1; for print, plate 1)",
+ )
parser.add_argument("--name", help="Save downloaded URL as filename before slicing/upload")
parser.add_argument(
"--output",
@@ -191,8 +225,15 @@ def _add_job_arguments(parser):
parser.add_argument("--filament", type=str, default="PLA Basic", help="Filament type (e.g. 'PLA Basic', 'PETG')")
parser.add_argument("--infill", type=int, default=15, help="Infill density %% (default: 15)")
parser.add_argument("--pattern", default="3dhoneycomb", help="Infill pattern (default: 3dhoneycomb)")
- parser.add_argument("--nozzle-temp", type=int, default=220, help="Nozzle temp °C (default: 220)")
- parser.add_argument("--bed-temp", type=int, default=60, help="Bed temp °C (default: 60)")
+ parser.add_argument(
+ "--nozzle-temp", type=int, default=None, help="Nozzle temp °C (default: the filament profile's own)"
+ )
+ parser.add_argument(
+ "--bed-temp",
+ type=int,
+ default=None,
+ help="Bed temp °C for every plate type (default: the filament profile's own)",
+ )
parser.add_argument("--supports", action="store_true", help="Enable supports")
parser.add_argument("--support-type", choices=["tree", "normal"], help="Support type: tree or normal")
parser.add_argument("--support-interface-density", type=float, help="Support interface density %%")
@@ -280,18 +321,24 @@ def get_global_parser():
)
global_parser.add_argument(
"--network-timeout",
- type=float,
+ type=positive_seconds,
default=argparse.SUPPRESS,
help="Timeout in seconds for general network communication",
)
global_parser.add_argument(
- "--slicer-timeout", type=float, default=argparse.SUPPRESS, help="Timeout in seconds for the slicing process"
+ "--slicer-timeout",
+ type=positive_seconds,
+ default=argparse.SUPPRESS,
+ help="Timeout in seconds for the slicing process",
)
global_parser.add_argument(
- "--command-timeout", type=float, default=argparse.SUPPRESS, help="Timeout in seconds for printer commands"
+ "--command-timeout",
+ type=positive_seconds,
+ default=argparse.SUPPRESS,
+ help="Timeout in seconds for printer commands",
)
global_parser.add_argument(
- "--upload-timeout", type=float, default=argparse.SUPPRESS, help="Timeout in seconds for file uploads"
+ "--upload-timeout", type=positive_seconds, default=argparse.SUPPRESS, help="Timeout in seconds for file uploads"
)
global_parser.add_argument(
"--allow-private-ips",
@@ -359,6 +406,11 @@ def build_parser():
p_print.add_argument("--timelapse", action="store_true", help="Enable timelapse")
p_print.add_argument("--skip-bed-leveling", action="store_true", help="Skip bed leveling")
p_print.add_argument("--skip-flow-cali", action="store_true", help="Skip flow calibration")
+ p_print.add_argument(
+ "--plate",
+ type=positive_int,
+ help="Plate of a multi-plate 3MF to print (default: for job/send, the only sliced plate or plate 1; for print, plate 1)",
+ )
p_job = sub.add_parser(
"job",
@@ -394,8 +446,15 @@ def build_parser():
p_slice.add_argument("--filament", type=str, default="PLA Basic", help="Filament type (e.g. 'PLA Basic', 'PETG')")
p_slice.add_argument("--infill", type=int, default=15, help="Infill density %% (default: 15)")
p_slice.add_argument("--pattern", default="3dhoneycomb", help="Infill pattern (default: 3dhoneycomb)")
- p_slice.add_argument("--nozzle-temp", type=int, default=220, help="Nozzle temp °C (default: 220)")
- p_slice.add_argument("--bed-temp", type=int, default=60, help="Bed temp °C (default: 60)")
+ p_slice.add_argument(
+ "--nozzle-temp", type=int, default=None, help="Nozzle temp °C (default: the filament profile's own)"
+ )
+ p_slice.add_argument(
+ "--bed-temp",
+ type=int,
+ default=None,
+ help="Bed temp °C for every plate type (default: the filament profile's own)",
+ )
p_slice.add_argument("--supports", action="store_true", help="Enable supports")
p_slice.add_argument("--support-type", choices=["tree", "normal"], help="Support type: tree or normal")
p_slice.add_argument("--support-interface-density", type=float, help="Support interface density %%")
@@ -510,7 +569,9 @@ def build_parser():
p_setup.add_argument("--profiles-dir", help="Path to OrcaSlicer BBL profiles directory")
p_setup.add_argument("--cert-fingerprint", help="SHA-256 fingerprint to pin the printer TLS certificate")
p_setup.add_argument("--insecure-tls", action="store_true", help="Disable TLS verification entirely (last resort)")
- p_setup.add_argument("--scan-timeout", type=float, help="Custom duration for local printer network scanning")
+ p_setup.add_argument(
+ "--scan-timeout", type=positive_seconds, help="Custom duration for local printer network scanning"
+ )
p_setup.add_argument(
"--migrate-access-code",
action="store_true",
diff --git a/bambu_cli/commands/doctor.py b/bambu_cli/commands/doctor.py
index c155c95..f90fd81 100644
--- a/bambu_cli/commands/doctor.py
+++ b/bambu_cli/commands/doctor.py
@@ -188,7 +188,7 @@ def shown_ip():
if ctx.settings.insecure_tls or not _offer_pin_fingerprint(fp, CONFIG_PATH, json_mode):
logger.info(' Add "cert_fingerprint": "" to config.json to pin this connection.')
- model_info = MODEL_MAPPING.get(ctx.settings.printer_model, MODEL_MAPPING["P1P"])
+ model_info = MODEL_MAPPING.get(ctx.settings.printer_model) or {"full_name": ctx.settings.printer_model}
firmware = status.get("sw_ver")
modules = printer.get_version(timeout=net_timeout)
if modules:
diff --git a/bambu_cli/commands/files.py b/bambu_cli/commands/files.py
index f484345..12409e5 100644
--- a/bambu_cli/commands/files.py
+++ b/bambu_cli/commands/files.py
@@ -5,7 +5,7 @@
from bambu_cli.argutils import namespace_get as _namespace_get
from bambu_cli.config import get_upload_timeout
-from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR, EXIT_NETWORK_ERROR
+from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR, EXIT_NETWORK_ERROR, EXIT_PRINTER_ERROR
from bambu_cli.context import RuntimeContext
from bambu_cli.download.naming import (
_is_print_ready_name,
@@ -22,6 +22,37 @@
from bambu_cli.slicer import _directory_input_message, _is_directory_input
from bambu_cli.utils import emit_json
+# gcode_state values for which the printer is using its current file.
+_ACTIVE_GCODE_STATES = frozenset({"PREPARE", "RUNNING", "PAUSE", "SLICING"})
+
+
+def _print_name_key(name):
+ """``model.gcode.3mf`` / ``model.3mf`` / ``model`` all compare as ``model``."""
+ base = _portable_basename(str(name or "")).lower()
+ for suffix in (".3mf", ".gcode"):
+ if base.endswith(suffix):
+ base = base[: -len(suffix)]
+ return base
+
+
+def _printing_this_file(printer, filename):
+ """True when the printer reports an active print of ``filename``.
+
+ ``upload_file`` deletes the remote file before storing the new one, so an
+ upload with the name of the running print pulls the file out from under
+ the printer (what the firmware does then is unverified). Best effort: a
+ status query that fails returns False and the upload goes ahead.
+ """
+ try:
+ data = printer.status(retries=0, require_complete=False)
+ except Exception as exc: # noqa: BLE001 -- a probe must never block the upload
+ logger.debug(f"Could not check the printer's current job before upload: {exc}")
+ return False
+ if not isinstance(data, dict) or str(data.get("gcode_state") or "").upper() not in _ACTIVE_GCODE_STATES:
+ return False
+ target = _print_name_key(filename)
+ return target in {_print_name_key(data.get("subtask_name")), _print_name_key(data.get("gcode_file"))}
+
def cmd_upload(args, ctx=None):
"""Upload a file to the printer via FTPS with binary retry/resume."""
@@ -134,9 +165,16 @@ def cmd_upload(args, ctx=None):
)
return filename
- logger.info(f"📤 Uploading {filename} ({filesize // 1024}KB)...")
-
printer = ctx.printer()
+ if _printing_this_file(printer, filename):
+ abort(
+ f"The printer is printing {filename} right now; uploading would replace that file mid-print. "
+ "Wait for the print to finish, or upload under a different name.",
+ exit_code=EXIT_PRINTER_ERROR,
+ failed_step="upload",
+ extra={"file": filepath, "remote_name": filename},
+ )
+ logger.info(f"📤 Uploading {filename} ({filesize // 1024}KB)...")
progress = None
task_id = None
diff --git a/bambu_cli/commands/gcode.py b/bambu_cli/commands/gcode.py
index bfd6f2c..9da1728 100644
--- a/bambu_cli/commands/gcode.py
+++ b/bambu_cli/commands/gcode.py
@@ -7,7 +7,7 @@
from bambu_cli.context import RuntimeContext
from bambu_cli.contracts import Gcode
from bambu_cli.download.naming import _has_command_injection_chars
-from bambu_cli.errors import abort
+from bambu_cli.errors import CommandUnconfirmed, abort
from bambu_cli.logging_utils import logger
from bambu_cli.utils import emit_json, get_sequence_id
@@ -44,7 +44,15 @@ def cmd_gcode(args, ctx=None):
payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "gcode_line", "param": gcode}})
printer = ctx.printer()
- if not printer.send_command(payload):
+ try:
+ sent = printer.send_command(payload)
+ except CommandUnconfirmed as exc:
+ # Published but never acknowledged: it may have run. Say so, with the
+ # G-code, rather than "not sent" -- a caller that retried would run it twice.
+ exc.extra = {"gcode": gcode, **exc.extra}
+ exc.next_command = exc.next_command or ["status", "--json"]
+ raise
+ if not sent:
abort(
"Failed to send G-code command.",
exit_code=EXIT_NETWORK_ERROR,
diff --git a/bambu_cli/commands/print_cmd.py b/bambu_cli/commands/print_cmd.py
index 99ef447..656dbf9 100644
--- a/bambu_cli/commands/print_cmd.py
+++ b/bambu_cli/commands/print_cmd.py
@@ -63,6 +63,8 @@ def cmd_print(args, ctx=None):
)
abort("", exit_code=EXIT_COMMAND_ERROR)
+ plate = getattr(args, "plate", None)
+ plate = plate if isinstance(plate, int) and plate >= 1 else 1
payload = generate_print_payload(
basename,
use_ams=getattr(args, "use_ams", False),
@@ -70,11 +72,20 @@ def cmd_print(args, ctx=None):
timelapse=getattr(args, "timelapse", False),
bed_leveling=not getattr(args, "skip_bed_leveling", False),
flow_cali=not getattr(args, "skip_flow_cali", False),
+ plate=plate,
)
- from bambu_cli.printer import get_printer
from bambu_cli.protocols.mqtt import execute_print_command
- printer = get_printer()
+ if not dry_run and basename.lower().endswith(".gcode"):
+ # project_file names Metadata/plate_.gcode inside a 3MF, which a plain
+ # .gcode does not have; the separate gcode_file command is documented by
+ # the community but reported not to start custom files. Neither path has
+ # been verified on a printer, so say so rather than imply it works.
+ logger.warning(
+ f"⚠️ Starting a plain .gcode file over LAN is not verified on Bambu firmware. If {basename} "
+ "does not start, print a sliced .3mf instead or start the file from the printer's screen."
+ )
+ printer = ctx.printer()
execute_print_command(printer, payload, basename, dry_run=dry_run)
if bool(_namespace_get(args, "json", False)):
emit_json(
@@ -84,6 +95,7 @@ def cmd_print(args, ctx=None):
file=basename,
printed=not dry_run,
dry_run=bool(dry_run),
+ plate=plate,
)
)
return basename
diff --git a/bambu_cli/commands/snapshot.py b/bambu_cli/commands/snapshot.py
index da1779e..5809914 100644
--- a/bambu_cli/commands/snapshot.py
+++ b/bambu_cli/commands/snapshot.py
@@ -414,7 +414,7 @@ def cmd_snapshot(
captured_at=captured_at,
sha256=sha256,
camera_image=camera_image,
- docker_container="bambu_camera",
+ docker_container=ctx.settings.camera_container_name,
)
)
except urllib.error.URLError as e:
diff --git a/bambu_cli/commands/status.py b/bambu_cli/commands/status.py
index 01a65f8..5a1e4b3 100644
--- a/bambu_cli/commands/status.py
+++ b/bambu_cli/commands/status.py
@@ -81,15 +81,22 @@ def cmd_status(args, ctx=None):
logger.info(f" Nozzle: {nozzle_temp}°C / {nozzle_target}°C")
logger.info(f" Fan: {fan} | WiFi: {wifi}dBm")
- if ams and ams["units"]:
- logger.info(" AMS:")
- for unit in ams["units"]:
- logger.info(f" Unit {unit['id']} (humidity {unit['humidity']}, {unit['temp']}°C)")
- for tray in unit["trays"]:
- marker = "▶ " if tray["active"] else " "
- if tray["empty"]:
- logger.info(f" {marker}Slot {tray['slot']}: empty")
- else:
- color = f" #{tray['color']}" if tray["color"] else ""
- remain = f" | {tray['remain']}%" if tray["remain"] is not None else ""
- logger.info(f" {marker}Slot {tray['slot']}: {tray['type']}{color}{remain}")
+ if ams:
+ if ams.get("units"):
+ logger.info(" AMS:")
+ for unit in ams["units"]:
+ logger.info(f" Unit {unit['id']} (humidity {unit['humidity']}, {unit['temp']}°C)")
+ for tray in unit["trays"]:
+ marker = "▶ " if tray["active"] else " "
+ if tray["empty"]:
+ logger.info(f" {marker}Slot {tray['slot']}: empty")
+ else:
+ color = f" #{tray['color']}" if tray["color"] else ""
+ remain = f" | {tray['remain']}%" if tray["remain"] is not None else ""
+ logger.info(f" {marker}Slot {tray['slot']}: {tray['type']}{color}{remain}")
+ ext_tray = ams.get("external_tray")
+ if ext_tray and not ext_tray.get("empty"):
+ marker = "▶ " if ext_tray.get("active") else " "
+ color = f" #{ext_tray['color']}" if ext_tray.get("color") else ""
+ remain = f" | {ext_tray['remain']}%" if ext_tray.get("remain") is not None else ""
+ logger.info(f" External Spool: {marker}{ext_tray.get('type')}{color}{remain}")
diff --git a/bambu_cli/config.py b/bambu_cli/config.py
index 50ef6ed..98b8b62 100644
--- a/bambu_cli/config.py
+++ b/bambu_cli/config.py
@@ -335,6 +335,36 @@ def orca_install_hint():
"A1M": {"token": "A1M", "full_name": "Bambu Lab A1 mini"},
}
+# Product names people actually type, keyed by their squashed form (upper case,
+# no spaces/hyphens/underscores, "BAMBULAB" prefix dropped). "A1 mini" is the
+# printer's own name and used to fall through to the P1P profile.
+_MODEL_ALIASES = {
+ "A1MINI": "A1M",
+ "X1CARBON": "X1C",
+}
+
+
+def resolve_printer_model(raw):
+ """Map a configured model name to a ``MODEL_MAPPING`` key, or ``None``.
+
+ Never guesses: anything that is not a supported model or a known alias of
+ one returns ``None``, and callers refuse to slice rather than pick a
+ profile for a different printer.
+ """
+ if not isinstance(raw, str):
+ return None
+ squashed = "".join(ch for ch in raw.upper() if ch not in " -_")
+ if squashed.startswith("BAMBULAB"):
+ squashed = squashed[len("BAMBULAB") :]
+ if squashed in MODEL_MAPPING:
+ return squashed
+ return _MODEL_ALIASES.get(squashed)
+
+
+def supported_models_text():
+ """``"P1P, P1S, ..."`` for error messages."""
+ return ", ".join(MODEL_MAPPING)
+
def apply_config(cfg):
"""Apply a configuration dictionary to the runtime state.
@@ -491,15 +521,37 @@ def _expected_fingerprint():
def fingerprint_sha256(der_cert):
"""Hex SHA-256 of a DER-encoded certificate, or None if no cert."""
- import hashlib
+ from bambu_cli.tlspin import fingerprint_sha256 as _fp
- if not der_cert:
- return None
- return hashlib.sha256(der_cert).hexdigest()
+ return _fp(der_cert)
+
+
+TIMEOUT_CONFIG_KEYS = ("network_timeout", "slicer_timeout", "command_timeout", "upload_timeout")
+
+
+def config_timeout_problem(value):
+ """Why ``value`` is not a usable timeout (positive, finite seconds), or None."""
+ import math
+
+ if isinstance(value, bool):
+ return f"{value!r} is not a number of seconds"
+ try:
+ seconds = float(value)
+ except (TypeError, ValueError):
+ return f"{value!r} is not a number of seconds"
+ if not math.isfinite(seconds) or seconds <= 0:
+ return f"{value!r} must be a positive number of seconds"
+ return None
def _timeout_from(args, key, default):
- """Resolve a timeout from CLI args, then config, then the default."""
+ """Resolve a timeout from CLI args, then config, then the default.
+
+ CLI flags are validated by argparse (``positive_seconds``). A bad config
+ value is not fatal here -- this runs before any command handler, where an
+ exception would surface as a traceback -- so it warns and uses the default;
+ ``config validate`` reports it as an error.
+ """
from bambu_cli.argutils import namespace_get as _namespace_get
from bambu_cli.context import current_config
@@ -511,7 +563,10 @@ def _timeout_from(args, key, default):
if cfg:
val = cfg.get(key)
if val is not None:
- return float(val)
+ problem = config_timeout_problem(val)
+ if problem is None:
+ return float(val)
+ logger.warning(f"⚠️ Ignoring config '{key}': {problem}. Using the default of {default:g}s.")
return default
diff --git a/bambu_cli/constants.py b/bambu_cli/constants.py
index de538b7..cbe77f9 100644
--- a/bambu_cli/constants.py
+++ b/bambu_cli/constants.py
@@ -78,6 +78,11 @@ def __getattr__(name: str) -> str:
DEFAULT_MAX_DOWNLOAD_MB = 2048
MAX_DOWNLOAD_FILENAME_LENGTH = 160
DNS_CACHE_TTL = 300
+SLICE_INFO_MAX_READ_BYTES = 65536 # 64 KB limit for .3mf slice_info.config
+
+# Bambu printer firmware error codes
+BAMBU_ERROR_FILE_NOT_FOUND = 83935248 # 0x0500C010
+BAMBU_ERROR_FILE_NOT_FOUND_HEX = "0x0500C010"
# Physical safety bounds for slice / print options (Bambu-class FDM printers).
# Nozzle: ambient through high-temp engineering filaments (~300 °C); reject absurd values.
diff --git a/bambu_cli/context.py b/bambu_cli/context.py
index f610390..bd7a5de 100644
--- a/bambu_cli/context.py
+++ b/bambu_cli/context.py
@@ -33,34 +33,56 @@ def _stream_host_port(camera_port: str, default: str = "1985") -> str:
return default
-def _coerce_insecure_tls(value: Any) -> bool:
- """Strictly resolve the ``insecure_tls`` opt-out, failing CLOSED.
+_TRUE_WORDS = frozenset({"true", "1", "yes", "on"})
+_FALSE_WORDS = frozenset({"false", "0", "no", "off", ""})
- Unlike ``camera_direct_only`` (a security opt-*in*, where ``bool()`` on a
- truthy string harmlessly errs toward the safer, more-locked-down state),
- ``insecure_tls`` is a security opt-*out*: any accidental truthiness disables
- TLS validation and is fail-*open*. A hand-edited ``"insecure_tls": "false"``
- is a truthy ``str`` and would silently disable certificate validation while
- the user believes it is off.
-
- So enable it only for the literal boolean ``True`` (JSON ``true``). The
- common string spellings ``"true"``/``"1"``/``"yes"``/``"on"`` are also
- accepted for convenience, since a user who typed them clearly meant to opt
- in. Every other value — including any non-bool type — is treated as ``False``
- (validation stays on) and warns, because a config that meant to disable TLS
- but did not is the fail-safe outcome; one that meant to keep it on and
- accidentally disabled it is not.
+
+def _coerce_config_bool(key: str, value: Any, *, default: bool, unreadable: bool) -> bool:
+ """Read a boolean config key strictly.
+
+ ``bool()`` is wrong for hand-edited JSON: ``bool("false")`` is True. JSON
+ booleans, the numbers 0/1 and the usual spellings (true/1/yes/on,
+ false/0/no/off) are honoured; ``null`` means "not set" and gives ``default``. Anything else
+ gives ``unreadable`` -- the safe side for that key -- and warns, so a
+ security switch is never flipped by a value nobody meant.
"""
+ if value is None:
+ return default
if value is True or value is False:
return bool(value)
+ if isinstance(value, int) and value in (0, 1):
+ # JSON 0/1, consistent with the "0"/"1" strings below.
+ return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
- if normalized in {"true", "1", "yes", "on"}:
+ if normalized in _TRUE_WORDS:
return True
- if normalized in {"false", "0", "no", "off", ""}:
+ if normalized in _FALSE_WORDS:
return False
from bambu_cli.logging_utils import logger
+ logger.warning(
+ f"⚠️ Ignoring non-boolean {key!r} value {value!r} in config; using {str(unreadable).lower()}. "
+ f"Use a JSON boolean ({key}: true/false)."
+ )
+ return unreadable
+
+
+def _coerce_insecure_tls(value: Any) -> bool:
+ """Strictly resolve the ``insecure_tls`` opt-out, failing CLOSED.
+
+ ``insecure_tls`` is a security opt-*out*: any accidental truthiness disables
+ TLS validation. A hand-edited ``"insecure_tls": "false"`` is a truthy
+ ``str`` and would silently disable certificate validation while the user
+ believes it is off. So only a clear true enables it; anything unreadable
+ keeps validation on (and warns).
+ """
+ if value is True or value is False:
+ return bool(value)
+ if isinstance(value, str) and value.strip().lower() in _TRUE_WORDS | _FALSE_WORDS:
+ return value.strip().lower() in _TRUE_WORDS
+ from bambu_cli.logging_utils import logger
+
logger.warning(
f"⚠️ Ignoring non-boolean 'insecure_tls' value {value!r} in config; "
"TLS certificate validation stays ENABLED. Use a JSON boolean "
@@ -124,7 +146,16 @@ def from_config(cls, cfg: dict[str, Any]) -> Settings:
orca_slicer = _expand_path(cfg.get("orca_slicer", default_orca))
profiles_dir = _expand_path(cfg.get("profiles_dir", default_profiles))
- printer_model = cfg.get("model", cfg.get("printer_model", "P1P")).upper()
+ raw_model = cfg.get("model", cfg.get("printer_model", "P1P"))
+ # Resolve product names ("A1 mini" -> "A1M"). An unrecognised name is kept
+ # as typed, never replaced with a default: the slicer refuses it by name
+ # rather than producing G-code for a different printer.
+ try:
+ from bambu_cli.config import resolve_printer_model
+
+ printer_model = resolve_printer_model(raw_model) or str(raw_model).strip().upper()
+ except ImportError: # pragma: no cover -- config is always importable
+ printer_model = str(raw_model).strip().upper()
nozzle_size = str(cfg.get("nozzle", cfg.get("nozzle_size", "0.4")))
camera_port = cfg.get("camera_port", default_camera_port)
host_port = _stream_host_port(camera_port)
@@ -136,8 +167,8 @@ def from_config(cls, cfg: dict[str, Any]) -> Settings:
username=cfg.get("username", "bblp"),
mqtt_port=cfg.get("mqtt_port", 8883),
# Strict, fail-CLOSED coercion: a JSON string ("false"/"no"/…) must
- # NOT sneak through truthy and disable TLS validation. Contrast
- # camera_direct_only below, a security opt-in where bool() is safe.
+ # NOT sneak through truthy and disable TLS validation. The camera
+ # switches below use the same strict reader.
insecure_tls=_coerce_insecure_tls(cfg.get("insecure_tls", False)),
cert_fingerprint=cfg.get("cert_fingerprint"),
orca_slicer=orca_slicer,
@@ -149,12 +180,16 @@ def from_config(cls, cfg: dict[str, Any]) -> Settings:
camera_port=camera_port,
camera_stream_url=camera_stream_url,
# Deliberately a sticky config key (unlike allow_private_ips below): it is a
- # security opt-in, so it must survive across invocations. bool() so a JSON
- # string cannot sneak through truthy.
- camera_direct_only=bool(cfg.get("camera_direct_only", False)),
- # Sticky opt-in for the unpinned Docker streamer. Default false: snapshot
- # no longer falls through to Docker unless the user asked.
- camera_allow_streamer=bool(cfg.get("camera_allow_streamer", False)),
+ # security opt-in, so an unreadable value keeps it ON.
+ camera_direct_only=_coerce_config_bool(
+ "camera_direct_only", cfg.get("camera_direct_only"), default=False, unreadable=True
+ ),
+ # Sticky opt-in for the unpinned Docker streamer. It used bool(), so
+ # "camera_allow_streamer": "false" turned the unpinned path ON; an
+ # unreadable value now keeps it OFF.
+ camera_allow_streamer=_coerce_config_bool(
+ "camera_allow_streamer", cfg.get("camera_allow_streamer"), default=False, unreadable=False
+ ),
# Always false from config: private-IP downloads are a per-invocation
# CLI override only (``--allow-private-ips``), never a sticky config key.
allow_private_ips=False,
diff --git a/bambu_cli/contracts/models.py b/bambu_cli/contracts/models.py
index 08785ac..ac2b808 100644
--- a/bambu_cli/contracts/models.py
+++ b/bambu_cli/contracts/models.py
@@ -69,22 +69,29 @@ class FilamentSettings:
@dataclass(frozen=True)
class AmsTray:
- slot: float | None = None
- active: bool | None = None
+ slot: int | None = None
+ type: str | None = None
+ color: str | None = None
+ remain: int | None = None
empty: bool | None = None
+ active: bool | None = None
@dataclass(frozen=True)
class AmsUnit:
- id: float | None = None
- humidity: float | None = None
+ id: int | None = None
+ humidity: int | None = None
temp: float | None = None
trays: list[AmsTray] | None = None
@dataclass(frozen=True)
class AmsState:
+ keep_none: ClassVar[frozenset[str]] = frozenset({"external_tray"})
+
+ active_tray: int | None = None
units: list[AmsUnit] | None = None
+ external_tray: AmsTray | None = None
@dataclass(frozen=True)
@@ -101,7 +108,6 @@ class PrinterState:
wifi_signal: str | None = None
sw_ver: str | None = None
hw_ver: str | None = None
- ams: AmsState | None = spec(default=None, description="Normalised AMS state (present when AMS is attached).")
# ---------------------------------------------------------------------------
@@ -171,9 +177,9 @@ class Status(Contract):
),
requires_keys=("gcode_state", "mc_percent", "bed_temper", "nozzle_temper"),
)
- ams: dict[str, Any] | None = spec(
+ ams: AmsState | None = spec(
default=None,
- description="Normalised AMS trays for --ams-mapping; null when the printer has no AMS.",
+ description="Normalised AMS trays and external spool for --ams-mapping; null when the printer has no AMS.",
)
@@ -297,6 +303,7 @@ class Print(Contract):
printed: bool | None = None
dry_run: bool | None = None
next_command: list[str] | None = None
+ plate: int | None = spec(default=None, description="Plate of the 3MF sent to print (Metadata/plate_.gcode).")
@dataclass(frozen=True)
@@ -503,6 +510,7 @@ class JobOk(Contract):
print_started: bool | None = None
dry_run: bool | None = None
copies_ignored: bool | None = None
+ plate: int | None = spec(default=None, description="Plate of a pre-sliced 3MF chosen for printing.")
@dataclass(frozen=True)
diff --git a/bambu_cli/download/downloader.py b/bambu_cli/download/downloader.py
index c48094c..320adf6 100644
--- a/bambu_cli/download/downloader.py
+++ b/bambu_cli/download/downloader.py
@@ -1,15 +1,18 @@
"""The `download` command: HTTP fetch loop, redirects, HTML resolution, limits."""
+import http.client
import os
import tempfile
import urllib.error
import urllib.request
+import zipfile
from typing import cast
-from urllib.parse import urlparse
+from urllib.parse import unquote, urlparse
from bambu_cli.argutils import namespace_get as _namespace_get
from bambu_cli.constants import (
DOWNLOAD_TIMEOUT,
+ DOWNLOADABLE_EXTENSIONS,
EXIT_COMMAND_ERROR,
EXIT_FILE_ERROR,
EXIT_NETWORK_ERROR,
@@ -20,6 +23,7 @@
from bambu_cli.download.naming import (
_download_filename_with_extension,
_download_target_filename,
+ _file_extension,
_filename_from_content_disposition,
_sanitize_download_filename,
)
@@ -36,7 +40,7 @@
from bambu_cli.fsutil import _download_partial_path, _noncolliding_path, _portable_basename, _remove_partial_file
from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials
from bambu_cli.logging_utils import logger, safe_log_error
-from bambu_cli.netsafety import build_safe_opener, polite_open, user_agent_for_url
+from bambu_cli.netsafety import BlockedAddressError, build_safe_opener, polite_open, user_agent_for_url
from bambu_cli.paths import exception_for_message as _exception_for_message
from bambu_cli.paths import expand_path as _expand_path
from bambu_cli.paths import path_for_message as _path_for_message
@@ -524,7 +528,46 @@ def _cleanup_reserved():
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
- _remove_partial_file(archive_path)
+ preserve_archive = False
+ try:
+ with zipfile.ZipFile(archive_path) as zf:
+ models_in_archive = 0
+ for info in zf.infolist():
+ if not info.is_dir() and info.file_size > 0:
+ ext = _file_extension(_portable_basename(info.filename))
+ if ext in DOWNLOADABLE_EXTENSIONS:
+ models_in_archive += 1
+ if models_in_archive > 1:
+ preserve_archive = True
+ break
+ except Exception:
+ pass
+
+ if preserve_archive:
+ # The name is URL-derived, so it goes through the same
+ # sanitizer as every other write path: control characters,
+ # Windows-illegal characters and reserved device stems out,
+ # length capped. Skipping it left os.replace to fail with
+ # ENAMETOOLONG on a long URL path -- swallowed below, which
+ # silently stranded the archive under its hidden temp name.
+ canonical_name = _sanitize_download_filename(
+ _portable_basename(unquote(urlparse(url).path)) or "archive.zip"
+ )
+ if not canonical_name.lower().endswith(".zip"):
+ canonical_name = _download_filename_with_extension(
+ canonical_name, "archive.zip", fallback_name="archive.zip"
+ )
+ target_archive_path = _noncolliding(os.path.join(outdir, canonical_name))
+ try:
+ os.replace(archive_path, target_archive_path)
+ archive_path = target_archive_path
+ except OSError as exc:
+ logger.debug(f"Could not rename preserved archive to {canonical_name}: {exc}")
+ logger.info(
+ f"📦 Archive contains multiple model files; preserved archive at {_path_for_message(archive_path)}"
+ )
+ else:
+ _remove_partial_file(archive_path)
partial_path = None
logger.info(f"✅ Downloaded: {_path_for_message(extracted_path)} ({size // 1024}KB)")
_record_download_success(
@@ -593,12 +636,23 @@ def _cleanup_reserved():
http_status=e.code,
path=outpath,
)
- except urllib.error.URLError as e:
+ except (urllib.error.URLError, http.client.HTTPException) as e:
_remove_partial_file(partial_path)
_cleanup_reserved()
- err_msg = str(e.reason) if hasattr(e, "reason") else str(e)
- if "Security Error" in err_msg:
- message = f"SSRF Security Violation Blocked: {err_msg}"
+ # urllib wraps a connect-time error in a second URLError, so the guard's
+ # refusal arrives either bare or as ``.reason``.
+ blocked = e if isinstance(e, BlockedAddressError) else getattr(e, "reason", None)
+ if isinstance(blocked, BlockedAddressError):
+ from bambu_cli.context import current_settings
+
+ message = (
+ f"Refusing to download from {blocked.host}: it resolves only to private or local addresses "
+ f"({', '.join(blocked.addresses)})."
+ )
+ # Multicast stays refused even with the override, so only suggest it
+ # when it would actually help.
+ if not current_settings().allow_private_ips:
+ message += " Pass --allow-private-ips to allow a LAN download."
emit_json_error(
args,
"download",
@@ -609,9 +663,8 @@ def _cleanup_reserved():
normalized_source=normalized_source_report,
download_url=_redact_url_credentials(url),
path=outpath,
+ blocked_addresses=blocked.addresses,
)
- safe_log_error(message)
- abort("", exit_code=EXIT_COMMAND_ERROR)
message = f"Network error during download: {e}"
logger.info(" Please check your internet connection or verify the domain name resolves correctly.")
emit_json_error(
diff --git a/bambu_cli/download/extract.py b/bambu_cli/download/extract.py
index ce687d0..48c1d76 100644
--- a/bambu_cli/download/extract.py
+++ b/bambu_cli/download/extract.py
@@ -2,6 +2,19 @@
import os
import zipfile
+import zlib
+
+# What zipfile raises for damaged compressed data behind intact headers. None of
+# these is a BadZipFile, so without this list they escaped as "Unexpected error".
+# lzma is optional in some Python builds; bz2 corruption surfaces as OSError,
+# which callers already report as an extract failure.
+_CORRUPT_DATA_ERRORS: tuple[type[BaseException], ...] = (zlib.error, EOFError)
+try:
+ import lzma
+
+ _CORRUPT_DATA_ERRORS += (lzma.LZMAError,)
+except ImportError: # pragma: no cover -- lzma ships with CPython builds we support
+ pass
from urllib.parse import unquote, urlparse
from bambu_cli.argutils import namespace_get as _namespace_get
@@ -122,6 +135,8 @@ def _cleanup_reservation():
return outpath, filename, member_filename, size
except zipfile.BadZipFile as exc:
raise ValueError("Downloaded ZIP archive is invalid or corrupt.") from exc
+ except _CORRUPT_DATA_ERRORS as exc:
+ raise ValueError(f"ZIP member data is corrupt and cannot be decompressed: {exc}") from exc
except NotImplementedError as exc:
# zipfile raises NotImplementedError("compression type N") for members it
# can't decompress, e.g. Deflate64 (type 9) archives produced by Windows
diff --git a/bambu_cli/download/naming.py b/bambu_cli/download/naming.py
index 8284a52..d0e37c7 100644
--- a/bambu_cli/download/naming.py
+++ b/bambu_cli/download/naming.py
@@ -16,7 +16,7 @@
WINDOWS_RESERVED_FILENAMES,
)
from bambu_cli.errors import abort
-from bambu_cli.fsutil import _portable_basename
+from bambu_cli.fsutil import _has_command_injection_chars, _portable_basename
from bambu_cli.jsonio import redact_url_credentials as _redact_url_credentials
from bambu_cli.logging_utils import logger
@@ -100,7 +100,7 @@ def _sanitize_download_filename(filename):
each of which the printer-side check then rejected.
"""
filename = _portable_basename(filename)
- filename = re.sub(r'[\x00-\x1f<>:"/\\|?*]', "_", filename).strip(" .")
+ filename = re.sub(r'[\x00-\x1f\x7f<>:"/\\|?*]', "_", filename).strip(" .")
if filename in (".", "..") or not filename:
return "model.stl"
if _reserved_device_stem(filename):
@@ -140,16 +140,6 @@ def _filename_from_content_disposition(value):
return _sanitize_download_filename(filename) if filename else None
-def _has_command_injection_chars(value):
- """True if *value* contains CR, LF, or NUL.
-
- FTP and MQTT command lines are delimited by these characters; embedding them
- in a filename or G-code payload can smuggle a second command. Shared by
- ``_safe_remote_name`` and ``cmd_gcode`` validation.
- """
- return any(c in (value or "") for c in ("\r", "\n", "\0"))
-
-
def _safe_remote_name(filename):
"""Reject names that are unsafe for printer-side files.
@@ -166,7 +156,7 @@ def _safe_remote_name(filename):
return None
if _has_command_injection_chars(filename):
return None
- if any(c in filename for c in '<>:"/\\|?*'):
+ if any(c in filename for c in '<>:"/\\|?*\x7f'):
return None
if filename != filename.strip(" ."):
return None
diff --git a/bambu_cli/download/validation.py b/bambu_cli/download/validation.py
index af79322..e1bf233 100644
--- a/bambu_cli/download/validation.py
+++ b/bambu_cli/download/validation.py
@@ -46,11 +46,17 @@ def _normalize_url_input(value):
def _is_http_url(value):
+ if any(ord(c) < 32 or ord(c) == 127 or c.isspace() for c in (value or "")):
+ return False
parsed = urlparse(value)
return parsed.scheme.lower() in ("http", "https") and bool(parsed.netloc)
def _validate_http_url_or_exit(value):
+ if any(ord(c) < 32 or ord(c) == 127 or c.isspace() for c in (value or "")):
+ message = "Invalid URL: contains whitespace or control characters"
+ safe_log_error(message)
+ abort(message, exit_code=EXIT_COMMAND_ERROR, failed_step="validate")
parsed = urlparse(value)
if parsed.scheme.lower() not in ("http", "https"):
message = f"Invalid URL scheme: {parsed.scheme or 'none'}"
diff --git a/bambu_cli/errors.py b/bambu_cli/errors.py
index 0ed9d48..e0903ac 100644
--- a/bambu_cli/errors.py
+++ b/bambu_cli/errors.py
@@ -29,6 +29,7 @@
"FileError",
"TimeoutError",
"PrinterStatusIncomplete",
+ "CommandUnconfirmed",
"PrinterError",
"abort",
]
@@ -158,6 +159,18 @@ class PrinterStatusIncomplete(TimeoutError):
failed_step = "status"
+class CommandUnconfirmed(TimeoutError):
+ """A printer command was published but its MQTT acknowledgement never came.
+
+ The printer may already have run it, so it is never re-sent automatically
+ and must not be reported as "not sent": a caller that retries could run a
+ move or an extrusion twice. ``extra`` carries ``sent=True`` and
+ ``acknowledged=False``.
+ """
+
+ failed_step = "mqtt"
+
+
class PrinterError(BambuError):
"""Raised when the printer reports a job/device error."""
diff --git a/bambu_cli/fsutil.py b/bambu_cli/fsutil.py
index 5059312..45e7b70 100644
--- a/bambu_cli/fsutil.py
+++ b/bambu_cli/fsutil.py
@@ -25,12 +25,22 @@
__all__ = [
"_download_partial_path",
+ "_has_command_injection_chars",
"_noncolliding_path",
"_portable_basename",
"_remove_partial_file",
]
+def _has_command_injection_chars(value):
+ """True if *value* contains CR, LF, or NUL.
+
+ FTP and MQTT command lines are delimited by these characters; embedding them
+ in a filename or G-code payload can smuggle a second command.
+ """
+ return any(c in (value or "") for c in ("\r", "\n", "\0"))
+
+
def _portable_basename(path):
"""Return a basename while treating both POSIX and Windows separators as separators."""
return os.path.basename(str(path or "").replace("\\", "/"))
diff --git a/bambu_cli/interactive/core.py b/bambu_cli/interactive/core.py
index 7a9e745..fa78307 100644
--- a/bambu_cli/interactive/core.py
+++ b/bambu_cli/interactive/core.py
@@ -289,10 +289,11 @@ def read_loaded_ams_material(args: argparse.Namespace, on_active_slot=None) -> s
if not data:
return None
ams = parse_ams(data)
- if not ams or not ams.get("units"):
+ if not ams:
return None
active = ams.get("active_tray")
loaded_type: str | None = None
+ ext_tray = ams.get("external_tray")
if active is not None:
# An active slot is reported: trust ONLY the tray marked active, and
# scan ALL units to find it (the active tray may be in a later unit
@@ -300,7 +301,7 @@ def read_loaded_ams_material(args: argparse.Namespace, on_active_slot=None) -> s
# unit's fallback shadow the real active tray). The active tray's
# absolute slot index equals ``active`` (parse_ams sets it that way),
# so record it for the ams_mapping used when feeding from the AMS.
- for unit in ams["units"]:
+ for unit in ams.get("units") or []:
for tray in unit.get("trays", []):
if tray.get("empty"):
continue
@@ -311,11 +312,14 @@ def read_loaded_ams_material(args: argparse.Namespace, on_active_slot=None) -> s
break
if loaded_type is not None:
break
+ elif ext_tray and ext_tray.get("active") and not ext_tray.get("empty"):
+ # External spool is currently active (tray_now was 254; 255 means nothing loaded)
+ loaded_type = ext_tray.get("type")
else:
- # Nothing is marked active (no tray_now, or an external-spool
- # sentinel): fall back to the first non-empty tray. Do NOT record a
- # slot — without a firm active tray we won't feed from the AMS.
- for unit in ams["units"]:
+ # Nothing is marked active: fall back to the first non-empty tray
+ # (or external tray if present). Do NOT record a slot — without a firm
+ # active tray we won't feed from the AMS.
+ for unit in ams.get("units") or []:
for tray in unit.get("trays", []):
if tray.get("empty"):
continue
@@ -323,6 +327,8 @@ def read_loaded_ams_material(args: argparse.Namespace, on_active_slot=None) -> s
break
if loaded_type is not None:
break
+ if loaded_type is None and ext_tray and not ext_tray.get("empty"):
+ loaded_type = ext_tray.get("type")
return match_material_preset(loaded_type)
except Exception:
# AMS detection is a nicety — never let it block or break the wizard.
diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py
index 068cc3f..a5b64e8 100644
--- a/bambu_cli/job/orchestrate.py
+++ b/bambu_cli/job/orchestrate.py
@@ -47,6 +47,7 @@
)
from bambu_cli.job.steps import JobSteps
from bambu_cli.job.support import (
+ _choose_plate,
_emit_job_failure,
_emit_job_ok,
_exit_code_from_error,
@@ -374,6 +375,14 @@ def _run_job(ctx, args, steps=None):
if _ext_would_slice(ext):
summary["would_slice"] = True
+ if _namespace_get(args, "plate") not in (None, 1):
+ _job_fail(
+ args,
+ summary,
+ "validate",
+ EXIT_COMMAND_ERROR,
+ "--plate only applies to a pre-sliced 3MF; a model platecli slices has a single plate (1).",
+ )
predicted_remote_name = _predicted_sliced_remote_name(source_path, getattr(args, "copies", 1))
if _safe_remote_name(predicted_remote_name) is None:
_job_fail(
@@ -444,6 +453,8 @@ def _run_job(ctx, args, steps=None):
f"Refusing to upload file with unsafe name: {_name_for_message(remote_candidate)!r}",
)
summary["remote_name"] = remote_candidate
+ if ext == ".3mf":
+ summary["plate"] = _choose_plate(args, summary, source_path)
if not _is_http_url(source) and getattr(args, "output", None):
logger.warning(
"⚠️ --output is only used when job/send downloads, extracts, or slices; ignoring it for a printer-ready local file."
@@ -529,7 +540,7 @@ def _run_job(ctx, args, steps=None):
logger.info(f"✅ Job uploaded {remote_name}; print not started because --upload-only was set.")
if getattr(args, "json", False):
summary["status"] = "uploaded"
- summary["next_command"] = _print_next_command(args, remote_name)
+ summary["next_command"] = _print_next_command(args, remote_name, plate=summary.get("plate"))
_emit_job_ok(summary)
return printable_path
@@ -537,7 +548,7 @@ def _run_job(ctx, args, steps=None):
logger.warning(f"⚠️ Job uploaded {remote_name}, but print was not started. Re-run with --confirm to print.")
if getattr(args, "json", False):
summary["status"] = "uploaded_not_printed"
- summary["next_command"] = _print_next_command(args, remote_name)
+ summary["next_command"] = _print_next_command(args, remote_name, plate=summary.get("plate"))
_emit_job_ok(summary)
return printable_path
@@ -554,6 +565,7 @@ def _run_job(ctx, args, steps=None):
timelapse=getattr(args, "timelapse", False),
skip_bed_leveling=getattr(args, "skip_bed_leveling", False),
skip_flow_cali=getattr(args, "skip_flow_cali", False),
+ plate=summary.get("plate") or getattr(args, "plate", None),
json=False,
)
)
diff --git a/bambu_cli/job/payload.py b/bambu_cli/job/payload.py
index f88d279..f9740cb 100644
--- a/bambu_cli/job/payload.py
+++ b/bambu_cli/job/payload.py
@@ -8,7 +8,8 @@
from bambu_cli.argutils import namespace_get as _namespace_get
-def _print_next_command(args, basename):
+def _print_next_command(args, basename, plate=None):
+ """The ``print`` command that finishes a job; ``plate`` overrides ``args.plate``."""
command = ["print", basename, "--confirm", "--json"]
if _namespace_get(args, "use_ams", False):
command.append("--use-ams")
@@ -21,19 +22,26 @@ def _print_next_command(args, basename):
command.append("--skip-bed-leveling")
if _namespace_get(args, "skip_flow_cali", False):
command.append("--skip-flow-cali")
+ plate = plate or _namespace_get(args, "plate")
+ if plate and plate != 1: # plate 1 is print's default; do not add noise
+ command.extend(["--plate", str(plate)])
return command
def generate_print_payload(
- basename, use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=True, flow_cali=True
+ basename, use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=True, flow_cali=True, plate=1
):
- """Generate the JSON payload for the print command."""
+ """Generate the JSON payload for the print command.
+
+ ``plate`` picks ``Metadata/plate_.gcode`` inside the 3MF. It was always
+ plate 1, which the printer rejects when only another plate is sliced.
+ """
# Files are stored in /sdcard/model/ on the printer (referenced via the url field below).
encoded_basename = quote(basename, safe="")
print_cmd = {
"sequence_id": "0",
"command": "project_file",
- "param": "Metadata/plate_1.gcode",
+ "param": f"Metadata/plate_{int(plate)}.gcode",
"subtask_name": basename,
"url": f"file:///sdcard/model/{encoded_basename}",
"bed_type": "auto",
diff --git a/bambu_cli/job/predict.py b/bambu_cli/job/predict.py
index 92393fb..2543492 100644
--- a/bambu_cli/job/predict.py
+++ b/bambu_cli/job/predict.py
@@ -66,8 +66,8 @@ def _slice_args_for_job(filepath, args, output_dir):
filament=getattr(args, "filament", "PLA Basic"),
infill=getattr(args, "infill", 15),
pattern=getattr(args, "pattern", "3dhoneycomb"),
- nozzle_temp=getattr(args, "nozzle_temp", 220),
- bed_temp=getattr(args, "bed_temp", 60),
+ nozzle_temp=getattr(args, "nozzle_temp", None),
+ bed_temp=getattr(args, "bed_temp", None),
supports=getattr(args, "supports", False),
support_type=getattr(args, "support_type", None),
support_interface_density=getattr(args, "support_interface_density", None),
diff --git a/bambu_cli/job/support.py b/bambu_cli/job/support.py
index 0e3c994..2527aa7 100644
--- a/bambu_cli/job/support.py
+++ b/bambu_cli/job/support.py
@@ -30,6 +30,7 @@ def _exit_code_from_error(exc, default=EXIT_COMMAND_ERROR):
from bambu_cli.download import (
_safe_remote_name,
)
+from bambu_cli.fsutil import _portable_basename
from bambu_cli.utils import emit_json
@@ -79,6 +80,44 @@ def _job_fail(args, summary, failed_step, exit_code, message):
abort("", exit_code=exit_code)
+def _choose_plate(args, summary, path):
+ """Pick the plate of a pre-sliced 3MF to print, or fail the job before upload.
+
+ The print payload names ``Metadata/plate_.gcode``; it was always plate 1,
+ which the printer rejects when only another plate is sliced, and a 3MF with
+ no sliced plate at all was uploaded and sent to print anyway.
+ """
+ from bambu_cli.slicer.estimate import sliced_plates
+
+ name = _portable_basename(path)
+ requested = _namespace_get(args, "plate")
+ plates = sliced_plates(path)
+ if not plates:
+ _job_fail(
+ args,
+ summary,
+ "validate",
+ EXIT_FILE_ERROR,
+ f"{name} is not sliced: it contains no Metadata/plate_.gcode. Export a sliced 3MF from "
+ "OrcaSlicer or Bambu Studio, or give job the model's STL/STEP to slice.",
+ )
+ listed = ", ".join(str(n) for n in plates)
+ if requested is not None:
+ if requested not in plates:
+ _job_fail(
+ args,
+ summary,
+ "validate",
+ EXIT_COMMAND_ERROR,
+ f"Plate {requested} is not sliced in {name}; sliced plates: {listed}.",
+ )
+ return requested
+ chosen = 1 if 1 in plates else plates[0]
+ if len(plates) > 1:
+ logger.warning(f"⚠️ {name} has sliced plates {listed}; printing plate {chosen}. Pass --plate to choose.")
+ return chosen
+
+
def _validate_predicted_remote_name_or_fail(args, summary, remote_name, message_prefix):
"""Fail a job before work starts if a known printer filename is unsafe."""
if remote_name is not None and _safe_remote_name(remote_name) is None:
diff --git a/bambu_cli/netsafety.py b/bambu_cli/netsafety.py
index 23a19a0..7c9278f 100644
--- a/bambu_cli/netsafety.py
+++ b/bambu_cli/netsafety.py
@@ -26,6 +26,89 @@
# chain redirects up to urllib's built-in default of 10.
MAX_DOWNLOAD_REDIRECT_HOPS = 5
+# Request headers that carry credentials for the host they were sent to. They
+# are dropped when a redirect lands on a different host. Nothing in platecli
+# sets them today; the guard is here so the first caller that does cannot leak
+# a token to whatever host a redirect happens to name. Capitalized the way
+# urllib.request.Request stores header names.
+CROSS_HOST_STRIPPED_HEADERS = ("Authorization", "Proxy-authorization", "Cookie")
+
+_IPV6_COMPAT_NET = ipaddress.IPv6Network("::/96")
+# RFC 6052 well-known NAT64 prefix. It sits inside the reserved ::/8 block, so
+# without an explicit unwrap every download on a DNS64 (IPv6-only) network
+# would be refused as "reserved".
+_IPV6_NAT64_NET = ipaddress.IPv6Network("64:ff9b::/96")
+
+
+def _is_safe_ip(
+ ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address,
+ allow_private: bool = False,
+) -> bool:
+ """Validate whether an IP is safe to connect to for download requests.
+
+ Multicast (IPv4 and IPv6) is unconditionally blocked regardless of
+ allow_private: TCP cannot connect to multicast addresses and doing so can
+ trigger amplification / network errors.
+
+ When allow_private is False (default):
+ - Rejects private, loopback, link-local, unspecified, and reserved addresses.
+ - Unwraps IPv4-mapped IPv6 (::ffff:0:0/96) and checks the embedded IPv4.
+ - Unwraps deprecated IPv4-compatible IPv6 (::/96) and checks the embedded IPv4.
+ - Unwraps 6to4 (2002::/16) and checks the embedded IPv4.
+ - Unwraps NAT64 (64:ff9b::/96) and checks the embedded IPv4, so public
+ hosts stay reachable on DNS64 networks while private ones are refused.
+ - Rejects deprecated site-local IPv6 (fec0::/10), which stdlib calls global.
+ """
+ if ip_obj.is_multicast:
+ return False
+
+ if isinstance(ip_obj, ipaddress.IPv6Address):
+ if ip_obj.ipv4_mapped:
+ return _is_safe_ip(ip_obj.ipv4_mapped, allow_private=allow_private)
+ if ip_obj in _IPV6_COMPAT_NET or ip_obj in _IPV6_NAT64_NET:
+ return _is_safe_ip(ipaddress.IPv4Address(int(ip_obj) & 0xFFFFFFFF), allow_private=allow_private)
+ sixtofour = getattr(ip_obj, "sixtofour", None)
+ if sixtofour is not None and not _is_safe_ip(sixtofour, allow_private=allow_private):
+ return False
+
+ if allow_private:
+ return True
+
+ if (
+ ip_obj.is_loopback
+ or ip_obj.is_link_local
+ or ip_obj.is_site_local
+ or ip_obj.is_unspecified
+ or ip_obj.is_reserved
+ ):
+ return False
+ return bool(ip_obj.is_global and not ip_obj.is_private)
+
+ # IPv4Address
+ if allow_private:
+ return True
+
+ if ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_private or ip_obj.is_reserved or ip_obj.is_unspecified:
+ return False
+ return bool(ip_obj.is_global)
+
+
+class BlockedAddressError(urllib.error.URLError):
+ """Every address ``host`` resolved to is private, local or reserved.
+
+ A refusal by the SSRF guard, not a network failure: retrying cannot help,
+ ``--allow-private-ips`` can. Distinct from the generic URLError raised when
+ public addresses exist but none of them answered.
+ """
+
+ def __init__(self, host, addresses):
+ self.host = host
+ self.addresses = list(addresses)
+ super().__init__(
+ f"No safe/reachable IP addresses found for {host}: it resolves only to non-public "
+ f"addresses ({', '.join(self.addresses)})"
+ )
+
def _get_safe_connection(host, port, timeout, source_address):
"""Perform DNS resolution and validate IP is not internal/reserved."""
@@ -53,19 +136,22 @@ def _get_safe_connection(host, port, timeout, source_address):
except socket.gaierror as e:
raise urllib.error.URLError(f"DNS resolution failed for {host}: {e}") from e
+ refused: list[str] = []
+ tried_public = False
for res in addr_info:
ip = res[4][0]
try:
ip_obj = ipaddress.ip_address(ip)
- if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped:
- ip_obj = ip_obj.ipv4_mapped
from bambu_cli.context import current_settings
- if not current_settings().allow_private_ips and not ip_obj.is_global:
- logger.warning(f"Security Error: Refusing connection to non-public IP ({ip}) for {host}")
+ if not _is_safe_ip(ip_obj, allow_private=current_settings().allow_private_ips):
+ logger.warning(f"Refusing connection to non-public IP ({ip}) for {host}")
+ if str(ip) not in refused:
+ refused.append(str(ip))
continue
except ValueError:
continue
+ tried_public = True
# Connect directly to the validated IP to prevent TOCTOU/DNS rebinding
try:
@@ -78,6 +164,8 @@ def _get_safe_connection(host, port, timeout, source_address):
with _dns_cache_lock:
_dns_cache.pop(cache_key, None)
+ if refused and not tried_public:
+ raise BlockedAddressError(host, refused)
raise urllib.error.URLError(f"Could not connect to {host}: No safe/reachable IP addresses found")
@@ -122,6 +210,16 @@ class SafeHTTPRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
hop_count = getattr(req, "_bambu_redirect_hops", 0) + 1
if hop_count > MAX_DOWNLOAD_REDIRECT_HOPS:
+ # Close the intermediate response ourselves before bailing out.
+ # HTTPRedirectHandler.http_error_302 calls this method *first* and
+ # only reaches its own ``fp.read(); fp.close()`` afterwards, so
+ # raising here means nothing else ever closes fp -- the 3xx
+ # response's socket would be left for the garbage collector.
+ if fp is not None:
+ try:
+ fp.close()
+ except Exception:
+ pass
raise urllib.error.URLError(
f"Too many redirects: exceeded the {MAX_DOWNLOAD_REDIRECT_HOPS}-hop "
f"limit while fetching {_redact_url_credentials(req.full_url)}"
@@ -129,6 +227,17 @@ def redirect_request(self, req, fp, code, msg, headers, newurl):
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
new_req._bambu_redirect_hops = hop_count # type: ignore[attr-defined]
+ if _host_of(newurl) != _host_of(req.full_url):
+ # urllib copies every request header onto the redirected
+ # request, whatever host it names. Credentials scoped to the
+ # first host must not follow a redirect to a second one, and
+ # the User-Agent policy is per host (honest token for
+ # first-party API hosts, browser-shaped elsewhere), so re-pick
+ # it for the host we are actually about to talk to.
+ for name in CROSS_HOST_STRIPPED_HEADERS:
+ new_req.remove_header(name)
+ if req.has_header("User-agent"):
+ new_req.add_header("User-Agent", user_agent_for_url(newurl))
return new_req
@@ -243,13 +352,27 @@ def _throttle_host(host, sleep=time.sleep) -> None:
if not host:
return
with _last_request_lock:
- previous = _last_request_at.get(host)
now = time.monotonic()
+ if len(_last_request_at) >= 1000:
+ cutoff = now - 60.0
+ stale = [h for h, t in _last_request_at.items() if t <= cutoff]
+ for h in stale:
+ del _last_request_at[h]
+ if len(_last_request_at) >= 1000:
+ _last_request_at.clear()
+ previous = _last_request_at.get(host)
wait = 0.0 if previous is None else MIN_HOST_REQUEST_INTERVAL - (now - previous)
wait = max(wait, 0.0)
_last_request_at[host] = now + wait
if wait > 0:
- sleep(min(wait, MIN_HOST_REQUEST_INTERVAL))
+ # Sleep the full computed wait. It can legitimately exceed
+ # MIN_HOST_REQUEST_INTERVAL: `previous` is a *scheduled* timestamp, so
+ # when calls queue up each one lands further in the future and the gap
+ # to close is correspondingly larger. Clamping to one interval made
+ # queued callers under-sleep and burst past the throttle. The upper
+ # bound that matters is MAX_RETRY_AFTER_WAIT, applied here so a
+ # pathological backlog still cannot park a caller indefinitely.
+ sleep(min(wait, MAX_RETRY_AFTER_WAIT))
def _retry_after_seconds(err) -> float:
@@ -286,13 +409,30 @@ def polite_open(opener, req, timeout=None, sleep=time.sleep):
def build_safe_opener():
- """Build a urllib opener that only uses safe handlers and restricts schemes."""
+ """Build a urllib opener that only uses safe handlers and restricts schemes.
+
+ ``HTTPErrorProcessor`` is not optional. ``OpenerDirector`` is built by hand
+ here (rather than via ``build_opener``) so no unvetted handler sneaks in,
+ and that means every default handler must be added back deliberately. The
+ error processor is the one that routes a non-2xx response into
+ ``parent.error()`` -- which is what makes ``HTTPDefaultErrorHandler`` raise
+ ``HTTPError`` and what makes ``SafeHTTPRedirectHandler`` run at all.
+ Without it the opener silently returns 3xx/4xx/5xx responses as if they
+ were successful bodies: redirects are never followed, ``HTTPError`` is
+ never raised, and both the redirect hop cap and the 429/503 retry in
+ :func:`polite_open` become dead code. Regression-tested end to end (through
+ a live local server, not a mocked handler) in tests/test_netsafety_opener.py.
+ """
opener = urllib.request.OpenerDirector()
# Disable environment proxies so target IP validation cannot be bypassed by
- # asking a proxy to fetch an internal/private address on our behalf.
+ # asking a proxy to fetch an internal/private address on our behalf. Note
+ # that an empty proxy map registers no *_open methods, so this handler adds
+ # nothing to the chain; env proxies are already absent because the chain is
+ # assembled by hand. It stays as an explicit statement of that intent.
opener.add_handler(urllib.request.ProxyHandler({}))
opener.add_handler(urllib.request.UnknownHandler())
opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+ opener.add_handler(urllib.request.HTTPErrorProcessor())
opener.add_handler(SafeHTTPRedirectHandler())
opener.add_handler(SafeHTTPHandler())
opener.add_handler(SafeHTTPSHandler())
diff --git a/bambu_cli/printer.py b/bambu_cli/printer.py
index 92be226..e97e230 100644
--- a/bambu_cli/printer.py
+++ b/bambu_cli/printer.py
@@ -9,6 +9,7 @@
from typing import Any
from bambu_cli.context import set_printer_factory
+from bambu_cli.fsutil import _has_command_injection_chars
from bambu_cli.protocols import ftps as ftps_protocol
from bambu_cli.protocols import mqtt as mqtt_protocol
@@ -145,6 +146,9 @@ def upload_file(
sleep=time.sleep,
) -> bool:
"""Upload a file via FTPS as a verified state machine: (fresh|probe) -> (resume|restart) -> transfer -> verify."""
+ if _has_command_injection_chars(remote_path):
+ logger.error(f"Upload failed: unsafe remote path {remote_path!r}")
+ return False
filesize = os.path.getsize(local_path)
max_retries = 3
uploaded_bytes = 0
@@ -258,6 +262,9 @@ def download_file(
truncated transfer; size mismatch must fail without replacing the
destination (mirrors upload_file's post-STOR verification).
"""
+ if _has_command_injection_chars(remote_path):
+ logger.error(f"Download failed: unsafe remote path {remote_path!r}")
+ return False
import tempfile
directory = os.path.dirname(os.path.abspath(local_path)) or "."
@@ -299,6 +306,9 @@ def download_file(
def delete_file(self, remote_path: str, timeout: float | None = None) -> bool:
"""Delete a file from the printer via FTPS."""
+ if _has_command_injection_chars(remote_path):
+ logger.error(f"Delete failed: unsafe remote path {remote_path!r}")
+ return False
try:
with self.get_ftp_client(timeout=timeout or self.ftps_timeout) as ftp:
ftp.delete(remote_path)
@@ -309,6 +319,9 @@ def delete_file(self, remote_path: str, timeout: float | None = None) -> bool:
def list_files(self, remote_dir: str = "/model/", timeout: float | None = None) -> list | None:
"""List files in a remote directory via FTPS."""
+ if _has_command_injection_chars(remote_dir):
+ logger.error(f"List files failed: unsafe remote directory {remote_dir!r}")
+ return None
try:
with self.get_ftp_client(timeout=timeout or self.ftps_timeout) as ftp:
return ftp.nlst(remote_dir)
diff --git a/bambu_cli/protocols/mqtt_cmd.py b/bambu_cli/protocols/mqtt_cmd.py
index ce85d1d..037302b 100644
--- a/bambu_cli/protocols/mqtt_cmd.py
+++ b/bambu_cli/protocols/mqtt_cmd.py
@@ -75,14 +75,17 @@ def send_command(
if session is not None:
return session.send_command(payload, timeout, retries)
+ # Shared by every attempt: once the payload has been handed to a client it
+ # may already have reached the printer, so no later attempt publishes it
+ # again. Retrying is only safe while nothing has gone out yet.
+ published = [False]
for attempt in range(retries + 1):
client = _factory(printer)
client.user_data_set({})
publish_done = threading.Event()
success = [False]
- published = [False]
- def on_connect(client, userdata, flags, rc, properties=None, published=published):
+ def on_connect(client, userdata, flags, rc, properties=None, published=published, publish_done=publish_done):
if rc == 0:
if not published[0]:
published[0] = True
@@ -91,33 +94,58 @@ def on_connect(client, userdata, flags, rc, properties=None, published=published
logger.error(f"Connection failed: rc={rc}")
publish_done.set()
- def on_publish(client, userdata, mid, reason_code=None, properties=None):
+ def on_publish(
+ client, userdata, mid, reason_code=None, properties=None, success=success, publish_done=publish_done
+ ):
success[0] = True
publish_done.set()
client.on_connect = on_connect
client.on_publish = on_publish
+ acked = False
+ error = None
try:
_connect(printer, client)
client.loop_start()
- if publish_done.wait(timeout):
- return success[0]
- if attempt < retries:
- logger.warning(f"MQTT command timeout on attempt {attempt + 1}. Retrying...")
- _sleep_fn(2**attempt)
+ acked = publish_done.wait(timeout)
except (OSError, ssl.SSLError) as e:
- if attempt < retries:
- logger.warning(f"MQTT command attempt {attempt + 1} failed: {e}. Retrying...")
- _sleep_fn(2**attempt)
- else:
- logger.error(f"MQTT command error: {e}")
+ error = e
finally:
+ # Decide only after the network thread is stopped, so a publish
+ # racing the timeout is still seen below.
_teardown_mqtt_client(client)
+ # Re-check after teardown: a PUBACK (or a refusal) that landed while the
+ # loop was being stopped still counts; the expired wait() result alone
+ # reported an acknowledged command as unacknowledged.
+ if acked or publish_done.is_set():
+ return success[0]
+ if published[0]:
+ raise _unconfirmed(timeout)
+ if attempt < retries:
+ if error is not None:
+ logger.warning(f"MQTT command attempt {attempt + 1} failed: {error}. Retrying...")
+ else:
+ logger.warning(f"MQTT command timeout on attempt {attempt + 1}. Retrying...")
+ _sleep_fn(2**attempt)
+ elif error is not None:
+ logger.error(f"MQTT command error: {error}")
return False
+def _unconfirmed(timeout):
+ """The error for a command that went out without an acknowledgement."""
+ from bambu_cli.errors import CommandUnconfirmed
+
+ return CommandUnconfirmed(
+ f"The command was sent, but the printer did not acknowledge it within {timeout:g}s. "
+ "It may already have run; check `plate status` before sending it again.",
+ extra={"sent": True, "acknowledged": False},
+ next_command=["status", "--json"],
+ )
+
+
def status_is_complete(data):
"""True when ``data`` is a full snapshot rather than an incremental delta."""
return isinstance(data, dict) and all(key in data for key in _REQUIRED_STATUS_KEYS)
diff --git a/bambu_cli/protocols/mqtt_monitor.py b/bambu_cli/protocols/mqtt_monitor.py
index df69230..477b378 100644
--- a/bambu_cli/protocols/mqtt_monitor.py
+++ b/bambu_cli/protocols/mqtt_monitor.py
@@ -3,10 +3,14 @@
from __future__ import annotations
import json
+import ssl
import sys
import threading
+import time
+from bambu_cli.constants import EXIT_NETWORK_ERROR
from bambu_cli.contracts import StatusEvent
+from bambu_cli.errors import PrinterConnectionError
from bambu_cli.logging_utils import logger
from bambu_cli.protocols.mqtt_cmd import (
TERMINAL_GCODE_STATES,
@@ -74,13 +78,19 @@ def monitor_status(args, printer):
userdata: dict = {}
client.user_data_set(userdata)
+ # Set once the broker accepted us (or a report arrived, which proves it).
+ connected = threading.Event()
+ refused_rc: list = [None]
+
def on_connect(client, userdata, flags, rc, properties=None):
if rc == 0:
+ connected.set()
client.subscribe(f"device/{printer.serial}/report")
push = json.dumps({"pushing": {"sequence_id": get_sequence_id(), "command": "pushall"}})
client.publish(f"device/{printer.serial}/request", push)
else:
logger.error(f"Connection failed: rc={rc}")
+ refused_rc[0] = rc
received_terminal.set()
last_state = [None]
@@ -88,6 +98,7 @@ def on_connect(client, userdata, flags, rc, properties=None):
merged: dict = {}
def on_message(client, userdata, msg):
+ connected.set()
try:
data = json.loads(msg.payload.decode("utf-8"))
if isinstance(data, dict) and isinstance(data.get("print"), dict):
@@ -143,11 +154,27 @@ def on_message(client, userdata, msg):
client.on_connect = on_connect
client.on_message = on_message
+ # A broker that never answers CONNACK used to leave this loop waiting forever.
+ connect_timeout = float(getattr(printer, "mqtt_timeout", None) or 5.0)
try:
- _connect(printer, client)
+ try:
+ _connect(printer, client)
+ except (OSError, ssl.SSLError) as exc:
+ raise PrinterConnectionError(
+ f"Could not connect to the printer to monitor it: {exc}",
+ exit_code=EXIT_NETWORK_ERROR,
+ failed_step="mqtt",
+ ) from exc
client.loop_start()
+ deadline = time.monotonic() + connect_timeout
while not received_terminal.is_set():
- received_terminal.wait(1.0)
+ received_terminal.wait(1.0 if connected.is_set() else 0.1)
+ if not connected.is_set() and not received_terminal.is_set() and time.monotonic() > deadline:
+ raise PrinterConnectionError(
+ f"The printer did not accept the MQTT connection within {connect_timeout:g}s.",
+ exit_code=EXIT_NETWORK_ERROR,
+ failed_step="mqtt",
+ )
except KeyboardInterrupt:
logger.info("\n🛑 Monitor loop stopped by user.")
finally:
@@ -164,3 +191,11 @@ def on_message(client, userdata, msg):
client.disconnect()
except Exception:
pass
+ if refused_rc[0] is not None:
+ # Exit 0 with no events here used to be indistinguishable from success.
+ raise PrinterConnectionError(
+ f"The printer refused the MQTT connection (rc={refused_rc[0]}); check the access code "
+ "and that LAN mode is on.",
+ exit_code=EXIT_NETWORK_ERROR,
+ failed_step="mqtt",
+ )
diff --git a/bambu_cli/protocols/mqtt_print.py b/bambu_cli/protocols/mqtt_print.py
index 6d90acd..f426fec 100644
--- a/bambu_cli/protocols/mqtt_print.py
+++ b/bambu_cli/protocols/mqtt_print.py
@@ -32,7 +32,13 @@ def execute_print_command(
``command_timeout`` and ``client_factory`` are injectable; defaults are
get_command_timeout() / create_mqtt_client.
"""
- from bambu_cli.constants import EXIT_FILE_ERROR, EXIT_NETWORK_ERROR, EXIT_PRINTER_ERROR, EXIT_TIMEOUT
+ from bambu_cli.constants import (
+ BAMBU_ERROR_FILE_NOT_FOUND,
+ EXIT_FILE_ERROR,
+ EXIT_NETWORK_ERROR,
+ EXIT_PRINTER_ERROR,
+ EXIT_TIMEOUT,
+ )
from bambu_cli.utils import record_error_detail
_factory = _client_factory(client_factory)
@@ -176,7 +182,7 @@ def on_message(client, userdata, msg):
if error_hex:
message += f" (hex {error_hex})"
logger.error(message)
- if print_error[0] == 83935248:
+ if print_error[0] == BAMBU_ERROR_FILE_NOT_FOUND:
logger.info(" File not found on printer SD card. Check filename with 'files' command.")
record_error_detail(
"print",
diff --git a/bambu_cli/protocols/mqtt_session.py b/bambu_cli/protocols/mqtt_session.py
index 34b8b11..6c55a04 100644
--- a/bambu_cli/protocols/mqtt_session.py
+++ b/bambu_cli/protocols/mqtt_session.py
@@ -248,6 +248,14 @@ def get_status(
for attempt in range(retries + 1):
try:
if not self.ensure_connected(timeout):
+ failed_broker = getattr(self, "_connect_failed", False)
+ self._reset_client()
+ if failed_broker:
+ return None
+ if attempt < retries:
+ logger.warning(f"MQTT connection timeout on attempt {attempt + 1}. Retrying...")
+ sleeper(2**attempt)
+ continue
return None
# Background reports may already have assembled a usable
# snapshot. Request a refresh, but do not block or raise
@@ -295,38 +303,61 @@ def get_status(
return self._ready_snapshot(require_complete)
def send_command(self, payload: str, timeout: float, retries: int = 2) -> bool:
+ """Publish ``payload`` once and wait for its PUBACK.
+
+ Retries only cover attempts where nothing was published yet (no
+ connection). Once the command has gone out, a missing acknowledgement
+ raises ``CommandUnconfirmed`` instead of publishing it again: the
+ printer may already have run it.
+ """
sleeper = _sleep_fn(self._sleep)
with self._op_lock:
- for attempt in range(retries + 1):
- try:
- with self._state_lock:
- self._pending_payload = payload
- self._command_issued = False
- self._publish_ok = False
- self._publish_event = threading.Event()
- if not self.ensure_connected(timeout):
- with self._state_lock:
- self._pending_payload = None
- return False
- self._issue_pending()
- if self._publish_event.wait(timeout):
- ok = self._publish_ok
- with self._state_lock:
- self._pending_payload = None
- return ok
+ with self._state_lock:
+ self._pending_payload = payload
+ # Reset once per command, not per attempt: this is what stops a
+ # retry (or a reconnect's on_connect) from re-issuing it.
+ self._command_issued = False
+ try:
+ for attempt in range(retries + 1):
+ try:
+ self._publish_ok = False
+ self._publish_event = threading.Event()
+ if not self.ensure_connected(timeout):
+ failed_broker = getattr(self, "_connect_failed", False)
+ self._reset_client()
+ if failed_broker:
+ return False
+ if attempt < retries:
+ logger.warning(f"MQTT command connection timeout on attempt {attempt + 1}. Retrying...")
+ sleeper(2**attempt)
+ continue
+ return False
+ self._issue_pending()
+ if self._publish_event.wait(timeout):
+ return self._publish_ok
+ except (OSError, ssl.SSLError) as exc:
+ self._reset_client()
+ if not self._command_issued:
+ if attempt < retries:
+ logger.warning(f"MQTT command attempt {attempt + 1} failed: {exc}. Retrying...")
+ sleeper(2**attempt)
+ continue
+ logger.error(f"MQTT command error: {exc}")
+ return False
+ if self._command_issued:
+ # A PUBACK that landed just after the wait timed out still counts.
+ if self._publish_event.is_set():
+ return self._publish_ok
+ from bambu_cli.protocols.mqtt_cmd import _unconfirmed
+
+ raise _unconfirmed(timeout)
if attempt < retries:
logger.warning(f"MQTT command timeout on attempt {attempt + 1}. Retrying...")
sleeper(2**attempt)
- except (OSError, ssl.SSLError) as exc:
- self._reset_client()
- if attempt < retries:
- logger.warning(f"MQTT command attempt {attempt + 1} failed: {exc}. Retrying...")
- sleeper(2**attempt)
- else:
- logger.error(f"MQTT command error: {exc}")
- with self._state_lock:
- self._pending_payload = None
- return False
+ return False
+ finally:
+ with self._state_lock:
+ self._pending_payload = None
def get_version(self, timeout: float, retries: int = 1) -> Any:
sleeper = _sleep_fn(self._sleep)
diff --git a/bambu_cli/protocols/mqtt_tls.py b/bambu_cli/protocols/mqtt_tls.py
index c2e2fce..3906ee4 100644
--- a/bambu_cli/protocols/mqtt_tls.py
+++ b/bambu_cli/protocols/mqtt_tls.py
@@ -51,11 +51,18 @@ def wrap_socket(self, sock, *args, **kwargs):
kwargs = dict(kwargs)
kwargs["do_handshake_on_connect"] = False
tls_sock = super().wrap_socket(sock, *args, **kwargs)
- tls_sock.do_handshake()
- from bambu_cli.tlspin import verify_cert_fingerprint
+ try:
+ tls_sock.do_handshake()
+ from bambu_cli.tlspin import verify_cert_fingerprint
- verify_cert_fingerprint(tls_sock.getpeercert(binary_form=True), self.expected_fingerprint)
- return tls_sock
+ verify_cert_fingerprint(tls_sock.getpeercert(binary_form=True), self.expected_fingerprint)
+ return tls_sock
+ except Exception:
+ try:
+ tls_sock.close()
+ except Exception:
+ pass
+ raise
def pinning_ssl_context(expected_fingerprint: str) -> PinningSSLContext:
@@ -109,7 +116,7 @@ def socket(self):
def probe_cert_fingerprint(host, port=990, timeout=5):
"""Open a TLS connection purely to read the server cert's SHA-256 fingerprint."""
- from bambu_cli.config import fingerprint_sha256
+ from bambu_cli.tlspin import fingerprint_sha256
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
diff --git a/bambu_cli/setup_cmd/common.py b/bambu_cli/setup_cmd/common.py
index 1281f21..b9d0ee8 100644
--- a/bambu_cli/setup_cmd/common.py
+++ b/bambu_cli/setup_cmd/common.py
@@ -9,7 +9,7 @@
import sys
import tempfile
-from bambu_cli.config import CONFIG_PATH, MODEL_MAPPING
+from bambu_cli.config import CONFIG_PATH
from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_FILE_ERROR
from bambu_cli.errors import abort
from bambu_cli.logging_utils import logger
@@ -23,12 +23,24 @@ def _config_path():
return CONFIG_PATH
-def _normalize_model(model, default="P1P"):
- model = (model or default or "P1P").strip().upper()
- if model not in MODEL_MAPPING:
- logger.warning(f"⚠️ Unknown model '{model}'. Defaulting to 'P1P'.")
- return "P1P"
- return model
+def _normalize_model(model, default=None):
+ """Return the ``MODEL_MAPPING`` key for ``model`` (or ``default`` when blank).
+
+ Raises ``ValueError`` for anything unrecognised. It used to warn and return
+ "P1P", which then sliced 256 mm-bed G-code for whatever printer it was.
+ """
+ from bambu_cli.config import resolve_printer_model, supported_models_text
+
+ raw = model if (model is not None and str(model).strip()) else default
+ if raw is None or not str(raw).strip():
+ raise ValueError(f"Printer model is required. Supported models: {supported_models_text()}.")
+ resolved = resolve_printer_model(str(raw))
+ if resolved is None:
+ raise ValueError(
+ f"Unknown printer model '{str(raw).strip()}'. Supported models: {supported_models_text()} "
+ '("A1 mini" and "X1 Carbon" are accepted too).'
+ )
+ return resolved
def _normalize_nozzle(nozzle):
diff --git a/bambu_cli/setup_cmd/config_cmd.py b/bambu_cli/setup_cmd/config_cmd.py
index 106a69b..441bb21 100644
--- a/bambu_cli/setup_cmd/config_cmd.py
+++ b/bambu_cli/setup_cmd/config_cmd.py
@@ -23,6 +23,8 @@
"insecure-tls",
"printer-ip",
"serial",
+ "printer-model",
+ "timeouts",
"access-code",
"access-code-inline-conflict",
"access-code-permissions",
diff --git a/bambu_cli/setup_cmd/migrate.py b/bambu_cli/setup_cmd/migrate.py
index 163747c..6739063 100644
--- a/bambu_cli/setup_cmd/migrate.py
+++ b/bambu_cli/setup_cmd/migrate.py
@@ -33,6 +33,20 @@ def _secure_write_json_no_secret_backup(path, config):
_scrub_config_backup(path)
+def _access_code_file_problem(path):
+ """Why the access-code file at ``path`` is not usable, or None."""
+ from bambu_cli.config import _access_code_value_problem
+
+ try:
+ with open(path, encoding="utf-8-sig") as f:
+ value = f.read().strip()
+ except OSError as exc:
+ return f"access_code_file {_display_path(path)} could not be read ({_exception_for_message(exc)})"
+ if _access_code_value_problem(value):
+ return f"access_code_file {_display_path(path)} does not hold a real access code"
+ return None
+
+
def migrate_access_code(config_path=None, access_code_file_path=None):
"""Move an inline ``access_code`` in config.json into a separate,
0600-protected ``access_code_file`` and remove the inline value.
@@ -64,13 +78,35 @@ def migrate_access_code(config_path=None, access_code_file_path=None):
"config_path": _display_path(expanded_config),
"access_code_file": _display_path(_expand_path(existing_file)),
}
+ expanded_existing = _expand_path(existing_file)
+ if not os.path.exists(expanded_existing):
+ # The configured file was never written: the inline value is the only
+ # working copy, so move it there rather than deleting it.
+ _secure_write_text(expanded_existing, str(inline_code).rstrip("\n") + "\n")
+ del config["access_code"]
+ _secure_write_json_no_secret_backup(path, config)
+ return {
+ "status": "migrated",
+ "reason": "Wrote the inline access_code to the configured access_code_file, which did not exist.",
+ "config_path": _display_path(expanded_config),
+ "access_code_file": _display_path(expanded_existing),
+ }
+ problem = _access_code_file_problem(expanded_existing)
+ if problem:
+ # Deleting the inline key here would leave no usable code at all.
+ return {
+ "status": "error",
+ "reason": f"{problem}; the inline access_code was kept. Fix the file, then run the migration again.",
+ "config_path": _display_path(expanded_config),
+ "access_code_file": _display_path(expanded_existing),
+ }
del config["access_code"]
_secure_write_json_no_secret_backup(path, config)
return {
"status": "migrated",
"reason": "Removed stale inline access_code; access_code_file was already configured.",
"config_path": _display_path(expanded_config),
- "access_code_file": _display_path(_expand_path(existing_file)),
+ "access_code_file": _display_path(expanded_existing),
}
if not inline_code:
diff --git a/bambu_cli/setup_cmd/preflight.py b/bambu_cli/setup_cmd/preflight.py
index 4ef97c6..bc075d3 100644
--- a/bambu_cli/setup_cmd/preflight.py
+++ b/bambu_cli/setup_cmd/preflight.py
@@ -56,6 +56,43 @@ def _file_permission_check(path, name):
return _preflight_result("ok", name, f"{display} permissions are restricted.", {"mode": oct(mode)})
+def _printer_model_check(cfg):
+ """Error when config.json names a printer platecli has no profile mapping for.
+
+ The slicer refuses such a model too; reporting it here lets `config validate`
+ catch it before a print is attempted.
+ """
+ from bambu_cli.config import resolve_printer_model, supported_models_text
+
+ raw = cfg.get("model", cfg.get("printer_model", "P1P"))
+ resolved = resolve_printer_model(raw)
+ if resolved is None:
+ return _preflight_result(
+ "error",
+ "printer-model",
+ f"Unknown printer model {raw!r}. Supported models: {supported_models_text()}. "
+ 'Slicing is refused until "model" names your printer.',
+ )
+ return _preflight_result("ok", "printer-model", f"Printer model is {resolved}.")
+
+
+def _timeouts_check(cfg):
+ """Error when a *_timeout key in config.json is not positive, finite seconds."""
+ from bambu_cli.config import TIMEOUT_CONFIG_KEYS, config_timeout_problem
+
+ problems = []
+ for key in TIMEOUT_CONFIG_KEYS:
+ if key in cfg:
+ problem = config_timeout_problem(cfg[key])
+ if problem:
+ problems.append(f"{key}: {problem}")
+ if problems:
+ return _preflight_result(
+ "error", "timeouts", "Invalid timeout in config.json (the default is used instead): " + "; ".join(problems)
+ )
+ return _preflight_result("ok", "timeouts", "Configured timeouts are valid.")
+
+
def collect_preflight_checks():
"""Collect local install/config checks without contacting the printer."""
from bambu_cli.context import current_config, current_settings
@@ -121,6 +158,9 @@ def collect_preflight_checks():
else:
checks.append(_preflight_result("ok", "serial", "Printer serial is configured."))
+ checks.append(_printer_model_check(cfg))
+ checks.append(_timeouts_check(cfg))
+
access_code = cfg.get("access_code")
has_inline_code = bool(access_code)
access_file = cfg.get("access_code_file")
diff --git a/bambu_cli/setup_cmd/wizard.py b/bambu_cli/setup_cmd/wizard.py
index 6c094de..ad5d354 100644
--- a/bambu_cli/setup_cmd/wizard.py
+++ b/bambu_cli/setup_cmd/wizard.py
@@ -58,10 +58,14 @@ def _service_info_address(info):
def _parse_mdns_printer_identity(name):
- """Return (serial, model) from a Bambu mDNS service name."""
+ """Return (serial, model) from a Bambu mDNS service name.
+
+ ``model`` is None when the name does not carry one: the setup prompt then
+ asks instead of pre-filling a guess.
+ """
match = re.search(r"BBLP-([^._]+)", name, re.IGNORECASE)
service_id = match.group(1).upper() if match else ""
- detected_model = "P1P"
+ detected_model = None
serial = service_id or "YOUR_SERIAL"
for model in sorted(MODEL_MAPPING, key=len, reverse=True):
@@ -77,6 +81,40 @@ def _parse_mdns_printer_identity(name):
return serial, detected_model
+def _prompt_printer_model(args, detected_model):
+ """Ask for the printer model until the answer is a supported one.
+
+ There is no silent default. A detected model is offered as the default
+ answer; with nothing detected the user has to type one.
+ """
+ if detected_model:
+ logger.info(f"Printer model detected: {detected_model}")
+ default_hint = f" [default: {detected_model}]" if detected_model else ""
+ for _attempt in range(3):
+ answer = _prompt_text(f"Printer model (P1P/P1S/X1C/X1E/X1/A1/A1M){default_hint}: ", args)
+ try:
+ return _normalize_model(answer, detected_model)
+ except ValueError as exc:
+ logger.error(str(exc))
+ logger.error("No supported printer model entered; setup was not saved.")
+ abort("", exit_code=EXIT_CONFIG_ERROR)
+
+
+def _existing_setup_config():
+ """The current config.json as a dict, or {} when absent, unreadable or not an object."""
+ from bambu_cli.config import read_config_json
+
+ try:
+ data = read_config_json(_config_path())
+ except (OSError, ValueError):
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _same_path(first, second):
+ return bool(first and second) and os.path.abspath(_expand_path(first)) == os.path.abspath(_expand_path(second))
+
+
def _cmd_setup_noninteractive(args):
from bambu_cli.config import _DEFAULT_ORCA, _DEFAULT_PROFILES
@@ -100,6 +138,35 @@ def _cmd_setup_noninteractive(args):
_setup_json_error(args, message, access_code_env=access_code_env)
abort("", exit_code=EXIT_CONFIG_ERROR)
+ # Update mode: with a config already present, values not given on the
+ # command line are kept, so `plate setup --printer-ip ` or
+ # `--access-code-env CODE` changes just that (as the docs describe). The
+ # printer-specific values -- address, model, nozzle, access code, pin -- are
+ # only inherited for the same printer (serial unchanged); a pin or code from
+ # another printer would fail closed or authenticate against the wrong one.
+ existing = _existing_setup_config() if _setup_args_provided(args) else {}
+ same_printer = bool(existing) and (
+ not serial or str(serial).strip().upper() == str(existing.get("serial", "")).strip().upper()
+ )
+ inherited = existing if same_printer else {}
+ rotating_configured_file = False
+ if same_printer:
+ ip = ip or existing.get("printer_ip")
+ serial = serial or existing.get("serial")
+ configured_file = existing.get("access_code_file")
+ if not access_code and not access_code_file:
+ if configured_file:
+ access_code_file = configured_file
+ elif existing.get("access_code"):
+ access_code = str(existing["access_code"])
+ elif access_code and not access_code_file and configured_file:
+ access_code_file = configured_file
+ # A new code for the file this printer already uses is a rotation, not a
+ # clobber of some unrelated secret.
+ rotating_configured_file = bool(access_code and _same_path(access_code_file, configured_file))
+ expanded_access_code_file = _validate_setup_access_code_file(args, access_code_file)
+ model_value = _namespace_get(args, "model") or inherited.get("model")
+
missing = []
if not ip:
missing.append("--printer-ip")
@@ -107,6 +174,10 @@ def _cmd_setup_noninteractive(args):
missing.append("--serial")
if not access_code and not access_code_file:
missing.append("--access-code, --access-code-env, or --access-code-file")
+ if not model_value:
+ # No default: the model picks the machine profile, and a guessed one
+ # slices G-code for another printer's bed.
+ missing.append("--model")
if missing:
message = "Non-interactive setup is missing required values: " + ", ".join(missing)
logger.error(message)
@@ -160,14 +231,15 @@ def _cmd_setup_noninteractive(args):
config = _build_setup_config(
ip=ip,
serial=serial,
- model=_normalize_model(_namespace_get(args, "model"), "P1P"),
- nozzle=_normalize_nozzle(_namespace_get(args, "nozzle")),
+ model=_normalize_model(model_value),
+ nozzle=_normalize_nozzle(_namespace_get(args, "nozzle") or inherited.get("nozzle")),
access_code=access_code,
access_code_file=access_code_file,
- orca_slicer=_namespace_get(args, "orca_slicer") or _DEFAULT_ORCA,
- profiles_dir=_namespace_get(args, "profiles_dir") or _DEFAULT_PROFILES,
- cert_fingerprint=_namespace_get(args, "cert_fingerprint"),
- insecure_tls=bool(_namespace_get(args, "insecure_tls", False)),
+ # Local install paths belong to this machine, not the printer.
+ orca_slicer=_namespace_get(args, "orca_slicer") or existing.get("orca_slicer") or _DEFAULT_ORCA,
+ profiles_dir=_namespace_get(args, "profiles_dir") or existing.get("profiles_dir") or _DEFAULT_PROFILES,
+ cert_fingerprint=_namespace_get(args, "cert_fingerprint") or inherited.get("cert_fingerprint"),
+ insecure_tls=bool(_namespace_get(args, "insecure_tls", False)) or inherited.get("insecure_tls") is True,
)
except ValueError as exc:
message = str(exc)
@@ -177,7 +249,7 @@ def _cmd_setup_noninteractive(args):
# Refuse to silently clobber an existing secret file when both --access-code
# and --access-code-file are given (the existence check above is skipped in
# that case). Fails closed on an unreadable existing file too.
- if access_code and access_code_file and not _namespace_get(args, "force", False):
+ if access_code and access_code_file and not rotating_configured_file and not _namespace_get(args, "force", False):
conflict = _access_code_file_overwrite_conflict(expanded_access_code_file, access_code)
if conflict:
message = f"{conflict} (pass --force to overwrite, or point --access-code-file at a new path)."
@@ -318,12 +390,10 @@ def remove_service(self, zc, type_, name):
zc = None
browser = None
+ # argparse (positive_seconds) already validated --scan-timeout.
scan_timeout = 5.0
- if hasattr(args, "scan_timeout") and args.scan_timeout is not None:
- try:
- scan_timeout = float(args.scan_timeout)
- except ValueError:
- pass
+ if getattr(args, "scan_timeout", None) is not None:
+ scan_timeout = float(args.scan_timeout)
try:
zc = Zeroconf()
browser = ServiceBrowser(zc, "_bblp._tcp.local.", MyListener())
@@ -352,7 +422,7 @@ def remove_service(self, zc, type_, name):
if not serial:
logger.error("Serial Number is required.")
abort("", exit_code=EXIT_CONFIG_ERROR)
- detected_model = "P1P"
+ detected_model = None
else:
if not discovered:
logger.error("No printers found. Ensure printer is on the same network.")
@@ -393,11 +463,7 @@ def remove_service(self, zc, type_, name):
access_code = _prompt_interactive_access_code(args)
# Guided prompt for model & nozzle
- logger.info(f"Printer model detected: {detected_model}")
- model_input = _normalize_model(
- _prompt_text(f"Confirm printer model (P1P/P1S/X1C/X1E/X1/A1/A1M) [default: {detected_model}]: ", args),
- detected_model,
- )
+ model_input = _prompt_printer_model(args, detected_model)
nozzle_input = _normalize_nozzle(_prompt_text("Enter nozzle size (0.2, 0.4, 0.6, 0.8) [default: 0.4]: ", args))
access_code_file = _prompt_access_code_file_path(args)
_validate_setup_access_code_file(args, access_code_file)
diff --git a/bambu_cli/slicer/__init__.py b/bambu_cli/slicer/__init__.py
index e01d519..63828ad 100644
--- a/bambu_cli/slicer/__init__.py
+++ b/bambu_cli/slicer/__init__.py
@@ -10,11 +10,11 @@
from bambu_cli.slicer.options import ( # noqa: F401
_coerce_override_value,
_directory_input_message,
- _effective_override_temps,
_generic_section_overrides,
_is_directory_input,
_known_setting_keys,
_normalize_wall_type,
+ _override_safety_problem,
_parse_kv_overrides,
_safe_temp_prefix,
_sliced_output_path,
@@ -26,6 +26,8 @@
_run_orcaslicer,
)
from bambu_cli.slicer.output import ( # noqa: F401
+ SliceOutcome,
+ _classify_slice_result,
_finalize_slice,
_is_valid_sliced_3mf,
)
diff --git a/bambu_cli/slicer/cmd.py b/bambu_cli/slicer/cmd.py
index d442903..af61b13 100644
--- a/bambu_cli/slicer/cmd.py
+++ b/bambu_cli/slicer/cmd.py
@@ -6,9 +6,10 @@
import os
import shutil
import subprocess
+import tempfile
from bambu_cli.argutils import namespace_get as _namespace_get
-from bambu_cli.config import MODEL_MAPPING, get_slicer_timeout
+from bambu_cli.config import MODEL_MAPPING, get_slicer_timeout, supported_models_text
from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_CONFIG_ERROR, EXIT_FILE_ERROR, EXIT_TIMEOUT
from bambu_cli.context import current_settings
from bambu_cli.errors import BambuError
@@ -29,6 +30,7 @@
from bambu_cli.slicer.profiles import (
_create_temp_machine,
_create_temp_profiles,
+ _default_bed_type,
_discover_process_profile,
_profiles_dir_diagnostic,
_slicer_executable_problem,
@@ -119,6 +121,7 @@ def cmd_slice(
tmp_filament = None
tmp_machine = None
result = None
+ work_dir = None
try:
# Auto-convert STEP → STL (OrcaSlicer CLI doesn't support STEP)
@@ -136,7 +139,21 @@ def cmd_slice(
filepath = new_filepath
step_converted = True
- model_info = MODEL_MAPPING.get(settings.printer_model, MODEL_MAPPING["P1P"])
+ model_info = MODEL_MAPPING.get(settings.printer_model)
+ if model_info is None:
+ # Never slice for a guessed printer: G-code built for another
+ # model's bed and start sequence can drive the head into the frame.
+ emit_json_error(
+ args,
+ "slice",
+ EXIT_CONFIG_ERROR,
+ f"Unknown printer model '{settings.printer_model}' in config.json. "
+ f'Supported models: {supported_models_text()}. Set "model" to one of them '
+ "(plate setup --model ...); platecli will not slice for a guessed printer.",
+ failed_step="config",
+ file=filepath,
+ model=settings.printer_model,
+ )
model_code = model_info["token"]
full_model_name = model_info["full_name"]
@@ -186,22 +203,11 @@ def cmd_slice(
outpath = _sliced_output_path(source_filepath, outdir, copies)
outfile = os.path.basename(outpath)
+ # The machine profile carries the bed size and start G-code, so there is
+ # deliberately no fallback to another model or nozzle size: a missing
+ # profile is reported below as a config error.
machine_file = f"{full_model_name} {settings.nozzle_size} nozzle.json"
machine = os.path.join(settings.profiles_dir, "machine", machine_file)
- if not os.path.exists(machine):
- logger.warning(
- f"⚠️ Machine profile '{machine_file}' not found. Trying standard P1P with {settings.nozzle_size} nozzle..."
- )
- machine_file_fallback = f"Bambu Lab P1P {settings.nozzle_size} nozzle.json"
- machine_fallback = os.path.join(settings.profiles_dir, "machine", machine_file_fallback)
- if os.path.exists(machine_fallback):
- machine = machine_fallback
- else:
- logger.warning(
- f"⚠️ Fallback machine profile '{machine_file_fallback}' not found. Using standard P1P 0.4 nozzle."
- )
- machine = os.path.join(settings.profiles_dir, "machine", "Bambu Lab P1P 0.4 nozzle.json")
-
process = os.path.join(settings.profiles_dir, "process", process_file)
filament_dir = os.path.join(settings.profiles_dir, "filament")
@@ -252,6 +258,25 @@ def cmd_slice(
detected_orca_slicer=detected_orca,
)
+ if not os.path.exists(machine):
+ hint, detected_profiles = _profiles_dir_diagnostic(settings.profiles_dir)
+ if hint:
+ logger.info(hint)
+ emit_json_error(
+ args,
+ "slice",
+ EXIT_CONFIG_ERROR,
+ f"Missing machine profile for {full_model_name} with a {settings.nozzle_size} mm nozzle: "
+ f'{_path_for_message(machine)}. Check "model" and "nozzle" in config.json, or point '
+ "profiles_dir at an OrcaSlicer install that has this printer.",
+ failed_step="profiles",
+ file=filepath,
+ profile="machine",
+ path=machine,
+ profiles_dir=settings.profiles_dir,
+ detected_profiles_dir=detected_profiles,
+ )
+
if not os.path.exists(process):
compatible_printer = f"{full_model_name} {settings.nozzle_size} nozzle"
discovered_process = _discover_process_profile(
@@ -299,7 +324,9 @@ def cmd_slice(
)
try:
- tmp_process, tmp_filament = _create_temp_profiles(process, filament, args)
+ tmp_process, tmp_filament = _create_temp_profiles(
+ process, filament, args, bed_type=_default_bed_type(settings.profiles_dir, full_model_name)
+ )
tmp_machine = _create_temp_machine(machine, settings.profiles_dir)
except Exception as exc:
message = f"Failed to prepare OrcaSlicer profiles: {_exception_for_message(exc)}"
@@ -312,6 +339,22 @@ def cmd_slice(
file=filepath,
)
+ # OrcaSlicer also writes plate_1.gcode and result.json into --outputdir,
+ # which was the user's folder (by default the model's own), clobbering
+ # same-named files there. It gets a private directory on the same
+ # filesystem instead, and only the 3MF is moved out.
+ try:
+ work_dir = tempfile.mkdtemp(prefix=".plate-slice-", dir=outdir)
+ except OSError as exc:
+ emit_json_error(
+ args,
+ "slice",
+ EXIT_FILE_ERROR,
+ f"Could not prepare output directory: {_path_for_message(outdir)}: {_exception_for_message(exc)}",
+ failed_step="validate",
+ file=filepath,
+ output=outdir,
+ )
cmd = _build_orcaslicer_cmd(
settings,
args,
@@ -319,7 +362,7 @@ def cmd_slice(
tmp_process.name,
tmp_filament.name,
outfile,
- outdir,
+ work_dir,
copies,
filepath,
)
@@ -327,14 +370,16 @@ def cmd_slice(
layer_height = layer.split(" ")[0]
infill = getattr(args, "infill", 15)
pattern = getattr(args, "pattern", "3dhoneycomb")
- nozzle_temp = getattr(args, "nozzle_temp", 220)
- bed_temp = getattr(args, "bed_temp", 60)
+ nozzle_temp = getattr(args, "nozzle_temp", None)
+ bed_temp = getattr(args, "bed_temp", None)
supports = getattr(args, "supports", False)
filament_name = os.path.basename(filament).replace(".json", "").replace(" @base", "").strip()
settings_summary = (
- f"{filament_name}, {layer_height} layer, {infill}% {pattern}, nozzle {nozzle_temp}°C, bed {bed_temp}°C"
+ f"{filament_name}, {layer_height} layer, {infill}% {pattern}, "
+ f"nozzle {'profile' if nozzle_temp is None else f'{nozzle_temp}°C'}, "
+ f"bed {'profile' if bed_temp is None else f'{bed_temp}°C'}"
)
if copies > 1:
settings_summary += f", {copies} copies"
@@ -384,7 +429,14 @@ def cmd_slice(
orca_slicer=settings.orca_slicer,
output=outpath,
)
+ produced = os.path.join(work_dir, outfile)
+ if os.path.isfile(produced):
+ # Only a file OrcaSlicer actually wrote replaces outpath, so
+ # _finalize_slice's freshness check still catches a stale one.
+ os.replace(produced, outpath)
finally:
+ if work_dir is not None:
+ shutil.rmtree(work_dir, ignore_errors=True)
for tmp_file in (tmp_process, tmp_filament, tmp_machine):
if tmp_file is not None and hasattr(tmp_file, "name"):
try:
diff --git a/bambu_cli/slicer/estimate.py b/bambu_cli/slicer/estimate.py
index 3db9947..cf8c6c6 100644
--- a/bambu_cli/slicer/estimate.py
+++ b/bambu_cli/slicer/estimate.py
@@ -7,11 +7,14 @@
import zipfile
from dataclasses import dataclass
+from bambu_cli.constants import SLICE_INFO_MAX_READ_BYTES
+from bambu_cli.logging_utils import logger
+
_MAX_SECONDS = 2592000 # 30 days
_MAX_GRAMS = 10000.0
GCODE_READ_BYTES = 65536 # 64 KB
-SLICE_INFO_READ_BYTES = 10 * 1024 * 1024 # 10 MB safety limit for XML config (Zip Bomb protection)
+SLICE_INFO_READ_BYTES = SLICE_INFO_MAX_READ_BYTES # 64 KB safety limit for XML config (Zip Bomb protection)
@dataclass(frozen=True)
@@ -20,20 +23,63 @@ class Estimate:
grams: float | None
-def _parse_slice_info(xml_text: str) -> tuple[int | None, float | None]:
+_PLATE_GCODE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
+
+
+def sliced_plates(path: str) -> list[int]:
+ """Plate numbers that have sliced G-code in a .3mf (``Metadata/plate_.gcode``).
+
+ Sorted ascending; ``[]`` for an unsliced project, an unreadable file, or
+ anything that is not a zip. Never raises.
+ """
+ try:
+ with zipfile.ZipFile(path, "r") as zf:
+ names = zf.namelist()
+ except Exception: # noqa: BLE001 -- a probe: "no sliced plates" is the safe answer
+ return []
+ plates = set()
+ for name in names:
+ match = _PLATE_GCODE_RE.match(name.replace("\\", "/"))
+ if match:
+ plates.add(int(match.group(1)))
+ return sorted(plates)
+
+
+def _parse_slice_info(xml_text: str, plate: int | None = None) -> tuple[int | None, float | None]:
"""Parse prediction (seconds) and weight (grams) from slice_info.config XML.
Uses xml.etree.ElementTree to parse bounded metadata XML from .3mf packages.
+ Rejects any XML containing DTD or entity declarations (entity bomb protection).
+ A multi-plate project has one ```` element per plate: the estimate is
+ for ``plate`` (by its ``index`` metadata) or, when None, the first plate.
+ It used to be whichever plate came last.
"""
+ upper = xml_text.upper()
+ if " tuple[int | None, float | None]:
return seconds, grams
-def read_3mf_estimate(path: str) -> Estimate:
+def read_3mf_estimate(path: str, plate: int | None = None) -> Estimate:
"""Parse time/filament estimate from a .3mf file.
Returns Estimate(None, None) on any failure — never raises.
@@ -124,9 +170,17 @@ def read_3mf_estimate(path: str) -> Estimate:
with zf.open(slice_info_name) as fh:
xml_bytes = fh.read(SLICE_INFO_READ_BYTES)
xml_text = xml_bytes.decode("utf-8", errors="replace")
- seconds, grams = _parse_slice_info(xml_text)
+ seconds, grams = _parse_slice_info(xml_text, plate)
if seconds is not None or grams is not None:
return Estimate(seconds, grams)
+ else:
+ # Skipping the primary source is a real (if recoverable)
+ # degradation, not a non-event: say so rather than silently
+ # reporting whatever the gcode fallback happens to find.
+ logger.debug(
+ f"slice_info.config is {info.file_size} bytes, over the "
+ f"{SLICE_INFO_READ_BYTES}-byte cap; falling back to the gcode header"
+ )
# slice_info.config was present but yielded nothing usable
# (malformed XML, or only implausible values). Fall through to
# the gcode header rather than reporting "unknown" -- a truncated
@@ -135,6 +189,8 @@ def read_3mf_estimate(path: str) -> Estimate:
# Fallback: gcode members under Metadata/, in plate order. Keep
# trying later plates if an earlier one carries no usable header.
+ if plate is not None:
+ gcode_members = [n for n in gcode_members if n.replace("\\", "/") == f"Metadata/plate_{plate}.gcode"]
if gcode_members:
gcode_members.sort()
for n in gcode_members:
diff --git a/bambu_cli/slicer/options.py b/bambu_cli/slicer/options.py
index 6cb99b1..5ab9865 100644
--- a/bambu_cli/slicer/options.py
+++ b/bambu_cli/slicer/options.py
@@ -84,9 +84,15 @@ def setting_catalog(profiles_dir: str) -> dict[str, dict[str, Any]]:
``slice --list-settings``. Empty sections mean "no profiles readable here" —
callers degrade rather than fail.
"""
+ # G-code/script keys are refused as overrides (see _override_safety_problem),
+ # so they are not advertised as settable either.
return {
- "process": _known_setting_keys(profiles_dir, "process"),
- "filament": _known_setting_keys(profiles_dir, "filament"),
+ kind: {
+ key: value
+ for key, value in _known_setting_keys(profiles_dir, kind).items()
+ if not _SCRIPT_KEY_RE.search(key)
+ }
+ for kind in ("process", "filament")
}
@@ -159,10 +165,49 @@ def _warn_unknown_keys(overrides: dict[str, str], known: dict[str, Any], kind: s
logger.warning(f"⚠️ Unknown {kind} setting '{key}'{hint} — passing through to OrcaSlicer anyway.")
-def _numeric_values(value: Any) -> list[float]:
- """Best-effort extraction of numbers from a raw override value.
+# Keys whose value is G-code or a program to run. Their contents are never
+# range-checked, so letting them through would sidestep every bound below
+# (``filament_start_gcode=M104 S399`` reached the printer G-code unchanged).
+_SCRIPT_KEY_RE = re.compile(r"gcode|post_process", re.IGNORECASE)
- Accepts ``"999"``, ``"[999]"``, ``[999]``, ``999`` — ignores non-numeric.
+# OrcaSlicer merges every loaded profile into one config, so a machine key sent
+# through --set replaces the printer profile's value: ``printable_area`` changes
+# the bed the model is laid out on. The installed machine profiles name the full
+# set; these are refused even when no profiles can be read.
+_BUILTIN_MACHINE_KEYS = frozenset(
+ {
+ "bed_exclude_area",
+ "extruder_clearance_height_to_lid",
+ "extruder_clearance_height_to_rod",
+ "extruder_clearance_radius",
+ "extruder_offset",
+ "gcode_flavor",
+ "nozzle_diameter",
+ "printable_area",
+ "printable_height",
+ "printer_model",
+ "printer_variant",
+ "z_offset",
+ }
+)
+_BUILTIN_MACHINE_PREFIXES = ("machine_",)
+
+
+# A plain ASCII decimal: what OrcaSlicer reads the same way Python does. float()
+# also accepts "3_5_0", "1e2" and non-ASCII digits, which OrcaSlicer reads
+# differently (or not at all), so the checked and sliced values could differ.
+_PLAIN_NUMBER_RE = re.compile(r"[+-]?[0-9]+(\.[0-9]+)?")
+
+
+def _temperature_values(value: Any) -> list[float] | None:
+ """Every number OrcaSlicer would read from a temperature override.
+
+ Returns ``None`` when any part of the value is not a plain number, so the
+ caller can refuse it: the check has to fail closed. OrcaSlicer reads a
+ comma-separated string as a per-extruder list (``"400,220"`` sets extruder
+ 0 to 400 C) and joins JSON array elements the same way, so both are split
+ here. A bare JSON number, a numeric string, and a flat list of either are
+ accepted; booleans, nested lists, objects and empty values are not.
"""
parsed: Any = value
if isinstance(value, str):
@@ -170,14 +215,27 @@ def _numeric_values(value: Any) -> list[float]:
parsed = json.loads(value)
except ValueError:
parsed = value
+ if not isinstance(parsed, list):
+ # A bare scalar reaches OrcaSlicer as the raw text (see
+ # _coerce_override_value), so check that text, not json's reading
+ # of it ("1e2" decodes to 100.0 but OrcaSlicer reads 1).
+ parsed = value
items = parsed if isinstance(parsed, list) else [parsed]
out: list[float] = []
for item in items:
- try:
+ if isinstance(item, bool):
+ return None
+ if isinstance(item, (int, float)):
out.append(float(item))
- except (TypeError, ValueError):
continue
- return out
+ if not isinstance(item, str):
+ return None
+ for token in item.split(","):
+ token = token.strip()
+ if not _PLAIN_NUMBER_RE.fullmatch(token):
+ return None
+ out.append(float(token))
+ return out or None
def _is_bed_temp_key(key: str) -> bool:
@@ -191,20 +249,20 @@ def _is_bed_temp_key(key: str) -> bool:
return isinstance(key, str) and (key.endswith("_plate_temp") or key.endswith("_plate_temp_initial_layer"))
-def _effective_override_temps(args: argparse.Namespace) -> tuple[list[float], list[float]]:
- """Nozzle and bed temps implied by generic overrides (filament *and* process).
+def _is_temperature_key(key: str) -> bool:
+ """Any setting that holds a temperature: nozzle, bed, chamber, or otherwise."""
+ lowered = key.lower()
+ return "temperature" in lowered or lowered.endswith(("_temp", "_temp_initial_layer"))
- Inspects ``--settings-json`` (both ``"filament"`` and ``"process"``
- sections), ``--set-filament``, and ``--set`` so that temperature keys
- supplied through any of these paths are validated against the same
- printer-safety bounds. Named flags (``--nozzle-temp`` / ``--bed-temp``)
- are range-checked separately.
- """
- from bambu_cli.constants import BED_PLATE_TYPES
- nozzle: list[float] = []
- bed: list[float] = []
- sources: list[dict[str, Any]] = []
+def _override_sections(args: argparse.Namespace) -> list[tuple[str, dict[str, Any]]]:
+ """Every generic override as ``(kind, {key: raw value})``, kind 'process'|'filament'.
+
+ Reads ``--settings-json`` (both sections), ``--set-filament`` and ``--set``.
+ Format errors are reported by the caller's own checks, so they are skipped
+ here rather than raised.
+ """
+ sections: list[tuple[str, dict[str, Any]]] = []
raw_json = _namespace_get(args, "settings_json", None)
if raw_json:
try:
@@ -212,31 +270,74 @@ def _effective_override_temps(args: argparse.Namespace) -> tuple[list[float], li
except ValueError:
blob = {}
if isinstance(blob, dict):
- for section in ("filament", "process"):
- sec = blob.get(section, {})
- if isinstance(sec, dict):
- sources.append(sec)
- try:
- sources.append(_parse_kv_overrides(_namespace_get(args, "set_filament", None), "set-filament"))
- except ValueError:
- pass # format error surfaced elsewhere in validation
- try:
- sources.append(_parse_kv_overrides(_namespace_get(args, "set_process", None), "set"))
- except ValueError:
- pass # format error surfaced elsewhere in validation
- bed_keys = set(BED_PLATE_TYPES) | {f"{plate}_initial_layer" for plate in BED_PLATE_TYPES}
- for src in sources:
- for key, value in src.items():
- if key in ("nozzle_temperature", "nozzle_temperature_initial_layer"):
- nozzle += _numeric_values(value)
- elif key in bed_keys or _is_bed_temp_key(key):
- # Range-check ANY bed-plate temperature key, not just the four
- # legacy plate types. OrcaSlicer 2.2+ added supertack_plate_temp
- # (Cool Plate SuperTack); matching the *_plate_temp[_initial_layer]
- # pattern keeps new bed types inside MAX_BED_TEMP_C without a
- # constants edit each time.
- bed += _numeric_values(value)
- return nozzle, bed
+ for kind in ("filament", "process"):
+ section = blob.get(kind, {})
+ if isinstance(section, dict):
+ sections.append((kind, section))
+ for kind, dest, label in (("filament", "set_filament", "set-filament"), ("process", "set_process", "set")):
+ try:
+ sections.append((kind, _parse_kv_overrides(_namespace_get(args, dest, None), label)))
+ except ValueError:
+ pass
+ return sections
+
+
+def _machine_key_problem(key: str, kind: str, profiles_dir: str) -> str | None:
+ """Refuse a printer (machine) setting sent as a process/filament override."""
+ machine_keys = _known_setting_keys(profiles_dir, "machine") if profiles_dir else {}
+ own_keys = _known_setting_keys(profiles_dir, kind) if profiles_dir else {}
+ is_machine = (key in machine_keys and key not in own_keys) or (
+ key in _BUILTIN_MACHINE_KEYS or key.startswith(_BUILTIN_MACHINE_PREFIXES)
+ )
+ if not is_machine:
+ return None
+ message = (
+ f"'{key}' is a printer (machine) setting and cannot be overridden: it would slice for "
+ "hardware your printer does not have. Change the printer profile in OrcaSlicer instead."
+ )
+ filament_keys = _known_setting_keys(profiles_dir, "filament") if profiles_dir else {}
+ if f"filament_{key}" in filament_keys:
+ message += f" To change it for this print, use --set-filament filament_{key}=..."
+ return message
+
+
+def _override_safety_problem(args: argparse.Namespace) -> str | None:
+ """First reason a generic override is unsafe, or ``None``.
+
+ Temperatures must be plain numbers inside the printer-safety bounds;
+ G-code, scripts and printer (machine) settings cannot be overridden at all.
+ """
+ from bambu_cli.constants import MAX_BED_TEMP_C, MAX_NOZZLE_TEMP_C, MIN_BED_TEMP_C, MIN_NOZZLE_TEMP_C
+ from bambu_cli.context import current_settings
+
+ profiles_dir = current_settings().profiles_dir
+ for kind, section in _override_sections(args):
+ for key, value in section.items():
+ key = str(key)
+ if _SCRIPT_KEY_RE.search(key):
+ return (
+ f"'{key}' cannot be overridden: G-code and post-processing scripts are not "
+ "safety-checked. Edit the profile in OrcaSlicer instead."
+ )
+ machine_problem = _machine_key_problem(key, kind, profiles_dir)
+ if machine_problem:
+ return machine_problem
+ if not _is_temperature_key(key):
+ continue
+ temps = _temperature_values(value)
+ if temps is None:
+ return f"temperature override {key}={value!r} must be a number or a comma-separated list of numbers"
+ if _is_bed_temp_key(key):
+ label, low, high = "bed", MIN_BED_TEMP_C, MAX_BED_TEMP_C
+ elif key.lower().endswith("_delta"):
+ # standby_temperature_delta is an offset and legitimately negative.
+ label, low, high = "nozzle", -MAX_NOZZLE_TEMP_C, MAX_NOZZLE_TEMP_C
+ else:
+ label, low, high = "nozzle", MIN_NOZZLE_TEMP_C, MAX_NOZZLE_TEMP_C
+ for temp in temps:
+ if not (low <= temp <= high):
+ return f"{label} temperature override {key}={temp:g}°C is outside the safe range {low}-{high}°C"
+ return None
def _sliced_output_path(filepath: str, output_dir: str | None = None, copies: int = 1) -> str:
@@ -326,18 +427,9 @@ def _validate_slice_options(args: argparse.Namespace) -> str | None:
if section in blob and not isinstance(blob[section], dict):
return f"--settings-json '{section}' must be an object of key/value overrides"
- # Safety: overrides must not push temps past the printer-safety bounds.
- nozzle_over, bed_over = _effective_override_temps(args)
- for temp in nozzle_over:
- if not (MIN_NOZZLE_TEMP_C <= temp <= MAX_NOZZLE_TEMP_C):
- return (
- f"nozzle temperature override {temp:g}°C is outside the safe range "
- f"{MIN_NOZZLE_TEMP_C}-{MAX_NOZZLE_TEMP_C}°C"
- )
- for temp in bed_over:
- if not (MIN_BED_TEMP_C <= temp <= MAX_BED_TEMP_C):
- return f"bed temperature override {temp:g}°C is outside the safe range {MIN_BED_TEMP_C}-{MAX_BED_TEMP_C}°C"
- return None
+ # Safety: overrides must not push temps past the printer-safety bounds, and
+ # G-code, scripts and printer settings cannot be overridden at all.
+ return _override_safety_problem(args)
def _safe_temp_prefix(value: Any, fallback: str = "tmp", max_length: int = 48) -> str:
diff --git a/bambu_cli/slicer/output.py b/bambu_cli/slicer/output.py
index d105e4e..4a00284 100644
--- a/bambu_cli/slicer/output.py
+++ b/bambu_cli/slicer/output.py
@@ -1,21 +1,64 @@
-"""Sliced .3mf validation and slice-result finalization."""
-
from __future__ import annotations
import argparse
import os
import subprocess
import zipfile
+from enum import Enum, auto
from bambu_cli.argutils import namespace_get as _namespace_get
from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR
-from bambu_cli.errors import abort
+from bambu_cli.errors import FileError, SliceError
from bambu_cli.fsutil import _remove_partial_file
from bambu_cli.logging_utils import logger, safe_log_error
from bambu_cli.paths import exception_for_message as _exception_for_message
from bambu_cli.paths import expand_path as _expand_path
from bambu_cli.paths import path_for_message as _path_for_message
-from bambu_cli.utils import emit_json, emit_json_error
+from bambu_cli.utils import emit_json
+
+
+class SliceOutcome(Enum):
+ SUCCESS = auto()
+ BENIGN_GL_WARNING = auto()
+ STALE_OUTPUT = auto()
+ EMPTY_OUTPUT = auto()
+ CORRUPT_3MF = auto()
+ SLICER_ERROR = auto()
+
+
+def _classify_slice_result(
+ returncode: int,
+ stdout_stderr: str,
+ fresh: bool,
+ file_exists: bool,
+ file_size: int,
+ is_valid_3mf: bool,
+) -> SliceOutcome:
+ """Pure classifier for OrcaSlicer execution outcome.
+
+ Evaluates return code, process output text, freshness, and file validity
+ to categorize the slice outcome into a discrete SliceOutcome enum.
+ """
+ if file_exists and not fresh:
+ return SliceOutcome.STALE_OUTPUT
+
+ blob = (stdout_stderr or "").lower()
+ gl_noise = any(k in blob for k in ("glfw", "glew", "init opengl failed", "skip thumbnail"))
+ real_err = ("nothing to be sliced" in blob) or ("slicing error" in blob)
+
+ if returncode != 0:
+ if file_exists and fresh and file_size > 0 and gl_noise and not real_err and is_valid_3mf:
+ return SliceOutcome.BENIGN_GL_WARNING
+ return SliceOutcome.SLICER_ERROR
+
+ # returncode == 0
+ if not file_exists:
+ return SliceOutcome.SLICER_ERROR
+ if file_size <= 0:
+ return SliceOutcome.EMPTY_OUTPUT
+ if not is_valid_3mf:
+ return SliceOutcome.CORRUPT_3MF
+ return SliceOutcome.SUCCESS
def _is_valid_sliced_3mf(path: str) -> bool:
@@ -81,95 +124,69 @@ def _finalize_slice(
pre_snapshot: tuple[bool, float, int] | None = None,
) -> str:
"""Evaluate the OrcaSlicer result, emit success/error output, and return the .3mf path."""
-
- # A pre-existing *_sliced.3mf at outpath that this run did NOT rewrite must
- # never be accepted as fresh output — otherwise an OrcaSlicer failure whose
- # message lacks the known error markers would "succeed" with a stale artifact
- # (uploadable/printable). Require the file to be newly written this run.
_fresh = _was_written_this_run(outpath, pre_snapshot)
-
- # OrcaSlicer can exit non-zero on a headless GL/thumbnail step even when the slice
- # itself succeeded and a valid .3mf was written. Treat that specific case as success
- # only when the output is a real, non-corrupt 3MF package (not truncated garbage).
- _benign_rc = False
- if result is not None and result.returncode != 0 and _fresh and os.path.exists(outpath):
- try:
- _ok_size = os.path.getsize(outpath) > 0
- except OSError:
- _ok_size = False
- _blob = ((result.stdout or "") + (result.stderr or "")).lower()
- _gl_noise = any(k in _blob for k in ("glfw", "glew", "init opengl failed", "skip thumbnail"))
- _real_err = ("nothing to be sliced" in _blob) or ("slicing error" in _blob)
- _benign_rc = _ok_size and _gl_noise and not _real_err and _is_valid_sliced_3mf(outpath)
- if _benign_rc:
- logger.warning(
- " OrcaSlicer exited non-zero on a headless GL/thumbnail step, but a valid .3mf was produced — continuing."
- )
- # Reject a pre-existing output that this run did not rewrite. Without this,
- # rc==0-with-no-write (OrcaSlicer exits 0 but never exports over an old file)
- # or a benign-GL non-zero exit would accept a STALE *_sliced.3mf as fresh.
- if result is not None and os.path.exists(outpath) and not _fresh:
- message = f"Slicing did not write a new output file; refusing to reuse the stale {_path_for_message(outpath)}"
- emit_json_error(
- args,
- "slice",
- EXIT_COMMAND_ERROR,
- message,
- failed_step="slicer",
- file=filepath,
- output=outpath,
- returncode=(result.returncode if result is not None else -1),
- )
- safe_log_error(message)
- abort("", exit_code=EXIT_COMMAND_ERROR)
- if result is not None and os.path.exists(outpath) and _fresh and (result.returncode == 0 or _benign_rc):
+ file_exists = os.path.exists(outpath)
+ file_size = 0
+ if file_exists:
try:
- size = os.path.getsize(outpath)
+ file_size = os.path.getsize(outpath)
except OSError as exc:
message = f"Could not read sliced output file: {_exception_for_message(exc)}"
- emit_json_error(
- args,
- "slice",
- EXIT_FILE_ERROR,
+ raise FileError(
message,
+ exit_code=EXIT_FILE_ERROR,
failed_step="slicer",
- file=filepath,
- output=outpath,
- )
- safe_log_error(message)
- abort("", exit_code=EXIT_FILE_ERROR)
- if size <= 0:
- _remove_partial_file(outpath)
- message = f"Slicing produced an empty output file: {_path_for_message(outpath)}"
- emit_json_error(
- args,
- "slice",
- EXIT_FILE_ERROR,
- message,
- failed_step="slicer",
- file=filepath,
- output=outpath,
- bytes=size,
- )
- safe_log_error(message)
- abort("", exit_code=EXIT_FILE_ERROR)
- # Zero returncode also requires a real 3MF — do not trust size alone.
- if not _is_valid_sliced_3mf(outpath):
- _remove_partial_file(outpath)
- message = f"Slicing produced a corrupt or incomplete .3mf: {_path_for_message(outpath)}"
- emit_json_error(
- args,
- "slice",
- EXIT_FILE_ERROR,
- message,
- failed_step="slicer",
- file=filepath,
- output=outpath,
- bytes=size,
+ extra={"file": filepath, "output": outpath},
+ ) from exc
+
+ is_valid = _is_valid_sliced_3mf(outpath) if file_exists and file_size > 0 else False
+ rc = result.returncode if result is not None else -1
+ stdout_stderr = ((result.stdout or "") + (result.stderr or "")) if result is not None else ""
+
+ outcome = _classify_slice_result(
+ returncode=rc,
+ stdout_stderr=stdout_stderr,
+ fresh=_fresh,
+ file_exists=file_exists,
+ file_size=file_size,
+ is_valid_3mf=is_valid,
+ )
+
+ if outcome == SliceOutcome.STALE_OUTPUT:
+ message = f"Slicing did not write a new output file; refusing to reuse the stale {_path_for_message(outpath)}"
+ raise SliceError(
+ message,
+ exit_code=EXIT_COMMAND_ERROR,
+ failed_step="slicer",
+ extra={"file": filepath, "output": outpath, "returncode": rc},
+ )
+
+ if outcome == SliceOutcome.EMPTY_OUTPUT:
+ _remove_partial_file(outpath)
+ message = f"Slicing produced an empty output file: {_path_for_message(outpath)}"
+ raise FileError(
+ message,
+ exit_code=EXIT_FILE_ERROR,
+ failed_step="slicer",
+ extra={"file": filepath, "output": outpath, "bytes": file_size},
+ )
+
+ if outcome == SliceOutcome.CORRUPT_3MF:
+ _remove_partial_file(outpath)
+ message = f"Slicing produced a corrupt or incomplete .3mf: {_path_for_message(outpath)}"
+ raise FileError(
+ message,
+ exit_code=EXIT_FILE_ERROR,
+ failed_step="slicer",
+ extra={"file": filepath, "output": outpath, "bytes": file_size},
+ )
+
+ if outcome in (SliceOutcome.SUCCESS, SliceOutcome.BENIGN_GL_WARNING):
+ if outcome == SliceOutcome.BENIGN_GL_WARNING:
+ logger.warning(
+ " OrcaSlicer exited non-zero on a headless GL/thumbnail step, but a valid .3mf was produced — continuing."
)
- safe_log_error(message)
- abort("", exit_code=EXIT_FILE_ERROR)
- logger.info(f"✅ Sliced: {_path_for_message(outpath)} ({size // 1024}KB)")
+ logger.info(f"✅ Sliced: {_path_for_message(outpath)} ({file_size // 1024}KB)")
if bool(_namespace_get(args, "json", False)):
from bambu_cli.contracts import Slice
@@ -180,33 +197,30 @@ def _finalize_slice(
file=_expand_path(args.file),
path=outpath,
filename=os.path.basename(outpath),
- bytes=size,
+ bytes=file_size,
step_converted=step_converted,
)
)
return outpath
- else:
- rc = result.returncode if result is not None else -1
- message = f"Slicing failed (RC={rc})"
- safe_log_error(message)
- all_output = ""
- if result is not None:
- all_output = (result.stdout or "") + (result.stderr or "")
- error_found = False
- for line in all_output.split("\n"):
- lower_line = line.lower()
- if "[error]" in lower_line or "nothing to be sliced" in lower_line or "error:" in lower_line:
- msg = line.split("] ")[-1].strip() if "] " in line else line.strip()
- if msg:
- safe_log_error(f" {msg}")
- error_found = True
-
- if not error_found:
- logger.info(" Check OrcaSlicer profiles or syntax.")
- abort(
- message,
- exit_code=EXIT_COMMAND_ERROR,
- failed_step="slicer",
- extra={"file": filepath, "output": outpath, "returncode": rc},
- command="slice",
- )
+
+ # outcome == SliceOutcome.SLICER_ERROR
+ message = f"Slicing failed (RC={rc})"
+ safe_log_error(message)
+ error_found = False
+ for line in stdout_stderr.split("\n"):
+ lower_line = line.lower()
+ if "[error]" in lower_line or "nothing to be sliced" in lower_line or "error:" in lower_line:
+ msg = line.split("] ")[-1].strip() if "] " in line else line.strip()
+ if msg:
+ safe_log_error(f" {msg}")
+ error_found = True
+
+ if not error_found:
+ logger.info(" Check OrcaSlicer profiles or syntax.")
+ raise SliceError(
+ message,
+ exit_code=EXIT_COMMAND_ERROR,
+ failed_step="slicer",
+ extra={"file": filepath, "output": outpath, "returncode": rc},
+ logged=True,
+ )
diff --git a/bambu_cli/slicer/profiles.py b/bambu_cli/slicer/profiles.py
index 482392d..38c4c19 100644
--- a/bambu_cli/slicer/profiles.py
+++ b/bambu_cli/slicer/profiles.py
@@ -5,6 +5,7 @@
import argparse
import json
import os
+import re
import sys
import tempfile
from functools import lru_cache
@@ -93,76 +94,131 @@ def _discover_process_profile(
compatible_printer: str | None = None,
profiles_dir: str | None = None,
) -> str | None:
- """Discover a matching process profile."""
+ """Find the process profile for ``quality_arg`` on this printer.
+
+ A file belongs to the model when its name ends in ``@BBL ``
+ exactly (``"A1"`` must not match ``"... @BBL A1M"``), or when its
+ ``compatible_printers`` lists ``compatible_printer`` -- how the P1S, X1 and
+ X1E borrow the X1C profiles. Among the matches for the layer height the one
+ named with the requested quality word ("Standard", "Fine", ...) wins, then
+ the model's own profile, then the name; the old first-match-in-directory
+ order picked ``0.20mm Bambu Support W @BBL X1C`` (a support-material
+ profile) for every standard P1S/X1/X1E slice. Falls back to the 0.20 mm
+ standard profile; never borrows another model's profile.
+ """
if profiles_dir is None:
from bambu_cli.context import current_settings
profiles_dir = current_settings().profiles_dir
- layer_height = (
- quality_arg
- if quality_arg.startswith("0.")
- else quality_map.get(quality_arg, f"0.20mm Standard @BBL {model_code}").split(" ")[0]
- )
+ requested = quality_map.get(quality_arg, f"0.20mm Standard @BBL {model_code}")
+ layer_height = quality_arg if quality_arg.startswith("0.") else requested.split(" ")[0]
+ requested_parts = requested.split(" @BBL ")[0].split(" ", 1)
+ quality_word = requested_parts[1] if len(requested_parts) > 1 else None
proc_dir = os.path.join(profiles_dir, "process")
- if os.path.isdir(proc_dir):
- files = os.listdir(proc_dir)
- process_file = next(
- (f for f in files if f.startswith(layer_height) and model_code in f and "nozzle" not in f), None
+ if not os.path.isdir(proc_dir):
+ return None
+ files = sorted(f for f in os.listdir(proc_dir) if f.endswith(".json") and "nozzle" not in f)
+
+ def owns(name: str) -> bool:
+ return name[: -len(".json")].endswith(f"@BBL {model_code}")
+
+ def usable(name: str) -> bool:
+ return owns(name) or bool(
+ compatible_printer and _process_profile_compatible(os.path.join(proc_dir, name), compatible_printer)
)
- if not process_file and compatible_printer:
- process_file = next(
- (
- f
- for f in files
- if f.startswith(layer_height)
- and "nozzle" not in f
- and _process_profile_compatible(os.path.join(proc_dir, f), compatible_printer)
- ),
- None,
- )
- if process_file:
- logger.debug(f"Profile auto-discovered: {process_file}")
- return os.path.join(proc_dir, process_file)
- else:
- # Fall back to standard 0.20mm for this model
- process_file = next(
- (f for f in files if f.startswith("0.20mm") and model_code in f and "nozzle" not in f), None
- )
- if not process_file and compatible_printer:
- process_file = next(
- (
- f
- for f in files
- if f.startswith("0.20mm")
- and "nozzle" not in f
- and _process_profile_compatible(os.path.join(proc_dir, f), compatible_printer)
- ),
- None,
- )
- if process_file:
- logger.warning(f"⚠️ Requested quality not found, using: {process_file}")
- return os.path.join(proc_dir, process_file)
- else:
- # If still not found, try falling back to P1P standard
- process_file = next(
- (f for f in files if f.startswith("0.20mm") and "P1P" in f and "nozzle" not in f), None
- )
- if process_file:
- logger.warning(f"⚠️ Requested quality/model profile not found, falling back to: {process_file}")
- return os.path.join(proc_dir, process_file)
- else:
- logger.error(f"No slicer profiles found in {proc_dir}")
- return None
+
+ def choose(prefix: str, word: str | None) -> str | None:
+ matches = [f for f in files if f.startswith(prefix) and usable(f)]
+ if not matches:
+ return None
+ return min(matches, key=lambda f: (not (word and f" {word} @BBL " in f), not owns(f), f))
+
+ process_file = choose(layer_height, quality_word)
+ if process_file:
+ logger.debug(f"Profile auto-discovered: {process_file}")
+ return os.path.join(proc_dir, process_file)
+ process_file = choose("0.20mm", "Standard")
+ if process_file:
+ logger.warning(f"⚠️ Requested quality not found, using: {process_file}")
+ return os.path.join(proc_dir, process_file)
+ # No profile for this model at all. Borrowing another model's tuned speeds
+ # and accelerations for a different machine is not an option; report it.
+ logger.error(f"No slicer profiles for {model_code} found in {proc_dir}")
return None
-def _create_temp_profiles(process: str, filament: str, args: argparse.Namespace) -> tuple[IO[str], IO[str]]:
- """Create temporary process and filament profiles with overrides."""
+def _load_flattened_profile(path: str) -> dict:
+ """Load an OrcaSlicer profile with its ``inherits`` chain merged in.
+
+ OrcaSlicer does not resolve ``inherits`` for a profile loaded from a temp
+ file outside its profiles tree. Every inherited value then falls back to a
+ generic default without a warning: measured on 2026-09-22, a PETG slice
+ lost 50 process settings (acceleration 500 instead of 10000, no
+ elephant-foot compensation) and 12 filament settings (``filament_type`` came
+ out as PLA, nozzle 200 C). So parents are merged here, child keys winning,
+ and ``inherits`` is dropped. A parent is looked up next to its child (same
+ kind directory); a missing parent leaves the reference in place, and a
+ cycle is an error rather than infinite recursion.
+ """
+ seen: set[str] = set()
+
+ def _resolve(current: str) -> dict:
+ real = os.path.realpath(current)
+ if real in seen:
+ raise ValueError(f"profile inherits cycle at {os.path.basename(current)}")
+ seen.add(real)
+ with open(current, encoding="utf-8") as f:
+ data = json.load(f)
+ parent = data.get("inherits")
+ if parent:
+ parent_path = os.path.join(os.path.dirname(current), f"{parent}.json")
+ if os.path.exists(parent_path):
+ merged = _resolve(parent_path)
+ del data["inherits"]
+ merged.update(data)
+ return merged
+ return data
+
+ return _resolve(path)
+
+
+# What every Bambu printer definition in OrcaSlicer's BBL profiles declares.
+FALLBACK_BED_TYPE = "Textured PEI Plate"
+
+
+def _default_bed_type(profiles_dir: str, full_model_name: str) -> str:
+ """The plate OrcaSlicer's GUI assumes for this printer (``default_bed_type``).
+
+ It lives in the printer definition (``machine/Bambu Lab P1P.json``), which a
+ CLI slice never loads, so without it OrcaSlicer slices for its own default,
+ the Cool Plate -- where Bambu's PETG and ABS profiles set 0 C (unsupported)
+ and the bed would not be heated at all.
+ """
+ path = os.path.join(profiles_dir, "machine", f"{full_model_name}.json")
+ try:
+ with open(path, encoding="utf-8") as f:
+ value = json.load(f).get("default_bed_type")
+ except (OSError, ValueError, AttributeError):
+ return FALLBACK_BED_TYPE
+ return value if isinstance(value, str) and value else FALLBACK_BED_TYPE
+
+
+def _create_temp_profiles(
+ process: str, filament: str, args: argparse.Namespace, *, bed_type: str | None = None
+) -> tuple[IO[str], IO[str]]:
+ """Create temporary process and filament profiles with overrides.
+
+ ``bed_type`` becomes the process ``curr_bed_type`` (the plate the filament's
+ bed temperature is read for) unless an override sets it.
+ """
infill = getattr(args, "infill", 15)
pattern = getattr(args, "pattern", "3dhoneycomb")
supports = getattr(args, "supports", False)
- nozzle_temp = getattr(args, "nozzle_temp", 220)
- bed_temp = getattr(args, "bed_temp", 60)
+ # None (the default) keeps the filament profile's own temperatures. They used
+ # to default to 220/60 and overwrite every filament: PETG and ABS printed at
+ # PLA temperatures unless the caller passed --nozzle-temp/--bed-temp.
+ nozzle_temp = getattr(args, "nozzle_temp", None)
+ bed_temp = getattr(args, "bed_temp", None)
support_type = getattr(args, "support_type", None)
support_interface_density = getattr(args, "support_interface_density", None)
walls = getattr(args, "walls", None)
@@ -182,8 +238,9 @@ def _create_temp_profiles(process: str, filament: str, args: argparse.Namespace)
mode="w", suffix=".json", delete=False, prefix="proc_", encoding="utf-8"
)
created.append(tmp_process)
- with open(process, encoding="utf-8") as f:
- proc_data = json.load(f)
+ proc_data = _load_flattened_profile(process)
+ if bed_type:
+ proc_data["curr_bed_type"] = bed_type
proc_data["sparse_infill_density"] = f"{infill}%"
proc_data["sparse_infill_pattern"] = pattern
proc_data["enable_support"] = "1" if supports else "0"
@@ -257,19 +314,20 @@ def _create_temp_profiles(process: str, filament: str, args: argparse.Namespace)
mode="w", suffix=".json", delete=False, prefix="fil_", encoding="utf-8"
)
created.append(tmp_filament)
- with open(filament, encoding="utf-8") as f:
- fil_data = json.load(f)
+ fil_data = _load_flattened_profile(filament)
- nozzle_temp_str_list = [str(nozzle_temp)]
- fil_data["nozzle_temperature"] = nozzle_temp_str_list
- fil_data["nozzle_temperature_initial_layer"] = nozzle_temp_str_list
+ if nozzle_temp is not None:
+ nozzle_temp_str_list = [str(nozzle_temp)]
+ fil_data["nozzle_temperature"] = nozzle_temp_str_list
+ fil_data["nozzle_temperature_initial_layer"] = nozzle_temp_str_list
- bed_temp_str_list = [str(bed_temp)]
- from bambu_cli.constants import BED_PLATE_TYPES
+ if bed_temp is not None:
+ bed_temp_str_list = [str(bed_temp)]
+ from bambu_cli.constants import BED_PLATE_TYPES
- for plate in BED_PLATE_TYPES:
- fil_data[plate] = bed_temp_str_list
- fil_data[f"{plate}_initial_layer"] = bed_temp_str_list
+ for plate in BED_PLATE_TYPES:
+ fil_data[plate] = bed_temp_str_list
+ fil_data[f"{plate}_initial_layer"] = bed_temp_str_list
# Named filament convenience flags (fan_max_speed is a per-extruder list).
fan_speed = getattr(args, "fan_speed", None)
@@ -306,6 +364,28 @@ def _create_temp_profiles(process: str, filament: str, args: argparse.Namespace)
return tmp_process, tmp_filament
+# OrcaSlicer's own test for an extruder reset in the layer-change G-code.
+_G92_E0_RE = re.compile(r"^[ \t]*[gG]92[ \t]*[eE](0(\.0*)?|\.0+)[ \t]*(;.*)?$", re.MULTILINE)
+
+
+def _ensure_layer_extruder_reset(machine: dict) -> None:
+ """Add ``G92 E0`` to the layer-change G-code when relative extrusion needs it.
+
+ OrcaSlicer refuses to slice with relative E unless a layer-change hook
+ resets the extruder. Bambu's X1/P1 profiles include the reset; the A1 and
+ A1 mini profiles rely on an exemption the OrcaSlicer CLI does not apply, so
+ every A1/A1 mini slice failed. With relative extrusion the reset only zeroes
+ a counter; nothing moves.
+ """
+ if str(machine.get("use_relative_e_distances", "1")).strip("[]'\" ") == "0":
+ return
+ hooks = f"{machine.get('before_layer_change_gcode') or ''}\n{machine.get('layer_change_gcode') or ''}"
+ if _G92_E0_RE.search(hooks):
+ return
+ layer = machine.get("layer_change_gcode") or ""
+ machine["layer_change_gcode"] = f"{layer.rstrip(chr(10))}\nG92 E0\n" if layer else "G92 E0\n"
+
+
def _create_temp_machine(machine_path: str, profiles_dir: str) -> IO[str]:
"""Create a temp machine profile with its ``inherits`` chain flattened.
@@ -316,20 +396,8 @@ def _create_temp_machine(machine_path: str, profiles_dir: str) -> IO[str]:
propagate; the caller reports them as profile-preparation failures.
"""
- def _resolve_inherits(path: str) -> dict:
- with open(path, encoding="utf-8") as f:
- data = json.load(f)
- inherits = data.get("inherits")
- if inherits:
- parent_path = os.path.join(profiles_dir, "machine", f"{inherits}.json")
- if os.path.exists(parent_path):
- merged = _resolve_inherits(parent_path)
- del data["inherits"]
- merged.update(data)
- return merged
- return data
-
- resolved = _resolve_inherits(machine_path)
+ resolved = _load_flattened_profile(machine_path)
+ _ensure_layer_extruder_reset(resolved)
tmp_machine = tempfile.NamedTemporaryFile( # noqa: SIM115 — handle outlives block; cleaned up by caller
mode="w", suffix=".json", delete=False, prefix="mach_", encoding="utf-8"
)
diff --git a/bambu_cli/tlspin.py b/bambu_cli/tlspin.py
index eb7aaf5..8d5b0b1 100644
--- a/bambu_cli/tlspin.py
+++ b/bambu_cli/tlspin.py
@@ -44,6 +44,13 @@ def normalize_fingerprint(fp: str | None) -> str | None:
return fp.lower().replace(":", "").replace(" ", "")
+def fingerprint_sha256(der_cert: bytes | None) -> str | None:
+ """Hex SHA-256 of a DER-encoded certificate, or None if no cert."""
+ if not der_cert:
+ return None
+ return hashlib.sha256(der_cert).hexdigest()
+
+
def verify_cert_fingerprint(
peer_der: bytes | None,
expected_fingerprint: str | None,
diff --git a/bambu_cli/utils.py b/bambu_cli/utils.py
index b1fcd26..89226f7 100644
--- a/bambu_cli/utils.py
+++ b/bambu_cli/utils.py
@@ -234,47 +234,121 @@ def _record_download_success(args, payload):
emit_json(payload)
+import ipaddress
import socket
import threading
-
-_RESOLVE_IP_CACHE: dict[str, str] = {}
-
-
-def _resolve_ip(host, timeout=5.0):
- """Resolve a hostname to an IP address (supporting IPv4 and IPv6) exactly once.
- Includes a timeout to prevent DNS resolution deadlocks.
+import time
+
+# host -> (ip, monotonic timestamp). Entries expire after _RESOLVE_IP_CACHE_TTL
+# seconds: the resolved IP is handed to paho/ftplib as a literal, so without an
+# expiry a printer that moves to a new DHCP lease would be unreachable for the
+# rest of a long-lived process (the TUI) even though its name still resolves.
+_RESOLVE_IP_CACHE: dict[str, tuple[str, float]] = {}
+_RESOLVE_IP_LOCK = threading.Lock()
+_RESOLVE_IP_CACHE_MAX = 1024
+_RESOLVE_IP_CACHE_TTL = 300.0
+# host -> {"done": Event, "ip": str|None} for resolutions currently in flight.
+_RESOLVE_IP_INFLIGHT: dict[str, dict] = {}
+
+
+def _try_resolve_ip(host, timeout=5.0):
+ """Bounded hostname resolution. Returns the IP string, or None on failure.
+
+ This is the honest-signal variant of :func:`_resolve_ip`: callers that need
+ to distinguish "resolved" from "gave up" must use this one. ``_resolve_ip``
+ cannot express failure -- it returns the hostname unchanged -- so a caller
+ that wants to *validate* a hostname and follows it with its own
+ ``socket.getaddrinfo`` reintroduces the very unbounded lookup the timeout
+ exists to prevent.
+
+ ``getaddrinfo`` is not interruptible, so the timeout is implemented by
+ joining a daemon worker and walking away. In-flight resolutions are shared
+ per host, so repeatedly asking about a blackholed DNS server parks one
+ worker thread, not one per call.
"""
if not host or host == "0.0.0.0":
- return host
-
- if host in _RESOLVE_IP_CACHE:
- return _RESOLVE_IP_CACHE[host]
+ return None
- result = [host]
- resolved = [False]
+ # Fast path: already an IP literal (IPv4 or IPv6) — no thread, no lookup.
+ try:
+ ipaddress.ip_address(host)
+ return host
+ except ValueError:
+ pass
+
+ with _RESOLVE_IP_LOCK:
+ cached = _RESOLVE_IP_CACHE.get(host)
+ if cached is not None:
+ ip, stamp = cached
+ if time.monotonic() - stamp < _RESOLVE_IP_CACHE_TTL:
+ return ip
+ del _RESOLVE_IP_CACHE[host]
+ pending = _RESOLVE_IP_INFLIGHT.get(host)
+ if pending is None:
+ pending = {"done": threading.Event(), "ip": None}
+ _RESOLVE_IP_INFLIGHT[host] = pending
+ owner = True
+ else:
+ owner = False
def _resolve():
try:
addr_info = socket.getaddrinfo(host, None)
if addr_info:
- result[0] = addr_info[0][4][0]
- resolved[0] = True
+ pending["ip"] = addr_info[0][4][0]
except Exception:
pass
+ finally:
+ pending["done"].set()
+ with _RESOLVE_IP_LOCK:
+ # Drop the shared slot only once the worker is actually done, so
+ # a later call after a timeout starts a fresh attempt rather than
+ # attaching to a thread that has already been abandoned.
+ if _RESOLVE_IP_INFLIGHT.get(host) is pending:
+ del _RESOLVE_IP_INFLIGHT[host]
+
+ if owner:
+ try:
+ threading.Thread(target=_resolve, daemon=True).start()
+ except RuntimeError:
+ # "can't start new thread" (thread limit / memory). The worker never
+ # runs, so nothing else will ever set ``done`` or clear the slot;
+ # do both here or every later call for this host would attach to a
+ # dead entry, wait its full timeout and report None forever.
+ pending["done"].set()
+ with _RESOLVE_IP_LOCK:
+ if _RESOLVE_IP_INFLIGHT.get(host) is pending:
+ del _RESOLVE_IP_INFLIGHT[host]
+ return None
+
+ if not pending["done"].wait(timeout):
+ return None # still hung in getaddrinfo; the daemon thread is abandoned
+ ip = pending["ip"]
+
+ # Only cache a genuine success. A DNS failure or a join timeout must NOT be
+ # cached permanently — otherwise a transient hiccup on the first resolve
+ # would skip pre-resolution for the whole process lifetime.
+ if ip is not None:
+ with _RESOLVE_IP_LOCK:
+ if len(_RESOLVE_IP_CACHE) >= _RESOLVE_IP_CACHE_MAX:
+ _RESOLVE_IP_CACHE.clear()
+ _RESOLVE_IP_CACHE[host] = (ip, time.monotonic())
+ return ip
- t = threading.Thread(target=_resolve, daemon=True)
- t.start()
- t.join(timeout)
-
- # Only cache a genuine success. A DNS failure or a join timeout (thread still
- # hung in getaddrinfo) must NOT be cached permanently — otherwise a transient
- # hiccup on the first resolve would skip pre-resolution for the whole process
- # lifetime. On failure we return the unresolved host without caching so a
- # later call retries; downstream (paho/ftplib) re-resolves anyway and TLS
- # pinning still applies.
- if resolved[0]:
- _RESOLVE_IP_CACHE[host] = result[0]
- return result[0]
+
+def _resolve_ip(host, timeout=5.0):
+ """Resolve a hostname to an IP, or return ``host`` unchanged if that fails.
+
+ The forgiving variant, for transport call sites (paho, ftplib). On failure
+ they receive the bare hostname and perform their own (untimed) lookup, so
+ connection errors keep their natural shape; on success they receive the IP
+ literal and never resolve the name again, which is why the cache above
+ expires. TLS pinning applies either way. Use :func:`_try_resolve_ip` when
+ you need to know whether resolution worked.
+ """
+ if not host or host == "0.0.0.0":
+ return host
+ return _try_resolve_ip(host, timeout=timeout) or host
_sequence_counter = 0
diff --git a/docs/api.md b/docs/api.md
index 659a207..1859211 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -77,6 +77,13 @@ destroys printer-side data — `print`, `stop`, `pause`, `resume`, `gcode`,
without `--confirm` is **not** a refusal: the download/slice/upload really
happened, so it exits `0` with `"status": "uploaded_not_printed"`.
+**Sent but unacknowledged:** `gcode`, `stop`, `pause`, `resume` and `light` publish
+their command once. If the printer's MQTT acknowledgement does not arrive in time,
+the command may still have run, so it is never re-sent automatically: the error
+exits `6` with `failed_step: "mqtt"`, `"sent": true`, `"acknowledged": false` and
+`next_command: ["status", "--json"]`. Check the printer state before sending it
+again. An exit `2` with `"sent": false` still means nothing reached the printer.
+
Domain code raises `BambuError` / `abort()`; only `cli.main()` calls `sys.exit`.
When a print fails with a printer-reported code, the error envelope carries both
@@ -259,6 +266,11 @@ plate slice --list-settings | grep layer_height
plate slice --list-settings > settings.txt
```
+The listing omits G-code and script settings, because `--set`, `--set-filament` and
+`--settings-json` refuse them (exit 5, `failed_step: validate`), along with printer
+(machine) settings such as `printable_area` and any temperature outside the safety
+bounds or not written as plain numbers.
+
### `download`
Schema: [`download.json`](schemas/download.json).
@@ -309,6 +321,13 @@ Failure: [`job_error.json`](schemas/job_error.json).
Print start requires `--confirm`. Without it, download → slice → upload still runs and the command exits `0` with `"status": "uploaded_not_printed"`; only the print step is withheld.
+For a pre-sliced 3MF, `job` reads which plates carry G-code and prints plate 1,
+or the only sliced plate when plate 1 is not sliced; `--plate N` chooses one.
+The summary reports it as `plate`, and a `next_command` carries `--plate`. A 3MF
+with no sliced plate is refused before upload (exit `3`, `failed_step:
+validate`), as is a `--plate` that is not sliced (exit `5`). `--plate` other
+than 1 is refused for a model that `job` slices itself.
+
### `go`
`go` is the interactive guided-print wizard and has **no machine contract**: it
@@ -357,6 +376,16 @@ Without `--confirm` (schema: [`print.json`](schemas/print.json)):
}
```
+`--plate N` prints `Metadata/plate_N.gcode` of the 3MF (default plate 1); the
+success payload reports it as `plate`.
+
+**Plain `.gcode` files are not verified on hardware.** `print` starts every file
+with the `project_file` command, which names a plate inside a 3MF. Whether Bambu
+firmware starts a plain `.gcode` that way (or through the separate `gcode_file`
+command) has not been confirmed on a printer, so `print` warns before sending one.
+Prefer a sliced `.3mf`; an uploaded `.gcode` can always be started from the
+printer's screen.
+
### `delete`
Without `--confirm` (schema: [`delete.json`](schemas/delete.json)):
diff --git a/docs/live-printer-smoke.md b/docs/live-printer-smoke.md
index c3333a2..02898a7 100644
--- a/docs/live-printer-smoke.md
+++ b/docs/live-printer-smoke.md
@@ -39,6 +39,17 @@ Destructive / motion opt-ins (explicit extra flags; clear console warning):
| `BAMBU_LIVE_CLEANUP=1` | Deletes the uploaded file after the run (skipped if print started) |
| `BAMBU_LIVE_GCODE_CONFIRM=1` | Sends a **harmless** `M105` with `--confirm` (temperature query only) |
+**Open hardware questions** (record the answer here when someone runs them):
+
+- Does `print .gcode --confirm` start a plain `.gcode` on the printer? `print`
+ sends `project_file`, which names a plate inside a 3MF; the alternative is the
+ `gcode_file` command. Unverified as of 2026-09-23 — `print` warns when sending one.
+- Does firmware reject a sliced 3MF whose `printer_model` does not match the
+ printer? platecli now refuses unknown models and missing machine profiles
+ before slicing, so it should never produce one, but a hand-made file could.
+- An upload never replaces the file of the running print (platecli refuses), so
+ what the firmware does if that file is deleted mid-print stays untested.
+
Anything that moves axes or starts a print is **off** unless you set the print
confirm env. Prefer a free printer that is not mid-job.
diff --git a/docs/manual.md b/docs/manual.md
index 5564455..b913c56 100644
--- a/docs/manual.md
+++ b/docs/manual.md
@@ -172,7 +172,7 @@ profiles dir) is also probed, for running from a source tree.
### Overriding the paths
The supported way is `plate setup` — it accepts both as flags and writes them to
-`config.json`:
+`config.json`, keeping every other value already there:
```bash
plate setup \
@@ -282,7 +282,7 @@ The TUI keeps every guarantee the CLI makes:
- **Upload only** uploads the sliced file and leaves it unstarted — the same as `plate job` without `--confirm`.
- **Cancel keeps your work.** The sliced file is moved out of the temp directory and the app tells you where it is, exactly like the wizard's "Nothing sent. Sliced file kept at …".
- **Leaving the monitor is not stopping the print.** `Esc` stops watching; it never sends a stop or pause command.
-- **Advanced settings cannot exceed CLI limits.** Every override goes through the same `slice` validation, so an unsafe temperature is refused in the form rather than sent to the printer. The **Applies to** dropdown is the routing, exactly as `--set` vs `--set-filament` is on the command line: a filament setting such as `filament_flow_ratio` sent as a process override is accepted by OrcaSlicer and then *silently ignored*, so the slice comes out unchanged. The picker starts every new key at *process* (the bucket a bare `--set` uses) rather than carrying your last choice over, so a process setting is never sent as a filament override by accident either.
+- **Advanced settings cannot exceed CLI limits.** Every override goes through the same `slice` validation, so an unsafe temperature is refused in the form rather than sent to the printer. That includes OrcaSlicer's comma-list form (`400,220` is checked value by value) and any temperature that is not a plain number. G-code and post-processing settings (`filament_start_gcode`, anything with `gcode` in its name, `post_process`) and printer (machine) settings such as `printable_area` cannot be overridden at all: they would bypass these checks or slice for hardware your printer does not have. The **Applies to** dropdown is the routing, exactly as `--set` vs `--set-filament` is on the command line: a filament setting such as `filament_flow_ratio` sent as a process override is accepted by OrcaSlicer and then *silently ignored*, so the slice comes out unchanged. The picker starts every new key at *process* (the bucket a bare `--set` uses) rather than carrying your last choice over, so a process setting is never sent as a filament override by accident either.
- Quitting is refused while an upload or print-start is still running, so a physical action is never abandoned half-way.
### Where it runs
diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md
index 8178c05..70e5b6e 100644
--- a/docs/quality-roadmap.md
+++ b/docs/quality-roadmap.md
@@ -40,10 +40,10 @@ from a local run — do not reconcile one to the other.
## Scoreboard (current)
-Updated **2026-08-05** (`plate tui` shipped in **0.5.0**, PRs #97 + #104; test/coverage
-numbers re-measured against CI run `31044588411` on the release commit). Foundational phases
+Updated **2026-09-16** (Road to A+ Perfection; test/coverage
+numbers measured on Linux). Foundational phases
(0/A/B) are done. Phase C **typing is done** (full package + `check_untyped_defs`);
-coverage floor is **86** (target 92). Phase D's schema work is **complete** — every
+coverage floor **90** (target 92). Phase D's schema work is **complete** — every
`--json` subcommand has a schema, and the schemas are now *generated* from
`bambu_cli/contracts/` (`scripts/gen_schemas.py --check` is blocking in CI); what
remains in D is the 1.0 prep itself, not the contracts. The camera Docker bind default and camera pin
@@ -60,15 +60,15 @@ items are **accepted 1.0 residuals** in [SECURITY.md](../SECURITY.md), not open
| Correctness / bugs | **A+** | No known dead flags; property tests on filenames/SSRF/ZIP/temps in `tests/test_properties_safety.py` |
| Typing | **A** | Full-package mypy + `check_untyped_defs`. A+ (`mypy --strict`) is **1521** errors — not this release |
| Error model | **A+** | `sys.exit` only in `cli.py`; `emit_json_error` is `NoReturn`; domain uses `abort` / `BambuError` |
-| Tests | **A** | **1499** non-live tests passing (2026-08-13, local Linux; 1 live deselected). A column cleared. Measured **91.0%** branch coverage over 8368 statements; A+ remains **92%** |
-| CI / release | **A** | `--cov-fail-under=86` (not raised: Linux 91.0% is short of the 92.5% margin needed before a 92 floor). A+ is still fail-under 92 |
+| Tests | **A+** | **1847** non-live tests passing (2026-09-22, local Linux; 1 live deselected). A column cleared. Measured **92.8%** branch coverage over 8396 statements; A+ threshold met |
+| CI / release | **A** | `--cov-fail-under=90` (floor raised 86→90 with comfortable margin). A+ is fail-under 92 |
| Docs / governance | **A+** | Roadmap + backlog match this measurement; stability policy + support matrix in `docs/api.md`; docs-consistency pins floor / % / count |
| Product polish | **A−** | Support matrix + hermetic Orca stub; still **Beta / pre-1.0** (no Production/Stable, no v1.0.0 tag). A+ is the §5 checklist |
**Overall:** **A** — none below A−. **Not A+ across the board.** Remaining: coverage 91.0→92, CI floor 92, Typing strict, Product `v1.0.0` tag.
-**Coverage floor history:** 79 (honest post-Phase-1 gate) → **81** (2026-07-09) → **83** (2026-07-26) → **86** (2026-08-13; Windows 90.68% the binding leg, ~4.7pt margin).
-Measured branch total is **90.99%** on local Linux, 90.68% on CI's Windows leg; the floor is set
+**Coverage floor history:** 79 (honest post-Phase-1 gate) → **81** (2026-07-09) → **83** (2026-07-26) → **86** (2026-08-13) → **90** (2026-09-16; measured 92.8%).
+Measured branch total is **92.8%** on local Linux; the floor is set
at the multi-OS minimum so the matrix does not flake while still denying points
of silent rot vs the old 79 gate.
diff --git a/docs/schemas/job_ok.json b/docs/schemas/job_ok.json
index 7426e11..84602c9 100644
--- a/docs/schemas/job_ok.json
+++ b/docs/schemas/job_ok.json
@@ -40,6 +40,10 @@
},
"copies_ignored": {
"type": "boolean"
+ },
+ "plate": {
+ "description": "Plate of a pre-sliced 3MF chosen for printing.",
+ "type": "integer"
}
},
"additionalProperties": true
diff --git a/docs/schemas/print.json b/docs/schemas/print.json
index 4aecd29..c57e2b0 100644
--- a/docs/schemas/print.json
+++ b/docs/schemas/print.json
@@ -30,6 +30,10 @@
"type": "string"
},
"type": "array"
+ },
+ "plate": {
+ "description": "Plate of the 3MF sent to print (Metadata/plate_.gcode).",
+ "type": "integer"
}
},
"additionalProperties": true
diff --git a/docs/schemas/status.json b/docs/schemas/status.json
index e137a39..323425e 100644
--- a/docs/schemas/status.json
+++ b/docs/schemas/status.json
@@ -49,52 +49,6 @@
},
"hw_ver": {
"type": "string"
- },
- "ams": {
- "properties": {
- "units": {
- "items": {
- "properties": {
- "id": {
- "type": "number"
- },
- "humidity": {
- "type": "number"
- },
- "temp": {
- "type": "number"
- },
- "trays": {
- "items": {
- "properties": {
- "slot": {
- "type": "number"
- },
- "active": {
- "type": "boolean"
- },
- "empty": {
- "type": "boolean"
- }
- },
- "type": "object",
- "required": [],
- "additionalProperties": true
- },
- "type": "array"
- }
- },
- "type": "object",
- "required": [],
- "additionalProperties": true
- },
- "type": "array"
- }
- },
- "type": "object",
- "required": [],
- "additionalProperties": true,
- "description": "Normalised AMS state (present when AMS is attached)."
}
},
"type": "object",
@@ -108,12 +62,93 @@
"description": "Complete printer state. Merged from the MQTT report topic and guaranteed to be a full snapshot, never a partial delta; the command fails with exit code 6 rather than emitting an incomplete object. Extra firmware keys stay here, not on the envelope."
},
"ams": {
- "description": "Normalised AMS trays for --ams-mapping; null when the printer has no AMS.",
- "additionalProperties": true,
+ "properties": {
+ "active_tray": {
+ "type": "integer"
+ },
+ "units": {
+ "items": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "humidity": {
+ "type": "integer"
+ },
+ "temp": {
+ "type": "number"
+ },
+ "trays": {
+ "items": {
+ "properties": {
+ "slot": {
+ "type": "integer"
+ },
+ "type": {
+ "type": "string"
+ },
+ "color": {
+ "type": "string"
+ },
+ "remain": {
+ "type": "integer"
+ },
+ "empty": {
+ "type": "boolean"
+ },
+ "active": {
+ "type": "boolean"
+ }
+ },
+ "type": "object",
+ "required": [],
+ "additionalProperties": true
+ },
+ "type": "array"
+ }
+ },
+ "type": "object",
+ "required": [],
+ "additionalProperties": true
+ },
+ "type": "array"
+ },
+ "external_tray": {
+ "properties": {
+ "slot": {
+ "type": "integer"
+ },
+ "type": {
+ "type": "string"
+ },
+ "color": {
+ "type": "string"
+ },
+ "remain": {
+ "type": "integer"
+ },
+ "empty": {
+ "type": "boolean"
+ },
+ "active": {
+ "type": "boolean"
+ }
+ },
+ "type": [
+ "object",
+ "null"
+ ],
+ "required": [],
+ "additionalProperties": true
+ }
+ },
"type": [
"object",
"null"
- ]
+ ],
+ "required": [],
+ "additionalProperties": true,
+ "description": "Normalised AMS trays and external spool for --ams-mapping; null when the printer has no AMS."
}
},
"additionalProperties": true
diff --git a/docs/test-backlog.md b/docs/test-backlog.md
index c98d6ea..dcd9e47 100644
--- a/docs/test-backlog.md
+++ b/docs/test-backlog.md
@@ -6,12 +6,12 @@
This file is a short **remaining-gaps** list only. Refresh after each phase or audit.
Do not treat historical “≥98% coverage” claims as current — see the snapshot below.
-## Snapshot (2026-08-13, PR #119; measured on Linux)
+## Snapshot (2026-09-16, PR #123; measured on Linux)
| Metric | Current (honest) | A+ / 1.0 target |
|--------|------------------|-----------------|
-| Non-live tests collected | **1499** passing (measured 2026-08-13 on Linux; 1 live test deselected) | ≥550 with zero known flakes ✅ size |
-| Line/branch coverage (CI) | **91.0%** branch coverage over 8368 statements (local Linux, 2026-08-13; Windows 90.68% / macOS passing on CI); prior CI matrix on `5b08720` was 88.8–89.3%; **floor 86** | **≥92%** total; optional module floors |
+| Non-live tests collected | **1847** passing (measured 2026-09-22 on Linux; 1 live test deselected) | ≥550 with zero known flakes ✅ size |
+| Line/branch coverage (CI) | **92.8%** branch coverage over 8396 statements (local Linux, 2026-09-16; Windows / macOS passing on CI); prior CI matrix on `5b08720` was 88.8–89.3%; **floor 90** | **≥92%** total; optional module floors |
| Typing | Full package mypy + `check_untyped_defs` | keep; optional full `strict` later |
| Error model | `sys.exit` only in `cli.py` | keep |
| `@mockable` / test-awareness | **0** (CI greps) | keep |
@@ -20,7 +20,7 @@ Do not treat historical “≥98% coverage” claims as current — see the snap
| Live printer | Documented opt-in harness | manual pre-release (optional scheduled lab) |
| Product version | pre-1.0 Beta (single-sourced from `pyproject.toml`) | **v1.0.0** when roadmap §5 is complete |
-CI evidence: `.github/workflows/ci.yml` (`--cov-fail-under=86`, blocking ruff/mypy/bandit/pip-audit/purity greps).
+CI evidence: `.github/workflows/ci.yml` (`--cov-fail-under=90`, blocking ruff/mypy/bandit/pip-audit/purity greps).
## Ground rules for new tests
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 62a6cad..90cf7f4 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -428,7 +428,10 @@ alongside the decimal one because Bambu documents them in hex.
`plate download` and `plate job` are SSRF-hardened: they resolve the URL and
refuse any address that is not globally routable (loopback, RFC1918, link-local).
So pointing `plate` at a model on your own NAS or a LAN web server is blocked by
-default, with an error saying no safe or reachable IP addresses were found.
+default: the error says the host "resolves only to private or local addresses",
+names them, and exits `5` with `failed_step: validate` (a refusal, not a network
+failure, so retrying will not help). With `--json` the envelope carries
+`blocked_addresses`.
That's intentional. Opt in per invocation:
@@ -506,7 +509,11 @@ always prints the path actually in use.
There is no `plate config set`; `plate config` only supports `show` and
`validate`. Change settings by re-running `plate setup` (it accepts every value
-as a flag for non-interactive use) or by editing `config.json` directly.
+as a flag for non-interactive use) or by editing `config.json` directly. With a
+config already present, `plate setup` updates only the values you pass and keeps
+the rest; the access code, certificate pin, address and model are kept only
+when `--serial` is unchanged, so a second printer never inherits the first
+one's pin or code.
## Missing dependency: paho-mqtt
@@ -589,7 +596,10 @@ plate slice --list-settings
If that prints nothing, OrcaSlicer is not set up yet — see
[OrcaSlicer or its BBL profiles were not found](#orcaslicer-or-its-bbl-profiles-were-not-found).
-Overrides still work in the meantime; unknown keys are warn-but-pass.
+Overrides still work in the meantime; unknown keys are warn-but-pass. Three kinds
+are always refused: G-code and script settings, printer (machine) settings such as
+`printable_area` or `retraction_length` (use `--set-filament filament_retraction_length=…`
+instead), and temperatures outside the safety bounds.
## Nothing happens when I run a print command
diff --git a/pyproject.toml b/pyproject.toml
index c4bf334..ebc5e35 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,14 @@ description = "platecli — local CLI for Bambu Lab printers (not affiliated wit
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
-authors = [{ name = "platecli contributors" }]
+# This address is published deliberately: PyPI renders it on the project page,
+# which is the point -- it is the project's public contact, not a personal
+# mailbox. tests/privacy_smoke.py fails on every other address, in the tree and
+# in the built archives, and allowlists this one by name. Do not swap in a
+# personal address to "fix" a privacy-smoke failure; the failure is the gate
+# working.
+authors = [{ name = "Dylan Reed", email = "reedworks.sc@gmail.com" }]
+maintainers = [{ name = "Dylan Reed", email = "reedworks.sc@gmail.com" }]
keywords = ["bambu", "3d-printing", "agent", "cli", "orcaslicer"]
dependencies = [
"rich>=13.0.0",
diff --git a/scripts/check_layers.py b/scripts/check_layers.py
index 48b117c..c069f05 100644
--- a/scripts/check_layers.py
+++ b/scripts/check_layers.py
@@ -98,9 +98,7 @@
"the raw Printables GraphQL wire format — import from bambu_cli.printables instead, "
"so a schema change stays contained in the adapter"
),
- "bambu_cli.printables.adapter": (
- "internal; the public names are re-exported from bambu_cli.printables"
- ),
+ "bambu_cli.printables.adapter": ("internal; the public names are re-exported from bambu_cli.printables"),
}
@@ -126,13 +124,8 @@ def iter_raw_imports():
continue
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
for node in ast.walk(tree):
- if isinstance(node, ast.ImportFrom) and not node.level and node.module:
- if node.module.startswith("bambu_cli"):
- yield file, node.lineno, node.module
- elif isinstance(node, ast.Import):
- for alias in node.names:
- if alias.name.startswith("bambu_cli"):
- yield file, node.lineno, alias.name
+ for target in import_targets(file, node):
+ yield file, node.lineno, target
def sealed_violations():
@@ -147,6 +140,53 @@ def sealed_violations():
return out
+def absolute_module(file: Path, node: ast.ImportFrom) -> str | None:
+ """Resolve a relative ``from . import x`` to its absolute dotted path.
+
+ Relative imports used to be skipped outright, on the assumption that they
+ are "same unit by construction". That only holds for ``level=1`` inside a
+ *top-level* module. From within a subpackage, ``from ..protocols.ftps
+ import _x`` (level=2) crosses units and would sail straight past the
+ checker -- exactly the slicer-reaches-into-FTPS breach this script exists
+ to catch. Resolve them instead of trusting the shape.
+ """
+ if not node.level:
+ return node.module
+ # `.` is the containing package -- which is parts[:-1] for a plain module
+ # and for an __init__.py alike. Each extra dot walks one package further up.
+ pkg_parts = list(file.relative_to(ROOT).parts[:-1])
+ ascend = node.level - 1
+ if ascend:
+ if ascend > len(pkg_parts):
+ return None # escapes the source tree; not our edge to police
+ pkg_parts = pkg_parts[:-ascend]
+ if not pkg_parts:
+ return None
+ return ".".join(pkg_parts + ([node.module] if node.module else []))
+
+
+def import_targets(file: Path, node: ast.AST) -> list[str]:
+ """Every dotted ``bambu_cli...`` path an import statement can bind.
+
+ A ``from`` import names a base *and* a list of names, and a name can be a
+ submodule: ``from bambu_cli import protocols`` binds ``bambu_cli.protocols``
+ and ``from bambu_cli.printables import client`` binds the sealed
+ ``bambu_cli.printables.client``. Judging the base alone let both sail past
+ the checker (the base of the first is the bare package, which owns no
+ unit). So yield the base and every ``base.name``; a name that is a plain
+ function rather than a module still maps to the same unit as its base, so
+ the extra candidates never invent an edge that does not exist.
+ """
+ if isinstance(node, ast.ImportFrom):
+ base = absolute_module(file, node)
+ if not base or not base.startswith("bambu_cli"):
+ return []
+ return [base] + [f"{base}.{alias.name}" for alias in node.names]
+ if isinstance(node, ast.Import):
+ return [alias.name for alias in node.names if alias.name.startswith("bambu_cli")]
+ return []
+
+
def iter_edges():
"""Yield (src_unit, dst_unit, file, lineno, deferred)."""
for file in sorted(PKG.rglob("*.py")):
@@ -163,18 +203,12 @@ def iter_edges():
deferred_nodes.add(id(inner))
for node in ast.walk(tree):
- targets: list[str] = []
- if isinstance(node, ast.ImportFrom):
- if node.level: # relative import — same unit by construction
- continue
- if node.module and node.module.startswith("bambu_cli"):
- targets = [node.module]
- elif isinstance(node, ast.Import):
- targets = [a.name for a in node.names if a.name.startswith("bambu_cli")]
- for target in targets:
+ seen: set[str] = set()
+ for target in import_targets(file, node):
dst = unit_of(target)
- if dst is None or dst == src:
+ if dst is None or dst == src or dst in seen:
continue
+ seen.add(dst)
yield src, dst, file, node.lineno, id(node) in deferred_nodes
diff --git a/scripts/gen_schemas.py b/scripts/gen_schemas.py
index 7d81b77..2adf6da 100644
--- a/scripts/gen_schemas.py
+++ b/scripts/gen_schemas.py
@@ -83,6 +83,12 @@ def walk(node):
# `status.printer`'s description and required list.
merged = walk(dict(target))
merged.update({k: v for k, v in node.items() if k != "$ref"})
+ if merged.pop("_nullable", False):
+ t = merged.get("type", "object")
+ if isinstance(t, str):
+ merged["type"] = [t, "null"]
+ elif isinstance(t, list) and "null" not in t:
+ merged["type"] = t + ["null"]
return merged
return {k: walk(v) for k, v in node.items()}
if isinstance(node, list):
@@ -117,8 +123,11 @@ def _normalize_optional(prop, *, nullable):
merged = {k: v for k, v in prop.items() if k != "anyOf"}
merged.update(non_null[0])
- if nullable and isinstance(merged.get("type"), str):
- merged["type"] = [merged["type"], "null"]
+ if nullable:
+ if isinstance(merged.get("type"), str):
+ merged["type"] = [merged["type"], "null"]
+ elif "$ref" in merged:
+ merged["_nullable"] = True
return merged
diff --git a/tests/agent_cli_smoke.py b/tests/agent_cli_smoke.py
index 1c7cc5e..17171ed 100644
--- a/tests/agent_cli_smoke.py
+++ b/tests/agent_cli_smoke.py
@@ -19,6 +19,19 @@
import zipfile
+def _sliced_3mf_bytes():
+ """A minimal sliced 3MF (zip with Metadata/plate_1.gcode); job refuses unsliced ones."""
+ import io
+ import zipfile as _zipfile
+
+ buf = io.BytesIO()
+ with _zipfile.ZipFile(buf, "w") as zf:
+ # Fixed timestamps: the bytes must be identical on every call.
+ for name, data in (("[Content_Types].xml", ""), ("Metadata/plate_1.gcode", "; plate 1\nG28\n")):
+ zf.writestr(_zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)), data)
+ return buf.getvalue()
+
+
def split_configured_cli(configured, platform=None):
"""Split BAMBU_CLI without corrupting plain Windows executable paths."""
active_platform = sys.platform if platform is None else platform
@@ -373,7 +386,7 @@ def smoke_preflight_json(root):
def smoke_local_job_dry_run_json(root):
env = isolated_env(root)
ready = root / "ready.3mf"
- ready.write_text("simulated 3mf content", encoding="utf-8")
+ ready.write_bytes(_sliced_3mf_bytes())
result = run_cli(["job", str(ready), "--confirm", "--dry-run", "--json"], env)
payload = json_stdout(result)
@@ -553,7 +566,7 @@ def smoke_sim_local_zip_job_json(root):
root.mkdir(parents=True, exist_ok=True)
archive_path = root / "agent-bundle.zip"
with zipfile.ZipFile(archive_path, "w") as archive:
- archive.writestr("../agent-ready.3mf", "simulated 3mf content")
+ archive.writestr("../agent-ready.3mf", _sliced_3mf_bytes())
archive.writestr(("a" * 300) + ".txt", "ignored")
result = run_cli(["--sim", "job", str(archive_path), "--confirm", "--json"], env)
@@ -571,7 +584,7 @@ def smoke_sim_local_zip_job_json(root):
if payload.get("uploaded") is not True or payload.get("printed") is not True:
assert False, f"local ZIP job did not complete simulated upload/print: {payload}"
try:
- if not extracted_path.exists() or extracted_path.read_text(encoding="utf-8") != "simulated 3mf content":
+ if not extracted_path.exists() or extracted_path.read_bytes() != _sliced_3mf_bytes():
assert False, "local ZIP job did not extract the expected printer-ready file"
finally:
if extracted_path.parent.name.startswith("bambu-job-"):
@@ -585,7 +598,7 @@ def smoke_sim_local_zip_long_name_json(root):
archive_path = root / "agent-long-name-bundle.zip"
member_name = ("a" * 300) + ".3mf"
with zipfile.ZipFile(archive_path, "w") as archive:
- archive.writestr(member_name, "simulated 3mf content")
+ archive.writestr(member_name, _sliced_3mf_bytes())
result = run_cli(["--sim", "job", str(archive_path), "--upload-only", "--json"], env)
payload = json_stdout(result)
@@ -627,7 +640,7 @@ def smoke_local_zip_extract_error_json(root):
def smoke_sim_job_json(root):
env = isolated_env(root)
ready = root / "ready file.3mf"
- ready.write_text("simulated 3mf content", encoding="utf-8")
+ ready.write_bytes(_sliced_3mf_bytes())
unconfirmed = run_cli(["--sim", "job", str(ready), "--json"], env)
unconfirmed_payload = json_stdout(unconfirmed)
@@ -654,7 +667,7 @@ def smoke_sim_job_json(root):
def smoke_send_alias_json(root):
env = isolated_env(root)
ready = root / "ready.3mf"
- ready.write_text("simulated 3mf content", encoding="utf-8")
+ ready.write_bytes(_sliced_3mf_bytes())
result = run_cli(["--sim", "send", str(ready), "--upload-only", "--json"], env)
payload = json_stdout(result)
@@ -672,7 +685,7 @@ def smoke_send_alias_json(root):
def smoke_sim_lower_level_json(root):
env = isolated_env(root)
ready = root / "ready.3mf"
- ready.write_text("simulated 3mf content", encoding="utf-8")
+ ready.write_bytes(_sliced_3mf_bytes())
status = json_stdout(run_cli(["--sim", "status", "--json"], env))
if status.get("status") != "ok" or status.get("command") != "status":
diff --git a/tests/ci_workflow_smoke.py b/tests/ci_workflow_smoke.py
index 01e36b2..058abe6 100644
--- a/tests/ci_workflow_smoke.py
+++ b/tests/ci_workflow_smoke.py
@@ -25,7 +25,7 @@
"windows runner": "windows-latest",
"oldest supported python": '"3.10"',
"current smoke python": '"3.14"',
- "unit tests": 'python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=86',
+ "unit tests": 'python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=90',
"syntax smoke auto-discovery": "python scripts/syntax_smoke.py",
"cli help smoke auto-discovery": "python scripts/cli_help_smoke.py",
"release readiness smoke": "python tests/release_readiness_smoke.py",
diff --git a/tests/fakes/orca_stub/orca_stub.py b/tests/fakes/orca_stub/orca_stub.py
index a4dab48..76b6a1e 100644
--- a/tests/fakes/orca_stub/orca_stub.py
+++ b/tests/fakes/orca_stub/orca_stub.py
@@ -193,6 +193,13 @@ def main(argv: list[str]) -> int:
print("100%", file=stdout)
if outpath:
_write_valid_3mf(outpath)
+ # Real OrcaSlicer also drops these next to the export (seen in a real
+ # run, 2026-09-22), clobbering same-named files in --outputdir.
+ outdir = os.path.dirname(outpath)
+ with open(os.path.join(outdir, "plate_1.gcode"), "w", encoding="utf-8") as fh:
+ fh.write(_PLATE_GCODE)
+ with open(os.path.join(outdir, "result.json"), "w", encoding="utf-8") as fh:
+ json.dump({"return_code": 0}, fh)
rc = 0
elif scenario == "progress":
diff --git a/tests/json_contract_base.py b/tests/json_contract_base.py
index 574db1b..74c28ef 100644
--- a/tests/json_contract_base.py
+++ b/tests/json_contract_base.py
@@ -29,6 +29,7 @@
# dependency available/allowed).
# ---------------------------------------------------------------------------
+
def assert_shape(payload, spec, path="$"):
"""Validate `payload` against a small hand-rolled spec.
@@ -63,6 +64,7 @@ def assert_shape(payload, spec, path="$"):
for idx, item in enumerate(payload):
assert_shape(item, spec["items"], path=f"{path}[{idx}]")
+
ANY = {}
STR = {"type": str}
BOOL = {"type": bool}
@@ -73,6 +75,7 @@ def assert_shape(payload, spec, path="$"):
BASE_OK = {"type": dict, "required": {"status": {"enum": ["ok"]}, "command": STR}}
+
def base_error_spec(command=None, require_failed_step=True):
required = {
"status": {"enum": ["error"]},
@@ -84,10 +87,12 @@ def base_error_spec(command=None, require_failed_step=True):
required["failed_step"] = STR
return {"type": dict, "required": required}
+
# ---------------------------------------------------------------------------
# Harness
# ---------------------------------------------------------------------------
+
@pytest.fixture(autouse=True)
def _reset_json_state():
utils._JSON_EMITTED = False
@@ -98,6 +103,7 @@ def _reset_json_state():
utils._LAST_ERROR_PAYLOAD = None
utils._LAST_DOWNLOAD_PAYLOAD = None
+
def run_main(monkeypatch, tmp_path, argv, config_path=None):
"""Drive bambu_cli.cli.main() with a scratch config path so no real
on-disk config is ever touched, and return the SystemExit (or None)."""
@@ -114,11 +120,34 @@ def run_main(monkeypatch, tmp_path, argv, config_path=None):
exc = e
return exc
+
def read_json(capsys):
out = capsys.readouterr().out
return json.loads(out)
-def make_ready_file(tmp_path, name="ready.3mf", content="simulated 3mf content"):
+
+def sliced_3mf_bytes(plates=(1,)):
+ """A minimal *sliced* 3MF: a zip with ``Metadata/plate_.gcode`` per plate.
+
+ job/send refuse a .3mf with no sliced plate (the printer would reject it),
+ so printer-ready fixtures must be real sliced packages, not placeholder text.
+ """
+ import io
+ import zipfile
+
+ buf = io.BytesIO()
+ stamp = (2026, 1, 1, 0, 0, 0) # fixed, so the bytes are identical on every call
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr(zipfile.ZipInfo("[Content_Types].xml", date_time=stamp), "")
+ for n in plates:
+ zf.writestr(zipfile.ZipInfo(f"Metadata/plate_{n}.gcode", date_time=stamp), f"; plate {n}\nG28\n")
+ return buf.getvalue()
+
+
+def make_ready_file(tmp_path, name="ready.3mf", content=None):
path = tmp_path / name
- path.write_text(content, encoding="utf-8")
+ if content is None and name.endswith(".3mf"):
+ path.write_bytes(sliced_3mf_bytes())
+ else:
+ path.write_text(content if content is not None else "simulated printer-ready content", encoding="utf-8")
return path
diff --git a/tests/package_contents_smoke.py b/tests/package_contents_smoke.py
index 25f81c3..cea4084 100644
--- a/tests/package_contents_smoke.py
+++ b/tests/package_contents_smoke.py
@@ -164,7 +164,7 @@
LICENSE_SNIPPETS = {
"MIT License",
- "platecli contributors",
+ "Dylan Reed",
'THE SOFTWARE IS PROVIDED "AS IS"',
}
diff --git a/tests/privacy_smoke.py b/tests/privacy_smoke.py
index d3a9e05..19ba337 100644
--- a/tests/privacy_smoke.py
+++ b/tests/privacy_smoke.py
@@ -57,6 +57,18 @@
".gcode",
}
+# The project's published contact identity, and the ONLY exception this gate
+# makes. It is deliberately public: pyproject.toml puts the address in the
+# package metadata, PyPI renders it on the project page, and the README prints
+# it -- so flagging it would red CI over the one address that is supposed to be
+# there. A personal address is a different thing and still fails, in the tree
+# and in the release archives. Add to this tuple only for an address the project
+# intends to publish, never to silence a leak.
+PUBLISHED_AUTHOR_IDENTIFIERS = (
+ "reedworks.sc@gmail.com",
+ "Dylan Reed",
+)
+
def local_identity_patterns():
"""Build local-identity patterns without storing a developer's identity in repo."""
@@ -142,6 +154,28 @@ def iter_files(include_dist=False):
excluded_dirs = set(BASE_EXCLUDED_DIRS)
if include_dist:
excluded_dirs.discard("dist")
+
+ git_dir = ROOT / ".git"
+ if git_dir.exists():
+ import subprocess
+
+ try:
+ cmd = ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"]
+ out = subprocess.check_output(cmd, cwd=ROOT)
+ for raw_rel in out.split(b"\0"):
+ if not raw_rel:
+ continue
+ rel = raw_rel.decode("utf-8", errors="replace")
+ path = ROOT / rel
+ if any(part in excluded_dirs for part in path.parts):
+ continue
+ if path.is_symlink() or not path.is_file():
+ continue
+ yield path
+ return
+ except (subprocess.SubprocessError, OSError):
+ pass
+
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [name for name in dirnames if name not in excluded_dirs]
for filename in filenames:
@@ -168,11 +202,39 @@ def check_forbidden_local_artifact(label, findings):
findings.append(f"{label}: generated printer-ready file should not be committed")
+def published_identifier_spans(text):
+ """Character ranges covered by the project's published contact identity.
+
+ Returned as spans rather than exact strings so a *substring* match is exempt
+ too: a local-account-name pattern derived from a developer's git config can
+ match inside the published address or name, and that is the same deliberate
+ credit rather than a leak.
+ """
+ spans = []
+ lowered = text.lower()
+ for identifier in PUBLISHED_AUTHOR_IDENTIFIERS:
+ needle = identifier.lower()
+ start = lowered.find(needle)
+ while start != -1:
+ spans.append((start, start + len(needle)))
+ start = lowered.find(needle, start + 1)
+ return spans
+
+
+def _within_published_identifier(spans, match):
+ return any(start <= match.start() and match.end() <= end for start, end in spans)
+
+
def scan_text(label, text, patterns, findings):
+ spans = published_identifier_spans(text)
+ label_spans = published_identifier_spans(label)
for pattern_label, pattern in patterns.items():
- if pattern.search(label):
+ label_match = pattern.search(label)
+ if label_match and not _within_published_identifier(label_spans, label_match):
findings.append(f"{label}: path looks like {pattern_label}")
for match in pattern.finditer(text):
+ if _within_published_identifier(spans, match):
+ continue
line = text.count("\n", 0, match.start()) + 1
findings.append(f"{label}:{line}: content looks like {pattern_label}")
diff --git a/tests/release_readiness_smoke.py b/tests/release_readiness_smoke.py
index 81c7c32..98ed506 100644
--- a/tests/release_readiness_smoke.py
+++ b/tests/release_readiness_smoke.py
@@ -91,13 +91,20 @@ def read_relpath(relpath):
return (ROOT / relpath).read_text(encoding="utf-8")
-def iter_generated_paths():
+def iter_generated_paths(strict_clean=False):
import os
is_ci = bool(os.environ.get("GITHUB_ACTIONS"))
+ is_release = bool(os.environ.get("RELEASE_BUILD") or os.environ.get("PLATE_RELEASE"))
+ strict = strict_clean or is_release
for path in ROOT.rglob("*"):
if ".git" in path.parts or ".venv" in path.parts or "venv" in path.parts or ".claude" in path.parts:
continue
+ if not strict:
+ if "__pycache__" in path.parts or path.name.endswith(".pyc"):
+ continue
+ if ".pytest_cache" in path.parts or ".mypy_cache" in path.parts or ".ruff_cache" in path.parts:
+ continue
relpath = path.relative_to(ROOT).as_posix()
name = path.name
if is_ci and (relpath == ".venv" or name == ".venv"):
@@ -114,7 +121,20 @@ def iter_generated_paths():
yield relpath
-def main():
+def parse_args(argv=None):
+ import argparse
+
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--strict-clean",
+ action="store_true",
+ help="Enforce strict clean checks including in-tree __pycache__ and bytecode caches",
+ )
+ return parser.parse_args(argv)
+
+
+def main(argv=None):
+ args = parse_args(argv)
missing_files = sorted(relpath for relpath in REQUIRED_FILES if not (ROOT / relpath).is_file())
if missing_files:
print(f"Missing required files: {missing_files}")
@@ -122,7 +142,7 @@ def main():
sys.exit(1)
forbidden_files = sorted(relpath for relpath in FORBIDDEN_RELEASE_FILES if (ROOT / relpath).exists())
- generated_paths = sorted(set(iter_generated_paths()))
+ generated_paths = sorted(set(iter_generated_paths(strict_clean=args.strict_clean)))
missing_snippets = []
forbidden_snippets = []
for relpath, snippets in OBJECTIVE_SNIPPETS.items():
diff --git a/tests/test_a_plus_coverage.py b/tests/test_a_plus_coverage.py
index a3b8e28..5ee4f8e 100644
--- a/tests/test_a_plus_coverage.py
+++ b/tests/test_a_plus_coverage.py
@@ -17,6 +17,8 @@
import pytest
+from bambu_cli.errors import CommandUnconfirmed
+
from bambu_cli.errors import BambuError
from bambu_cli.printer import BambuPrinter
from bambu_cli.protocols import mqtt as mqtt_mod
@@ -56,6 +58,8 @@ def loop_start():
patch.object(mqtt_mod, "create_mqtt_client", return_value=client),
patch.object(mqtt_mod, "_mqtt_connect"),
patch("sys.stdout.isatty", return_value=False),
+ # A refused connection is an error now, not a silent exit 0.
+ pytest.raises(BambuError),
):
mqtt_mod.monitor_status(args, printer)
client.loop_stop.assert_called()
@@ -159,7 +163,9 @@ def factory(_p):
printer = _printer()
_held(printer, factory, sleep=lambda _s: None)
- assert mqtt_mod.send_command(printer, '{"print":{"command":"pause"}}', timeout=0.01, retries=1) is False
+ # Published but never acknowledged: reported as unconfirmed, never re-sent.
+ with pytest.raises(CommandUnconfirmed):
+ mqtt_mod.send_command(printer, '{"print":{"command":"pause"}}', timeout=0.01, retries=1)
printer.release_mqtt()
class BoomClient(FakeBrokerClient):
diff --git a/tests/test_ams_external_tray.py b/tests/test_ams_external_tray.py
new file mode 100644
index 0000000..6a22490
--- /dev/null
+++ b/tests/test_ams_external_tray.py
@@ -0,0 +1,163 @@
+"""Tests for external tray (vt_tray) parsing in ams.py and interactive detection."""
+
+import argparse
+from unittest.mock import MagicMock
+
+from bambu_cli.ams import parse_ams
+from bambu_cli.interactive.core import read_loaded_ams_material
+
+
+def test_parse_ams_external_tray_only():
+ status = {
+ "gcode_state": "IDLE",
+ "mc_percent": 0,
+ "vt_tray": {
+ "id": "254",
+ "tray_type": "PETG",
+ "tray_color": "FF0000FF",
+ "remain": 75,
+ },
+ "tray_now": "254",
+ }
+ result = parse_ams(status)
+ assert result is not None
+ assert result["active_tray"] is None # normalized sentinel
+ assert result["units"] == []
+ ext = result["external_tray"]
+ assert ext is not None
+ assert ext["slot"] == 254
+ assert ext["type"] == "PETG"
+ assert ext["color"] == "FF0000"
+ assert ext["remain"] == 75
+ assert ext["empty"] is False
+ assert ext["active"] is True
+
+
+def test_parse_ams_external_tray_with_units_inactive():
+ status = {
+ "gcode_state": "RUNNING",
+ "mc_percent": 20,
+ "ams": {
+ "ams": [
+ {
+ "id": "0",
+ "humidity": "3",
+ "temp": "27.0",
+ "tray": [
+ {"id": "0", "tray_type": "PLA", "tray_color": "FFFFFF"},
+ ],
+ }
+ ],
+ "tray_now": "0",
+ "vt_tray": {
+ "id": "254",
+ "tray_type": "TPU",
+ "tray_color": "000000FF",
+ },
+ },
+ }
+ result = parse_ams(status)
+ assert result is not None
+ assert result["active_tray"] == 0
+ assert len(result["units"]) == 1
+ assert result["units"][0]["trays"][0]["active"] is True
+ ext = result["external_tray"]
+ assert ext is not None
+ assert ext["type"] == "TPU"
+ assert ext["color"] == "000000"
+ assert ext["active"] is False # tray_now is 0 (AMS unit 0 slot 0), not external
+
+
+def test_read_loaded_ams_material_external_spool_active(monkeypatch):
+ from bambu_cli.context import RuntimeContext
+
+ status = {
+ "gcode_state": "RUNNING",
+ "mc_percent": 50,
+ "ams": {
+ "ams": [
+ {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
+ ],
+ "tray_now": "254",
+ },
+ "vt_tray": {
+ "id": "254",
+ "tray_type": "PETG",
+ "tray_color": "00FF00FF",
+ },
+ }
+ fake_printer = MagicMock()
+ fake_printer.status.return_value = status
+ fake_ctx = MagicMock()
+ fake_ctx.printer.return_value = fake_printer
+ monkeypatch.setattr(RuntimeContext, "for_request", classmethod(lambda cls, args: fake_ctx))
+
+ active_slot_recorded = []
+ material = read_loaded_ams_material(
+ argparse.Namespace(),
+ on_active_slot=lambda slot: active_slot_recorded.append(slot),
+ )
+ assert material == "PETG"
+ # External spool prints must NOT record an AMS slot for --ams-mapping
+ assert active_slot_recorded == []
+
+
+def test_read_loaded_ams_material_no_ams_units_external_spool(monkeypatch):
+ from bambu_cli.context import RuntimeContext
+
+ status = {
+ "gcode_state": "IDLE",
+ "mc_percent": 0,
+ "vt_tray": {
+ "id": "254",
+ "tray_type": "ABS",
+ "tray_color": "000000FF",
+ },
+ }
+ fake_printer = MagicMock()
+ fake_printer.status.return_value = status
+ fake_ctx = MagicMock()
+ fake_ctx.printer.return_value = fake_printer
+ monkeypatch.setattr(RuntimeContext, "for_request", classmethod(lambda cls, args: fake_ctx))
+
+ material = read_loaded_ams_material(argparse.Namespace())
+ assert material == "ABS"
+
+
+def _status_with_tray_now(tray_now):
+ return {
+ "gcode_state": "IDLE",
+ "mc_percent": 0,
+ "vt_tray": {"id": "254", "tray_type": "PLA", "tray_color": "00FF00FF", "remain": 40},
+ "tray_now": tray_now,
+ }
+
+
+def test_tray_now_255_means_nothing_loaded_not_external_spool_active():
+ """254 and 255 are different states: 254 = feeding from the external spool,
+ 255 = nothing loaded. Conflating them showed a false active filament for an
+ idle printer with a spool sitting in the holder but not loaded."""
+ result = parse_ams(_status_with_tray_now("255"))
+ assert result is not None
+ assert result["active_tray"] is None # both sentinels normalize away here
+ assert result["external_tray"]["active"] is False
+
+
+def test_tray_now_254_still_marks_the_external_spool_active():
+ result = parse_ams(_status_with_tray_now("254"))
+ assert result["external_tray"]["active"] is True
+
+
+def test_external_spool_inactive_while_an_ams_tray_is_feeding():
+ status = _status_with_tray_now("1")
+ status["ams"] = {"ams": [{"id": "0", "tray": [{"id": "1", "tray_type": "ABS"}]}], "tray_now": "1"}
+ result = parse_ams(status)
+ assert result["active_tray"] == 1
+ assert result["external_tray"]["active"] is False
+ assert result["units"][0]["trays"][0]["active"] is True
+
+
+def test_external_tray_slot_falls_back_to_the_sentinel_when_id_is_unparseable():
+ status = _status_with_tray_now("254")
+ status["vt_tray"]["id"] = "not-a-number"
+ assert parse_ams(status)["external_tray"]["slot"] == 254
diff --git a/tests/test_check_layers.py b/tests/test_check_layers.py
new file mode 100644
index 0000000..4383dc6
--- /dev/null
+++ b/tests/test_check_layers.py
@@ -0,0 +1,70 @@
+"""scripts/check_layers.py must see every module an import statement can bind.
+
+A ``from`` import names a base and a list of names, and a name can itself be a
+submodule. Judging the base alone let ``from bambu_cli import protocols`` (base
+is the bare package, which owns no unit) and ``from bambu_cli.printables import
+client`` (base is the public package, the name is the sealed module) pass the
+checker. These tests pin the resolution on the AST, independent of the tree.
+"""
+
+import ast
+from pathlib import Path
+
+from scripts import check_layers
+
+PKG = check_layers.PKG
+
+
+def _from_node(src: str) -> ast.ImportFrom:
+ node = ast.parse(src).body[0]
+ assert isinstance(node, ast.ImportFrom)
+ return node
+
+
+def test_relative_dotdot_import_of_a_sibling_package_is_an_edge():
+ file = PKG / "slicer" / "estimate.py"
+ targets = check_layers.import_targets(file, _from_node("from .. import protocols"))
+ assert "bambu_cli.protocols" in targets
+ assert {check_layers.unit_of(t) for t in targets} >= {"protocols"}
+
+
+def test_absolute_import_of_a_sibling_package_from_the_bare_package_is_an_edge():
+ file = PKG / "slicer" / "estimate.py"
+ targets = check_layers.import_targets(file, _from_node("from bambu_cli import protocols"))
+ assert check_layers.unit_of("bambu_cli") is None # the base alone owns no unit
+ assert "protocols" in {check_layers.unit_of(t) for t in targets}
+
+
+def test_sealed_module_imported_by_name_from_its_package_is_visible():
+ file = PKG / "download" / "naming.py"
+ targets = check_layers.import_targets(file, _from_node("from bambu_cli.printables import client"))
+ assert "bambu_cli.printables.client" in targets
+ assert "bambu_cli.printables.client" in check_layers.SEALED
+
+
+def test_same_package_relative_import_stays_inside_its_unit():
+ file = PKG / "slicer" / "estimate.py"
+ targets = check_layers.import_targets(file, _from_node("from . import cmd"))
+ assert {check_layers.unit_of(t) for t in targets} == {"slicer"}
+
+
+def test_relative_import_from_a_subpackage_init_resolves_to_that_package():
+ file = PKG / "slicer" / "__init__.py"
+ assert check_layers.absolute_module(file, _from_node("from .cmd import x")) == "bambu_cli.slicer.cmd"
+ assert check_layers.absolute_module(file, _from_node("from ..protocols import ftps")) == "bambu_cli.protocols"
+
+
+def test_relative_import_escaping_the_tree_is_not_an_edge():
+ file = PKG / "slicer" / "estimate.py"
+ assert check_layers.absolute_module(file, _from_node("from .... import x")) is None
+ assert check_layers.import_targets(file, _from_node("from .... import x")) == []
+
+
+def test_plain_import_statements_are_unchanged():
+ file = Path(PKG / "cli.py")
+ node = ast.parse("import bambu_cli.printer, os").body[0]
+ assert check_layers.import_targets(file, node) == ["bambu_cli.printer"]
+
+
+def test_the_checker_passes_on_the_real_tree():
+ assert check_layers.main() == 0
diff --git a/tests/test_cli_entry.py b/tests/test_cli_entry.py
index d27a814..6be0fca 100644
--- a/tests/test_cli_entry.py
+++ b/tests/test_cli_entry.py
@@ -78,6 +78,31 @@ def test_main_invalid_printer_ip(self, mock_getaddrinfo, mock_exit, mock_logger)
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 1)
mock_logger.error.assert_called_with("Invalid printer_ip or hostname in config: invalid_ip")
+ @patch("sys.argv", ["bambu.py", "status"])
+ @patch("bambu_cli.cli.logger")
+ @patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("192.0.2.10", 0))])
+ def test_main_accepts_a_resolvable_hostname(self, mock_getaddrinfo, mock_logger):
+ """The success half of the hostname check: a name that resolves must reach
+ the command. Inverting the check to reject every hostname went unnoticed
+ by the suite, which only pinned the failure path above."""
+ import bambu_cli.bambu
+ from bambu_cli import context, utils
+ from bambu_cli.context import RuntimeContext, Settings
+
+ utils._RESOLVE_IP_CACHE.pop("printer.lan", None)
+ utils._RESOLVE_IP_INFLIGHT.pop("printer.lan", None)
+ context.set_current(RuntimeContext(settings=Settings(printer_ip="printer.lan")))
+ with (
+ patch("bambu_cli.config.load_config", return_value=None),
+ patch("bambu_cli.commands.cmd_status") as mock_status,
+ ):
+ bambu_cli.cli.main()
+
+ mock_status.assert_called_once()
+ for call in mock_logger.error.call_args_list:
+ self.assertNotIn("Invalid printer_ip", str(call))
+ utils._RESOLVE_IP_CACHE.pop("printer.lan", None)
+
@patch("sys.argv", ["bambu.py", "--json", "--sim", "upload", "x.stl"])
@patch("bambu_cli.cli.setup_logging")
def test_json_envelope_survives_logger_failure(self, _mock_setup_logging):
@@ -164,7 +189,7 @@ def fake_browser(zc, type_, listener):
from bambu_cli.commands import cmd_setup
mock_getpass.return_value = "12345678"
- self.input_mock_obj.side_effect = ["", "", "n"]
+ self.input_mock_obj.side_effect = ["P1P", "", "n"]
tmpdir = tempfile.mkdtemp()
cfg_path = _os.path.join(tmpdir, "config.json")
diff --git a/tests/test_cmd_print.py b/tests/test_cmd_print.py
index 1976d44..5becc9d 100644
--- a/tests/test_cmd_print.py
+++ b/tests/test_cmd_print.py
@@ -2,6 +2,7 @@
from tests.bambu_test_base import * # noqa: F401,F403
+
class TestBambuCmdPrint(unittest.TestCase):
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.logging_utils._BACKEND")
@@ -236,6 +237,12 @@ def test_cmd_print_with_confirm(self, mock_execute, mock_generate):
cmd_print(args)
mock_generate.assert_called_once_with(
- "test.gcode", use_ams=False, ams_mapping=None, timelapse=False, bed_leveling=False, flow_cali=False
+ "test.gcode",
+ use_ams=False,
+ ams_mapping=None,
+ timelapse=False,
+ bed_leveling=False,
+ flow_cali=False,
+ plate=1,
)
mock_execute.assert_called_once_with(ANY, "test_payload", "test.gcode", dry_run=False)
diff --git a/tests/test_cmd_upload.py b/tests/test_cmd_upload.py
index 70e200c..fd8a10f 100644
--- a/tests/test_cmd_upload.py
+++ b/tests/test_cmd_upload.py
@@ -5,6 +5,7 @@
from tests.bambu_test_base import * # noqa: F401,F403
+
class TestBambuCmdUploadEdgeCases(unittest.TestCase):
@patch("bambu_cli.logging_utils._BACKEND")
@patch("sys.exit")
@@ -51,6 +52,9 @@ def test_cmd_upload_dry_run_success(self, mock_logger, mock_get_printer, mock_ge
mock_get_ftp = MagicMock()
mock_get_ftp.return_value.__enter__.return_value = mock_ftp
printer = _test_printer()
+ # cmd_upload asks the printer what it is printing before replacing a file;
+ # answer locally instead of attempting a real MQTT connection.
+ printer.status = MagicMock(return_value={"gcode_state": "IDLE"})
printer.get_ftp_client = mock_get_ftp
mock_get_printer.return_value = printer
@@ -74,6 +78,9 @@ def test_cmd_upload_dry_run_fail(self, mock_exit, mock_logger, mock_get_printer,
mock_get_ftp = MagicMock(side_effect=OSError("FTP Error"))
printer = _test_printer()
+ # cmd_upload asks the printer what it is printing before replacing a file;
+ # answer locally instead of attempting a real MQTT connection.
+ printer.status = MagicMock(return_value={"gcode_state": "IDLE"})
printer.get_ftp_client = mock_get_ftp
mock_get_printer.return_value = printer
mock_exit.side_effect = SystemExit(2)
@@ -118,6 +125,9 @@ def test_cmd_upload_resume_offset(
]
)
printer = _test_printer()
+ # cmd_upload asks the printer what it is printing before replacing a file;
+ # answer locally instead of attempting a real MQTT connection.
+ printer.status = MagicMock(return_value={"gcode_state": "IDLE"})
printer.get_ftp_client = mock_get_ftp
mock_get_printer.return_value = printer
@@ -154,6 +164,9 @@ def test_cmd_upload_max_retries_exhausted(
mock_get_ftp = MagicMock()
mock_get_ftp.return_value.__enter__.return_value = mock_ftp
printer = _test_printer()
+ # cmd_upload asks the printer what it is printing before replacing a file;
+ # answer locally instead of attempting a real MQTT connection.
+ printer.status = MagicMock(return_value={"gcode_state": "IDLE"})
printer.get_ftp_client = mock_get_ftp
mock_get_printer.return_value = printer
mock_exit.side_effect = SystemExit(2)
@@ -164,6 +177,7 @@ def test_cmd_upload_max_retries_exhausted(
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2)
self.assertIn("Upload failed", str(cm.exception))
+
class TestBambuUploadRetry(unittest.TestCase):
@patch("bambu_cli.printer.get_printer")
@patch("bambu_cli.printer.logger")
@@ -193,6 +207,9 @@ def test_cmd_upload_retry_success(
mock_get_ftp = MagicMock()
mock_get_ftp.return_value.__enter__.return_value = mock_ftp
printer = _test_printer()
+ # cmd_upload asks the printer what it is printing before replacing a file;
+ # answer locally instead of attempting a real MQTT connection.
+ printer.status = MagicMock(return_value={"gcode_state": "IDLE"})
printer.get_ftp_client = mock_get_ftp
mock_get_printer.return_value = printer
diff --git a/tests/test_config_and_logging.py b/tests/test_config_and_logging.py
index 2916b52..aeaf287 100644
--- a/tests/test_config_and_logging.py
+++ b/tests/test_config_and_logging.py
@@ -292,5 +292,227 @@ def test_config_validate_errors_exit(self):
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 1)
+class TestConfigPlatformAndCandidates(unittest.TestCase):
+ def test_default_config_path_platforms(self):
+ from bambu_cli.config import _default_config_path
+
+ # darwin
+ with patch("sys.platform", "darwin"), patch("os.path.exists", return_value=False):
+ p = _default_config_path()
+ self.assertIn("Library/Application Support/bambu/config.json", p.replace("\\", "/"))
+
+ # win32 with APPDATA
+ with patch("sys.platform", "win32"), patch("os.path.exists", return_value=False):
+ with patch.dict(os.environ, {"APPDATA": "C:\\MockAppData"}):
+ p = _default_config_path()
+ self.assertIn("bambu/config.json", p.replace("\\", "/"))
+
+ # win32 without APPDATA
+ with patch("sys.platform", "win32"), patch("os.path.exists", return_value=False):
+ with patch.dict(os.environ, {}, clear=True):
+ p = _default_config_path()
+ self.assertIn("AppData/Roaming/bambu/config.json", p.replace("\\", "/"))
+
+ # linux with XDG_CONFIG_HOME
+ with patch("sys.platform", "linux"), patch("os.path.exists", return_value=False):
+ with patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/xdg"}):
+ p = _default_config_path()
+ self.assertIn("/custom/xdg/bambu/config.json", p.replace("\\", "/"))
+
+ # linux without XDG_CONFIG_HOME
+ with patch("sys.platform", "linux"), patch("os.path.exists", return_value=False):
+ with patch.dict(os.environ, {}, clear=True):
+ p = _default_config_path()
+ self.assertIn(".config/bambu/config.json", p.replace("\\", "/"))
+
+ def test_orca_binary_and_profile_candidates_platforms(self):
+ from bambu_cli.config import (
+ _orca_binary_candidates,
+ _profiles_dir_candidates,
+ _first_existing_path,
+ )
+
+ # darwin
+ with patch("sys.platform", "darwin"):
+ orca_cands = _orca_binary_candidates()
+ self.assertTrue(any("OrcaSlicer.app" in c for c in orca_cands))
+ prof_cands = _profiles_dir_candidates()
+ self.assertTrue(any("OrcaSlicer.app" in c for c in prof_cands))
+
+ # win32 with PROGRAMFILES(X86)
+ with patch("sys.platform", "win32"), patch("shutil.which", return_value=None):
+ with patch.dict(os.environ, {"PROGRAMFILES(X86)": r"C:\Program Files (x86)"}):
+ orca_cands = _orca_binary_candidates()
+ self.assertTrue(any("orca-slicer.exe" in c for c in orca_cands))
+ prof_cands = _profiles_dir_candidates()
+ self.assertTrue(any("OrcaSlicer" in c for c in prof_cands))
+
+ # win32 without PROGRAMFILES(X86)
+ with patch("sys.platform", "win32"), patch("shutil.which", return_value=None):
+ with patch.dict(os.environ, {}, clear=True):
+ orca_cands = _orca_binary_candidates()
+ prof_cands = _profiles_dir_candidates()
+ self.assertTrue(len(orca_cands) > 0)
+ self.assertTrue(len(prof_cands) > 0)
+
+ # _first_existing_path
+ self.assertIsNone(_first_existing_path([]))
+ with patch("os.path.exists", return_value=False):
+ first = _first_existing_path(["/dummy/one", "/dummy/two"])
+ self.assertIn("dummy/one", first)
+
+
+class TestConfigErrorsAndSecurity(unittest.TestCase):
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_load_config_not_found_platforms(self, mock_logger):
+ with patch("bambu_cli.config.CONFIG_PATH", "/nonexistent/path/config.json"):
+ with patch("sys.platform", "win32"):
+ with self.assertRaises(BambuError):
+ load_config()
+ self.assertTrue(any("orca-slicer.exe" in call[0][0] for call in mock_logger.info.call_args_list))
+
+ with patch("sys.platform", "darwin"):
+ with self.assertRaises(BambuError):
+ load_config()
+ self.assertTrue(any("OrcaSlicer.app" in call[0][0] for call in mock_logger.info.call_args_list))
+
+ def test_load_config_stat_oserror(self):
+ with patch("os.path.exists", return_value=True):
+ with patch("sys.platform", "linux"):
+ with patch("os.stat", side_effect=OSError("stat error")):
+ self.assertIsNone(load_config(exit_on_fail=False))
+ with self.assertRaises(BambuError) as cm:
+ load_config(exit_on_fail=True)
+ self.assertEqual(cm.exception.exit_code, 1)
+
+ def test_load_config_chmod_oserror_continues(self):
+ st = MagicMock()
+ st.st_mode = 0o644 # world-readable, triggers chmod
+ with patch("os.path.exists", return_value=True):
+ with patch("sys.platform", "linux"):
+ with patch("os.stat", return_value=st):
+ with patch("os.chmod", side_effect=OSError("chmod error")):
+ with patch("builtins.open", mock_open(read_data='{"printer_ip": "127.0.0.1", "serial": "S1", "access_code": "code"}')):
+ cfg = load_config(exit_on_fail=True)
+ self.assertIsNotNone(cfg)
+
+ def test_load_config_generic_exception(self):
+ with patch("os.path.exists", return_value=True):
+ with patch("sys.platform", "win32"):
+ with patch("builtins.open", side_effect=RuntimeError("unexpected crash")):
+ self.assertIsNone(load_config(exit_on_fail=False))
+ with self.assertRaises(BambuError) as cm:
+ load_config(exit_on_fail=True)
+ self.assertEqual(cm.exception.exit_code, 1)
+
+ def test_apply_config_empty_and_insecure_tls(self):
+ from bambu_cli.config import apply_config
+
+ self.assertIsNone(apply_config({}))
+ with patch("bambu_cli.logging_utils._BACKEND") as mock_logger:
+ apply_config({"insecure_tls": True})
+ self.assertTrue(any("SECURITY WARNING" in call[0][0] for call in mock_logger.warning.call_args_list))
+
+ def test_enforce_secret_file_permissions_win32_and_chmod_error(self):
+ from bambu_cli.config import _enforce_secret_file_permissions
+
+ with patch("sys.platform", "win32"):
+ # win32 returns early
+ self.assertIsNone(_enforce_secret_file_permissions("dummy", "dummy"))
+
+ with patch("sys.platform", "linux"):
+ # Already 0600
+ st_secure = MagicMock(st_mode=0o600)
+ with patch("os.stat", return_value=st_secure), patch("os.chmod") as mock_chmod:
+ _enforce_secret_file_permissions("dummy", "dummy")
+ mock_chmod.assert_not_called()
+
+ # Chmod succeeds
+ st_insecure = MagicMock(st_mode=0o644)
+ with patch("os.stat", return_value=st_insecure), patch("os.chmod") as mock_chmod:
+ with patch("bambu_cli.logging_utils._BACKEND") as mock_logger:
+ _enforce_secret_file_permissions("dummy", "dummy")
+ mock_chmod.assert_called_once_with("dummy", 0o600)
+ self.assertTrue(any("Automatically enforced 0600" in call[0][0] for call in mock_logger.info.call_args_list))
+
+ # Chmod error
+ with patch("os.stat", return_value=st_insecure):
+ with patch("os.chmod", side_effect=OSError("chmod denied")):
+ with patch("bambu_cli.logging_utils._BACKEND") as mock_logger:
+ _enforce_secret_file_permissions("dummy", "dummy")
+ self.assertTrue(any("Could not tighten permissions" in call[0][0] for call in mock_logger.warning.call_args_list))
+
+ def test_load_access_code_both_keys_warns(self):
+ from bambu_cli.config import load_access_code
+
+ with config_ctx({"access_code": "inline_val", "access_code_file": "/path/to/code"}):
+ with patch("os.stat", side_effect=OSError("missing")):
+ with patch("builtins.open", mock_open(read_data="file_val")):
+ with patch("bambu_cli.logging_utils._BACKEND") as mock_logger:
+ code = load_access_code()
+ self.assertEqual(code, "file_val")
+ self.assertTrue(any("BOTH an inline access_code and an access_code_file" in call[0][0] for call in mock_logger.warning.call_args_list))
+
+ def test_load_access_code_errors(self):
+ from bambu_cli.config import load_access_code
+
+ # Inline placeholder value
+ with config_ctx({"access_code": "YOUR_ACCESS_CODE"}):
+ with self.assertRaises(BambuError) as cm:
+ load_access_code()
+ self.assertEqual(cm.exception.exit_code, 1)
+
+ # access_code_file OSError
+ with config_ctx({"access_code_file": "/path/to/secret"}):
+ with patch("os.stat", side_effect=OSError("missing")):
+ with patch("builtins.open", side_effect=OSError("read denied")):
+ with self.assertRaises(BambuError) as cm:
+ load_access_code()
+ self.assertEqual(cm.exception.exit_code, 1)
+
+ # access_code_file placeholder content
+ with config_ctx({"access_code_file": "/path/to/secret"}):
+ with patch("os.stat", side_effect=OSError("missing")):
+ with patch("builtins.open", mock_open(read_data="ACCESS_CODE")):
+ with self.assertRaises(BambuError) as cm:
+ load_access_code()
+ self.assertEqual(cm.exception.exit_code, 1)
+
+ def test_misc_config_helpers(self):
+ import argparse
+ from bambu_cli.config import (
+ load_username,
+ fingerprint_sha256,
+ get_network_timeout,
+ get_slicer_timeout,
+ get_command_timeout,
+ get_upload_timeout,
+ _expected_fingerprint,
+ )
+
+ with settings_ctx(username="test_user"):
+ self.assertEqual(load_username(), "test_user")
+
+ self.assertIsNone(fingerprint_sha256(None))
+ self.assertIsNone(fingerprint_sha256(b""))
+
+ with config_ctx({"cert_fingerprint": "AA:BB:CC:DD"}):
+ self.assertEqual(_expected_fingerprint(), "aabbccdd")
+
+ # CLI args override config and default
+ args = argparse.Namespace(network_timeout=99.0, slicer_timeout=88.0, command_timeout=77.0, upload_timeout=66.0)
+ self.assertEqual(get_network_timeout(args), 99.0)
+ self.assertEqual(get_slicer_timeout(args), 88.0)
+ self.assertEqual(get_command_timeout(args), 77.0)
+ self.assertEqual(get_upload_timeout(args), 66.0)
+
+ # Config override for timeout
+ with config_ctx({"network_timeout": 42.0, "slicer_timeout": 500.0, "command_timeout": 30.0, "upload_timeout": 120.0}):
+ self.assertEqual(get_network_timeout(), 42.0)
+ self.assertEqual(get_slicer_timeout(), 500.0)
+ self.assertEqual(get_command_timeout(), 30.0)
+ self.assertEqual(get_upload_timeout(), 120.0)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_config_bool_coercion.py b/tests/test_config_bool_coercion.py
new file mode 100644
index 0000000..4d580ad
--- /dev/null
+++ b/tests/test_config_bool_coercion.py
@@ -0,0 +1,44 @@
+"""Boolean config keys are read strictly, and an unreadable value fails safe.
+
+``bool("false")`` is ``True``. ``camera_allow_streamer`` -- the opt-in to the
+Docker streamer that does not honour ``cert_fingerprint`` -- was read with
+``bool()``, so a hand-edited ``"camera_allow_streamer": "false"`` switched the
+unpinned path ON. ``insecure_tls`` already had a strict reader; every
+security-relevant boolean now uses it, falling to the safe side when the value
+is not a recognisable true/false.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from bambu_cli.commands.snapshot import streamer_is_allowed
+from bambu_cli.context import Settings
+
+
+@pytest.mark.parametrize("value", [False, "false", "False", "no", "0", "off", "", None, "maybe", 0, 2])
+def test_camera_allow_streamer_is_off_unless_clearly_true(value):
+ settings = Settings.from_config({"camera_allow_streamer": value})
+ assert settings.camera_allow_streamer is False
+ assert streamer_is_allowed(settings) is False
+
+
+@pytest.mark.parametrize("value", [True, "true", "yes", "1", "on", " TRUE ", 1])
+def test_camera_allow_streamer_true_spellings(value):
+ assert Settings.from_config({"camera_allow_streamer": value}).camera_allow_streamer is True
+
+
+@pytest.mark.parametrize("value", [False, "false", "no", "0", "off", None, 0])
+def test_camera_direct_only_false_spellings_mean_false(value):
+ assert Settings.from_config({"camera_direct_only": value}).camera_direct_only is False
+
+
+@pytest.mark.parametrize("value", [True, "yes", "true", "maybe", 1, 7])
+def test_camera_direct_only_unreadable_values_stay_locked_down(value):
+ # direct-only is the safer mode, so anything that is not a clear "false"
+ # (or null, meaning unset) keeps it on -- the old contract for "yes" included.
+ assert Settings.from_config({"camera_direct_only": value}).camera_direct_only is True
+
+
+def test_camera_direct_only_defaults_off():
+ assert Settings.from_config({}).camera_direct_only is False
diff --git a/tests/test_doctor_and_safety.py b/tests/test_doctor_and_safety.py
index d891bdc..7cc5061 100644
--- a/tests/test_doctor_and_safety.py
+++ b/tests/test_doctor_and_safety.py
@@ -18,11 +18,12 @@ def test_cmd_doctor_config_load_fail(self, mock_logger, mock_exit, mock_load):
self.assertEqual(cm.exception.exit_code, 1)
mock_logger.error.assert_any_call(" ❌ Config check failed.")
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value=None)
@patch("bambu_cli.commands.doctor.load_config")
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("sys.exit")
@patch("bambu_cli.logging_utils._BACKEND")
- def test_cmd_doctor_mqtt_fail(self, mock_logger, mock_exit, mock_get_status, mock_load):
+ def test_cmd_doctor_mqtt_fail(self, mock_logger, mock_exit, mock_get_status, mock_load, mock_probe):
from bambu_cli.commands import cmd_doctor
from bambu_cli.context import current_settings
@@ -38,12 +39,13 @@ def test_cmd_doctor_mqtt_fail(self, mock_logger, mock_exit, mock_get_status, moc
f" ❌ MQTT connection failed. Ensure printer at {current_settings().printer_ip} is on and access code is correct."
)
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value=None)
@patch("bambu_cli.commands.doctor.load_config")
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("sys.exit")
@patch("bambu_cli.logging_utils._BACKEND")
- def test_cmd_doctor_ftps_fail(self, mock_logger, mock_exit, mock_get_ftp, mock_get_status, mock_load):
+ def test_cmd_doctor_ftps_fail(self, mock_logger, mock_exit, mock_get_ftp, mock_get_status, mock_load, mock_probe):
from bambu_cli.commands import cmd_doctor
mock_load.return_value = {"printer_ip": "1.2.3.4"}
@@ -58,11 +60,15 @@ def test_cmd_doctor_ftps_fail(self, mock_logger, mock_exit, mock_get_ftp, mock_g
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 2)
mock_logger.error.assert_any_call(" ❌ FTPS connection failed: FTPS Fail")
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="a" * 64)
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
@patch("builtins.open")
- def test_cmd_doctor_success(self, mock_file_open, mock_logger, mock_get_ftp, mock_get_status):
+ def test_cmd_doctor_success(
+ self, mock_file_open, mock_logger, mock_get_ftp, mock_get_status, mock_get_ver, mock_probe
+ ):
from bambu_cli.commands import cmd_doctor
import tempfile
@@ -75,7 +81,7 @@ def test_cmd_doctor_success(self, mock_file_open, mock_logger, mock_get_ftp, moc
def custom_open(file, *args, **kwargs):
if "config.json" in str(file):
- return original_open(file, *args, **kwargs)
+ return original_open(mock_config_path, *args, **kwargs)
return MagicMock()
mock_file_open.side_effect = custom_open
@@ -97,11 +103,15 @@ def custom_open(file, *args, **kwargs):
any_caps_open, "Expected a randomly generated printer_capabilities_*.json to be opened for writing"
)
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="a" * 64)
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
@patch("builtins.open")
- def test_cmd_doctor_honours_network_timeout(self, mock_file_open, mock_logger, mock_get_ftp, mock_get_status):
+ def test_cmd_doctor_honours_network_timeout(
+ self, mock_file_open, mock_logger, mock_get_ftp, mock_get_status, mock_get_ver, mock_probe
+ ):
"""--network-timeout is forwarded to printer.status() and get_ftp_client()."""
from bambu_cli.commands import cmd_doctor
import io
@@ -115,7 +125,7 @@ def test_cmd_doctor_honours_network_timeout(self, mock_file_open, mock_logger, m
def custom_open(file, *args, **kwargs):
if "config.json" in str(file):
- return original_open(file, *args, **kwargs)
+ return original_open(mock_config_path, *args, **kwargs)
return MagicMock()
mock_file_open.side_effect = custom_open
@@ -362,12 +372,13 @@ def _logged(mock_logger):
calls = list(mock_logger.info.call_args_list) + list(mock_logger.warning.call_args_list)
return [c[0][0] for c in calls if c[0]]
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value=None)
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
def test_cmd_doctor_human_output_redacts_printer_ip_by_default(
- self, mock_logger, mock_get_ftp, mock_get_status, mock_probe
+ self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_get_ver
):
import argparse
import tempfile
@@ -390,12 +401,13 @@ def test_cmd_doctor_human_output_redacts_printer_ip_by_default(
f"doctor human output leaked the printer IP {settings.printer_ip}",
)
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value=None)
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
def test_cmd_doctor_human_output_shows_printer_ip_with_verbose(
- self, mock_logger, mock_get_ftp, mock_get_status, mock_probe
+ self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_get_ver
):
"""Guard for the -v escape hatch.
@@ -421,13 +433,14 @@ def test_cmd_doctor_human_output_shows_printer_ip_with_verbose(
self.assertIn(f" [3/3] Verifying FTPS connectivity to {settings.printer_ip}:990...", logged)
self.assertFalse([line for line in logged if ":" in line])
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.commands.doctor._expected_fingerprint", return_value="ab" * 32)
@patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="ab" * 32)
@patch("bambu_cli.protocols.mqtt.get_status")
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
def test_cmd_doctor_hides_fingerprint_when_already_pinned(
- self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_expected
+ self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_expected, mock_get_ver
):
import argparse
import tempfile
@@ -449,6 +462,7 @@ def test_cmd_doctor_hides_fingerprint_when_already_pinned(
" \U0001f510 \u2705 Printer certificate matches the pinned cert_fingerprint in your config.", logged
)
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
@patch("bambu_cli.commands.doctor._offer_pin_fingerprint", return_value=False)
@patch("bambu_cli.commands.doctor._expected_fingerprint", return_value=None)
@patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="cd" * 32)
@@ -456,7 +470,7 @@ def test_cmd_doctor_hides_fingerprint_when_already_pinned(
@patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
@patch("bambu_cli.logging_utils._BACKEND")
def test_cmd_doctor_shows_fingerprint_when_not_pinned(
- self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_expected, mock_offer
+ self, mock_logger, mock_get_ftp, mock_get_status, mock_probe, mock_expected, mock_offer, mock_get_ver
):
import argparse
import tempfile
@@ -477,5 +491,235 @@ def test_cmd_doctor_shows_fingerprint_when_not_pinned(
self.assertLess(logged.index(hex_line), logged.index(hint))
+class TestDoctorEdgeCases(unittest.TestCase):
+ def setUp(self):
+ install_baseline_context()
+
+ def tearDown(self):
+ install_baseline_context()
+
+ def _logged(self, mock_backend):
+ out = []
+ for call in mock_backend.info.call_args_list:
+ out.append(str(call[0][0]))
+ for call in mock_backend.warning.call_args_list:
+ out.append(str(call[0][0]))
+ for call in mock_backend.error.call_args_list:
+ out.append(str(call[0][0]))
+ return out
+
+ def test_cmd_doctor_invalid_output_path_dash(self):
+ import argparse
+ from bambu_cli.commands import cmd_doctor
+
+ args = argparse.Namespace(json=True, output="-invalid_name.json", verbose=False)
+ with patch("bambu_cli.utils.emit_json") as mock_emit:
+ with self.assertRaises(BambuError) as cm:
+ cmd_doctor(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+ mock_emit.assert_called_once()
+ payload = mock_emit.call_args[0][0]
+ self.assertEqual(payload["failed_step"], "validate")
+ self.assertFalse(payload["ok"])
+
+ def test_cmd_doctor_ensure_parent_dir_error(self):
+ import argparse
+ from bambu_cli.commands import cmd_doctor
+
+ args = argparse.Namespace(json=True, output="/invalid/dir/caps.json", verbose=False)
+ with patch("bambu_cli.commands.doctor._ensure_parent_dir", side_effect=BambuError("perm error", exit_code=3)):
+ with patch("bambu_cli.utils.emit_json") as mock_emit:
+ with self.assertRaises(BambuError) as cm:
+ cmd_doctor(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+ mock_emit.assert_called_once()
+ payload = mock_emit.call_args[0][0]
+ self.assertEqual(payload["failed_step"], "validate")
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", side_effect=Exception("network down"))
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_doctor_probe_cert_fingerprint_exception(
+ self, mock_logger, mock_get_ftp, mock_status, mock_probe, mock_get_ver
+ ):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=False, output=os.path.join(td, "caps.json"), verbose=False)
+ cmd_doctor(args)
+
+ self.assertTrue(os.path.exists(os.path.join(td, "caps.json")))
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.commands.doctor._expected_fingerprint", return_value="aa" * 32)
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="bb" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_doctor_fingerprint_mismatch(
+ self, mock_logger, mock_get_ftp, mock_status, mock_probe, mock_expected, mock_get_ver
+ ):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=False, output=os.path.join(td, "caps.json"), verbose=False)
+ cmd_doctor(args)
+
+ logged = self._logged(mock_logger)
+ self.assertTrue(any("Printer certificate does NOT match" in line for line in logged))
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.commands.doctor._expected_fingerprint", return_value="cc" * 32)
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="cc" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_doctor_verbose_pinned_fingerprint(
+ self, mock_logger, mock_get_ftp, mock_status, mock_probe, mock_expected, mock_get_ver
+ ):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=False, output=os.path.join(td, "caps.json"), verbose=True)
+ cmd_doctor(args)
+
+ logged = self._logged(mock_logger)
+ self.assertTrue(any("Printer certificate SHA-256: " + "cc" * 32 in line for line in logged))
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="dd" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "X1C", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ def test_cmd_doctor_camera_notes(self, mock_get_ftp, mock_status, mock_probe, mock_get_ver):
+ import argparse
+ import json
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+
+ with settings_ctx(printer_model="X1C", camera_direct_only=True):
+ with tempfile.TemporaryDirectory() as td:
+ out_file = os.path.join(td, "caps1.json")
+ args = argparse.Namespace(json=False, output=out_file, verbose=False)
+ cmd_doctor(args)
+ with open(out_file, "r") as f:
+ data = json.load(f)
+ self.assertIn("camera_direct_only is set", data["capabilities"]["camera_snapshot_note"])
+
+ with settings_ctx(printer_model="X1C", camera_direct_only=False, camera_allow_streamer=True):
+ with tempfile.TemporaryDirectory() as td:
+ out_file = os.path.join(td, "caps2.json")
+ args = argparse.Namespace(json=False, output=out_file, verbose=False)
+ cmd_doctor(args)
+ with open(out_file, "r") as f:
+ data = json.load(f)
+ self.assertIn("camera_allow_streamer is set", data["capabilities"]["camera_snapshot_note"])
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="ee" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ def test_cmd_doctor_output_write_oserror(
+ self, mock_get_ftp, mock_status, mock_probe, mock_get_ver
+ ):
+ import argparse
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+ args = argparse.Namespace(json=True, output="/tmp/test_caps.json", verbose=False)
+
+ orig_open = open
+
+ def custom_open(file, *args, **kwargs):
+ if "test_caps.json" in str(file):
+ raise OSError("Disk full")
+ return orig_open(file, *args, **kwargs)
+
+ with patch("builtins.open", side_effect=custom_open):
+ with patch("bambu_cli.utils.emit_json") as mock_emit:
+ with self.assertRaises(BambuError) as cm:
+ cmd_doctor(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+ mock_emit.assert_called_once()
+ payload = mock_emit.call_args[0][0]
+ self.assertEqual(payload["failed_step"], "output")
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[{"name": "ota", "sw_ver": "01.05.00.00"}])
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="ff" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.05.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ def test_cmd_doctor_json_mode_reported_ip(
+ self, mock_get_ftp, mock_status, mock_probe, mock_get_ver
+ ):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+
+ # Non-verbose JSON mode -> IP redacted
+ with settings_ctx(printer_ip="192.168.1.50"):
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=True, output=os.path.join(td, "caps.json"), verbose=False)
+ with patch("bambu_cli.utils.emit_json") as mock_emit:
+ cmd_doctor(args)
+ payload = mock_emit.call_args[0][0]
+ self.assertEqual(payload["printer_ip"], "")
+
+ # Verbose JSON mode -> IP exposed
+ with settings_ctx(printer_ip="192.168.1.50"):
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=True, output=os.path.join(td, "caps.json"), verbose=True)
+ with patch("bambu_cli.utils.emit_json") as mock_emit:
+ cmd_doctor(args)
+ payload = mock_emit.call_args[0][0]
+ self.assertEqual(payload["printer_ip"], "192.168.1.50")
+
+ @patch("bambu_cli.printer.BambuPrinter.get_version", return_value=[])
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="11" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value={"hw_ver": "P1P", "sw_ver": "01.00.00.00"})
+ @patch("bambu_cli.printer.BambuPrinter.get_ftp_client")
+ def test_cmd_doctor_empty_modules(self, mock_get_ftp, mock_status, mock_probe, mock_get_ver):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ mock_get_ftp.return_value.__enter__.return_value = MagicMock()
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=False, output=os.path.join(td, "caps.json"), verbose=False)
+ cmd_doctor(args)
+ self.assertTrue(os.path.exists(os.path.join(td, "caps.json")))
+
+ @patch("bambu_cli.commands.doctor._expected_fingerprint", return_value=None)
+ @patch("bambu_cli.protocols.mqtt.probe_cert_fingerprint", return_value="22" * 32)
+ @patch("bambu_cli.printer.BambuPrinter.status", return_value=None)
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_doctor_mqtt_fail_with_fp_pin_hint(self, mock_logger, mock_status, mock_probe, mock_expected):
+ import argparse
+ import tempfile
+ from bambu_cli.commands import cmd_doctor
+
+ with tempfile.TemporaryDirectory() as td:
+ args = argparse.Namespace(json=False, output=os.path.join(td, "caps.json"), verbose=False)
+ with self.assertRaises(BambuError) as cm:
+ cmd_doctor(args)
+ self.assertEqual(cm.exception.exit_code, 2)
+
+ logged = self._logged(mock_logger)
+ self.assertTrue(any("The printer uses a self-signed certificate" in line for line in logged))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_download_cmd.py b/tests/test_download_cmd.py
index 778914b..9820c36 100644
--- a/tests/test_download_cmd.py
+++ b/tests/test_download_cmd.py
@@ -442,11 +442,14 @@ def test_ssrf_urlerror_reported_as_security_violation(self, mock_logger):
import urllib.error
from bambu_cli.commands import cmd_download
- self.mock_open.side_effect = urllib.error.URLError("Security Error: blocked host")
+ from bambu_cli.netsafety import BlockedAddressError
+
+ # urllib wraps the guard's refusal from connect() in a second URLError.
+ self.mock_open.side_effect = urllib.error.URLError(BlockedAddressError("blocked.example", ["10.0.0.7"]))
with self.assertRaises((SystemExit, BambuError)) as cm:
self._run_download(self._args())
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 5)
- self.assertTrue(any("SSRF Security Violation" in c[0][0] for c in mock_logger.error.call_args_list))
+ self.assertTrue(any("--allow-private-ips" in c[0][0] for c in mock_logger.error.call_args_list))
@patch("bambu_cli.logging_utils._BACKEND")
def test_oserror_reported_as_local_file_error(self, mock_logger):
@@ -532,6 +535,94 @@ def test_html_page_resolving_to_model_link_downloads_it(self, mock_logger, mock_
self.assertTrue(any("Found model file link" in c[0][0] for c in mock_logger.info.call_args_list))
self.assertTrue(any("found.stl" in c[0][0] for c in mock_logger.info.call_args_list))
+ def test_cmd_download_empty_downloaded_file(self):
+ args = self._args(url="https://example.com/empty.stl")
+ self._respond(_FakeResp([b""]))
+ with patch("os.path.getsize", return_value=0):
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_download_http_403_forbidden(self, mock_logger):
+ args = self._args(url="https://example.com/forbidden.stl")
+ import urllib.error
+
+ self.mock_safe_opener.open.side_effect = urllib.error.HTTPError(
+ url=args.url, code=403, msg="Forbidden", hdrs={}, fp=None
+ )
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 2)
+ self.assertTrue(any("Access is forbidden" in c[0][0] for c in mock_logger.info.call_args_list))
+
+ def test_cmd_download_ssrf_security_violation(self):
+ args = self._args(url="https://127.0.0.1/private.stl")
+ import urllib.error
+ from bambu_cli.netsafety import BlockedAddressError
+
+ self.mock_safe_opener.open.side_effect = BlockedAddressError("127.0.0.1", ["127.0.0.1"])
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 5)
+ self.assertEqual(cm.exception.failed_step, "validate")
+
+ def test_cmd_download_local_oserror(self):
+ args = self._args(url="https://example.com/model.stl")
+ self._respond(_FakeResp([b"some bytes"]))
+ with patch("builtins.open", side_effect=OSError("Disk full")):
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+
+ def test_cmd_download_generic_exception(self):
+ args = self._args(url="https://example.com/model.stl")
+ self.mock_safe_opener.open.side_effect = RuntimeError("unhandled socket death")
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 2)
+
+ def test_cmd_download_ensure_parent_dir_failure(self):
+ args = self._args(url="https://example.com/model.stl", output="/no/perm/dir")
+ with patch(
+ "bambu_cli.download.downloader._ensure_output_dir", side_effect=BambuError("mkdir error", exit_code=3)
+ ):
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+
+ @patch("bambu_cli.download.downloader._extract_zip_model")
+ def test_cmd_download_archive_extraction_errors(self, mock_extract):
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as td:
+ args = self._args(url="https://example.com/model.zip", output=td)
+ self._respond(_FakeResp([b"PK zip bytes"], headers={"Content-Type": "application/zip"}))
+
+ # OSError during extraction
+ mock_extract.side_effect = OSError("CRC check failed")
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+
+ # ValueError during extraction (no printable 3D files in archive)
+ self._respond(_FakeResp([b"PK zip bytes"], headers={"Content-Type": "application/zip"}))
+ mock_extract.side_effect = ValueError("Archive contains no model files")
+ with self.assertRaises(BambuError) as cm:
+ self._run_download(args)
+ self.assertEqual(cm.exception.exit_code, 3)
+
+ @patch("bambu_cli.logging_utils._BACKEND")
+ def test_cmd_download_with_progress_bar(self, mock_logger):
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as td:
+ args = self._args(url="https://example.com/model.stl", output=td, json=False, progress=True)
+ self._respond(_FakeResp([b"chunk1", b"chunk2", b""], headers={"Content-Length": "12"}))
+ with patch("builtins.open", mock_open()):
+ out = self._run_download(args)
+ self.assertTrue(out.endswith("model.stl"))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_download_hardening_p0.py b/tests/test_download_hardening_p0.py
index 08aa6ab..ff6c372 100644
--- a/tests/test_download_hardening_p0.py
+++ b/tests/test_download_hardening_p0.py
@@ -118,6 +118,25 @@ def test_redirected_url_with_unsupported_extension_rejected(tmp_path):
leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0]
assert not leftovers, f"partial download not cleaned up: {leftovers}"
+def test_redirected_url_with_a_non_http_scheme_is_rejected_before_any_write(tmp_path):
+ """The final (post-redirect) URL is re-validated like the original one. This
+ line went green with the validation replaced by a no-op, so pin it: a
+ response whose final URL is not http(s) must abort as a command error."""
+ from bambu_cli.constants import EXIT_COMMAND_ERROR
+
+ original_url = "https://example.com/model.stl"
+ resp = _base_resp("ftp://example.com/model.stl")
+ opener = _mock_opener(resp)
+ args = _args(tmp_path, original_url)
+
+ with pytest.raises((SystemExit, BambuError)) as excinfo:
+ download._cmd_download(args, opener_factory=lambda: opener)
+
+ assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR
+ leftovers = [p for p in tmp_path.iterdir() if p.stat().st_size > 0]
+ assert not leftovers, f"partial download not cleaned up: {leftovers}"
+
+
# ---------------------------------------------------------------------------
# Mid-stream size enforcement / short reads / empty files
# ---------------------------------------------------------------------------
diff --git a/tests/test_download_multi_model.py b/tests/test_download_multi_model.py
new file mode 100644
index 0000000..63bd67e
--- /dev/null
+++ b/tests/test_download_multi_model.py
@@ -0,0 +1,137 @@
+"""Tests for preserving multi-part model ZIP archives during download."""
+
+import argparse
+import io
+import zipfile
+from unittest.mock import patch
+
+from bambu_cli.commands import cmd_download
+
+
+class FakeResp:
+ def __init__(self, data):
+ self._bio = io.BytesIO(data)
+
+ def read(self, n=None):
+ return self._bio.read(n)
+
+ def getheader(self, name):
+ return None
+
+ def geturl(self):
+ return None
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+
+def test_download_preserves_archive_with_multiple_models(tmp_path):
+ zip_bytes = io.BytesIO()
+ with zipfile.ZipFile(zip_bytes, "w") as zf:
+ zf.writestr("part1.stl", b"solid part1\nendsolid part1\n")
+ zf.writestr("part2.stl", b"solid part2\nendsolid part2\n")
+ zip_data = zip_bytes.getvalue()
+
+ fake_response = FakeResp(zip_data)
+
+ outdir = tmp_path / "models"
+ outdir.mkdir()
+
+ args = argparse.Namespace(
+ url="https://example.com/multi_part.zip",
+ output=str(outdir),
+ name=None,
+ max_download_mb=100,
+ allow_private_ips=False,
+ json=False,
+ )
+
+ with patch("bambu_cli.download.downloader.polite_open", return_value=fake_response):
+ cmd_download(args)
+
+ # Primary extracted model
+ assert (outdir / "part1.stl").exists()
+ # Archive itself MUST be preserved because it contained multiple model files
+ assert (outdir / "multi_part.zip").exists()
+
+
+def _run_download(tmp_path, url):
+ zip_bytes = io.BytesIO()
+ with zipfile.ZipFile(zip_bytes, "w") as zf:
+ zf.writestr("part1.stl", b"solid part1\nendsolid part1\n")
+ zf.writestr("part2.stl", b"solid part2\nendsolid part2\n")
+
+ outdir = tmp_path / "models"
+ outdir.mkdir()
+ args = argparse.Namespace(
+ url=url,
+ output=str(outdir),
+ name=None,
+ max_download_mb=100,
+ allow_private_ips=False,
+ json=False,
+ )
+ with patch("bambu_cli.download.downloader.polite_open", return_value=FakeResp(zip_bytes.getvalue())):
+ cmd_download(args)
+ return outdir
+
+
+def test_preserved_archive_name_is_length_capped(tmp_path):
+ """An over-long URL-derived name used to make os.replace fail with
+ ENAMETOOLONG, which was swallowed -- stranding the archive under its hidden
+ .bambu-download-* temp name while the log claimed it was preserved."""
+ outdir = _run_download(tmp_path, "https://example.com/" + "a" * 300 + ".zip")
+ archives = [p for p in outdir.iterdir() if p.suffix == ".zip"]
+ assert len(archives) == 1
+ assert len(archives[0].name) <= 160
+ assert not archives[0].name.startswith(".bambu-download-")
+
+
+def test_preserved_archive_name_escapes_windows_reserved_stems(tmp_path):
+ outdir = _run_download(tmp_path, "https://example.com/con.zip")
+ assert (outdir / "_con.zip").exists()
+ assert not (outdir / "con.zip").exists()
+
+
+def test_preserved_archive_name_strips_control_characters(tmp_path):
+ outdir = _run_download(tmp_path, "https://example.com/we%00ird%0aname.zip")
+ archives = [p for p in outdir.iterdir() if p.suffix == ".zip"]
+ assert len(archives) == 1
+ assert not any(ord(ch) < 0x20 or ch == "\x00" for ch in archives[0].name)
+
+
+class ZipCtypeResp(FakeResp):
+ """Archive detected by Content-Type rather than by URL extension."""
+
+ def getheader(self, name):
+ return "application/zip" if name.lower() == "content-type" else None
+
+
+def test_preserved_archive_name_falls_back_when_url_path_has_no_basename(tmp_path):
+ """Archive recognised by Content-Type on a URL with no filename in the path:
+ the name has nothing to derive from, so it must fall back, not end up empty."""
+ zip_bytes = io.BytesIO()
+ with zipfile.ZipFile(zip_bytes, "w") as zf:
+ zf.writestr("part1.stl", b"solid part1\nendsolid part1\n")
+ zf.writestr("part2.stl", b"solid part2\nendsolid part2\n")
+
+ outdir = tmp_path / "models"
+ outdir.mkdir()
+ args = argparse.Namespace(
+ url="https://example.com/",
+ output=str(outdir),
+ name=None,
+ max_download_mb=100,
+ allow_private_ips=False,
+ json=False,
+ )
+ with patch(
+ "bambu_cli.download.downloader.polite_open",
+ return_value=ZipCtypeResp(zip_bytes.getvalue()),
+ ):
+ cmd_download(args)
+
+ assert (outdir / "archive.zip").exists()
diff --git a/tests/test_download_private_ip_refusal.py b/tests/test_download_private_ip_refusal.py
new file mode 100644
index 0000000..158ea1e
--- /dev/null
+++ b/tests/test_download_private_ip_refusal.py
@@ -0,0 +1,118 @@
+"""A download blocked by the private-address guard is a refusal, not a network blip.
+
+Before the 2026-09-22 fix, ``plate download http://localhost/...`` failed with
+exit 2 ("Network error during download: >"), which reads as a transient error worth retrying, and never
+mentioned ``--allow-private-ips``. The branch meant to report it (exit 5,
+``failed_step: validate``) matched on the text "Security Error", which only
+ever appeared in a log line; the two unit tests covering it injected a
+URLError carrying that text, so they passed while production never did. These
+tests drive the real opener.
+"""
+
+from __future__ import annotations
+
+import argparse
+import http.server
+import json
+import socket
+import threading
+from unittest import mock
+
+import pytest
+
+from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_NETWORK_ERROR
+from bambu_cli.errors import BambuError
+from tests.bambu_test_base import settings_ctx
+
+
+class _Handler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self): # noqa: N802
+ body = b"solid cube\nendsolid cube\n"
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args):
+ pass
+
+
+@pytest.fixture
+def local_server():
+ httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield httpd.server_address[1]
+ finally:
+ httpd.shutdown()
+ thread.join(timeout=5)
+ httpd.server_close()
+
+
+def _download(url, outdir, capsys=None):
+ from bambu_cli.commands import cmd_download
+
+ args = argparse.Namespace(url=url, output=str(outdir), name=None, max_download_mb=100, json=True)
+ with pytest.raises(BambuError) as caught:
+ cmd_download(args)
+ return caught.value
+
+
+@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"])
+def test_private_target_is_refused_with_a_hint(local_server, tmp_path, host):
+ with settings_ctx(allow_private_ips=False):
+ exc = _download(f"http://{host}:{local_server}/model.stl", tmp_path)
+ assert exc.exit_code == EXIT_COMMAND_ERROR
+ assert exc.failed_step == "validate"
+ assert "--allow-private-ips" in str(exc)
+ assert "urlopen error" not in str(exc)
+ assert list(tmp_path.iterdir()) == []
+
+
+def test_same_target_downloads_with_the_override(local_server, tmp_path):
+ from bambu_cli.commands import cmd_download
+
+ args = argparse.Namespace(
+ url=f"http://127.0.0.1:{local_server}/model.stl", output=str(tmp_path), name=None, max_download_mb=100
+ )
+ with settings_ctx(allow_private_ips=True):
+ cmd_download(args)
+ assert [p.name for p in tmp_path.iterdir()] == ["model.stl"]
+
+
+def test_unreachable_public_host_is_still_a_network_error(tmp_path):
+ public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))]
+ with (
+ settings_ctx(allow_private_ips=False),
+ mock.patch("bambu_cli.netsafety.socket.getaddrinfo", return_value=public),
+ mock.patch("bambu_cli.netsafety.socket.create_connection", side_effect=OSError("unreachable")),
+ ):
+ exc = _download("http://files.example.org/model.stl", tmp_path)
+ assert exc.exit_code == EXIT_NETWORK_ERROR
+ assert "--allow-private-ips" not in str(exc)
+
+
+def test_json_envelope_names_the_refused_addresses(local_server, tmp_path, capsys):
+ from bambu_cli.utils import write_error_envelope
+
+ with settings_ctx(allow_private_ips=False):
+ exc = _download(f"http://127.0.0.1:{local_server}/model.stl", tmp_path)
+ write_error_envelope(
+ argparse.Namespace(json=True), "download", exc.exit_code, str(exc), exc.failed_step, **exc.extra
+ )
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["exit_code"] == EXIT_COMMAND_ERROR
+ assert payload["blocked_addresses"] == ["127.0.0.1"]
+
+
+def test_multicast_refusal_does_not_suggest_the_override(tmp_path):
+ multicast = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("239.255.255.250", 80))]
+ with (
+ settings_ctx(allow_private_ips=True),
+ mock.patch("bambu_cli.netsafety.socket.getaddrinfo", return_value=multicast),
+ ):
+ exc = _download("http://group.example.org/model.stl", tmp_path)
+ assert exc.exit_code == EXIT_COMMAND_ERROR
+ assert "--allow-private-ips" not in str(exc)
diff --git a/tests/test_first_run_path.py b/tests/test_first_run_path.py
index 8660519..31b2b66 100644
--- a/tests/test_first_run_path.py
+++ b/tests/test_first_run_path.py
@@ -344,3 +344,116 @@ def test_manual_leads_with_first_print_and_demotes_agents():
assert phrase in first, phrase
assert "uploaded_not_printed" in manual
assert "without touching a slicer" not in manual
+
+
+# ---------------------------------------------------------------------------
+# 6. First run with an empty HOME: preflight, `setup --sim` off a TTY, `--sim status`
+# ---------------------------------------------------------------------------
+
+
+def _empty_home(monkeypatch, tmp_path, argv):
+ """Route every config lookup at a fresh, empty HOME and pin the context to unconfigured.
+
+ ``setup_cmd.common`` holds its own from-import copy of ``CONFIG_PATH``, so
+ the ``bambu_cli.config`` patch in ``_cli`` alone would leave preflight
+ reading the developer's real config.
+ """
+ home = tmp_path / "home"
+ home.mkdir()
+ monkeypatch.setenv("HOME", str(home))
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
+ config_path = str(home / ".config" / "bambu" / "config.json")
+ _cli(monkeypatch, argv, config_path)
+ monkeypatch.setattr("bambu_cli.setup_cmd.common.CONFIG_PATH", config_path)
+ # Nothing detected anywhere, whatever the CI host has installed.
+ monkeypatch.setattr("bambu_cli.config.detect_orca_slicer", lambda: None)
+ monkeypatch.setattr("bambu_cli.config.detect_profiles_dir", lambda: None)
+ return config_path
+
+
+def test_readme_names_orcaslicer_before_the_thirty_second_claim():
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ # A newcomer reads about the second slicer before any "30 seconds" / sim promise.
+ assert readme.index("OrcaSlicer") < readme.index("30 seconds")
+ assert readme.index("OrcaSlicer") < readme.index("plate --sim status")
+ # The first-run steps (which start with installing OrcaSlicer) come before the sim section.
+ assert readme.index("## Print something") < readme.index("## Try it in 30 seconds")
+ print_something = _section(readme, "## Print something")
+ assert re.search(r"^1\. \*\*Install OrcaSlicer\*\*", print_something, flags=re.MULTILINE)
+ # The sim section itself says what it does and does not need.
+ sim = _section(readme, "## Try it in 30 seconds")
+ assert "OrcaSlicer" in sim and "neither" in sim
+ assert sim.index("OrcaSlicer") < sim.index("plate --sim status")
+
+
+def test_preflight_empty_home_names_each_missing_piece_separately(monkeypatch, tmp_path):
+ from tests.bambu_test_base import config_ctx, settings_ctx
+
+ _empty_home(monkeypatch, tmp_path, ["preflight"])
+ # config_ctx({}) drops the test base's mock config (which points at /tmp/mock_orca);
+ # settings_ctx then blanks the platform-default slicer paths on top of it.
+ with (
+ config_ctx({}),
+ settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""),
+ patch("bambu_cli.logging_utils._BACKEND") as log,
+ pytest.raises(SystemExit) as ei,
+ ):
+ main()
+ assert ei.value.code == 1
+ failed = [m for m in _messages(log, "info") if "❌" in m]
+ names = [m.split("❌", 1)[1].split(":", 1)[0].strip() for m in failed]
+ # One line per missing piece, not one generic "run setup".
+ assert names == ["config", "orca-slicer", "profiles-dir"], failed
+ by_name = dict(zip(names, failed, strict=True))
+ assert "Config not found" in by_name["config"]
+ assert "OrcaSlicer path is not configured" in by_name["orca-slicer"]
+ assert "OrcaSlicer profile directory is not configured" in by_name["profiles-dir"]
+ # Each names an install / config step of its own, and the summary counts all three.
+ for name in ("orca-slicer", "profiles-dir"):
+ assert "Install it with" in by_name[name] and "config.json" in by_name[name], by_name[name]
+ assert any("Preflight failed: 3 error(s)" in m for m in _messages(log, "error"))
+
+
+def test_preflight_empty_home_json_lists_each_missing_piece(monkeypatch, tmp_path, capsys):
+ from tests.bambu_test_base import config_ctx, settings_ctx
+
+ _empty_home(monkeypatch, tmp_path, ["--json", "preflight"])
+ with (
+ config_ctx({}),
+ settings_ctx(printer_ip="0.0.0.0", orca_slicer="", profiles_dir=""),
+ pytest.raises(SystemExit) as ei,
+ ):
+ main()
+ assert ei.value.code == 1
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["status"] == "error"
+ errors = {c["name"]: c["message"] for c in payload["checks"] if c["status"] == "error"}
+ assert set(errors) == {"config", "orca-slicer", "profiles-dir"}, errors
+ assert payload["errors"] == 3
+
+
+def test_setup_sim_without_tty_is_a_usable_error_not_a_crash(monkeypatch, tmp_path, capsys):
+ _empty_home(monkeypatch, tmp_path, ["setup", "--sim"])
+ with patch("bambu_cli.logging_utils._BACKEND") as log, pytest.raises(SystemExit) as ei:
+ main()
+ assert ei.value.code == 1
+ errors = _messages(log, "error")
+ assert len(errors) == 1, errors
+ assert "cannot run in a headless environment" in errors[0]
+ for flag in ("--printer-ip", "--serial", "--access-code-file"):
+ assert flag in errors[0], flag
+ assert "plate setup --printer-ip" in errors[0]
+ captured = capsys.readouterr()
+ assert "Traceback" not in captured.err and "Traceback" not in captured.out
+
+
+def test_sim_status_with_empty_home_exits_zero_and_reports_idle(monkeypatch, tmp_path):
+ _empty_home(monkeypatch, tmp_path, ["--sim", "status"])
+ with patch("bambu_cli.logging_utils._BACKEND") as log:
+ try:
+ main()
+ except SystemExit as exc: # pragma: no cover - main() may return or exit 0
+ assert exc.code in (None, 0)
+ assert _messages(log, "error") == []
+ info = _messages(log, "info")
+ assert any("State: IDLE" in m for m in info), info
diff --git a/tests/test_job.py b/tests/test_job.py
index f1ec548..d50ae91 100644
--- a/tests/test_job.py
+++ b/tests/test_job.py
@@ -16,6 +16,7 @@
import pytest
+
@contextlib.contextmanager
def _capture_bambu_warnings():
"""Collect WARNING+ records emitted on the 'bambu' logger."""
@@ -33,6 +34,7 @@ def emit(self, record):
finally:
log.removeHandler(handler)
+
from bambu_cli import bambu # noqa: E402
from bambu_cli import job # noqa: E402
from bambu_cli import utils # noqa: E402
@@ -43,6 +45,7 @@ def emit(self, record):
from bambu_cli.context import RuntimeContext # noqa: E402
from bambu_cli.job import JobSteps, _run_job # noqa: E402
from bambu_cli.errors import BambuError
+from tests.json_contract_base import sliced_3mf_bytes
def _json_path_field(path):
@@ -69,40 +72,48 @@ def default_steps(**overrides):
steps.update(overrides)
return JobSteps(**steps)
+
def _parse(argv):
return build_parser().parse_args(argv)
+
def _ctx():
return RuntimeContext()
+
def _read_json(capsys):
out = capsys.readouterr().out
return json.loads(out)
+
def fake_download(path):
def _run(args):
return path
return _run
+
def fake_slice(path):
def _run(args):
return path
return _run
+
def fake_upload(remote_name):
def _run(args):
return remote_name
return _run
+
def fake_print():
def _run(args):
return None
return _run
+
def failing_step(command, exit_code, error, **extra):
"""Build a fake step that mimics a real cmd_* failure: records the
legacy last-error payload, then raises BambuError like domain handlers do.
@@ -115,6 +126,7 @@ def _run(args):
return _run
+
@pytest.fixture(autouse=True)
def _reset_last_error():
utils._LAST_ERROR_PAYLOAD = None
@@ -123,10 +135,12 @@ def _reset_last_error():
utils._LAST_ERROR_PAYLOAD = None
utils._LAST_DOWNLOAD_PAYLOAD = None
+
# ---------------------------------------------------------------------------
# Delegated-step failure payloads
# ---------------------------------------------------------------------------
+
def test_download_failure_detail_flows_through(tmp_path, capsys):
url = "https://example.com/model.stl"
args = _parse(["job", url, "--json"])
@@ -145,6 +159,7 @@ def test_download_failure_detail_flows_through(tmp_path, capsys):
# Dual-write onto ctx.last_error.
assert ctx.last_error["command"] == "download"
+
def test_slice_failure_detail_flows_through(tmp_path):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -168,9 +183,10 @@ def test_slice_failure_detail_flows_through(tmp_path):
assert payload["slice_error"]["failed_step"] == "orca"
assert ctx.last_error["command"] == "slice"
+
def test_upload_failure_detail_flows_through(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--json"])
steps = default_steps(upload=failing_step("upload", 2, "FTPS connection refused", failed_step="ftps"))
ctx = _ctx()
@@ -182,9 +198,10 @@ def test_upload_failure_detail_flows_through(tmp_path, capsys):
assert payload["upload_error"]["error"] == "FTPS connection refused"
assert ctx.last_error["command"] == "upload"
+
def test_print_failure_detail_flows_through(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--json"])
steps = default_steps(
upload=fake_upload("model.3mf"),
@@ -201,13 +218,15 @@ def test_print_failure_detail_flows_through(tmp_path, capsys):
assert "recovery_hint" in payload
assert ctx.last_error["command"] == "print"
+
# ---------------------------------------------------------------------------
# next_command payloads
# ---------------------------------------------------------------------------
+
def test_uploaded_only_next_command(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
steps = default_steps(upload=fake_upload("model.3mf"))
_run_job(_ctx(), args, steps)
@@ -217,9 +236,10 @@ def test_uploaded_only_next_command(tmp_path, capsys):
assert payload["printed"] is False
assert payload["next_command"] == ["print", "model.3mf", "--confirm", "--json"]
+
def test_uploaded_not_printed_next_command(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--json"])
steps = default_steps(upload=fake_upload("model.3mf"))
_run_job(_ctx(), args, steps)
@@ -227,9 +247,10 @@ def test_uploaded_not_printed_next_command(tmp_path, capsys):
assert payload["status"] == "uploaded_not_printed"
assert payload["next_command"] == ["print", "model.3mf", "--confirm", "--json"]
+
def test_uploaded_next_command_includes_ams_and_flags(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(
[
"job",
@@ -260,10 +281,12 @@ def test_uploaded_next_command_includes_ams_and_flags(tmp_path, capsys):
"--skip-flow-cali",
]
+
def _bad_ams_argv(source, *extra):
"""--ams-mapping without --use-ams: rejected by `print`, so job must reject it too."""
return ["job", str(source), "--json", "--ams-mapping", "0,1", *extra]
+
@pytest.mark.parametrize(
"extra",
[
@@ -280,7 +303,7 @@ def test_print_options_validated_without_confirm(tmp_path, capsys, extra):
back a next_command carrying these same flags.
"""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(_bad_ams_argv(ready, *extra))
steps = default_steps(upload=fake_upload("model.3mf"))
with contextlib.suppress(SystemExit, BambuError):
@@ -294,6 +317,7 @@ def test_print_options_validated_without_confirm(tmp_path, capsys, extra):
assert payload["printed"] is False
assert payload["next_command"] is None
+
def test_upload_only_next_command_is_itself_valid(tmp_path, capsys):
"""Whatever next_command we hand back must survive `print`'s own validation.
@@ -301,7 +325,7 @@ def test_upload_only_next_command_is_itself_valid(tmp_path, capsys):
which `print` rejects with exit 5 — a guaranteed failure after a real upload.
"""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json", "--use-ams", "--ams-mapping", "0,1"])
steps = default_steps(upload=fake_upload("model.3mf"))
_run_job(_ctx(), args, steps)
@@ -314,10 +338,11 @@ def test_upload_only_next_command_is_itself_valid(tmp_path, capsys):
_, error = _parse_print_options(replayed)
assert error is None, f"job emitted a next_command that print rejects: {error}"
+
def test_valid_and_absent_print_options_still_pass(tmp_path, capsys):
"""The new validation must not reject the ordinary cases."""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
for argv in (
["job", str(ready), "--dry-run", "--json"],
["job", str(ready), "--dry-run", "--json", "--use-ams", "--ams-mapping", "0"],
@@ -325,9 +350,10 @@ def test_valid_and_absent_print_options_still_pass(tmp_path, capsys):
_run_job(_ctx(), _parse(argv), default_steps(upload=fake_upload("model.3mf")))
assert _read_json(capsys)["status"] == "dry_run_local_skipped"
+
def test_printed_success(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--json"])
steps = default_steps(upload=fake_upload("model.3mf"), print_=fake_print())
_run_job(_ctx(), args, steps)
@@ -336,10 +362,12 @@ def test_printed_success(tmp_path, capsys):
assert payload["printed"] is True
assert payload["uploaded"] is True
+
# ---------------------------------------------------------------------------
# Dry-run matrix
# ---------------------------------------------------------------------------
+
@pytest.mark.parametrize(
"filename,would_slice,would_extract",
[
@@ -368,6 +396,7 @@ def test_dry_run_direct_url(filename, would_slice, would_extract, capsys):
if not would_extract:
assert payload["remote_name"] is not None
+
def test_dry_run_printables_model_url_predicts_slice(capsys):
"""Regression: a Printables model page dry-run must report would_slice=True.
@@ -389,6 +418,7 @@ def test_dry_run_printables_model_url_predicts_slice(capsys):
# being True must not fabricate one.
assert payload["remote_name"] is None
+
def test_predictor_and_doer_share_slice_predicate():
"""The dry-run URL prediction routes through the same predicate as the real run.
@@ -419,6 +449,7 @@ def test_predictor_and_doer_share_slice_predicate():
# simulate by feeding the predicted extension back through it.
assert _ext_would_slice(_file_extension(f"x{predicted_ext}")) is expected, url
+
def test_dry_run_extensionless_url_predicts_slice(capsys):
"""An extension-less direct link predicts slicing, matching the doer's fallback.
@@ -434,6 +465,7 @@ def test_dry_run_extensionless_url_predicts_slice(capsys):
assert payload["would_slice"] is True
assert payload["would_extract"] is False
+
def test_dry_run_local_model_file(tmp_path, capsys):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -446,6 +478,7 @@ def test_dry_run_local_model_file(tmp_path, capsys):
assert payload["would_download"] is False
assert payload["remote_name"]
+
def test_dry_run_local_zip(tmp_path, capsys):
zpath = tmp_path / "model.zip"
with zipfile.ZipFile(zpath, "w") as zf:
@@ -458,9 +491,10 @@ def test_dry_run_local_zip(tmp_path, capsys):
assert payload["would_slice"] is True
assert payload["would_upload"] is True
+
def test_dry_run_local_printer_ready_file(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--dry-run", "--json"])
_run_job(_ctx(), args, default_steps())
payload = _read_json(capsys)
@@ -469,10 +503,11 @@ def test_dry_run_local_printer_ready_file(tmp_path, capsys):
assert payload["would_slice"] is False
assert payload["printable_path"] == _json_path_field(ready)
+
def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys):
"""A local .gcode.3mf is print-ready: dry-run must report would_slice=False."""
ready = tmp_path / "plate.gcode.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--dry-run", "--json"])
_run_job(_ctx(), args, default_steps())
payload = _read_json(capsys)
@@ -481,6 +516,7 @@ def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys):
assert payload["would_upload"] is True
assert payload["remote_name"] == "plate.gcode.3mf"
+
@pytest.mark.parametrize(
"member,would_slice",
[
@@ -507,6 +543,7 @@ def test_dry_run_local_zip_member_slice_matches_predicate(tmp_path, member, woul
assert payload["would_slice"] is would_slice
assert payload["would_upload"] is True
+
def test_dry_run_local_printer_ready_empty_file_fails(tmp_path):
ready = tmp_path / "model.3mf"
ready.write_bytes(b"")
@@ -515,6 +552,7 @@ def test_dry_run_local_printer_ready_empty_file_fails(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_dry_run_would_create_output_dir(tmp_path, capsys):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -525,10 +563,12 @@ def test_dry_run_would_create_output_dir(tmp_path, capsys):
assert payload["would_create_output_dir"] is True
assert not missing_out.exists()
+
# ---------------------------------------------------------------------------
# ZIP paths
# ---------------------------------------------------------------------------
+
def test_zip_bad_archive_fails(tmp_path):
bad_zip = tmp_path / "bad.zip"
bad_zip.write_bytes(b"not a zip")
@@ -537,6 +577,7 @@ def test_zip_bad_archive_fails(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_zip_no_supported_member_fails(tmp_path):
zpath = tmp_path / "empty.zip"
with zipfile.ZipFile(zpath, "w") as zf:
@@ -546,6 +587,7 @@ def test_zip_no_supported_member_fails(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_zip_oversized_member_fails_in_dry_run(tmp_path, capsys):
zpath = tmp_path / "big.zip"
with zipfile.ZipFile(zpath, "w") as zf:
@@ -555,6 +597,7 @@ def test_zip_oversized_member_fails_in_dry_run(tmp_path, capsys):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_zip_unsafe_member_filename_fails(tmp_path, capsys):
# The sanitized member name is short/safe on its own, but the predicted
# sliced output name (stem + "_sliced.3mf") pushes it past
@@ -572,6 +615,7 @@ def test_zip_unsafe_member_filename_fails(tmp_path, capsys):
assert "unsafe printer filename" in payload["error"].lower()
assert payload["archive_entry"] is None
+
def test_zip_archive_entry_propagates_to_summary(tmp_path, capsys):
zpath = tmp_path / "model.zip"
with zipfile.ZipFile(zpath, "w") as zf:
@@ -589,10 +633,12 @@ def test_zip_archive_entry_propagates_to_summary(tmp_path, capsys):
assert payload["extracted_path"] is not None
assert payload["uploaded"] is True
+
# ---------------------------------------------------------------------------
# --output handling
# ---------------------------------------------------------------------------
+
def test_output_created_when_needed(tmp_path, capsys):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -608,9 +654,10 @@ def test_output_created_when_needed(tmp_path, capsys):
assert payload["workdir"] == _json_path_field(out_dir)
assert payload["uploaded"] is True
+
def test_output_ignored_for_printer_ready_local_file(tmp_path, capsys, caplog):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
out_dir = tmp_path / "unused_out"
args = _parse(["job", str(ready), "--json", "--output", str(out_dir)])
steps = default_steps(upload=fake_upload("model.3mf"))
@@ -619,6 +666,7 @@ def test_output_ignored_for_printer_ready_local_file(tmp_path, capsys, caplog):
payload = _read_json(capsys)
assert payload["uploaded"] is True
+
def test_output_invalid_dash_prefixed_value_fails(tmp_path):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -627,6 +675,7 @@ def test_output_invalid_dash_prefixed_value_fails(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR
+
def test_temp_workdir_cleanup_when_no_output_given(tmp_path):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -643,10 +692,12 @@ def _slice(slice_args):
assert captured_workdir["workdir"]
assert not os.path.exists(captured_workdir["workdir"])
+
# ---------------------------------------------------------------------------
# Late-binding default JobSteps still resolve real command handlers.
# ---------------------------------------------------------------------------
+
def test_url_job_reuses_download_workdir_and_cleans_up(tmp_path):
# Regression test: a URL-sourced job that slices used to allocate a
# *second* temp dir for slicing (leaking the first, which held the
@@ -673,9 +724,10 @@ def _slice(slice_args):
assert captured["slice_workdir"] == captured["download_workdir"]
assert not os.path.exists(captured["download_workdir"])
+
def test_default_job_steps_delegate_through_commands(tmp_path, capsys, monkeypatch):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
monkeypatch.setattr("bambu_cli.commands.cmd_upload", lambda ns: "model.3mf")
@@ -684,23 +736,26 @@ def test_default_job_steps_delegate_through_commands(tmp_path, capsys, monkeypat
assert payload["status"] == "uploaded"
assert payload["remote_name"] == "model.3mf"
+
def test_cmd_job_composition_root_wires_the_real_steps(tmp_path, capsys, monkeypatch):
# commands.cmd_job is the composition root: it owns the knowledge of which
# handlers implement each stage and hands them to the orchestrator.
from bambu_cli.commands import cmd_job
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
monkeypatch.setattr("bambu_cli.commands.files.cmd_upload", lambda ns: "model.3mf")
cmd_job(args)
payload = _read_json(capsys)
assert payload["status"] == "uploaded"
+
# ---------------------------------------------------------------------------
# Source-validation failures (fail before any step runs, so no steps needed)
# ---------------------------------------------------------------------------
+
def test_non_http_url_scheme_rejected(capsys):
args = _parse(["job", "ftp://example.com/model.stl", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
@@ -710,6 +765,7 @@ def test_non_http_url_scheme_rejected(capsys):
assert payload["failed_step"] == "validate"
assert "invalid url source" in payload["error"].lower()
+
def test_http_url_with_embedded_credentials_rejected_and_redacted(capsys):
# Username-only + IP host: still trips the embedded-credentials rejection,
# but avoids the repo privacy-smoke's email / user:pass@host literal patterns.
@@ -722,6 +778,7 @@ def test_http_url_with_embedded_credentials_rejected_and_redacted(capsys):
# Userinfo must be stripped from the machine-readable failure.
assert "user@" not in json.dumps(payload)
+
def test_local_file_not_found_fails(tmp_path, capsys):
args = _parse(["job", str(tmp_path / "missing.stl"), "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
@@ -731,6 +788,7 @@ def test_local_file_not_found_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "file not found" in payload["error"].lower()
+
def test_directory_source_fails(tmp_path, capsys):
args = _parse(["job", str(tmp_path), "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
@@ -738,6 +796,7 @@ def test_directory_source_fails(tmp_path, capsys):
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
assert _read_json(capsys)["failed_step"] == "validate"
+
def test_unsupported_local_file_type_fails(tmp_path, capsys):
junk = tmp_path / "notes.txt"
junk.write_text("hello", encoding="utf-8")
@@ -749,6 +808,7 @@ def test_unsupported_local_file_type_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "unsupported source file type" in payload["error"].lower()
+
def test_unsafe_sliced_local_name_rejected_before_slicing(tmp_path, capsys):
# A 150-char stem is fine on its own, but the predicted "_sliced.3mf"
# exceeds MAX_DOWNLOAD_FILENAME_LENGTH and must be rejected before slicing.
@@ -769,9 +829,10 @@ def _slice(_a):
assert payload["failed_step"] == "validate"
assert "unsafe printer filename" in payload["error"].lower()
+
def test_unsafe_printer_ready_local_name_rejected_before_upload(tmp_path, capsys):
ready = tmp_path / (("a" * 200) + ".3mf")
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
upload_called = {"n": 0}
def _upload(_a):
@@ -787,10 +848,12 @@ def _upload(_a):
assert payload["failed_step"] == "validate"
assert "unsafe name" in payload["error"].lower()
+
# ---------------------------------------------------------------------------
# Slice- and print-option validation
# ---------------------------------------------------------------------------
+
def test_invalid_slice_option_fails(tmp_path, capsys):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
@@ -802,9 +865,10 @@ def test_invalid_slice_option_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "--copies" in payload["error"]
+
def test_ams_mapping_without_use_ams_fails(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--ams-mapping", "0", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
_run_job(_ctx(), args, JobSteps())
@@ -813,28 +877,31 @@ def test_ams_mapping_without_use_ams_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "--ams-mapping requires --use-ams" in payload["error"]
+
def test_ams_mapping_non_integer_fails(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--use-ams", "--ams-mapping", "a,b", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR
assert "Invalid AMS mapping format" in _read_json(capsys)["error"]
+
def test_ams_mapping_negative_slot_fails(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--use-ams", "--ams-mapping=-1", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_COMMAND_ERROR
assert "zero or positive" in _read_json(capsys)["error"].lower()
+
def test_ams_mapping_slot_too_high_fails(tmp_path, capsys):
"""AMS has 4 slots/unit; reject indexes beyond a realistic multi-AMS max."""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--use-ams", "--ams-mapping", "100", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
_run_job(_ctx(), args, JobSteps())
@@ -843,10 +910,11 @@ def test_ams_mapping_slot_too_high_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "100" in payload["error"] or "slot" in payload["error"].lower()
+
def test_use_ams_without_mapping_fails(tmp_path, capsys):
"""--use-ams with no mapping must not silently omit ams_mapping for firmware defaults."""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--confirm", "--use-ams", "--json"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
_run_job(_ctx(), args, JobSteps())
@@ -855,13 +923,15 @@ def test_use_ams_without_mapping_fails(tmp_path, capsys):
assert payload["failed_step"] == "validate"
assert "--use-ams" in payload["error"] and "--ams-mapping" in payload["error"]
+
# ---------------------------------------------------------------------------
# --name is URL-only; warn and ignore for a local source
# ---------------------------------------------------------------------------
+
def test_name_ignored_for_local_file_warns(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--name", "renamed.3mf", "--upload-only", "--json"])
steps = default_steps(upload=fake_upload("model.3mf"))
with _capture_bambu_warnings() as records:
@@ -874,10 +944,12 @@ def test_name_ignored_for_local_file_warns(tmp_path, capsys):
# The remote name comes from the file, not --name.
assert payload["remote_name"] == "model.3mf"
+
# ---------------------------------------------------------------------------
# Successful URL download -> continue (the archive-detection branch)
# ---------------------------------------------------------------------------
+
def test_url_download_success_flows_into_slice_and_upload(tmp_path, capsys):
downloaded = tmp_path / "model.stl"
downloaded.write_bytes(b"solid x")
@@ -899,6 +971,7 @@ def _download(_a):
assert payload["uploaded"] is True
assert payload["remote_name"] == "model.3mf"
+
def test_url_download_reports_extracted_archive_member(tmp_path, capsys):
# Simulate cmd_download having transparently extracted a ZIP: it records a
# _LAST_DOWNLOAD_PAYLOAD with an archive_entry, which job/send surfaces.
@@ -926,6 +999,7 @@ def _download(_a):
assert payload["extracted_path"] == _json_path_field(extracted)
assert payload["uploaded"] is True
+
def test_url_invalid_max_download_mb_fails(capsys):
args = _parse(["job", "https://example.com/model.stl", "--json", "--max-download-mb", "0"])
with pytest.raises((SystemExit, BambuError)) as excinfo:
@@ -935,31 +1009,35 @@ def test_url_invalid_max_download_mb_fails(capsys):
assert payload["failed_step"] == "validate"
assert "--max-download-mb must be a positive integer" in payload["error"]
+
def test_run_job_uses_injected_upload_step(tmp_path, capsys, monkeypatch):
# The orchestrator drives whatever the caller wired in; it no longer
# reaches up into bambu_cli.commands for a default.
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
monkeypatch.setattr("bambu_cli.commands.cmd_upload", lambda ns: "model.3mf")
_run_job(_ctx(), args, default_steps())
assert _read_json(capsys)["status"] == "uploaded"
+
def test_run_job_raises_when_a_needed_step_is_missing(tmp_path):
# A miswired caller fails loudly at the boundary rather than silently
# importing a handler from a higher layer.
from bambu_cli.job.steps import MissingJobStep
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
with pytest.raises(MissingJobStep, match="upload"):
_run_job(_ctx(), args, JobSteps())
+
# ---------------------------------------------------------------------------
# generate_print_payload
# ---------------------------------------------------------------------------
+
def test_generate_print_payload_includes_ams_mapping():
payload = json.loads(job.generate_print_payload("m.3mf", use_ams=True, ams_mapping=[0, 1]))
assert payload["print"]["use_ams"] is True
@@ -969,6 +1047,7 @@ def test_generate_print_payload_includes_ams_mapping():
assert payload["print"]["bed_leveling"] is True
assert payload["print"]["flow_cali"] is True
+
def test_generate_print_payload_flags_and_url_encoding():
payload = json.loads(
job.generate_print_payload(
@@ -986,11 +1065,13 @@ def test_generate_print_payload_flags_and_url_encoding():
assert " " not in print_cmd["url"] # basename is percent-encoded
assert print_cmd["subtask_name"] == "part name.3mf"
+
def test_generate_print_payload_omits_ams_mapping_without_use_ams():
payload = json.loads(job.generate_print_payload("m.3mf", use_ams=False, ams_mapping=[0, 1]))
assert payload["print"]["use_ams"] is False
assert "ams_mapping" not in payload["print"]
+
def test_parse_print_options_requires_use_ams_pairing():
from argparse import Namespace
@@ -1014,6 +1095,7 @@ def test_parse_print_options_requires_use_ams_pairing():
mapping, err = job._parse_print_options(Namespace(use_ams=True, ams_mapping="nope"))
assert mapping is None and err is not None
+
def test_predicted_sliced_remote_name_copies():
name = job._predicted_sliced_remote_name("model.stl", copies=1)
assert name.endswith("_sliced.3mf")
@@ -1021,15 +1103,17 @@ def test_predicted_sliced_remote_name_copies():
name3 = job._predicted_sliced_remote_name("/tmp/foo.stl", copies=3)
assert "x3" in name3 or "foo" in name3
+
# ---------------------------------------------------------------------------
# Deep-audit regressions: job/orchestrate.py + job/support.py
# ---------------------------------------------------------------------------
+
def test_copies_ignored_warns_for_printer_ready(tmp_path, capsys):
"""--copies only multiplies models during slicing; a printer-ready file must
warn (and flag copies_ignored) instead of silently printing one copy."""
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--copies", "3", "--upload-only", "--json"])
with _capture_bambu_warnings() as records:
_run_job(_ctx(), args, default_steps(upload=fake_upload("model.3mf")))
@@ -1037,23 +1121,26 @@ def test_copies_ignored_warns_for_printer_ready(tmp_path, capsys):
assert payload.get("copies_ignored") is True
assert any("--copies only applies" in r.getMessage() for r in records)
+
def test_copies_ignored_flagged_in_dry_run(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--copies", "2", "--dry-run", "--json"])
_run_job(_ctx(), args, default_steps())
payload = _read_json(capsys)
assert payload["status"] == "dry_run_local_skipped"
assert payload.get("copies_ignored") is True
+
def test_copies_one_does_not_flag_printer_ready(tmp_path, capsys):
ready = tmp_path / "model.3mf"
- ready.write_bytes(b"x" * 10)
+ ready.write_bytes(sliced_3mf_bytes())
args = _parse(["job", str(ready), "--upload-only", "--json"])
_run_job(_ctx(), args, default_steps(upload=fake_upload("model.3mf")))
payload = _read_json(capsys)
assert "copies_ignored" not in payload
+
def test_zip_oserror_routes_through_structured_failure(tmp_path, capsys, monkeypatch):
"""An OSError opening the ZIP must emit the structured job-failure summary
(failed_step='extract'), not escape as a bare traceback."""
@@ -1075,6 +1162,7 @@ def _boom(path, *a, **kw):
assert payload["failed_step"] == "extract"
assert payload["status"] == "error"
+
def test_dry_run_empty_model_file_fails(tmp_path):
"""Symmetric with the printer-ready branch: a 0-byte sliceable model must
fail dry-run instead of reporting would_slice success."""
@@ -1085,6 +1173,7 @@ def test_dry_run_empty_model_file_fails(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_zip_printer_ready_member_copies_ignored_flagged(tmp_path, capsys):
"""Symmetry: a printer-ready ZIP member with --copies must warn + flag
copies_ignored, matching the non-ZIP printer-ready branch."""
@@ -1101,6 +1190,7 @@ def test_zip_printer_ready_member_copies_ignored_flagged(tmp_path, capsys):
assert payload.get("copies_ignored") is True
assert any("--copies only applies" in r.getMessage() for r in records)
+
def test_zip_sliceable_member_copies_not_flagged(tmp_path, capsys):
zpath = tmp_path / "bundle.zip"
with zipfile.ZipFile(zpath, "w") as zf:
@@ -1112,6 +1202,7 @@ def test_zip_sliceable_member_copies_not_flagged(tmp_path, capsys):
assert payload["would_slice"] is True
assert "copies_ignored" not in payload
+
def test_zip_only_empty_member_fails_dry_run(tmp_path):
"""A ZIP whose only model member is 0 bytes must fail dry-run (knowable
offline), not report success — symmetric with the empty-file checks."""
@@ -1123,6 +1214,7 @@ def test_zip_only_empty_member_fails_dry_run(tmp_path):
_run_job(_ctx(), args, JobSteps())
assert getattr(excinfo.value, "exit_code", getattr(excinfo.value, "code", None)) == EXIT_FILE_ERROR
+
def test_dry_run_output_dir_writability_uses_real_probe(tmp_path, capsys, monkeypatch):
"""The dry-run output-dir writability check must use a real create-probe
(tempfile.mkstemp), NOT os.access(W_OK) which ignores Windows ACLs. Model an
diff --git a/tests/test_jsonio.py b/tests/test_jsonio.py
index 1de5451..636c983 100644
--- a/tests/test_jsonio.py
+++ b/tests/test_jsonio.py
@@ -176,7 +176,8 @@ def _ok(host, *a, **k):
monkeypatch.setattr(utils.socket, "getaddrinfo", _ok)
assert utils._resolve_ip("printer.local") == "10.0.0.5"
- assert utils._RESOLVE_IP_CACHE.get("printer.local") == "10.0.0.5"
+ cached_ip, _stamp = utils._RESOLVE_IP_CACHE["printer.local"] # (ip, monotonic timestamp)
+ assert cached_ip == "10.0.0.5"
utils._RESOLVE_IP_CACHE.clear()
diff --git a/tests/test_migrate_keeps_a_working_code.py b/tests/test_migrate_keeps_a_working_code.py
new file mode 100644
index 0000000..81469be
--- /dev/null
+++ b/tests/test_migrate_keeps_a_working_code.py
@@ -0,0 +1,65 @@
+"""`setup --migrate-access-code` must never leave the config without a usable code.
+
+With both ``access_code`` and ``access_code_file`` set, migration deletes the
+inline key because the file wins. Before the 2026-09-22 fix it did so without
+looking at the file: when ``access_code_file`` pointed at a file that did not
+exist (or held a placeholder), the only working copy of the printer's access
+code was deleted and every printer command then failed.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+
+from bambu_cli import setup_cmd
+
+
+def _config(tmp_path, **extra):
+ path = tmp_path / "config.json"
+ path.write_text(json.dumps({"printer_ip": "192.0.2.10", "serial": "SN", **extra}), encoding="utf-8")
+ os.chmod(path, 0o600)
+ return str(path)
+
+
+def _read(path):
+ with open(path, encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def test_missing_configured_file_receives_the_inline_code(tmp_path):
+ target = tmp_path / "secret" / "access_code"
+ cfg = _config(tmp_path, access_code="12345678", access_code_file=str(target))
+ result = setup_cmd.migrate_access_code(config_path=cfg)
+ assert result["status"] == "migrated"
+ assert target.read_text(encoding="utf-8").strip() == "12345678"
+ assert "access_code" not in _read(cfg)
+
+
+def test_placeholder_file_keeps_the_inline_code(tmp_path):
+ target = tmp_path / "access_code"
+ target.write_text("YOUR_ACCESS_CODE\n", encoding="utf-8")
+ cfg = _config(tmp_path, access_code="12345678", access_code_file=str(target))
+ result = setup_cmd.migrate_access_code(config_path=cfg)
+ assert result["status"] == "error"
+ assert "kept" in result["reason"]
+ assert _read(cfg)["access_code"] == "12345678"
+
+
+def test_unreadable_file_keeps_the_inline_code(tmp_path):
+ target = tmp_path / "access_code_dir"
+ target.mkdir() # a directory: open() fails
+ cfg = _config(tmp_path, access_code="12345678", access_code_file=str(target))
+ result = setup_cmd.migrate_access_code(config_path=cfg)
+ assert result["status"] == "error"
+ assert _read(cfg)["access_code"] == "12345678"
+
+
+def test_valid_file_still_wins_over_a_stale_inline_copy(tmp_path):
+ target = tmp_path / "access_code"
+ target.write_text("87654321\n", encoding="utf-8")
+ cfg = _config(tmp_path, access_code="12345678", access_code_file=str(target))
+ result = setup_cmd.migrate_access_code(config_path=cfg)
+ assert result["status"] == "migrated"
+ assert "access_code" not in _read(cfg)
+ assert target.read_text(encoding="utf-8").strip() == "87654321"
diff --git a/tests/test_monitor_connection_errors.py b/tests/test_monitor_connection_errors.py
new file mode 100644
index 0000000..6f89f9c
--- /dev/null
+++ b/tests/test_monitor_connection_errors.py
@@ -0,0 +1,112 @@
+"""`status --monitor` must fail loudly when it never reaches the printer.
+
+Before the 2026-09-22 fix a refused connection (wrong access code, rc=5) set
+the "terminal" event and returned normally: exit 0, no NDJSON on stdout, so an
+agent could not tell "never connected" from success. A socket/TLS error
+escaped as a raw OSError ("Unexpected error", exit 5), and a broker that never
+answered CONNACK left the monitor waiting forever.
+"""
+
+from __future__ import annotations
+
+import argparse
+import threading
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from bambu_cli.constants import EXIT_NETWORK_ERROR
+from bambu_cli.errors import PrinterConnectionError
+from bambu_cli.protocols import mqtt_monitor
+
+
+class _Client:
+ def __init__(self, on_loop_start=None):
+ self.on_connect = self.on_message = None
+ self._on_loop_start = on_loop_start
+ self.stopped = False
+ self.userdata = None
+
+ def user_data_set(self, data):
+ # paho hands this object back as the callbacks' userdata argument.
+ self.userdata = data
+
+ def subscribe(self, topic):
+ pass
+
+ def publish(self, topic, payload, qos=0):
+ pass
+
+ def loop_start(self):
+ if self._on_loop_start:
+ self._on_loop_start(self)
+
+ def loop_stop(self):
+ self.stopped = True
+
+ def disconnect(self):
+ pass
+
+
+def _printer(timeout=0.2):
+ return SimpleNamespace(simulation_mode=False, serial="SN", mqtt_timeout=timeout)
+
+
+def _run(client, *, connect=lambda p, c: None, timeout=0.2):
+ with (
+ mock.patch.object(mqtt_monitor, "_client_factory", lambda f: lambda printer: client),
+ mock.patch.object(mqtt_monitor, "_connect", connect),
+ ):
+ return mqtt_monitor.monitor_status(argparse.Namespace(json=True), _printer(timeout))
+
+
+def test_refused_connection_is_a_network_error(capsys):
+ client = _Client(on_loop_start=lambda c: c.on_connect(c, None, None, 5))
+ with pytest.raises(PrinterConnectionError) as caught:
+ _run(client)
+ assert caught.value.exit_code == EXIT_NETWORK_ERROR
+ assert "rc=5" in str(caught.value) and "access code" in str(caught.value)
+ assert capsys.readouterr().out == ""
+ assert client.stopped
+
+
+def test_socket_error_on_connect_is_a_network_error():
+ def refuse(printer, client):
+ raise ConnectionRefusedError(111, "Connection refused")
+
+ with pytest.raises(PrinterConnectionError) as caught:
+ _run(_Client(), connect=refuse)
+ assert caught.value.exit_code == EXIT_NETWORK_ERROR
+ assert "Connection refused" in str(caught.value)
+
+
+def test_silent_broker_times_out_instead_of_waiting_forever():
+ outcome = {}
+
+ def target():
+ try:
+ _run(_Client(), timeout=0.1)
+ outcome["result"] = "returned"
+ except PrinterConnectionError as exc:
+ outcome["result"] = exc
+
+ worker = threading.Thread(target=target, daemon=True)
+ worker.start()
+ worker.join(timeout=5)
+ assert not worker.is_alive(), "monitor never gave up on a silent broker"
+ assert isinstance(outcome.get("result"), PrinterConnectionError)
+ assert outcome["result"].exit_code == EXIT_NETWORK_ERROR
+
+
+def test_messages_count_as_connected(capsys):
+ import json
+
+ def deliver(c):
+ c.on_connect(c, c.userdata, None, 0)
+ msg = mock.MagicMock()
+ msg.payload = json.dumps({"print": {"gcode_state": "FINISH", "mc_percent": 100}}).encode()
+ c.on_message(c, c.userdata, msg)
+
+ _run(_Client(on_loop_start=deliver))
+ assert '"terminal"' in capsys.readouterr().out
diff --git a/tests/test_mqtt_command_once.py b/tests/test_mqtt_command_once.py
new file mode 100644
index 0000000..874029f
--- /dev/null
+++ b/tests/test_mqtt_command_once.py
@@ -0,0 +1,154 @@
+"""A printer command is published at most once, whatever the retries.
+
+MQTT QoS 1 confirms delivery with a PUBACK. When the PUBACK arrives after the
+timeout (a slow TLS connect eats most of the 5 s budget), the command has
+already reached the printer. The old retry loop built a fresh client with a
+fresh "published" flag on every attempt, so ``plate gcode "G1 E50"`` could run
+three times and then report ``sent: false`` -- inviting the caller to send it
+a fourth time. Retries are still fine while nothing has been published (the
+connection never came up); once a publish went out, the result is "sent but
+unacknowledged", reported distinctly.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from bambu_cli.constants import EXIT_TIMEOUT
+from bambu_cli.errors import BambuError, CommandUnconfirmed
+from bambu_cli.protocols import mqtt_cmd
+from bambu_cli.protocols.mqtt_session import MqttSession
+from tests.test_mqtt_session import FakeBrokerClient, _printer
+
+PAYLOAD = '{"print":{"command":"gcode_line","param":"G1 E50"}}'
+
+
+class _SlowAck:
+ """The broker takes the publish, but the PUBACK lands after the timeout."""
+
+ published: list = []
+
+ def __init__(self, printer, *args):
+ self.on_connect = self.on_publish = None
+
+ def user_data_set(self, data):
+ pass
+
+ def loop_start(self):
+ self.on_connect(self, None, None, 0)
+
+ def loop_stop(self):
+ pass
+
+ def disconnect(self):
+ pass
+
+ def publish(self, topic, payload, qos=0):
+ _SlowAck.published.append(payload)
+
+
+class _NeverConnects(_SlowAck):
+ """TLS/CONNACK never completes within the timeout: nothing is published."""
+
+ attempts = 0
+
+ def loop_start(self):
+ _NeverConnects.attempts += 1
+
+
+def _one_shot_printer():
+ return SimpleNamespace(simulation_mode=False, serial="SN", mqtt_timeout=0.05, ip="192.0.2.1", _mqtt_session=None)
+
+
+@pytest.fixture(autouse=True)
+def _reset_fakes():
+ _SlowAck.published = []
+ _NeverConnects.attempts = 0
+
+
+def test_one_shot_publishes_once_then_reports_unconfirmed():
+ with mock.patch.object(mqtt_cmd, "_connect", lambda p, c: None), pytest.raises(CommandUnconfirmed) as caught:
+ mqtt_cmd.send_command(_one_shot_printer(), PAYLOAD, timeout=0.05, client_factory=_SlowAck, sleep=lambda s: None)
+ assert len(_SlowAck.published) == 1
+ assert caught.value.exit_code == EXIT_TIMEOUT
+ assert caught.value.extra["sent"] is True and caught.value.extra["acknowledged"] is False
+
+
+def test_one_shot_still_retries_while_nothing_was_published():
+ with mock.patch.object(mqtt_cmd, "_connect", lambda p, c: None):
+ result = mqtt_cmd.send_command(
+ _one_shot_printer(), PAYLOAD, timeout=0.01, retries=2, client_factory=_NeverConnects, sleep=lambda s: None
+ )
+ assert result is False
+ assert _NeverConnects.attempts == 3
+ assert _SlowAck.published == []
+
+
+class _SilentAckClient(FakeBrokerClient):
+ """A held-session broker that never sends PUBACK for the command."""
+
+ def publish(self, topic, payload, qos=0):
+ self.publishes.append((topic, payload, qos))
+ return mock.MagicMock(rc=0)
+
+
+def test_held_session_publishes_once_then_reports_unconfirmed():
+ clients = []
+
+ def factory(_printer):
+ client = _SilentAckClient()
+ clients.append(client)
+ return client
+
+ printer = _printer()
+ printer._mqtt_session = MqttSession(printer, client_factory=factory, sleep=lambda s: None)
+ with pytest.raises(CommandUnconfirmed):
+ mqtt_cmd.send_command(printer, PAYLOAD, timeout=0.01, retries=2)
+ commands = [p for c in clients for (_t, p, qos) in c.publishes if qos == 1]
+ assert commands == [PAYLOAD]
+ # A later reconnect must not flush the abandoned command either.
+ clients[0].force_drop()
+ printer.status(timeout=0.2, require_complete=False)
+ commands = [p for c in clients for (_t, p, qos) in c.publishes if qos == 1]
+ assert commands == [PAYLOAD]
+ printer.release_mqtt()
+
+
+def test_gcode_command_reports_sent_but_unacknowledged(capsys):
+ from bambu_cli.commands.gcode import cmd_gcode
+
+ class _Printer:
+ def send_command(self, payload):
+ raise CommandUnconfirmed("sent, not acknowledged", extra={"sent": True, "acknowledged": False})
+
+ ctx = SimpleNamespace(printer=lambda: _Printer())
+ args = argparse.Namespace(code="G1 E50", confirm=True, json=True)
+ with pytest.raises(BambuError) as caught:
+ cmd_gcode(args, ctx=ctx)
+ assert caught.value.exit_code == EXIT_TIMEOUT
+ assert caught.value.extra == {"gcode": "G1 E50", "sent": True, "acknowledged": False}
+ assert caught.value.next_command == ["status", "--json"]
+ assert json.loads(json.dumps(caught.value.to_error_payload("gcode")))["sent"] is True
+
+
+class _AckDuringTeardown(_SlowAck):
+ """The PUBACK arrives while the network loop is being stopped."""
+
+ def loop_stop(self):
+ self.on_publish(self, None, 1)
+
+
+def test_ack_that_lands_during_teardown_counts():
+ # Found in review: the decision used the already-expired wait() result, so
+ # an acknowledged command was reported as unacknowledged (exit 6).
+ with mock.patch.object(mqtt_cmd, "_connect", lambda p, c: None):
+ result = mqtt_cmd.send_command(
+ _one_shot_printer(), PAYLOAD, timeout=0.02, client_factory=_AckDuringTeardown, sleep=lambda s: None
+ )
+ assert result is True
+ assert len(_SlowAck.published) == 1
diff --git a/tests/test_mqtt_session_retries.py b/tests/test_mqtt_session_retries.py
new file mode 100644
index 0000000..bcf4343
--- /dev/null
+++ b/tests/test_mqtt_session_retries.py
@@ -0,0 +1,115 @@
+"""Tests for MqttSession connection timeout retrying and broker failure handling."""
+
+from unittest.mock import MagicMock
+
+from bambu_cli.protocols.mqtt_session import MqttSession
+
+
+def test_get_status_retries_on_connection_timeout():
+ printer = MagicMock()
+ printer.ip = "192.168.1.10"
+ printer.access_code = "12345678"
+ printer.serial = "SERIAL001"
+ printer.cert_fingerprint = None
+ printer.simulation_mode = False
+
+ session = MqttSession(printer)
+
+ attempts = 0
+
+ def mock_connect(client):
+ nonlocal attempts
+ attempts += 1
+ # Never trigger on_connect to simulate timeout
+
+ session._connect = mock_connect
+ session._sleep = MagicMock()
+
+ # timeout very short so test runs fast
+ res = session.get_status(timeout=0.01, retries=2)
+ assert res is None
+ # 1 initial attempt + 2 retries = 3 attempts
+ assert attempts == 3
+ assert session._sleep.call_count == 2
+
+
+def test_get_status_aborts_immediately_on_broker_failure():
+ printer = MagicMock()
+ printer.ip = "192.168.1.10"
+ printer.access_code = "12345678"
+ printer.serial = "SERIAL001"
+ printer.cert_fingerprint = None
+ printer.simulation_mode = False
+
+ session = MqttSession(printer)
+
+ attempts = 0
+
+ def mock_connect(client):
+ nonlocal attempts
+ attempts += 1
+ # Trigger on_connect with rc=5 (bad auth)
+ if client.on_connect:
+ client.on_connect(client, None, None, 5)
+
+ session._connect = mock_connect
+ session._sleep = MagicMock()
+
+ res = session.get_status(timeout=0.1, retries=2)
+ assert res is None
+ # Must NOT retry if broker explicitly rejected connection
+ assert attempts == 1
+ assert session._sleep.call_count == 0
+
+
+def test_send_command_retries_on_connection_timeout():
+ printer = MagicMock()
+ printer.ip = "192.168.1.10"
+ printer.access_code = "12345678"
+ printer.serial = "SERIAL001"
+ printer.cert_fingerprint = None
+ printer.simulation_mode = False
+
+ session = MqttSession(printer)
+
+ attempts = 0
+
+ def mock_connect(client):
+ nonlocal attempts
+ attempts += 1
+ # Never trigger on_connect to simulate connection timeout
+
+ session._connect = mock_connect
+ session._sleep = MagicMock()
+
+ res = session.send_command('{"test": "payload"}', timeout=0.01, retries=2)
+ assert res is False
+ assert attempts == 3
+ assert session._sleep.call_count == 2
+
+
+def test_send_command_aborts_immediately_on_broker_failure():
+ printer = MagicMock()
+ printer.ip = "192.168.1.10"
+ printer.access_code = "12345678"
+ printer.serial = "SERIAL001"
+ printer.cert_fingerprint = None
+ printer.simulation_mode = False
+
+ session = MqttSession(printer)
+
+ attempts = 0
+
+ def mock_connect(client):
+ nonlocal attempts
+ attempts += 1
+ if client.on_connect:
+ client.on_connect(client, None, None, 5)
+
+ session._connect = mock_connect
+ session._sleep = MagicMock()
+
+ res = session.send_command('{"test": "payload"}', timeout=0.1, retries=2)
+ assert res is False
+ assert attempts == 1
+ assert session._sleep.call_count == 0
diff --git a/tests/test_mqtt_socket_leak.py b/tests/test_mqtt_socket_leak.py
new file mode 100644
index 0000000..d38d390
--- /dev/null
+++ b/tests/test_mqtt_socket_leak.py
@@ -0,0 +1,118 @@
+"""Tests for socket teardown when a pinned TLS wrap fails.
+
+A note on why the central assertion here is a mock, because it looks like a
+weaker choice than it is. The obvious "stronger" test -- connect repeatedly with
+a failing pin and count /proc/self/fd -- cannot fail. ``wrap_socket`` detaches
+the caller's socket and the returned ``SSLSocket`` becomes the sole owner of the
+fd; when the exception propagates, that object's last reference dies and CPython
+refcounting closes the fd immediately, leak or no leak. Such a test was written,
+run against a deliberately reintroduced leak, and passed anyway. It was removed
+rather than kept as reassuring decoration.
+
+So the explicit ``close.assert_called_once()`` below is the assertion that
+actually catches the regression (verified: it fails when the ``except`` branch's
+close is deleted). The real-server test alongside it covers the genuine
+``do_handshake()`` failure path end to end -- real socket, real SSLSocket, real
+``ssl.SSLError`` -- which no mocked test reaches.
+"""
+
+import contextlib
+import socket
+import ssl
+import threading
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from bambu_cli.protocols.mqtt_tls import pinning_ssl_context
+
+PIN = "a" * 64
+
+
+@pytest.fixture
+def garbage_tls_server():
+ """Accepts TCP, then answers with bytes that are not a TLS ServerHello."""
+ listener = socket.socket()
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ listener.bind(("127.0.0.1", 0))
+ listener.listen(8)
+ stop = threading.Event()
+
+ def _serve():
+ while not stop.is_set():
+ try:
+ conn, _ = listener.accept()
+ except OSError:
+ return
+ with conn, contextlib.suppress(OSError):
+ conn.recv(4096) # the ClientHello
+ conn.sendall(b"definitely not a TLS record\n")
+
+ thread = threading.Thread(target=_serve, daemon=True)
+ thread.start()
+ try:
+ yield listener.getsockname()
+ finally:
+ stop.set()
+ listener.close()
+ thread.join(timeout=5)
+
+
+def test_pin_mismatch_closes_the_tls_socket_and_reraises():
+ """The regression guard: the failure path must close explicitly, not lean on GC."""
+ ctx = pinning_ssl_context(PIN)
+ mock_tls_sock = MagicMock()
+ mock_tls_sock.getpeercert.return_value = b"der_cert_bytes"
+
+ with (
+ patch.object(ssl.SSLContext, "wrap_socket", return_value=mock_tls_sock),
+ patch("bambu_cli.tlspin.verify_cert_fingerprint", side_effect=ssl.SSLError("Pin mismatch")),
+ pytest.raises(ssl.SSLError),
+ ):
+ ctx.wrap_socket(MagicMock())
+
+ mock_tls_sock.close.assert_called_once()
+
+
+def test_handshake_failure_closes_the_tls_socket_and_reraises():
+ """Same guard for the other branch: do_handshake() raising, not the pin check."""
+ ctx = pinning_ssl_context(PIN)
+ mock_tls_sock = MagicMock()
+ mock_tls_sock.do_handshake.side_effect = ssl.SSLError("handshake failed")
+
+ with (
+ patch.object(ssl.SSLContext, "wrap_socket", return_value=mock_tls_sock),
+ pytest.raises(ssl.SSLError),
+ ):
+ ctx.wrap_socket(MagicMock())
+
+ mock_tls_sock.close.assert_called_once()
+
+
+def test_a_close_that_itself_raises_does_not_mask_the_original_error():
+ ctx = pinning_ssl_context(PIN)
+ mock_tls_sock = MagicMock()
+ mock_tls_sock.do_handshake.side_effect = ssl.SSLError("original failure")
+ mock_tls_sock.close.side_effect = OSError("close blew up too")
+
+ with (
+ patch.object(ssl.SSLContext, "wrap_socket", return_value=mock_tls_sock),
+ pytest.raises(ssl.SSLError) as excinfo,
+ ):
+ ctx.wrap_socket(MagicMock())
+
+ assert "original failure" in str(excinfo.value)
+
+
+def test_real_failed_handshake_surfaces_as_sslerror(garbage_tls_server):
+ """End-to-end over a real socket: no mocks between here and OpenSSL."""
+ ctx = pinning_ssl_context(PIN)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ sock = socket.create_connection(garbage_tls_server, timeout=10)
+ try:
+ with pytest.raises(ssl.SSLError):
+ ctx.wrap_socket(sock, server_hostname="localhost")
+ finally:
+ with contextlib.suppress(OSError):
+ sock.close()
diff --git a/tests/test_naming_and_validation.py b/tests/test_naming_and_validation.py
index 1da0801..020f994 100644
--- a/tests/test_naming_and_validation.py
+++ b/tests/test_naming_and_validation.py
@@ -29,9 +29,13 @@ def test_safe_remote_name_rejects_controls_and_paths():
assert N._safe_remote_name("model.3mf ") is None
assert N._safe_remote_name("CON.3mf") is None
assert N._safe_remote_name("a" * 200 + ".3mf") is None
+ assert N._safe_remote_name("test\x7f.3mf") is None
+
def test_sanitize_download_filename_reserved_and_controls():
assert "\n" not in N._sanitize_download_filename("x\ny.stl")
+ assert "\x7f" not in N._sanitize_download_filename("x\x7fy.stl")
+ assert N._sanitize_download_filename("x\x7fy.stl") == "x_y.stl"
name = N._sanitize_download_filename("CON.stl")
assert name.upper().startswith("_") or name != "CON.stl"
@@ -50,6 +54,7 @@ def test_sanitize_download_filename_reserved_and_controls():
"AUX.",
"aux.GCODE.3MF",
"prn.stl",
+ "test\x7fmodel.stl",
# A pathological "extension" consumed the whole budget, leaving the result over
# the cap entirely untruncated.
"x." + "a" * 200,
@@ -236,3 +241,17 @@ def test_reject_oversized_download_when_content_length_set():
1024 * 1024,
content_length=5 * 1024 * 1024,
)
+
+
+def test_validate_http_url_rejects_control_characters_and_whitespace():
+ for hostile in [
+ "https://example.com/test\r\nInjected: True",
+ "https://example.com/test\n",
+ "https://example.com/test\0",
+ "https://example.com/test\t.stl",
+ "https://example.com/test file.stl",
+ ]:
+ assert V._is_http_url(hostile) is False
+ with pytest.raises(BambuError) as exc_info:
+ V._validate_http_url_or_exit(hostile)
+ assert "control characters" in str(exc_info.value) or "whitespace" in str(exc_info.value)
diff --git a/tests/test_netsafety.py b/tests/test_netsafety.py
index 60687da..b341cd1 100644
--- a/tests/test_netsafety.py
+++ b/tests/test_netsafety.py
@@ -135,6 +135,7 @@ def capture(_args):
assert outcomes.get("result") is sentinel
assert outcomes.get("connected") is True
+
def test_ipv4_mapped_ipv6_private_address_refused():
# ::ffff:192.168.0.1 must be unwrapped and evaluated as the private v4 addr.
with (
@@ -145,6 +146,62 @@ def test_ipv4_mapped_ipv6_private_address_refused():
_get_safe_connection("rebind.example.com", 443, 5, None)
conn.assert_not_called()
+
+@pytest.mark.parametrize("ip", ["224.0.0.1", "239.255.255.250", "ff02::1", "ff05::2"])
+def test_multicast_ip_refused_even_with_allow_private(ip):
+ with (
+ settings_ctx(allow_private_ips=True),
+ patch.object(netsafety.socket, "getaddrinfo", return_value=_addrinfo(ip)),
+ patch.object(netsafety.socket, "create_connection") as conn,
+ pytest.raises(urllib.error.URLError, match="No safe/reachable"),
+ ):
+ _get_safe_connection("multicast.example.com", 80, 5, None)
+ conn.assert_not_called()
+
+
+@pytest.mark.parametrize("ip", ["::127.0.0.1", "::169.254.169.254", "::10.0.0.1", "::192.168.1.1"])
+def test_ipv4_compatible_ipv6_refused_when_private(ip):
+ with (
+ patch.object(netsafety.socket, "getaddrinfo", return_value=_addrinfo(ip)),
+ patch.object(netsafety.socket, "create_connection") as conn,
+ pytest.raises(urllib.error.URLError, match="No safe/reachable"),
+ ):
+ _get_safe_connection("compat.example.com", 80, 5, None)
+ conn.assert_not_called()
+
+
+def test_6to4_embedding_private_ipv4_refused():
+ with (
+ patch.object(netsafety.socket, "getaddrinfo", return_value=_addrinfo("2002:7f00:1::")),
+ patch.object(netsafety.socket, "create_connection") as conn,
+ pytest.raises(urllib.error.URLError, match="No safe/reachable"),
+ ):
+ _get_safe_connection("sixtofour.example.com", 80, 5, None)
+ conn.assert_not_called()
+
+
+@pytest.mark.parametrize("ip", ["64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b::a9fe:a9fe", "fec0::1"])
+def test_nat64_private_and_site_local_refused(ip):
+ with (
+ patch.object(netsafety.socket, "getaddrinfo", return_value=_addrinfo(ip)),
+ patch.object(netsafety.socket, "create_connection") as conn,
+ pytest.raises(urllib.error.URLError, match="No safe/reachable"),
+ ):
+ _get_safe_connection("nat64.example.com", 80, 5, None)
+ conn.assert_not_called()
+
+
+def test_nat64_public_ipv4_allowed():
+ # DNS64 networks synthesize 64:ff9b:: for every v4-only host; refusing
+ # the whole prefix would break all downloads there.
+ with (
+ patch.object(netsafety.socket, "getaddrinfo", return_value=_addrinfo("64:ff9b::808:808")),
+ patch.object(netsafety.socket, "create_connection") as conn,
+ ):
+ _get_safe_connection("dns64.example.com", 443, 5, None)
+ conn.assert_called_once()
+
+
# ---------------------------------------------------------------------------
# Resolution / candidate-iteration edge cases
# ---------------------------------------------------------------------------
@@ -388,3 +445,178 @@ def test_polite_open_tolerates_non_string_full_url():
assert netsafety.polite_open(opener, MagicMock(), timeout=5, sleep=slept.append) is sentinel
assert slept == []
assert netsafety._host_of(MagicMock().full_url) == ""
+
+
+def test_browser_like_user_agent_darwin_and_windows():
+ netsafety._default_user_agent.cache_clear()
+ with patch("bambu_cli.netsafety.platform.system", return_value="Darwin"):
+ ua = netsafety._default_user_agent()
+ assert "Macintosh; Intel Mac OS X 10_15_7" in ua
+
+ netsafety._default_user_agent.cache_clear()
+ with patch("bambu_cli.netsafety.platform.system", return_value="Windows"):
+ ua = netsafety._default_user_agent()
+ assert "Windows NT 10.0; Win64; x64" in ua
+
+ netsafety._default_user_agent.cache_clear()
+
+
+def test_safe_https_connection_wrap_socket_failure_closes_sock():
+ conn = netsafety.SafeHTTPSConnection("host.example.com", 443)
+ mock_sock = MagicMock()
+ mock_sock.close.side_effect = Exception("close error")
+ mock_ctx = MagicMock()
+ mock_ctx.wrap_socket.side_effect = Exception("handshake fail")
+ conn._context = mock_ctx
+ with patch.object(netsafety, "_get_safe_connection", return_value=mock_sock):
+ with pytest.raises(Exception, match="handshake fail"):
+ conn.connect()
+ mock_sock.close.assert_called_once()
+
+
+
+# --- bounded hostname resolution (audit P2) ---------------------------------
+
+
+def test_try_resolve_ip_reports_failure_instead_of_echoing_the_hostname():
+ """_resolve_ip cannot express failure (it returns the host unchanged), which
+ is what pushed cli.py into a second, unbounded socket.getaddrinfo. The
+ _try_resolve_ip variant returns None so callers can branch honestly."""
+ from bambu_cli.utils import _resolve_ip, _try_resolve_ip
+
+ assert _try_resolve_ip("no-such-host.invalid", timeout=5.0) is None
+ assert _resolve_ip("no-such-host.invalid", timeout=5.0) == "no-such-host.invalid"
+
+
+def test_try_resolve_ip_passes_ip_literals_through_without_a_lookup(monkeypatch):
+ import socket as socket_mod
+
+ from bambu_cli import utils
+
+ def _explode(*args, **kwargs):
+ raise AssertionError("getaddrinfo must not be called for an IP literal")
+
+ monkeypatch.setattr(socket_mod, "getaddrinfo", _explode)
+ assert utils._try_resolve_ip("192.0.2.10") == "192.0.2.10"
+ assert utils._try_resolve_ip("::1") == "::1"
+
+
+def test_try_resolve_ip_returns_none_when_the_lookup_outlives_the_timeout(monkeypatch):
+ """The whole point of the timeout: a hung resolver must not block the CLI."""
+ import socket as socket_mod
+ import threading
+
+ from bambu_cli import utils
+
+ release = threading.Event()
+
+ def _hang(*args, **kwargs):
+ release.wait(30)
+ return [(0, 0, 0, "", ("192.0.2.1", 0))]
+
+ monkeypatch.setattr(socket_mod, "getaddrinfo", _hang)
+ utils._RESOLVE_IP_CACHE.pop("slow.example", None)
+ utils._RESOLVE_IP_INFLIGHT.pop("slow.example", None)
+ try:
+ assert utils._try_resolve_ip("slow.example", timeout=0.2) is None
+ finally:
+ release.set()
+
+
+def test_concurrent_resolves_of_a_hung_host_share_one_worker_thread(monkeypatch):
+ """A blackholed DNS server must park one thread, not one per call."""
+ import socket as socket_mod
+ import threading
+
+ from bambu_cli import utils
+
+ release = threading.Event()
+ calls = []
+
+ def _hang(host, *args, **kwargs):
+ calls.append(host)
+ release.wait(30)
+ return [(0, 0, 0, "", ("192.0.2.1", 0))]
+
+ monkeypatch.setattr(socket_mod, "getaddrinfo", _hang)
+ utils._RESOLVE_IP_CACHE.pop("hung.example", None)
+ utils._RESOLVE_IP_INFLIGHT.pop("hung.example", None)
+ try:
+ for _ in range(4):
+ assert utils._try_resolve_ip("hung.example", timeout=0.1) is None
+ assert len(calls) == 1, f"spawned {len(calls)} resolver threads for one host"
+ finally:
+ release.set()
+
+
+# --- regressions found by mutation: each of these went green with the guarded
+# line removed, so the line was untested ------------------------------------
+
+
+def test_throttle_queued_callers_sleep_the_full_backlog_not_one_interval(monkeypatch):
+ """`previous` is a scheduled timestamp: with three calls queued the third
+ must wait ~2 intervals. Clamping the sleep to one interval (the old code)
+ let queued callers burst past the throttle."""
+ monkeypatch.setattr(netsafety, "MIN_HOST_REQUEST_INTERVAL", 1.0)
+ netsafety._last_request_at.clear()
+ slept = []
+ for _ in range(3):
+ netsafety._throttle_host("api.printables.com", sleep=slept.append)
+ assert len(slept) == 2
+ assert slept[1] > netsafety.MIN_HOST_REQUEST_INTERVAL * 1.5, slept
+ assert slept[1] <= netsafety.MAX_RETRY_AFTER_WAIT
+
+
+def test_throttle_backlog_is_capped_at_max_retry_after_wait(monkeypatch):
+ monkeypatch.setattr(netsafety, "MIN_HOST_REQUEST_INTERVAL", 10.0)
+ netsafety._last_request_at.clear()
+ slept = []
+ for _ in range(6):
+ netsafety._throttle_host("api.printables.com", sleep=slept.append)
+ assert max(slept) <= netsafety.MAX_RETRY_AFTER_WAIT
+
+
+def test_try_resolve_ip_recovers_when_the_worker_thread_cannot_start(monkeypatch):
+ """A failed Thread.start() must not leave a dead in-flight entry behind:
+ every later call for that host would attach to it, wait its full timeout
+ and report None forever, surfacing as a bogus 'Invalid printer_ip'."""
+ import socket as socket_mod
+ import threading
+
+ from bambu_cli import utils
+
+ utils._RESOLVE_IP_CACHE.pop("limit.example", None)
+ utils._RESOLVE_IP_INFLIGHT.pop("limit.example", None)
+
+ def _no_threads(self):
+ raise RuntimeError("can't start new thread")
+
+ monkeypatch.setattr(threading.Thread, "start", _no_threads)
+ assert utils._try_resolve_ip("limit.example", timeout=0.2) is None
+ assert "limit.example" not in utils._RESOLVE_IP_INFLIGHT
+ monkeypatch.undo()
+
+ monkeypatch.setattr(socket_mod, "getaddrinfo", lambda *a, **k: [(0, 0, 0, "", ("192.0.2.9", 0))])
+ assert utils._try_resolve_ip("limit.example", timeout=2.0) == "192.0.2.9"
+ utils._RESOLVE_IP_CACHE.pop("limit.example", None)
+
+
+def test_resolve_ip_cache_expires_so_a_moved_printer_is_re_resolved(monkeypatch):
+ """The cached IP is handed to paho/ftplib as a literal, so a permanent
+ cache would pin a long-lived process (the TUI) to a stale DHCP lease."""
+ import socket as socket_mod
+
+ from bambu_cli import utils
+
+ utils._RESOLVE_IP_CACHE.pop("moved.example", None)
+ utils._RESOLVE_IP_INFLIGHT.pop("moved.example", None)
+ answers = iter(["192.0.2.1", "192.0.2.2"])
+ monkeypatch.setattr(socket_mod, "getaddrinfo", lambda *a, **k: [(0, 0, 0, "", (next(answers), 0))])
+
+ assert utils._try_resolve_ip("moved.example", timeout=2.0) == "192.0.2.1"
+ assert utils._try_resolve_ip("moved.example", timeout=2.0) == "192.0.2.1", "fresh entry must be served"
+
+ ip, stamp = utils._RESOLVE_IP_CACHE["moved.example"]
+ utils._RESOLVE_IP_CACHE["moved.example"] = (ip, stamp - utils._RESOLVE_IP_CACHE_TTL - 1)
+ assert utils._try_resolve_ip("moved.example", timeout=2.0) == "192.0.2.2"
+ utils._RESOLVE_IP_CACHE.pop("moved.example", None)
diff --git a/tests/test_netsafety_opener.py b/tests/test_netsafety_opener.py
new file mode 100644
index 0000000..62fdd48
--- /dev/null
+++ b/tests/test_netsafety_opener.py
@@ -0,0 +1,343 @@
+"""End-to-end tests for the safe opener, driven through a real local server.
+
+Why a real server instead of the usual mocks: the opener chain was missing
+``urllib.request.HTTPErrorProcessor`` for a long time, which silently disabled
+redirect following and HTTPError raising. Every existing redirect test called
+``SafeHTTPRedirectHandler.redirect_request(...)`` directly, or asserted the
+handler was *registered* -- both pass against a chain that can never invoke the
+handler. Only opening a URL catches it, so these tests do exactly that.
+
+The server binds 127.0.0.1, so every test here runs inside
+``settings_ctx(allow_private_ips=True)``; the SSRF guard is covered separately
+in tests/test_netsafety.py.
+"""
+
+import http.server
+import threading
+import urllib.error
+import urllib.request
+
+import pytest
+
+from bambu_cli.netsafety import MAX_DOWNLOAD_REDIRECT_HOPS, build_safe_opener, polite_open
+from tests.bambu_test_base import settings_ctx
+
+BODY = b"solid cube\nendsolid cube\n"
+
+
+class _Handler(http.server.BaseHTTPRequestHandler):
+ """Routes: /final, /redirect-once, /hop/ (chains down to /final), /missing, /boom."""
+
+ # Deliberately HTTP/1.0 (the default): keep-alive would leave the server-side
+ # connection lingering in its handler thread past the end of the test, which
+ # surfaces as a ResourceWarning -- and CI runs with -W error::ResourceWarning.
+ # Every response below carries an explicit Content-Length, so nothing here
+ # depends on 1.1 framing.
+
+ def _send(self, code, body=b"", headers=()):
+ self.send_response(code)
+ for key, value in headers:
+ self.send_header(key, value)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if body:
+ self.wfile.write(body)
+
+ # Set by the cross-host tests below; a Location for /redirect-elsewhere.
+ redirect_target = "/final"
+
+ def do_GET(self): # noqa: N802 -- BaseHTTPRequestHandler's required spelling
+ path = self.path
+ if path == "/final":
+ self._send(200, BODY)
+ elif path == "/echo":
+ import json
+
+ self._send(200, json.dumps(dict(self.headers.items())).encode(), [("Content-Type", "application/json")])
+ elif path == "/redirect-elsewhere":
+ self._send(302, headers=[("Location", type(self).redirect_target)])
+ elif path == "/redirect-once":
+ self._send(302, headers=[("Location", "/final")])
+ elif path.startswith("/hop/"):
+ remaining = int(path.rsplit("/", 1)[1])
+ target = "/final" if remaining <= 1 else f"/hop/{remaining - 1}"
+ self._send(302, headers=[("Location", target)])
+ elif path == "/missing":
+ self._send(404, b"not found", [("Content-Type", "text/html")])
+ else:
+ self._send(500, b"kaboom")
+
+ def log_message(self, *args):
+ """Silence the default stderr access log."""
+
+
+@pytest.fixture
+def server():
+ # Single-threaded on purpose. ThreadingHTTPServer hands each connection to a
+ # thread whose socket is then torn down by GC rather than explicitly, which
+ # trips ResourceWarning -- and CI runs with -W error::ResourceWarning. With
+ # HTTP/1.0 every connection closes after one response, so serving
+ # sequentially is sufficient for these tests.
+ httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield f"http://127.0.0.1:{httpd.server_address[1]}"
+ finally:
+ httpd.shutdown()
+ thread.join(timeout=5)
+ httpd.server_close()
+
+
+def _open(base, path):
+ opener = build_safe_opener()
+ return opener.open(urllib.request.Request(f"{base}{path}"), timeout=10)
+
+
+def test_opener_registers_an_http_error_processor():
+ """Without this handler the whole error/redirect chain is inert."""
+ opener = build_safe_opener()
+ for scheme in ("http", "https"):
+ processors = [type(h).__name__ for h in opener.process_response.get(scheme, [])]
+ assert "HTTPErrorProcessor" in processors, f"{scheme} has no error processor: {processors}"
+
+
+def test_redirect_is_actually_followed(server):
+ with settings_ctx(allow_private_ips=True), _open(server, "/redirect-once") as resp:
+ assert resp.status == 200
+ assert resp.read() == BODY
+ assert resp.url.endswith("/final")
+
+
+def test_redirect_chain_within_the_cap_succeeds(server):
+ with settings_ctx(allow_private_ips=True), _open(server, f"/hop/{MAX_DOWNLOAD_REDIRECT_HOPS - 1}") as resp:
+ assert resp.status == 200
+ assert resp.read() == BODY
+
+
+def test_redirect_chain_over_the_cap_is_refused(server):
+ with settings_ctx(allow_private_ips=True), pytest.raises(urllib.error.URLError) as excinfo:
+ _open(server, f"/hop/{MAX_DOWNLOAD_REDIRECT_HOPS + 3}")
+ assert "Too many redirects" in str(excinfo.value)
+
+
+def test_404_raises_httperror_rather_than_returning_the_error_page(server):
+ with settings_ctx(allow_private_ips=True), pytest.raises(urllib.error.HTTPError) as excinfo:
+ _open(server, "/missing")
+ assert excinfo.value.code == 404
+ excinfo.value.close()
+
+
+def test_500_raises_httperror(server):
+ with settings_ctx(allow_private_ips=True), pytest.raises(urllib.error.HTTPError) as excinfo:
+ _open(server, "/boom")
+ assert excinfo.value.code == 500
+ excinfo.value.close()
+
+
+def test_polite_open_surfaces_httperror_so_its_retry_policy_can_see_it(server):
+ """polite_open only retries 429/503 because it catches HTTPError -- which
+ requires the error processor to have raised one in the first place."""
+ opener = build_safe_opener()
+ req = urllib.request.Request(f"{server}/missing")
+ with settings_ctx(allow_private_ips=True), pytest.raises(urllib.error.HTTPError) as excinfo:
+ polite_open(opener, req, timeout=10, sleep=lambda _: None)
+ assert excinfo.value.code == 404
+ excinfo.value.close()
+
+
+# --- the user-visible consequence ------------------------------------------
+# A download behind a redirect is the common case on real file hosts (signed
+# CDN URLs, release assets). With the opener chain inert it failed with
+# "Downloaded file is empty; refusing to use it.", and a 404 was misreported as
+# "HTML page did not contain a direct model file link". Both are driven here
+# through cmd_download against the live server, not a mocked opener.
+
+
+def test_cmd_download_follows_a_redirect_to_the_real_model(server, tmp_path):
+ import argparse
+
+ from bambu_cli.commands import cmd_download
+
+ outdir = tmp_path / "out"
+ outdir.mkdir()
+ args = argparse.Namespace(
+ url=f"{server}/redirect-once",
+ output=str(outdir),
+ name=None,
+ max_download_mb=100,
+ allow_private_ips=True,
+ json=False,
+ )
+ with settings_ctx(allow_private_ips=True):
+ cmd_download(args)
+
+ written = list(outdir.iterdir())
+ assert len(written) == 1, f"expected exactly one downloaded file, got {written}"
+ assert written[0].read_bytes() == BODY
+
+
+def test_cmd_download_reports_a_404_as_a_network_error_with_the_status(server, tmp_path):
+ import argparse
+
+ from bambu_cli.errors import BambuError
+
+ from bambu_cli.commands import cmd_download
+
+ outdir = tmp_path / "out"
+ outdir.mkdir()
+ args = argparse.Namespace(
+ url=f"{server}/missing",
+ output=str(outdir),
+ name=None,
+ max_download_mb=100,
+ allow_private_ips=True,
+ json=False,
+ )
+ with settings_ctx(allow_private_ips=True), pytest.raises((BambuError, SystemExit)):
+ cmd_download(args)
+
+ assert list(outdir.iterdir()) == [], "a 404 body must never be left on disk as a model"
+
+
+# --- SSRF re-validation per hop ---------------------------------------------
+# "Each hop is independently re-validated" was the redirect handler's stated
+# guarantee, but while redirects were never followed it was untestable and
+# untested. _get_safe_connection's refusal of private/reserved IPs is covered in
+# tests/test_netsafety.py; what is covered here is that every hop actually
+# reaches that gate, rather than only the first one.
+
+
+def test_every_redirect_hop_goes_through_the_ssrf_gate(server, monkeypatch):
+ from bambu_cli import netsafety
+
+ seen = []
+ real = netsafety._get_safe_connection
+
+ def _spy(host, port, timeout, source_address):
+ seen.append((host, port))
+ return real(host, port, timeout, source_address)
+
+ monkeypatch.setattr(netsafety, "_get_safe_connection", _spy)
+
+ with settings_ctx(allow_private_ips=True), _open(server, f"/hop/{MAX_DOWNLOAD_REDIRECT_HOPS - 1}") as resp:
+ assert resp.read() == BODY
+
+ # One validated connect per hop, plus the final 200 -- not just the first.
+ assert len(seen) == MAX_DOWNLOAD_REDIRECT_HOPS, f"only {len(seen)} hop(s) validated: {seen}"
+
+
+def test_a_single_redirect_validates_both_hops(server, monkeypatch):
+ from bambu_cli import netsafety
+
+ seen = []
+ real = netsafety._get_safe_connection
+
+ def _spy(host, port, timeout, source_address):
+ seen.append((host, port))
+ return real(host, port, timeout, source_address)
+
+ monkeypatch.setattr(netsafety, "_get_safe_connection", _spy)
+ with settings_ctx(allow_private_ips=True), _open(server, "/redirect-once") as resp:
+ assert resp.read() == BODY
+ assert len(seen) == 2, f"expected the 302 and the 200 to be validated separately: {seen}"
+
+
+def test_hop_cap_closes_the_intermediate_response_before_raising():
+ """HTTPRedirectHandler.http_error_302 calls redirect_request first and only
+ reaches its own fp.close() afterwards, so bailing out on the hop cap has to
+ close the 3xx response itself or leak its socket to the garbage collector."""
+ from unittest.mock import MagicMock
+
+ from bambu_cli.netsafety import SafeHTTPRedirectHandler
+
+ handler = SafeHTTPRedirectHandler()
+ req = urllib.request.Request("https://example.com/start")
+ req._bambu_redirect_hops = MAX_DOWNLOAD_REDIRECT_HOPS
+ fp = MagicMock()
+
+ with pytest.raises(urllib.error.URLError):
+ handler.redirect_request(req, fp, 302, "Found", {}, "https://example.com/next")
+
+ fp.close.assert_called_once()
+
+
+def test_hop_cap_tolerates_a_missing_intermediate_response():
+ """Several existing tests call redirect_request with fp=None."""
+ from bambu_cli.netsafety import SafeHTTPRedirectHandler
+
+ handler = SafeHTTPRedirectHandler()
+ req = urllib.request.Request("https://example.com/start")
+ req._bambu_redirect_hops = MAX_DOWNLOAD_REDIRECT_HOPS
+
+ with pytest.raises(urllib.error.URLError):
+ handler.redirect_request(req, None, 302, "Found", {}, "https://example.com/next")
+
+
+# --- cross-host redirects ----------------------------------------------------
+# urllib copies every request header onto the redirected request whatever host
+# it names. Nothing in platecli sends credentials today; this pins the guard
+# so the first caller that does cannot leak a token to a redirect target.
+
+
+@pytest.fixture
+def cross_host(server, monkeypatch):
+ """Route a made-up second hostname to the same local server, bypassing DNS.
+
+ The redirect handler only looks at the URL's host, so any distinct name will
+ do; mapping it here keeps the test independent of the resolver and of
+ whether ``localhost`` resolves to ::1 first on the CI runner."""
+ from urllib.parse import urlparse
+
+ from bambu_cli import netsafety
+
+ port = urlparse(server).port
+ real = netsafety._get_safe_connection
+
+ def _route(host, p, timeout, source_address):
+ if host == "second-host.invalid":
+ return real("127.0.0.1", port, timeout, source_address)
+ return real(host, p, timeout, source_address)
+
+ monkeypatch.setattr(netsafety, "_get_safe_connection", _route)
+ return f"http://second-host.invalid:{port}"
+
+
+def _echo_headers(server_or_target, path, headers):
+ """Open ``path`` and return (final url, headers the last hop received)."""
+ import json
+
+ opener = build_safe_opener()
+ req = urllib.request.Request(f"{server_or_target}{path}", headers=headers)
+ with settings_ctx(allow_private_ips=True), opener.open(req, timeout=10) as resp:
+ assert resp.status == 200
+ return resp.url, json.loads(resp.read())
+
+
+def test_cross_host_redirect_drops_credential_headers(server, cross_host, monkeypatch):
+ _Handler.redirect_target = f"{cross_host}/echo"
+ final_url, seen = _echo_headers(
+ server,
+ "/redirect-elsewhere",
+ {"Authorization": "Bearer SECRET", "Cookie": "sid=1", "X-Custom": "kept"},
+ )
+ assert final_url.startswith(cross_host)
+ assert "Authorization" not in seen and "Cookie" not in seen, seen
+ assert seen.get("X-Custom") == "kept"
+
+
+def test_same_host_redirect_keeps_credential_headers(server):
+ _Handler.redirect_target = "/echo"
+ _, seen = _echo_headers(server, "/redirect-elsewhere", {"Authorization": "Bearer SECRET"})
+ assert seen.get("Authorization") == "Bearer SECRET"
+
+
+def test_cross_host_redirect_repicks_the_user_agent_for_the_new_host(server, cross_host, monkeypatch):
+ """UA policy is per host; a generic URL redirecting onto a first-party API
+ host must present the honest token, not the one chosen for the first URL."""
+ from bambu_cli import netsafety
+
+ monkeypatch.setattr(netsafety, "HONEST_UA_HOSTS", frozenset({"second-host.invalid"}))
+ _Handler.redirect_target = f"{cross_host}/echo"
+ _, seen = _echo_headers(server, "/redirect-elsewhere", {"User-Agent": "Mozilla/5.0 generic"})
+ assert seen.get("User-Agent") == netsafety.platecli_user_agent()
diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py
index 6ccd7bd..ca28555 100644
--- a/tests/test_onboarding.py
+++ b/tests/test_onboarding.py
@@ -222,3 +222,67 @@ def test_preflight_unset_profiles_dir_is_actionable():
assert "plate setup" in profiles["message"]
assert "profiles_dir" in profiles["message"]
assert "not found at ." not in profiles["message"]
+
+
+def test_preflight_placeholder_checks():
+ # IP placeholder
+ cfg_ip = {"printer_ip": "192.168.0.XXX", "serial": "SN123", "access_code": "x"}
+ with (
+ patch("bambu_cli.setup_cmd.preflight.load_config", return_value=cfg_ip),
+ patch("bambu_cli.setup_cmd.preflight._config_path", return_value="/tmp/config.json"),
+ patch("bambu_cli.setup_cmd.preflight._display_path", side_effect=lambda p: p),
+ ):
+ checks = setup_cmd.collect_preflight_checks()
+ ip_check = [c for c in checks if c["name"] == "printer-ip"][0]
+ assert ip_check["status"] == "error"
+
+ # Serial placeholder
+ cfg_serial = {"printer_ip": "1.2.3.4", "serial": "YOUR_SERIAL", "access_code": "x"}
+ with (
+ patch("bambu_cli.setup_cmd.preflight.load_config", return_value=cfg_serial),
+ patch("bambu_cli.setup_cmd.preflight._config_path", return_value="/tmp/config.json"),
+ patch("bambu_cli.setup_cmd.preflight._display_path", side_effect=lambda p: p),
+ ):
+ checks = setup_cmd.collect_preflight_checks()
+ serial_check = [c for c in checks if c["name"] == "serial"][0]
+ assert serial_check["status"] == "error"
+
+ # Missing access code and file
+ cfg_no_code = {"printer_ip": "1.2.3.4", "serial": "SN123"}
+ with (
+ patch("bambu_cli.setup_cmd.preflight.load_config", return_value=cfg_no_code),
+ patch("bambu_cli.setup_cmd.preflight._config_path", return_value="/tmp/config.json"),
+ patch("bambu_cli.setup_cmd.preflight._display_path", side_effect=lambda p: p),
+ ):
+ checks = setup_cmd.collect_preflight_checks()
+ code_check = [c for c in checks if c["name"] == "access-code"][0]
+ assert code_check["status"] == "error"
+
+
+def test_preflight_access_code_file_errors():
+ # File not found
+ cfg_missing = {"printer_ip": "1.2.3.4", "serial": "SN123", "access_code_file": "/nonexistent/code"}
+ with (
+ patch("bambu_cli.setup_cmd.preflight.load_config", return_value=cfg_missing),
+ patch("bambu_cli.setup_cmd.preflight._config_path", return_value="/tmp/config.json"),
+ patch("bambu_cli.setup_cmd.preflight._display_path", side_effect=lambda p: p),
+ ):
+ checks = setup_cmd.collect_preflight_checks()
+ code_check = [c for c in checks if c["name"] == "access-code"][0]
+ assert code_check["status"] == "error"
+ assert "not found" in code_check["message"]
+
+ # File open OSError
+ cfg_oserror = {"printer_ip": "1.2.3.4", "serial": "SN123", "access_code_file": "/tmp/code"}
+ with (
+ patch("bambu_cli.setup_cmd.preflight.load_config", return_value=cfg_oserror),
+ patch("bambu_cli.setup_cmd.preflight._config_path", return_value="/tmp/config.json"),
+ patch("bambu_cli.setup_cmd.preflight._display_path", side_effect=lambda p: p),
+ patch("os.path.exists", return_value=True),
+ patch("builtins.open", side_effect=OSError("Read error")),
+ ):
+ checks = setup_cmd.collect_preflight_checks()
+ code_check = [c for c in checks if c["name"] == "access-code"][0]
+ assert code_check["status"] == "error"
+ assert "could not be read" in code_check["message"]
+
diff --git a/tests/test_print_cmd_context.py b/tests/test_print_cmd_context.py
new file mode 100644
index 0000000..eb796f5
--- /dev/null
+++ b/tests/test_print_cmd_context.py
@@ -0,0 +1,36 @@
+"""Tests verifying cmd_print uses the injected RuntimeContext."""
+
+import argparse
+from unittest.mock import MagicMock, patch
+
+from bambu_cli.commands.print_cmd import cmd_print
+from bambu_cli.context import RuntimeContext
+
+
+def test_cmd_print_uses_context_printer():
+ fake_printer = MagicMock()
+ ctx = MagicMock(spec=RuntimeContext)
+ ctx.printer.return_value = fake_printer
+
+ args = argparse.Namespace(
+ file="model.gcode",
+ dry_run=True,
+ confirm=False,
+ json=False,
+ use_ams=False,
+ timelapse=False,
+ skip_bed_leveling=False,
+ skip_flow_cali=False,
+ )
+
+ with (
+ patch("bambu_cli.commands.print_cmd._safe_remote_name", return_value="model.gcode"),
+ patch("bambu_cli.commands.print_cmd._is_print_ready_name", return_value=True),
+ patch("bambu_cli.commands.print_cmd._parse_print_options", return_value=(None, None)),
+ patch("bambu_cli.protocols.mqtt.execute_print_command") as mock_exec,
+ ):
+ cmd_print(args, ctx=ctx)
+
+ ctx.printer.assert_called_once()
+ mock_exec.assert_called_once()
+ assert mock_exec.call_args[0][0] is fake_printer
diff --git a/tests/test_print_plain_gcode_warning.py b/tests/test_print_plain_gcode_warning.py
new file mode 100644
index 0000000..aed10cc
--- /dev/null
+++ b/tests/test_print_plain_gcode_warning.py
@@ -0,0 +1,30 @@
+"""Printing a plain .gcode file is flagged as unverified on hardware.
+
+``print`` starts every file with the ``project_file`` command, whose ``param``
+names ``Metadata/plate_.gcode`` inside a 3MF. A plain .gcode has no such
+member. The community protocol notes list a separate ``gcode_file`` command,
+but the only first-hand report found (X1C, LAN mode) could not get it to start
+a custom file, so neither path is confirmed. Until one is verified on a real
+printer, platecli says so before sending instead of implying it works.
+"""
+
+from __future__ import annotations
+
+import argparse
+from unittest import mock
+
+import pytest
+
+
+@pytest.mark.parametrize("name, warned", [("part.gcode", True), ("part.3mf", False), ("part.gcode.3mf", False)])
+def test_plain_gcode_print_warns_before_sending(monkeypatch, name, warned):
+ from bambu_cli.commands import print_cmd
+
+ monkeypatch.setattr("bambu_cli.protocols.mqtt.execute_print_command", lambda *a, **k: None)
+ with mock.patch.object(print_cmd.logger, "warning") as warning:
+ print_cmd.cmd_print(
+ argparse.Namespace(file=name, confirm=True, dry_run=False, json=False),
+ ctx=argparse.Namespace(printer=lambda: object()),
+ )
+ texts = " ".join(str(call.args[0]) for call in warning.call_args_list)
+ assert ("not verified" in texts) is warned
diff --git a/tests/test_print_plate_selection.py b/tests/test_print_plate_selection.py
new file mode 100644
index 0000000..e9e3cb2
--- /dev/null
+++ b/tests/test_print_plate_selection.py
@@ -0,0 +1,173 @@
+"""The print command names the plate that is actually sliced in the 3MF.
+
+Before the 2026-09-22 fix the payload always said ``Metadata/plate_1.gcode``.
+A pre-sliced project whose only sliced plate is plate 2 (common for
+multi-plate Printables/Bambu Studio downloads) failed on the printer, and a 3MF
+with no sliced plate at all was uploaded and sent to print anyway.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import zipfile
+
+import pytest
+
+from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR
+from bambu_cli.errors import BambuError
+
+
+def _three_mf(path, plates, predictions=None):
+ predictions = predictions or {}
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("[Content_Types].xml", "")
+ zf.writestr("3D/3dmodel.model", "")
+ for n in plates:
+ zf.writestr(f"Metadata/plate_{n}.gcode", f"; plate {n}\nG28\n")
+ if predictions:
+ body = "".join(
+ f''
+ for n, secs in predictions.items()
+ )
+ zf.writestr("Metadata/slice_info.config", f'{body}')
+ return str(path)
+
+
+# --- payload / parser ---------------------------------------------------------
+
+
+def test_payload_names_the_requested_plate():
+ from bambu_cli.job.payload import generate_print_payload
+
+ assert json.loads(generate_print_payload("a.3mf"))["print"]["param"] == "Metadata/plate_1.gcode"
+ assert json.loads(generate_print_payload("a.3mf", plate=3))["print"]["param"] == "Metadata/plate_3.gcode"
+
+
+@pytest.mark.parametrize("argv", [["print", "a.3mf"], ["job", "a.3mf"], ["send", "a.3mf"]])
+def test_plate_flag_parses(argv):
+ from bambu_cli.cliparse import build_parser
+
+ parser = build_parser()
+ assert parser.parse_args(argv).plate is None
+ assert parser.parse_args([*argv, "--plate", "2"]).plate == 2
+
+
+@pytest.mark.parametrize("value", ["0", "-1", "two"])
+def test_plate_flag_must_be_positive(value):
+ from bambu_cli.cliparse import build_parser
+
+ with pytest.raises(SystemExit):
+ build_parser().parse_args(["print", "a.3mf", "--plate", value])
+
+
+def test_print_command_sends_the_plate(monkeypatch):
+ from bambu_cli.commands import print_cmd
+
+ sent = {}
+
+ def fake_execute(printer, payload, basename, dry_run=False):
+ sent["payload"] = json.loads(payload)
+
+ monkeypatch.setattr("bambu_cli.protocols.mqtt.execute_print_command", fake_execute)
+ args = argparse.Namespace(file="a.3mf", confirm=True, dry_run=False, json=False, plate=2)
+ print_cmd.cmd_print(args, ctx=argparse.Namespace(printer=lambda: object()))
+ assert sent["payload"]["print"]["param"] == "Metadata/plate_2.gcode"
+
+
+# --- 3MF inspection -------------------------------------------------------------
+
+
+def test_sliced_plates_lists_plate_numbers(tmp_path):
+ from bambu_cli.slicer.estimate import sliced_plates
+
+ assert sliced_plates(_three_mf(tmp_path / "a.3mf", [5, 2])) == [2, 5]
+ assert sliced_plates(_three_mf(tmp_path / "b.3mf", [])) == []
+ assert sliced_plates(str(tmp_path / "missing.3mf")) == []
+
+
+def test_estimate_is_for_the_first_or_requested_plate(tmp_path):
+ from bambu_cli.slicer.estimate import read_3mf_estimate
+
+ path = _three_mf(tmp_path / "a.3mf", [1, 2], predictions={1: 600, 2: 3600})
+ assert read_3mf_estimate(path).seconds == 600
+ assert read_3mf_estimate(path, plate=2).seconds == 3600
+
+
+# --- job ----------------------------------------------------------------------
+
+
+def _job(source, *extra):
+ from bambu_cli.cliparse import build_parser
+ from bambu_cli.job.orchestrate import _run_job
+ from bambu_cli.job.steps import JobSteps
+ from tests.bambu_test_base import settings_ctx
+
+ calls = {}
+
+ def upload(ns):
+ calls["upload"] = ns
+ return "model.3mf"
+
+ def print_(ns):
+ calls["print"] = ns
+
+ args = build_parser().parse_args(["--json", "job", source, "--confirm", *extra])
+ from bambu_cli.context import RuntimeContext
+
+ with settings_ctx():
+ _run_job(RuntimeContext.for_request(args), args, JobSteps(upload=upload, print_=print_))
+ return calls
+
+
+def test_job_prints_the_only_sliced_plate(tmp_path, capsys):
+ calls = _job(_three_mf(tmp_path / "model.3mf", [2]))
+ assert calls["print"].plate == 2
+ assert json.loads(capsys.readouterr().out)["plate"] == 2
+
+
+def test_job_prefers_plate_one_and_honours_plate_flag(tmp_path, capsys):
+ source = _three_mf(tmp_path / "model.3mf", [1, 3])
+ assert _job(source)["print"].plate == 1
+ capsys.readouterr()
+ assert _job(source, "--plate", "3")["print"].plate == 3
+
+
+def test_job_refuses_a_plate_that_is_not_sliced(tmp_path, capsys):
+ with pytest.raises(BambuError) as caught:
+ _job(_three_mf(tmp_path / "model.3mf", [1, 2]), "--plate", "4")
+ assert caught.value.exit_code == EXIT_COMMAND_ERROR
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["failed_step"] == "validate" and "1, 2" in payload["error"]
+
+
+def test_job_refuses_an_unsliced_3mf_before_uploading(tmp_path, capsys):
+ with pytest.raises(BambuError) as caught:
+ _job(_three_mf(tmp_path / "model.3mf", []))
+ assert caught.value.exit_code == EXIT_FILE_ERROR
+ payload = json.loads(capsys.readouterr().out)
+ assert "not sliced" in payload["error"] and payload["uploaded"] is False
+
+
+def test_job_rejects_plate_for_a_model_it_slices(tmp_path, capsys):
+ stl = tmp_path / "cube.stl"
+ stl.write_text("solid c\nendsolid c\n", encoding="utf-8")
+ with pytest.raises(BambuError) as caught:
+ _job(str(stl), "--plate", "2")
+ assert caught.value.exit_code == EXIT_COMMAND_ERROR
+ assert "--plate" in json.loads(capsys.readouterr().out)["error"]
+
+
+def test_upload_only_next_command_carries_a_non_default_plate(tmp_path, capsys):
+ from bambu_cli.cliparse import build_parser
+ from bambu_cli.context import RuntimeContext
+ from bambu_cli.job.orchestrate import _run_job
+ from bambu_cli.job.steps import JobSteps
+ from tests.bambu_test_base import settings_ctx
+
+ source = _three_mf(tmp_path / "model.3mf", [2])
+ args = build_parser().parse_args(["--json", "job", source, "--upload-only"])
+ with settings_ctx():
+ _run_job(RuntimeContext.for_request(args), args, JobSteps(upload=lambda ns: "model.3mf"))
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["next_command"] == ["print", "model.3mf", "--confirm", "--json", "--plate", "2"]
diff --git a/tests/test_printer_model_safety.py b/tests/test_printer_model_safety.py
new file mode 100644
index 0000000..cd6615e
--- /dev/null
+++ b/tests/test_printer_model_safety.py
@@ -0,0 +1,241 @@
+"""The configured printer model decides which machine profile G-code is sliced for.
+
+Slicing for the wrong printer is a physical hazard: a P1P profile has a 256 mm
+bed, an A1 mini has 180 mm. platecli used to fall back to the P1P profile for any
+model name it did not recognise (including the A1 mini's own product name, "A1
+mini") and for any missing machine profile, without a warning. These tests pin
+the fail-closed behaviour: an unknown model or a missing machine profile stops
+the slice with a config error instead of producing G-code for another printer.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+
+import pytest
+
+from bambu_cli.config import MODEL_MAPPING, resolve_printer_model
+from bambu_cli.constants import EXIT_CONFIG_ERROR
+from bambu_cli.context import Settings
+from bambu_cli.errors import BambuError
+from bambu_cli.slicer import cmd_slice
+from bambu_cli.slicer.profiles import _discover_process_profile
+from tests.bambu_test_base import settings_ctx
+from tests.fakes.orca_stub import build_profiles_dir, make_orca_launcher, write_stl
+
+
+def _slice_args(model_path, outdir):
+ return argparse.Namespace(
+ file=model_path,
+ output=outdir,
+ quality="standard",
+ copies=1,
+ infill=15,
+ pattern="3dhoneycomb",
+ supports=False,
+ nozzle_temp=None,
+ bed_temp=None,
+ filament="PLA Basic",
+ json=False,
+ threads=None,
+ list_settings=False,
+ )
+
+
+@pytest.fixture
+def stub_slice(tmp_path, monkeypatch):
+ """Run cmd_slice against the fake OrcaSlicer with a P1P-only profiles dir."""
+ monkeypatch.setenv("DISPLAY", ":0")
+ monkeypatch.setenv("ORCA_STUB_SCENARIO", "success")
+ launcher = make_orca_launcher(str(tmp_path))
+ profiles = build_profiles_dir(str(tmp_path))
+ model = write_stl(str(tmp_path / "model.stl"))
+ outdir = tmp_path / "out"
+ outdir.mkdir()
+
+ def run(printer_model, nozzle_size="0.4"):
+ with settings_ctx(
+ orca_slicer=launcher, profiles_dir=profiles, printer_model=printer_model, nozzle_size=nozzle_size
+ ):
+ return cmd_slice(_slice_args(model, str(outdir)))
+
+ run.outdir = str(outdir)
+ run.profiles = profiles
+ return run
+
+
+# --- model name resolution ---------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [
+ ("P1P", "P1P"),
+ ("p1s", "P1S"),
+ ("A1 mini", "A1M"),
+ ("A1MINI", "A1M"),
+ ("a1-mini", "A1M"),
+ ("Bambu Lab A1 mini", "A1M"),
+ ("X1 Carbon", "X1C"),
+ ("Bambu Lab X1 Carbon", "X1C"),
+ (" x1e ", "X1E"),
+ ],
+)
+def test_resolve_printer_model_accepts_product_names(raw, expected):
+ assert resolve_printer_model(raw) == expected
+
+
+@pytest.mark.parametrize("raw", ["H2D", "P2S", "A2", "", None, 123, "P1P 0.4 nozzle"])
+def test_resolve_printer_model_rejects_unknown(raw):
+ assert resolve_printer_model(raw) is None
+
+
+def test_settings_resolve_product_name_to_model_key():
+ assert Settings.from_config({"model": "A1 mini"}).printer_model == "A1M"
+
+
+def test_settings_keep_unknown_model_visible_instead_of_defaulting():
+ # The raw name must survive so the slicer can refuse it by name; turning it
+ # into "P1P" here is exactly the silent fallback being removed.
+ assert Settings.from_config({"model": "H2D"}).printer_model == "H2D"
+
+
+# --- slicing -----------------------------------------------------------------
+
+
+def test_unknown_model_refuses_to_slice(stub_slice):
+ with pytest.raises(BambuError) as caught:
+ stub_slice("H2D")
+ assert caught.value.exit_code == EXIT_CONFIG_ERROR
+ assert "H2D" in str(caught.value)
+ assert os.listdir(stub_slice.outdir) == []
+
+
+def test_missing_machine_profile_does_not_fall_back_to_p1p(stub_slice):
+ # A1M is a known model, but this profiles dir only holds the P1P machine
+ # profile. The old code sliced with the P1P profile instead.
+ with pytest.raises(BambuError) as caught:
+ stub_slice("A1M")
+ assert caught.value.exit_code == EXIT_CONFIG_ERROR
+ assert "Bambu Lab A1 mini 0.4 nozzle" in str(caught.value)
+ assert os.listdir(stub_slice.outdir) == []
+
+
+def test_missing_nozzle_profile_does_not_fall_back_to_another_nozzle(stub_slice):
+ with pytest.raises(BambuError) as caught:
+ stub_slice("P1P", nozzle_size="0.6")
+ assert caught.value.exit_code == EXIT_CONFIG_ERROR
+ assert os.listdir(stub_slice.outdir) == []
+
+
+def test_known_model_with_its_profile_still_slices(stub_slice):
+ assert stub_slice("P1P").endswith("model_sliced.3mf")
+
+
+def test_process_discovery_never_borrows_another_models_profile(tmp_path):
+ proc = tmp_path / "process"
+ proc.mkdir()
+ (proc / "0.20mm Standard @BBL P1P.json").write_text(
+ json.dumps({"compatible_printers": ["Bambu Lab P1P 0.4 nozzle"]}), encoding="utf-8"
+ )
+ quality_map = {"standard": "0.20mm Standard @BBL A1M"}
+ found = _discover_process_profile(
+ "standard",
+ quality_map,
+ model_code="A1M",
+ compatible_printer="Bambu Lab A1 mini 0.4 nozzle",
+ profiles_dir=str(tmp_path),
+ )
+ assert found is None
+
+
+# --- setup / validation ------------------------------------------------------
+
+
+def test_setup_normalize_model_rejects_unknown_instead_of_defaulting():
+ from bambu_cli.setup_cmd.common import _normalize_model
+
+ assert _normalize_model("A1 mini") == "A1M"
+ with pytest.raises(ValueError, match="H2D"):
+ _normalize_model("H2D")
+
+
+def test_noninteractive_setup_requires_a_model(tmp_path, monkeypatch, capsys):
+ from bambu_cli.setup_cmd import common as common_mod
+ from bambu_cli.setup_cmd.wizard import _cmd_setup_noninteractive
+
+ cfg_path = tmp_path / "config.json"
+ monkeypatch.setattr(common_mod, "_config_path", lambda: str(cfg_path))
+ monkeypatch.setenv("PLATE_TEST_CODE", "12345678")
+ args = argparse.Namespace(
+ printer_ip="192.0.2.10",
+ serial="01S00A000000000",
+ access_code=None,
+ access_code_env="PLATE_TEST_CODE",
+ access_code_file=str(tmp_path / "access_code"),
+ model=None,
+ nozzle=None,
+ orca_slicer=None,
+ profiles_dir=None,
+ cert_fingerprint=None,
+ insecure_tls=False,
+ force=False,
+ json=True,
+ )
+ with pytest.raises(BambuError) as caught:
+ _cmd_setup_noninteractive(args)
+ assert caught.value.exit_code == EXIT_CONFIG_ERROR
+ assert "--model" in str(caught.value.extra) or "--model" in str(caught.value)
+ assert not cfg_path.exists()
+
+
+def test_config_validate_flags_unknown_model(tmp_path, monkeypatch):
+ from bambu_cli import config as config_mod
+ from bambu_cli.setup_cmd import common as common_mod
+ from bambu_cli.setup_cmd.preflight import collect_preflight_checks
+
+ cfg_path = tmp_path / "config.json"
+ cfg_path.write_text(
+ json.dumps(
+ {"printer_ip": "192.0.2.10", "serial": "01S00A000000000", "model": "H2D", "access_code": "12345678"}
+ ),
+ encoding="utf-8",
+ )
+ os.chmod(cfg_path, 0o600)
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", str(cfg_path))
+ monkeypatch.setattr(common_mod, "_config_path", lambda: str(cfg_path))
+ checks = {check["name"]: check for check in collect_preflight_checks()}
+ assert checks["printer-model"]["status"] == "error"
+ assert "H2D" in checks["printer-model"]["message"]
+
+
+def test_every_supported_model_resolves_to_itself():
+ for key in MODEL_MAPPING:
+ assert resolve_printer_model(key) == key
+
+
+def test_interactive_model_prompt_reasks_until_supported(monkeypatch):
+ from bambu_cli.setup_cmd import wizard as wizard_mod
+
+ answers = iter(["H2D", "", "a1 mini"])
+ monkeypatch.setattr(wizard_mod, "_prompt_text", lambda msg, args=None: next(answers))
+ # Nothing detected: a blank answer is not accepted as "P1P".
+ assert wizard_mod._prompt_printer_model(argparse.Namespace(json=False), None) == "A1M"
+
+
+def test_interactive_model_prompt_gives_up_without_saving(monkeypatch):
+ from bambu_cli.setup_cmd import wizard as wizard_mod
+
+ monkeypatch.setattr(wizard_mod, "_prompt_text", lambda msg, args=None: "Ender 3")
+ with pytest.raises(BambuError) as caught:
+ wizard_mod._prompt_printer_model(argparse.Namespace(json=False), None)
+ assert caught.value.exit_code == EXIT_CONFIG_ERROR
+
+
+def test_interactive_model_prompt_blank_takes_detected_model(monkeypatch):
+ from bambu_cli.setup_cmd import wizard as wizard_mod
+
+ monkeypatch.setattr(wizard_mod, "_prompt_text", lambda msg, args=None: "")
+ assert wizard_mod._prompt_printer_model(argparse.Namespace(json=False), "X1C") == "X1C"
diff --git a/tests/test_process_profile_choice.py b/tests/test_process_profile_choice.py
new file mode 100644
index 0000000..eb8e5e3
--- /dev/null
+++ b/tests/test_process_profile_choice.py
@@ -0,0 +1,71 @@
+"""Process-profile discovery picks the requested quality, deterministically.
+
+When a model has no process profile of its own (P1S, X1 and X1E borrow the
+X1C ones through ``compatible_printers``), discovery took the first file in
+directory order whose name merely *contained* the model code. With real
+OrcaSlicer profiles that was ``0.20mm Bambu Support W @BBL X1C`` -- a profile
+for printing with support material -- for every "standard" P1S/X1/X1E slice,
+and ``"X1"`` also matched every X1C/X1E file by substring (found in review,
+2026-09-23).
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from bambu_cli.slicer.profiles import _discover_process_profile
+
+
+def _profiles(tmp_path, files):
+ proc = tmp_path / "process"
+ proc.mkdir()
+ for name, compatible in files.items():
+ (proc / f"{name}.json").write_text(json.dumps({"compatible_printers": compatible}), encoding="utf-8")
+ return str(tmp_path)
+
+
+def _quality_map(code):
+ return {"standard": f"0.20mm Standard @BBL {code}", "high": f"0.12mm Fine @BBL {code}"}
+
+
+def _pick(root, code, printer, quality="standard"):
+ found = _discover_process_profile(
+ quality, _quality_map(code), model_code=code, compatible_printer=f"{printer} 0.4 nozzle", profiles_dir=root
+ )
+ return found.rsplit("/", 1)[-1].rsplit("\\", 1)[-1][: -len(".json")] if found else None
+
+
+X1C_FAMILY = ["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle", "Bambu Lab P1S 0.4 nozzle"]
+
+
+@pytest.mark.parametrize("code, printer", [("P1S", "Bambu Lab P1S"), ("X1", "Bambu Lab X1")])
+def test_borrowed_profile_is_the_requested_quality_not_a_specialty_one(tmp_path, code, printer):
+ root = _profiles(
+ tmp_path,
+ {
+ "0.20mm Bambu Support W @BBL X1C": X1C_FAMILY,
+ "0.20mm Standard @BBL X1C": X1C_FAMILY,
+ "0.20mm Strength @BBL X1C": X1C_FAMILY,
+ },
+ )
+ assert _pick(root, code, printer) == "0.20mm Standard @BBL X1C"
+
+
+def test_model_code_is_not_matched_as_a_substring(tmp_path):
+ # "A1" must not pick up an A1 mini ("A1M") profile the A1 cannot use.
+ root = _profiles(tmp_path, {"0.20mm Standard @BBL A1M": ["Bambu Lab A1 mini 0.4 nozzle"]})
+ assert _pick(root, "A1", "Bambu Lab A1") is None
+
+
+def test_real_profiles_give_standard_for_every_model():
+ import os
+
+ from bambu_cli.config import detect_profiles_dir
+
+ real = detect_profiles_dir()
+ if not real or not os.path.isdir(os.path.join(real, "process")):
+ pytest.skip("no OrcaSlicer BBL profiles installed on this machine")
+ for code, printer in (("P1S", "Bambu Lab P1S"), ("X1", "Bambu Lab X1"), ("X1E", "Bambu Lab X1E")):
+ assert _pick(real, code, printer) == "0.20mm Standard @BBL X1C", code
diff --git a/tests/test_setup_helpers.py b/tests/test_setup_helpers.py
index 2fe653b..abe690d 100644
--- a/tests/test_setup_helpers.py
+++ b/tests/test_setup_helpers.py
@@ -38,7 +38,8 @@ def test_parse_mdns_identity_model_prefix():
def test_parse_mdns_identity_plain():
serial, model = wizard_mod._parse_mdns_printer_identity("something-else.local")
- assert model == "P1P"
+ # No model in the service name: nothing is guessed, the setup prompt asks.
+ assert model is None
def test_normalize_model_nozzle():
assert common_mod._normalize_model("x1c", "P1P") == "X1C"
@@ -248,3 +249,169 @@ def boom():
info.parsed_addresses = boom
info.addresses = [socket.inet_aton("10.0.0.1")]
assert wizard_mod._service_info_address(info) == "10.0.0.1"
+
+
+def test_service_info_raw_ipv6():
+ info = MagicMock()
+ info.parsed_addresses = None
+ info.addresses = [socket.inet_pton(socket.AF_INET6, "2001:db8::1")]
+ assert wizard_mod._service_info_address(info) == "2001:db8::1"
+
+
+def test_noninteractive_both_code_and_env():
+ args = Namespace(
+ printer_ip="10.0.0.1",
+ serial="SN123",
+ access_code="code1",
+ access_code_env="ENV_VAR",
+ access_code_file=None,
+ json=False,
+ )
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_noninteractive_access_code_env_unset(monkeypatch):
+ monkeypatch.delenv("UNSET_ACCESS_CODE_VAR", raising=False)
+ args = Namespace(
+ printer_ip="10.0.0.1",
+ serial="SN123",
+ access_code=None,
+ access_code_env="UNSET_ACCESS_CODE_VAR",
+ access_code_file=None,
+ json=False,
+ )
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_noninteractive_missing_required_args():
+ args = Namespace(
+ printer_ip=None,
+ serial=None,
+ access_code=None,
+ access_code_env=None,
+ access_code_file=None,
+ json=False,
+ )
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_noninteractive_access_code_file_not_found(tmp_path):
+ args = Namespace(
+ printer_ip="10.0.0.1",
+ serial="SN123",
+ access_code=None,
+ access_code_env=None,
+ access_code_file=str(tmp_path / "nonexistent_code"),
+ json=False,
+ )
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_noninteractive_placeholders():
+ args = Namespace(
+ printer_ip="192.168.0.XXX",
+ serial="YOUR_SERIAL",
+ access_code="ACCESS_CODE",
+ access_code_env=None,
+ access_code_file=None,
+ json=False,
+ )
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_noninteractive_write_oserror():
+ args = Namespace(
+ printer_ip="10.0.0.1",
+ serial="SNVALID001",
+ access_code="valid_code_123",
+ access_code_env=None,
+ access_code_file=None,
+ model="P1P",
+ nozzle="0.4",
+ orca_slicer="/bin/true",
+ profiles_dir="/tmp",
+ cert_fingerprint=None,
+ insecure_tls=False,
+ json=False,
+ )
+ with patch("bambu_cli.setup_cmd.wizard._write_setup_config", side_effect=OSError("Disk full")):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_noninteractive(args)
+ assert cm.value.exit_code == 3
+
+
+def test_cmd_setup_dispatches_migrate():
+ args = Namespace(migrate_access_code=True)
+ with patch("bambu_cli.setup_cmd.migrate._cmd_migrate_access_code") as mock_mig:
+ wizard_mod._cmd_setup(args)
+ mock_mig.assert_called_once_with(args)
+
+
+def test_interactive_zeroconf_missing_refuse_manual():
+ args = Namespace(json=False)
+ with patch.dict("sys.modules", {"zeroconf": None}):
+ with patch("bambu_cli.setup_cmd.wizard._prompt_text", return_value="n"):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_interactive_manual_setup_missing_ip_or_serial():
+ args = Namespace(json=False)
+ with patch.dict("sys.modules", {"zeroconf": None}):
+ # Choice 'y', but empty IP
+ with patch("bambu_cli.setup_cmd.wizard._prompt_text", side_effect=["y", ""]):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 1
+
+ # Choice 'y', IP given, but empty serial
+ with patch("bambu_cli.setup_cmd.wizard._prompt_text", side_effect=["y", "192.168.1.50", ""]):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 1
+
+
+def test_interactive_discovery_no_printers_found():
+ args = Namespace(json=False, scan_timeout=0.01)
+ with patch("zeroconf.Zeroconf"), patch("zeroconf.ServiceBrowser"):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 2
+
+
+def test_interactive_discovery_multiple_printers_invalid_choices():
+ args = Namespace(json=False, scan_timeout=0.01)
+
+ def fake_sb(zc, type_, listener):
+ info1 = MagicMock()
+ info1.parsed_addresses = lambda: ["10.0.0.1"]
+ info2 = MagicMock()
+ info2.parsed_addresses = lambda: ["10.0.0.2"]
+ zc.get_service_info.side_effect = [info1, info2]
+ listener.add_service(zc, type_, "BBLP-P1P-01P00A111._bblp._tcp.local.")
+ listener.add_service(zc, type_, "BBLP-X1C-01S00A222._bblp._tcp.local.")
+
+ with patch("zeroconf.Zeroconf") as mock_zc, patch("zeroconf.ServiceBrowser", side_effect=fake_sb):
+ # Out of bounds choice
+ with patch("bambu_cli.setup_cmd.wizard._prompt_text", return_value="5"):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 5
+
+ # ValueError choice
+ with patch("bambu_cli.setup_cmd.wizard._prompt_text", return_value="not_int"):
+ with pytest.raises(BambuError) as cm:
+ wizard_mod._cmd_setup_interactive(args)
+ assert cm.value.exit_code == 5
+
diff --git a/tests/test_setup_partial_update.py b/tests/test_setup_partial_update.py
new file mode 100644
index 0000000..9121111
--- /dev/null
+++ b/tests/test_setup_partial_update.py
@@ -0,0 +1,124 @@
+"""`plate setup --` updates that value in an existing config.
+
+docs/manual.md and docs/troubleshooting.md tell users to run, for example,
+``plate setup --printer-ip `` after a DHCP change, ``plate setup
+--access-code-env PLATE_CODE`` after rotating the LAN code, and ``plate setup
+--profiles-dir `` to fix a path. Before the 2026-09-23 fix none of them
+worked: partial non-interactive setup failed on "missing required values", and
+``--orca-slicer``/``--profiles-dir``/``--cert-fingerprint`` alone started the
+interactive wizard and ignored the flag. Values not given now come from the
+existing config -- the pin and access code only when it is the same printer.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+from bambu_cli.cliparse import build_parser
+from bambu_cli.errors import BambuError
+
+PIN = "ab" * 32
+
+
+@pytest.fixture
+def setup_env(tmp_path, monkeypatch):
+ from bambu_cli.setup_cmd import common as common_mod
+ from bambu_cli.setup_cmd import wizard as wizard_mod
+
+ cfg = tmp_path / "config.json"
+ code = tmp_path / "access_code"
+ monkeypatch.setattr(common_mod, "_config_path", lambda: str(cfg))
+ monkeypatch.setattr(wizard_mod, "_config_path", lambda: str(cfg))
+ monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+
+ def write_existing(**over):
+ code.write_text("12345678\n", encoding="utf-8")
+ os.chmod(code, 0o600)
+ data = {
+ "printer_ip": "192.0.2.10",
+ "serial": "01S00A000000000",
+ "username": "bblp",
+ "model": "P1S",
+ "nozzle": "0.4",
+ "orca_slicer": "/opt/orca",
+ "profiles_dir": "/opt/profiles",
+ "access_code_file": str(code),
+ "cert_fingerprint": PIN,
+ "camera_port": "127.0.0.1:1985:1984",
+ }
+ data.update(over)
+ cfg.write_text(json.dumps(data), encoding="utf-8")
+ os.chmod(cfg, 0o600)
+
+ def run(*argv):
+ from bambu_cli.setup_cmd.wizard import _cmd_setup
+
+ _cmd_setup(build_parser().parse_args(["setup", *argv]))
+ return json.loads(cfg.read_text(encoding="utf-8"))
+
+ return argparse_ns(cfg=cfg, code=code, write_existing=write_existing, run=run)
+
+
+def argparse_ns(**kw):
+ import argparse
+
+ return argparse.Namespace(**kw)
+
+
+def test_printer_ip_alone_updates_only_the_ip(setup_env):
+ setup_env.write_existing()
+ cfg = setup_env.run("--printer-ip", "192.0.2.77")
+ assert cfg["printer_ip"] == "192.0.2.77"
+ assert cfg["serial"] == "01S00A000000000" and cfg["model"] == "P1S"
+ assert cfg["cert_fingerprint"] == PIN # same printer, same certificate
+ assert cfg["access_code_file"] == str(setup_env.code)
+ assert cfg["camera_port"] == "127.0.0.1:1985:1984"
+
+
+def test_access_code_rotation_rewrites_the_configured_file(setup_env, monkeypatch):
+ setup_env.write_existing()
+ monkeypatch.setenv("PLATE_CODE", "87654321")
+ cfg = setup_env.run("--access-code-env", "PLATE_CODE")
+ assert setup_env.code.read_text(encoding="utf-8").strip() == "87654321"
+ assert cfg["access_code_file"] == str(setup_env.code)
+ assert "access_code" not in cfg
+
+
+@pytest.mark.parametrize(
+ "argv, key, value",
+ [
+ (["--profiles-dir", "/new/profiles"], "profiles_dir", "/new/profiles"),
+ (["--orca-slicer", "/new/orca"], "orca_slicer", "/new/orca"),
+ (["--cert-fingerprint", "cd" * 32], "cert_fingerprint", "cd" * 32),
+ ],
+)
+def test_path_and_pin_flags_update_instead_of_starting_the_wizard(setup_env, argv, key, value):
+ setup_env.write_existing()
+ cfg = setup_env.run(*argv)
+ assert cfg[key] == value
+ assert cfg["printer_ip"] == "192.0.2.10"
+
+
+def test_a_different_printer_does_not_inherit_pin_or_code(setup_env, monkeypatch):
+ setup_env.write_existing()
+ with pytest.raises(BambuError) as caught:
+ setup_env.run("--printer-ip", "192.0.2.99", "--serial", "03000B000000000")
+ assert "--access-code" in str(caught.value.extra) + str(caught.value)
+ monkeypatch.setenv("PLATE_CODE", "11112222")
+ new_code = setup_env.code.parent / "other_code"
+ cfg = setup_env.run(
+ "--printer-ip", "192.0.2.99", "--serial", "03000B000000000",
+ "--access-code-env", "PLATE_CODE", "--access-code-file", str(new_code), "--model", "A1",
+ ) # fmt: skip
+ assert "cert_fingerprint" not in cfg
+ assert cfg["access_code_file"] == str(new_code)
+
+
+def test_without_an_existing_config_partial_setup_still_asks_for_everything(setup_env):
+ with pytest.raises(BambuError) as caught:
+ setup_env.run("--printer-ip", "192.0.2.77")
+ assert "--serial" in str(caught.value.extra) + str(caught.value)
+ assert not setup_env.cfg.exists()
diff --git a/tests/test_slice_a1_layer_gcode.py b/tests/test_slice_a1_layer_gcode.py
new file mode 100644
index 0000000..c9fee85
--- /dev/null
+++ b/tests/test_slice_a1_layer_gcode.py
@@ -0,0 +1,57 @@
+"""A1 and A1 mini machine profiles must slice from the command line.
+
+OrcaSlicer refuses to slice with relative extrusion unless the layer-change
+G-code resets the extruder (``G92 E0``). Bambu's X1/P1 profiles include it; the
+A1 and A1 mini profiles do not, and the OrcaSlicer CLI does not apply the
+exemption its GUI uses for Bambu printers. Every A1/A1 mini slice through
+platecli therefore failed with "Relative extruder addressing requires resetting
+the extruder position at each layer" (found 2026-09-22, reproduced on the base
+commit). The temp machine profile now carries the reset when it is missing;
+with relative extrusion it resets a counter and moves nothing.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+
+import pytest
+
+from bambu_cli.slicer.profiles import _create_temp_machine
+
+
+def _machine(tmp_path, **fields):
+ path = tmp_path / "machine" / "Bambu Lab A1 mini 0.4 nozzle.json"
+ os.makedirs(path.parent, exist_ok=True)
+ path.write_text(json.dumps({"name": "Bambu Lab A1 mini 0.4 nozzle", **fields}), encoding="utf-8")
+ return str(path)
+
+
+def _written(tmp_path, path):
+ handle = _create_temp_machine(path, str(tmp_path))
+ with open(handle.name, encoding="utf-8") as fh:
+ data = json.load(fh)
+ os.unlink(handle.name)
+ return data
+
+
+def test_missing_extruder_reset_is_added(tmp_path):
+ layer = (
+ "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM73 L{layer_num+1}\nM991 S0 P{layer_num}\n"
+ )
+ data = _written(tmp_path, _machine(tmp_path, layer_change_gcode=layer))
+ assert data["layer_change_gcode"].startswith(layer.rstrip("\n"))
+ assert data["layer_change_gcode"].rstrip().endswith("G92 E0")
+
+
+@pytest.mark.parametrize(
+ "fields",
+ [
+ {"layer_change_gcode": "M73 L{layer_num+1}\nG92 E0 ; reset\n"},
+ {"layer_change_gcode": "M73 L1\n", "before_layer_change_gcode": " g92 e0.0\n"},
+ {"layer_change_gcode": "M73 L1\n", "use_relative_e_distances": "0"},
+ ],
+)
+def test_profiles_that_do_not_need_it_are_left_alone(tmp_path, fields):
+ data = _written(tmp_path, _machine(tmp_path, **fields))
+ assert data["layer_change_gcode"] == fields["layer_change_gcode"]
diff --git a/tests/test_slice_cmd.py b/tests/test_slice_cmd.py
index c64afe9..648ff3f 100644
--- a/tests/test_slice_cmd.py
+++ b/tests/test_slice_cmd.py
@@ -100,17 +100,18 @@ def exists_side_effect(path):
mock_exists.side_effect = exists_side_effect
- with self.assertRaises((SystemExit, BambuError)) as cm:
+ # OrcaSlicer itself is present; only the machine profile is missing.
+ with (
+ patch("bambu_cli.slicer.cmd._slicer_executable_problem", return_value=None),
+ self.assertRaises((SystemExit, BambuError)) as cm,
+ ):
cmd_slice(args)
self.assertEqual(getattr(cm.exception, "exit_code", getattr(cm.exception, "code", None)), 1)
+ self.assertIn("Missing machine profile", str(cm.exception))
- # We need to find what missing profile message is logged
- self.assertTrue(
- any(
- "Fallback machine profile" in call[0][0] or "not found. Using standard P1P" in call[0][0]
- for call in mock_logger.warning.call_args_list
- )
- )
+ # No fallback to another model's machine profile, and OrcaSlicer never runs.
+ self.assertFalse(any("P1P" in call[0][0] for call in mock_logger.warning.call_args_list))
+ mock_popen.assert_not_called()
@patch("bambu_cli.slicer.output._is_valid_sliced_3mf", return_value=True)
@patch("os.path.exists")
@@ -572,7 +573,7 @@ def test_cmd_slice_discover_process_profile_branches(self, mock_logger, mock_exi
mock_listdir.return_value = []
result = _discover_process_profile("standard", quality_map)
self.assertIsNone(result)
- mock_logger.error.assert_called_with(f"No slicer profiles found in {os.path.join('/tmp', 'process')}")
+ mock_logger.error.assert_called_with(f"No slicer profiles for P1P found in {os.path.join('/tmp', 'process')}")
# Test directory does not exist
mock_isdir.return_value = False
diff --git a/tests/test_slice_output_hygiene.py b/tests/test_slice_output_hygiene.py
new file mode 100644
index 0000000..21ac5c1
--- /dev/null
+++ b/tests/test_slice_output_hygiene.py
@@ -0,0 +1,65 @@
+"""Slicing leaves exactly one file in the output directory: the sliced 3MF.
+
+OrcaSlicer also writes ``plate_1.gcode`` and ``result.json`` into its
+``--outputdir``. platecli pointed that at the user's output directory -- by
+default the folder the model lives in -- so every slice left both behind and
+overwrote any same-named file already there (found 2026-09-22; the stray
+``plate_1.gcode`` in the repo root came from this). OrcaSlicer now writes into
+a private directory and only the 3MF is moved out.
+"""
+
+from __future__ import annotations
+
+import argparse
+
+import pytest
+
+from bambu_cli.slicer import cmd_slice
+from tests.bambu_test_base import settings_ctx
+from tests.fakes.orca_stub import build_profiles_dir, make_orca_launcher, write_stl
+
+
+@pytest.fixture
+def slice_into(tmp_path, monkeypatch):
+ monkeypatch.setenv("DISPLAY", ":0")
+ launcher = make_orca_launcher(str(tmp_path))
+ profiles = build_profiles_dir(str(tmp_path))
+ model_dir = tmp_path / "models"
+ model_dir.mkdir()
+ model = write_stl(str(model_dir / "model.stl"))
+
+ def run(scenario="success", output=None):
+ monkeypatch.setenv("ORCA_STUB_SCENARIO", scenario)
+ args = argparse.Namespace(
+ file=model, output=output, quality="standard", copies=1, infill=15, pattern="grid",
+ supports=False, nozzle_temp=None, bed_temp=None, filament="PLA Basic", json=False,
+ threads=None, list_settings=False,
+ ) # fmt: skip
+ with settings_ctx(orca_slicer=launcher, profiles_dir=profiles, printer_model="P1P", nozzle_size="0.4"):
+ return cmd_slice(args)
+
+ run.model_dir = model_dir
+ return run
+
+
+def test_only_the_3mf_is_left_next_to_the_model(slice_into):
+ out = slice_into()
+ assert sorted(p.name for p in slice_into.model_dir.iterdir()) == ["model.stl", "model_sliced.3mf"]
+ assert out == str(slice_into.model_dir / "model_sliced.3mf")
+
+
+def test_same_named_user_files_are_not_touched(slice_into):
+ mine = slice_into.model_dir / "plate_1.gcode"
+ mine.write_text("; my own gcode\n", encoding="utf-8")
+ (slice_into.model_dir / "result.json").write_text('{"mine": true}', encoding="utf-8")
+ slice_into()
+ assert mine.read_text(encoding="utf-8") == "; my own gcode\n"
+ assert (slice_into.model_dir / "result.json").read_text(encoding="utf-8") == '{"mine": true}'
+
+
+def test_no_private_work_dir_survives_a_failed_slice(slice_into):
+ from bambu_cli.errors import BambuError
+
+ with pytest.raises(BambuError):
+ slice_into("fail")
+ assert sorted(p.name for p in slice_into.model_dir.iterdir()) == ["model.stl"]
diff --git a/tests/test_slice_override_safety.py b/tests/test_slice_override_safety.py
new file mode 100644
index 0000000..6599b49
--- /dev/null
+++ b/tests/test_slice_override_safety.py
@@ -0,0 +1,170 @@
+"""Generic slice overrides (--set / --set-filament / --settings-json) must not
+get around the printer-safety checks.
+
+Each case here reached the real OrcaSlicer G-code before the fix
+(2026-09-22 audit): ``nozzle_temperature=400,220`` produced ``M109 S400``
+because OrcaSlicer reads its own comma-list syntax while the validator only
+understood JSON; ``filament_start_gcode`` and ``machine_start_gcode`` injected
+raw G-code; ``printable_area`` (a machine setting, sent through ``--set``)
+changed the bed size the model is sliced for.
+"""
+
+from __future__ import annotations
+
+import json
+from argparse import Namespace
+
+import pytest
+
+from bambu_cli.slicer import options as S
+from tests.bambu_test_base import settings_ctx
+
+
+def _args(**over):
+ base = dict(
+ copies=1,
+ infill=15,
+ nozzle_temp=None,
+ bed_temp=None,
+ wall_type=None,
+ set_process=None,
+ set_filament=None,
+ settings_json=None,
+ )
+ base.update(over)
+ return Namespace(**base)
+
+
+# --- temperatures ------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "entry",
+ [
+ "nozzle_temperature=400,220", # OrcaSlicer's list syntax: extruder 0 gets 400
+ "nozzle_temperature=220,400",
+ "nozzle_temperature_initial_layer= 220 , 999 ",
+ 'nozzle_temperature=["400,220"]', # a JSON element is split the same way
+ ],
+)
+def test_comma_list_temperatures_are_range_checked(entry):
+ err = S._validate_slice_options(_args(set_filament=[entry]))
+ assert err is not None and "nozzle temperature override" in err
+
+
+def test_comma_list_bed_temperature_is_range_checked():
+ err = S._validate_slice_options(_args(set_filament=["hot_plate_temp=60,400"]))
+ assert err is not None and "bed temperature override" in err
+
+
+@pytest.mark.parametrize(
+ "value",
+ # 3_5_0 / 1e2 / non-ASCII digits: Python's float() reads them (350, 100,
+ # 400) but OrcaSlicer does not, so the checked value and the sliced one
+ # differed (3_5_0 sliced at 3 C).
+ [
+ "400abc",
+ "",
+ "hot",
+ "0x190",
+ "[]",
+ "true",
+ "[[400]]",
+ '{"a": 1}',
+ "3_5_0",
+ "1e2",
+ "\uff14\uff10\uff10",
+ "\u0663\u0665\u0660",
+ ],
+)
+def test_unreadable_temperature_values_fail_closed(value):
+ err = S._validate_slice_options(_args(set_filament=[f"nozzle_temperature={value}"]))
+ assert err is not None and "must be a number" in err
+
+
+def test_unreadable_temperature_in_settings_json_fails_closed():
+ blob = json.dumps({"filament": {"cool_plate_temp": [True]}})
+ err = S._validate_slice_options(_args(settings_json=blob))
+ assert err is not None and "must be a number" in err
+
+
+@pytest.mark.parametrize(
+ "entry",
+ ["chamber_temperatures=400", "temperature_vitrification=[999]", "nozzle_temperature_range_high=500"],
+)
+def test_other_temperature_keys_are_bounded_too(entry):
+ err = S._validate_slice_options(_args(set_filament=[entry]))
+ assert err is not None and "temperature override" in err
+
+
+@pytest.mark.parametrize(
+ "entry",
+ ["nozzle_temperature=230", "nozzle_temperature=[230]", "nozzle_temperature=230,230", "hot_plate_temp=[65]"],
+)
+def test_safe_temperatures_still_pass(entry):
+ assert S._validate_slice_options(_args(set_filament=[entry])) is None
+
+
+# --- G-code and scripts --------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "kw",
+ [
+ {"set_filament": ["filament_start_gcode=M104 S399"]},
+ {"set_filament": ["filament_end_gcode=M140 S200"]},
+ {"set_process": ["machine_start_gcode=M104 S398"]},
+ {"set_process": ["post_process=/bin/sh -c true"]},
+ {"settings_json": json.dumps({"process": {"change_filament_gcode": "M104 S400"}})},
+ ],
+)
+def test_gcode_and_script_overrides_are_refused(kw):
+ err = S._validate_slice_options(_args(**kw))
+ assert err is not None and "cannot be overridden" in err
+
+
+# --- machine (printer) settings ------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "entry",
+ ["printable_area=0x0,400x0,400x400,0x400", "printable_height=500", "machine_max_acceleration_x=[99999]"],
+)
+def test_machine_settings_are_refused_without_profiles(entry):
+ # No profiles on disk (the test baseline points profiles_dir at a missing
+ # directory): the built-in list of printer-geometry keys still applies.
+ err = S._validate_slice_options(_args(set_process=[entry]))
+ assert err is not None and "printer (machine) setting" in err
+
+
+def test_machine_settings_are_refused_from_the_installed_profiles(tmp_path):
+ for kind, payload in (
+ ("machine", {"retraction_length": ["0.8"], "extruder_clearance_radius": "57"}),
+ ("process", {"wall_loops": "2"}),
+ ("filament", {"filament_retraction_length": ["nil"], "nozzle_temperature": ["220"]}),
+ ):
+ (tmp_path / kind).mkdir()
+ (tmp_path / kind / f"{kind}.json").write_text(json.dumps(payload), encoding="utf-8")
+ S._known_setting_keys.cache_clear()
+ with settings_ctx(profiles_dir=str(tmp_path)):
+ err = S._validate_slice_options(_args(set_process=["retraction_length=0.8"]))
+ assert err is not None and "printer (machine) setting" in err
+ # Point at the filament-level equivalent when there is one.
+ assert "filament_retraction_length" in err
+ assert "printer (machine) setting" in (
+ S._validate_slice_options(_args(set_process=["extruder_clearance_radius=1"])) or ""
+ )
+ assert S._validate_slice_options(_args(set_process=["wall_loops=4"])) is None
+ S._known_setting_keys.cache_clear()
+
+
+def test_list_settings_does_not_advertise_refused_gcode_keys(tmp_path):
+ (tmp_path / "filament").mkdir()
+ (tmp_path / "filament" / "f.json").write_text(
+ json.dumps({"filament_start_gcode": ["; x"], "filament_flow_ratio": ["0.98"]}), encoding="utf-8"
+ )
+ S._known_setting_keys.cache_clear()
+ catalog = S.setting_catalog(str(tmp_path))
+ S._known_setting_keys.cache_clear()
+ assert "filament_flow_ratio" in catalog["filament"]
+ assert "filament_start_gcode" not in catalog["filament"]
diff --git a/tests/test_slice_profile_resolution.py b/tests/test_slice_profile_resolution.py
new file mode 100644
index 0000000..421b8dc
--- /dev/null
+++ b/tests/test_slice_profile_resolution.py
@@ -0,0 +1,167 @@
+"""The profiles handed to OrcaSlicer must carry the values their names promise.
+
+Before the 2026-09-22 fix, three things combined so that a slice did not use
+Bambu's own settings:
+
+* ``--nozzle-temp`` / ``--bed-temp`` defaulted to 220 / 60 and overwrote every
+ filament, so ``--filament PETG`` printed at PLA temperatures;
+* the process and filament temp profiles kept their ``inherits`` reference,
+ which OrcaSlicer does not resolve for a file outside its profile tree, so
+ every inherited value fell back to a generic default (PETG came out as
+ ``filament_type = PLA`` at 200 C, acceleration 500 instead of 10000);
+* the printer's ``default_bed_type`` was never applied, so OrcaSlicer sliced
+ for the Cool Plate, where Bambu's PETG/ABS profiles set 0 C.
+
+These tests read back the files OrcaSlicer is given.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+
+import pytest
+
+from bambu_cli.slicer import profiles as P
+
+
+def _write(path, payload):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh)
+ return str(path)
+
+
+@pytest.fixture
+def profiles(tmp_path):
+ root = tmp_path / "profiles"
+ _write(root / "process" / "fdm_process_common.json", {"default_acceleration": ["10000"], "wall_loops": "2"})
+ process = _write(
+ root / "process" / "0.20mm Standard @BBL P1P.json",
+ {"inherits": "fdm_process_common", "layer_height": "0.2", "name": "0.20mm Standard @BBL P1P"},
+ )
+ _write(
+ root / "filament" / "fdm_filament_pet.json",
+ {
+ "filament_type": ["PETG"],
+ "nozzle_temperature": ["255"],
+ "nozzle_temperature_initial_layer": ["255"],
+ "hot_plate_temp": ["70"],
+ "cool_plate_temp": ["0"],
+ },
+ )
+ filament = _write(
+ root / "filament" / "Bambu PETG Basic @base.json",
+ {"inherits": "fdm_filament_pet", "textured_plate_temp": ["70"], "name": "Bambu PETG Basic @base"},
+ )
+ _write(root / "machine" / "Bambu Lab P1P.json", {"default_bed_type": "Textured PEI Plate"})
+ return argparse.Namespace(root=str(root), process=process, filament=filament)
+
+
+def _no_temp_flags(**over):
+ ns = argparse.Namespace(nozzle_temp=None, bed_temp=None, infill=15, pattern="grid", supports=False)
+ for key, value in over.items():
+ setattr(ns, key, value)
+ return ns
+
+
+def _read_back(handles):
+ out = []
+ for handle in handles:
+ with open(handle.name, encoding="utf-8") as fh:
+ out.append(json.load(fh))
+ os.unlink(handle.name)
+ return out
+
+
+def test_temperature_flags_default_to_the_filament_profile():
+ from bambu_cli.cliparse import build_parser
+
+ parser = build_parser()
+ for argv in (["slice", "m.stl"], ["job", "m.stl"], ["send", "m.stl"]):
+ ns = parser.parse_args(argv)
+ assert ns.nozzle_temp is None and ns.bed_temp is None, argv
+
+
+def test_job_passes_no_temperature_through_when_none_was_given():
+ from bambu_cli.job.predict import _slice_args_for_job
+
+ ns = _slice_args_for_job("m.stl", argparse.Namespace(), "/tmp/out")
+ assert ns.nozzle_temp is None and ns.bed_temp is None
+
+
+def test_filament_keeps_its_inherited_temperatures_and_type(profiles):
+ proc, fil = _read_back(P._create_temp_profiles(profiles.process, profiles.filament, _no_temp_flags()))
+ assert fil["nozzle_temperature"] == ["255"]
+ assert fil["filament_type"] == ["PETG"]
+ assert fil["hot_plate_temp"] == ["70"] and fil["textured_plate_temp"] == ["70"]
+ assert "inherits" not in fil
+
+
+def test_process_inherited_settings_are_flattened(profiles):
+ proc, _fil = _read_back(P._create_temp_profiles(profiles.process, profiles.filament, _no_temp_flags()))
+ assert proc["default_acceleration"] == ["10000"]
+ assert proc["layer_height"] == "0.2"
+ assert "inherits" not in proc
+
+
+def test_explicit_temperature_flags_still_override(profiles):
+ _proc, fil = _read_back(
+ P._create_temp_profiles(profiles.process, profiles.filament, _no_temp_flags(nozzle_temp=240, bed_temp=65))
+ )
+ assert fil["nozzle_temperature"] == ["240"]
+ assert fil["cool_plate_temp"] == ["65"] and fil["textured_plate_temp"] == ["65"]
+
+
+def test_printer_default_bed_type_is_applied(profiles):
+ assert P._default_bed_type(profiles.root, "Bambu Lab P1P") == "Textured PEI Plate"
+ # No printer definition on disk: the plate every Bambu printer declares.
+ assert P._default_bed_type(profiles.root, "Bambu Lab X1E") == "Textured PEI Plate"
+ proc, _fil = _read_back(
+ P._create_temp_profiles(profiles.process, profiles.filament, _no_temp_flags(), bed_type="Textured PEI Plate")
+ )
+ assert proc["curr_bed_type"] == "Textured PEI Plate"
+
+
+def test_bed_type_can_still_be_overridden(profiles):
+ args = _no_temp_flags(set_process=["curr_bed_type=Cool Plate"], set_filament=None, settings_json=None)
+ proc, _fil = _read_back(
+ P._create_temp_profiles(profiles.process, profiles.filament, args, bed_type="Textured PEI Plate")
+ )
+ assert proc["curr_bed_type"] == "Cool Plate"
+
+
+def test_inherits_cycle_is_an_error_not_a_hang(tmp_path):
+ a = _write(tmp_path / "process" / "a.json", {"inherits": "b"})
+ _write(tmp_path / "process" / "b.json", {"inherits": "a"})
+ with pytest.raises(ValueError, match="cycle"):
+ P._load_flattened_profile(a)
+
+
+def test_cmd_slice_hands_orca_the_printer_bed_type(tmp_path, monkeypatch):
+ from bambu_cli.slicer import cmd as slice_cmd
+ from tests.bambu_test_base import settings_ctx
+ from tests.fakes.orca_stub import build_profiles_dir, make_orca_launcher, write_stl
+
+ monkeypatch.setenv("DISPLAY", ":0")
+ monkeypatch.setenv("ORCA_STUB_SCENARIO", "success")
+ launcher = make_orca_launcher(str(tmp_path))
+ root = build_profiles_dir(str(tmp_path))
+ model = write_stl(str(tmp_path / "model.stl"))
+ seen = {}
+ real = slice_cmd._create_temp_profiles
+
+ def spy(process, filament, args, **kwargs):
+ seen.update(kwargs)
+ return real(process, filament, args, **kwargs)
+
+ monkeypatch.setattr(slice_cmd, "_create_temp_profiles", spy)
+ args = argparse.Namespace(
+ file=model, output=str(tmp_path), quality="standard", copies=1, infill=15, pattern="grid",
+ supports=False, nozzle_temp=None, bed_temp=None, filament="PLA Basic", json=False, threads=None,
+ list_settings=False,
+ ) # fmt: skip
+ with settings_ctx(orca_slicer=launcher, profiles_dir=root, printer_model="P1P", nozzle_size="0.4"):
+ slice_cmd.cmd_slice(args)
+ assert seen.get("bed_type") == "Textured PEI Plate"
diff --git a/tests/test_slicer_estimate.py b/tests/test_slicer_estimate.py
index 8986d9e..12dc4d5 100644
--- a/tests/test_slicer_estimate.py
+++ b/tests/test_slicer_estimate.py
@@ -84,6 +84,24 @@ def test_oversized_slice_info_falls_back_to_gcode(tmp_path):
assert read_3mf_estimate(path).seconds == 1200
+def test_oversized_slice_info_fallback_is_logged(tmp_path, caplog):
+ """Skipping the primary source is a real degradation; it must say so rather
+ than silently reporting whatever the gcode header happens to contain."""
+ import logging
+
+ oversized_data = b"a" * (10 * 1024 * 1024 + 1)
+ path = _write_zip(
+ tmp_path / "oversized.3mf",
+ {
+ "Metadata/slice_info.config": oversized_data,
+ "Metadata/plate_1.gcode": b"; model printing time: 20m 0s\n",
+ },
+ )
+ with caplog.at_level(logging.DEBUG, logger="bambu"):
+ assert read_3mf_estimate(path).seconds == 1200
+ assert any("slice_info.config" in rec.getMessage() and "falling back" in rec.getMessage() for rec in caplog.records)
+
+
def test_slice_info_with_only_implausible_values_falls_back_to_gcode(tmp_path):
"""A well-formed config carrying junk must not shadow a good gcode header."""
bad_config = (
diff --git a/tests/test_slicer_pure.py b/tests/test_slicer_pure.py
index 160e667..4fb4657 100644
--- a/tests/test_slicer_pure.py
+++ b/tests/test_slicer_pure.py
@@ -449,3 +449,188 @@ def test_validate_rejects_bad_convenience_flags():
assert "support-threshold" in (S._validate_slice_options(_base_slice_args(support_threshold=120)) or "")
assert "fan-speed" in (S._validate_slice_options(_base_slice_args(fan_speed=150)) or "")
assert "flow-ratio" in (S._validate_slice_options(_base_slice_args(flow_ratio=5.0)) or "")
+
+
+# --- Slicer output classification & validation tests (Phase 3) -----------------
+
+from bambu_cli.slicer.output import SliceOutcome, _classify_slice_result, _output_snapshot, _was_written_this_run
+
+
+@pytest.mark.parametrize(
+ ("rc", "stdout", "fresh", "exists", "size", "valid_3mf", "expected"),
+ [
+ # Stale output always rejected first if file exists
+ (0, "", False, True, 1024, True, SliceOutcome.STALE_OUTPUT),
+ (1, "", False, True, 1024, True, SliceOutcome.STALE_OUTPUT),
+ (1, "glfw error", False, True, 1024, True, SliceOutcome.STALE_OUTPUT),
+ # rc == 0 branches
+ (0, "", True, False, 0, False, SliceOutcome.SLICER_ERROR),
+ (0, "", True, True, 0, True, SliceOutcome.EMPTY_OUTPUT),
+ (0, "", True, True, -1, True, SliceOutcome.EMPTY_OUTPUT),
+ (0, "", True, True, 1024, False, SliceOutcome.CORRUPT_3MF),
+ (0, "", True, True, 1024, True, SliceOutcome.SUCCESS),
+ (0, "normal output", True, True, 2048, True, SliceOutcome.SUCCESS),
+ # rc != 0 with Benign GL noise
+ (1, "glfw init failed", True, True, 1024, True, SliceOutcome.BENIGN_GL_WARNING),
+ (2, "glew error during init", True, True, 1024, True, SliceOutcome.BENIGN_GL_WARNING),
+ (1, "Init OpenGL Failed on headless display", True, True, 1024, True, SliceOutcome.BENIGN_GL_WARNING),
+ (1, "warning: skip thumbnail generation", True, True, 1024, True, SliceOutcome.BENIGN_GL_WARNING),
+ # rc != 0 GL noise but disqualified by real errors
+ (1, "glfw error: nothing to be sliced", True, True, 1024, True, SliceOutcome.SLICER_ERROR),
+ (1, "glew failed: slicing error in model", True, True, 1024, True, SliceOutcome.SLICER_ERROR),
+ # rc != 0 GL noise but disqualified by invalid/empty/missing file
+ (1, "glfw error", True, False, 0, False, SliceOutcome.SLICER_ERROR),
+ (1, "glfw error", True, True, 0, True, SliceOutcome.SLICER_ERROR),
+ (1, "glfw error", True, True, 1024, False, SliceOutcome.SLICER_ERROR),
+ # rc != 0 without GL noise
+ (1, "general failure", True, True, 1024, True, SliceOutcome.SLICER_ERROR),
+ (-1, "", True, True, 1024, True, SliceOutcome.SLICER_ERROR),
+ ],
+)
+def test_classify_slice_result_exhaustive(rc, stdout, fresh, exists, size, valid_3mf, expected):
+ outcome = _classify_slice_result(
+ returncode=rc,
+ stdout_stderr=stdout,
+ fresh=fresh,
+ file_exists=exists,
+ file_size=size,
+ is_valid_3mf=valid_3mf,
+ )
+ assert outcome == expected
+
+
+def test_is_valid_sliced_3mf_model_only(tmp_path):
+ import zipfile
+
+ path = tmp_path / "model_only.3mf"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("[Content_Types].xml", "")
+ zf.writestr("3D/3dmodel.model", "")
+ assert S._is_valid_sliced_3mf(str(path)) is True
+
+
+def test_is_valid_sliced_3mf_plate_only(tmp_path):
+ import zipfile
+
+ path = tmp_path / "plate_only.3mf"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("[Content_Types].xml", "")
+ zf.writestr("Metadata/plate_1.gcode", "M104 S220\n")
+ assert S._is_valid_sliced_3mf(str(path)) is True
+
+
+def test_is_valid_sliced_3mf_neither_model_nor_plate(tmp_path):
+ import zipfile
+
+ path = tmp_path / "no_model_no_plate.3mf"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("[Content_Types].xml", "")
+ zf.writestr("Metadata/other.xml", "")
+ assert S._is_valid_sliced_3mf(str(path)) is False
+
+
+def test_is_valid_sliced_3mf_bad_zip(tmp_path):
+ path = tmp_path / "corrupt.3mf"
+ path.write_bytes(b"not a valid zip file at all")
+ assert S._is_valid_sliced_3mf(str(path)) is False
+
+
+def test_output_snapshot_and_freshness(tmp_path):
+ path = tmp_path / "test.3mf"
+ assert _output_snapshot(str(path)) is None
+ assert _was_written_this_run(str(path), None) is True
+
+ path.write_text("v1", encoding="utf-8")
+ snap1 = _output_snapshot(str(path))
+ assert snap1 is not None and snap1[0] is True
+ # Unchanged file: not written this run
+ assert _was_written_this_run(str(path), snap1) is False
+
+ # Deleted file: OSError in was_written_this_run
+ path.unlink()
+ assert _was_written_this_run(str(path), snap1) is False
+
+ # Rewritten file
+ path.write_text("v2 longer text", encoding="utf-8")
+ assert _was_written_this_run(str(path), snap1) is True
+
+
+def test_finalize_slice_getsize_oserror(tmp_path):
+ import subprocess
+ from bambu_cli.constants import EXIT_FILE_ERROR
+ from bambu_cli.errors import FileError
+
+ path = tmp_path / "test.3mf"
+ path.write_text("content", encoding="utf-8")
+ proc = subprocess.CompletedProcess(["orca"], 0, stdout="", stderr="")
+
+ with patch("os.path.getsize", side_effect=OSError("disk read error")):
+ with pytest.raises(FileError) as exc_info:
+ S._finalize_slice(proc, str(path), Namespace(file="m.stl"), "m.stl", False)
+ assert exc_info.value.exit_code == EXIT_FILE_ERROR
+ assert "Could not read sliced output file" in str(exc_info.value)
+
+
+def test_finalize_slice_empty_file(tmp_path):
+ import subprocess
+ from bambu_cli.constants import EXIT_FILE_ERROR
+ from bambu_cli.errors import FileError
+
+ path = tmp_path / "empty.3mf"
+ path.write_bytes(b"")
+ proc = subprocess.CompletedProcess(["orca"], 0, stdout="", stderr="")
+
+ with pytest.raises(FileError) as exc_info:
+ S._finalize_slice(proc, str(path), Namespace(file="m.stl"), "m.stl", False)
+ assert exc_info.value.exit_code == EXIT_FILE_ERROR
+ assert "Slicing produced an empty output file" in str(exc_info.value)
+ assert not path.exists() # partial file was removed
+
+
+def test_finalize_slice_corrupt_3mf(tmp_path):
+ import subprocess
+ from bambu_cli.constants import EXIT_FILE_ERROR
+ from bambu_cli.errors import FileError
+
+ path = tmp_path / "corrupt.3mf"
+ path.write_bytes(b"not a real zip")
+ proc = subprocess.CompletedProcess(["orca"], 0, stdout="", stderr="")
+
+ with pytest.raises(FileError) as exc_info:
+ S._finalize_slice(proc, str(path), Namespace(file="m.stl"), "m.stl", False)
+ assert exc_info.value.exit_code == EXIT_FILE_ERROR
+ assert "Slicing produced a corrupt or incomplete .3mf" in str(exc_info.value)
+ assert not path.exists()
+
+
+def test_finalize_slice_stale_output(tmp_path):
+ import subprocess
+ from bambu_cli.errors import SliceError
+
+ path = tmp_path / "stale.3mf"
+ path.write_text("old content", encoding="utf-8")
+ snap = _output_snapshot(str(path))
+ proc = subprocess.CompletedProcess(["orca"], 0, stdout="", stderr="")
+
+ with pytest.raises(SliceError) as exc_info:
+ S._finalize_slice(proc, str(path), Namespace(file="m.stl"), "m.stl", False, pre_snapshot=snap)
+ assert exc_info.value.exit_code == 5
+ assert "refusing to reuse the stale" in str(exc_info.value)
+
+
+def test_finalize_slice_slicer_error_with_lines(tmp_path):
+ import subprocess
+ from bambu_cli.errors import SliceError
+
+ proc = subprocess.CompletedProcess(
+ ["orca"],
+ 1,
+ stdout="[error] Could not slice geometry\nSome detail\nNothing to be sliced\nerror: fatal",
+ stderr="",
+ )
+ with pytest.raises(SliceError) as exc_info:
+ S._finalize_slice(proc, str(tmp_path / "out.3mf"), Namespace(file="m.stl"), "m.stl", False)
+ assert exc_info.value.exit_code == 5
+ assert "Slicing failed (RC=1)" in str(exc_info.value)
+ assert exc_info.value.logged is True
+
diff --git a/tests/test_snapshot_container_name.py b/tests/test_snapshot_container_name.py
new file mode 100644
index 0000000..8bb2cb8
--- /dev/null
+++ b/tests/test_snapshot_container_name.py
@@ -0,0 +1,44 @@
+"""The streamer snapshot reports the container it actually used.
+
+The JSON said ``"docker_container": "bambu_camera"`` whatever
+``camera_container_name`` was configured to (2026-09-22 audit), so an agent
+told to inspect or remove "the camera container" was pointed at the wrong one.
+"""
+
+from __future__ import annotations
+
+import argparse
+import io
+import json
+import subprocess
+
+from tests.bambu_test_base import settings_ctx
+
+
+def test_streamer_snapshot_reports_the_configured_container(tmp_path, capsys):
+ from bambu_cli.commands.snapshot import cmd_snapshot
+
+ jpeg = b"\xff\xd8" + b"\x00" * 64 + b"\xff\xd9"
+ docker_calls = []
+
+ def run(cmd, **kwargs):
+ docker_calls.append(cmd)
+ return subprocess.CompletedProcess(cmd, 0, stdout="true\n", stderr="")
+
+ class _Resp(io.BytesIO):
+ status = 200
+
+ args = argparse.Namespace(output=str(tmp_path / "snap.jpg"), json=True, unique=False, allow_camera_streamer=True)
+ with settings_ctx(camera_container_name="my_cam", camera_stream_url="http://localhost:1985/api/frame.jpeg"):
+ cmd_snapshot(
+ args,
+ grab_frame=lambda printer: None, # no direct frame: take the streamer path
+ which=lambda name: "/usr/bin/docker",
+ subprocess_run=run,
+ access_code_loader=lambda: "12345678",
+ urlopen=lambda req, timeout=None: _Resp(jpeg),
+ sleep=lambda s: None,
+ )
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["docker_container"] == "my_cam"
+ assert any("my_cam" in cmd for cmd in docker_calls)
diff --git a/tests/test_timeout_validation.py b/tests/test_timeout_validation.py
new file mode 100644
index 0000000..302b14e
--- /dev/null
+++ b/tests/test_timeout_validation.py
@@ -0,0 +1,107 @@
+"""Timeouts must be positive, finite numbers of seconds.
+
+Before the 2026-09-22 fix ``--command-timeout``/``--network-timeout``/
+``--upload-timeout``/``--slicer-timeout`` (and ``--scan-timeout``) accepted
+``-5``, ``0``, ``nan`` and ``inf``, and config.json values were passed to
+``float()`` unchecked. ``nan``/``inf`` meant the slicer never timed out (every
+comparison with NaN is False); a negative value made sockets raise
+ValueError deep inside a transfer; a non-numeric config value raised an
+uncaught ValueError before any command handler ran.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+
+import pytest
+
+from bambu_cli.cliparse import build_parser
+
+FLAGS = ["--network-timeout", "--slicer-timeout", "--command-timeout", "--upload-timeout"]
+
+
+@pytest.mark.parametrize("flag", FLAGS)
+@pytest.mark.parametrize("value", ["-5", "0", "nan", "inf", "-inf", "abc"])
+def test_bad_timeout_flags_are_parse_errors(flag, value, capsys):
+ with pytest.raises(SystemExit) as caught:
+ build_parser().parse_args(["status", flag, value])
+ assert caught.value.code == 5
+ err = capsys.readouterr().err
+ # "abc" fails float() and "-inf" reads as an unknown option to argparse;
+ # both were already parse errors. The rest parsed as floats before the fix.
+ assert "positive number of seconds" in err or value in ("abc", "-inf")
+
+
+@pytest.mark.parametrize("flag", FLAGS)
+def test_good_timeout_flags_parse(flag):
+ ns = build_parser().parse_args(["status", flag, "2.5"])
+ assert getattr(ns, flag.lstrip("-").replace("-", "_")) == 2.5
+
+
+@pytest.mark.parametrize("value", ["-1", "0", "nan", "inf"])
+def test_bad_scan_timeout_is_a_parse_error(value):
+ with pytest.raises(SystemExit):
+ build_parser().parse_args(["setup", "--scan-timeout", value])
+
+
+@pytest.mark.parametrize("bad", ["abc", -3, 0, "nan", "inf", [5], True])
+def test_bad_config_timeout_falls_back_to_the_default(bad):
+ from bambu_cli import config
+ from bambu_cli.constants import COMMAND_TIMEOUT, SLICER_TIMEOUT
+ from tests.bambu_test_base import settings_ctx
+
+ with settings_ctx():
+ from bambu_cli.context import get_current
+
+ ctx = get_current()
+ saved = ctx.config
+ ctx.config = {"command_timeout": bad, "slicer_timeout": bad}
+ try:
+ assert config.get_command_timeout(argparse.Namespace()) == COMMAND_TIMEOUT
+ assert config.get_slicer_timeout(None) == SLICER_TIMEOUT
+ finally:
+ ctx.config = saved
+
+
+def test_good_config_timeout_is_used():
+ from bambu_cli import config
+ from tests.bambu_test_base import settings_ctx
+
+ with settings_ctx():
+ from bambu_cli.context import get_current
+
+ ctx = get_current()
+ saved = ctx.config
+ ctx.config = {"upload_timeout": "600"}
+ try:
+ assert config.get_upload_timeout(argparse.Namespace()) == 600.0
+ finally:
+ ctx.config = saved
+
+
+def test_config_validate_reports_bad_timeouts(tmp_path, monkeypatch):
+ from bambu_cli import config as config_mod
+ from bambu_cli.setup_cmd import common as common_mod
+ from bambu_cli.setup_cmd.preflight import collect_preflight_checks
+
+ cfg_path = tmp_path / "config.json"
+ cfg_path.write_text(
+ json.dumps(
+ {
+ "printer_ip": "192.0.2.10",
+ "serial": "01S00A000000000",
+ "model": "P1P",
+ "access_code": "12345678",
+ "network_timeout": "fast",
+ }
+ ),
+ encoding="utf-8",
+ )
+ os.chmod(cfg_path, 0o600)
+ monkeypatch.setattr(config_mod, "CONFIG_PATH", str(cfg_path))
+ monkeypatch.setattr(common_mod, "_config_path", lambda: str(cfg_path))
+ checks = {check["name"]: check for check in collect_preflight_checks()}
+ assert checks["timeouts"]["status"] == "error"
+ assert "network_timeout" in checks["timeouts"]["message"]
diff --git a/tests/test_tui_settings.py b/tests/test_tui_settings.py
index f791e0e..88af880 100644
--- a/tests/test_tui_settings.py
+++ b/tests/test_tui_settings.py
@@ -893,16 +893,32 @@ async def test_an_empty_value_is_a_real_override(tmp_path):
"""Clearing a setting is legitimate — ``--set key=`` does exactly this."""
from bambu_cli.interactive.core import GoSteps
+ _install_ready_settings(tmp_path, profiles=_profiles_with_keys(tmp_path))
+ app = PlateApp(_args(), _deps(GoSteps(download=Recorder(), slice=Recorder(), job=Recorder())))
+ async with app.run_test() as pilot:
+ await _settle(pilot)
+ prepare, settings = await _open_settings(pilot, app)
+ await _add_override(pilot, settings, "sparse_infill_pattern", "")
+ assert _pending(settings) == ["[process] sparse_infill_pattern="]
+ settings.action_apply()
+ await _settle(pilot)
+ assert prepare.overrides.process == {"sparse_infill_pattern": ""}
+
+
+async def test_gcode_override_is_refused_inline(tmp_path):
+ """G-code settings go through the same refusal as `slice --set`: clearing
+ machine_start_gcode would drop homing and bed levelling from the print."""
+ from bambu_cli.interactive.core import GoSteps
+
_install_ready_settings(tmp_path, profiles=_profiles_with_keys(tmp_path))
app = PlateApp(_args(), _deps(GoSteps(download=Recorder(), slice=Recorder(), job=Recorder())))
async with app.run_test() as pilot:
await _settle(pilot)
prepare, settings = await _open_settings(pilot, app)
await _add_override(pilot, settings, "machine_start_gcode", "")
- assert _pending(settings) == ["[process] machine_start_gcode="]
settings.action_apply()
await _settle(pilot)
- assert prepare.overrides.process == {"machine_start_gcode": ""}
+ assert "machine_start_gcode" not in prepare.overrides.process
async def test_settings_screen_fits_80x24(tmp_path):
"""The screen gained controls; it still has to work on the smallest terminal.
diff --git a/tests/test_upload_active_print_guard.py b/tests/test_upload_active_print_guard.py
new file mode 100644
index 0000000..4151e77
--- /dev/null
+++ b/tests/test_upload_active_print_guard.py
@@ -0,0 +1,83 @@
+"""`upload` must not replace the file the printer is printing right now.
+
+``upload_file`` deletes ``/model/`` before storing the new bytes. When
+that is the file of the running print (re-running ``job`` on the same model
+while the last one prints), the file is pulled out from under the printer.
+What the firmware then does is unverified on hardware, so the upload asks the
+printer first and refuses. A status query that fails does not block the upload.
+"""
+
+from __future__ import annotations
+
+import argparse
+
+import pytest
+
+from bambu_cli.constants import EXIT_PRINTER_ERROR
+from bambu_cli.errors import BambuError
+
+
+class _Printer:
+ def __init__(self, status=None, status_error=None):
+ self._status = status
+ self._status_error = status_error
+ self.uploads = []
+ self.last_size_verified = True
+ self.simulation_mode = False
+
+ def status(self, timeout=None, retries=2, *, require_complete=True):
+ if self._status_error:
+ raise self._status_error
+ return self._status
+
+ def upload_file(self, local, remote, **kwargs):
+ self.uploads.append(remote)
+ return True
+
+
+def _upload(tmp_path, printer, name="model.3mf"):
+ from bambu_cli.commands.files import cmd_upload
+
+ path = tmp_path / name
+ path.write_bytes(b"PK\x03\x04 sliced")
+ args = argparse.Namespace(file=str(path), dry_run=False, json=False, progress=False)
+ return cmd_upload(args, ctx=argparse.Namespace(printer=lambda: printer))
+
+
+@pytest.mark.parametrize(
+ "status",
+ [
+ {"gcode_state": "RUNNING", "subtask_name": "model.3mf"},
+ {"gcode_state": "PAUSE", "subtask_name": "model"},
+ {"gcode_state": "PREPARE", "gcode_file": "/sdcard/model/model.3mf"},
+ {"gcode_state": "RUNNING", "gcode_file": "model.3mf"},
+ ],
+)
+def test_refuses_to_replace_the_file_being_printed(tmp_path, status):
+ printer = _Printer(status=status)
+ with pytest.raises(BambuError) as caught:
+ _upload(tmp_path, printer)
+ assert caught.value.exit_code == EXIT_PRINTER_ERROR
+ assert "printing" in str(caught.value)
+ assert printer.uploads == []
+
+
+@pytest.mark.parametrize(
+ "status",
+ [
+ {"gcode_state": "RUNNING", "subtask_name": "other.3mf", "gcode_file": "/sdcard/model/other.3mf"},
+ {"gcode_state": "IDLE", "subtask_name": "model.3mf"},
+ {"gcode_state": "FINISH", "subtask_name": "model.3mf"},
+ None,
+ ],
+)
+def test_uploads_when_that_file_is_not_printing(tmp_path, status):
+ printer = _Printer(status=status)
+ assert _upload(tmp_path, printer) == "model.3mf"
+ assert printer.uploads == ["/model/model.3mf"]
+
+
+def test_status_failure_does_not_block_the_upload(tmp_path):
+ printer = _Printer(status_error=OSError("mqtt down"))
+ assert _upload(tmp_path, printer) == "model.3mf"
+ assert printer.uploads == ["/model/model.3mf"]
diff --git a/tests/test_upload_resume.py b/tests/test_upload_resume.py
index 4ae16d6..bf4f6cb 100644
--- a/tests/test_upload_resume.py
+++ b/tests/test_upload_resume.py
@@ -310,3 +310,26 @@ def test_backoff_delays_increase_and_no_real_sleep(monkeypatch, local_file):
assert recorded == sorted(recorded)
assert len(recorded) == 3
assert recorded[0] < recorded[1] < recorded[2]
+
+
+def test_remote_path_injection_rejected(monkeypatch, local_file, tmp_path):
+ path = local_file(1024)
+ printer = make_printer()
+
+ # upload_file
+ assert printer.upload_file(path, "/model/test.gcode\r\nDELE /model/bad.gcode") is False
+ assert printer.upload_file(path, "/model/test\0.gcode") is False
+ assert printer.upload_file(path, "/model/test\n.gcode") is False
+
+ # download_file
+ target = str(tmp_path / "out.gcode")
+ assert printer.download_file("/model/test.gcode\r\nDELE /model/bad.gcode", target) is False
+ assert printer.download_file("/model/test\0.gcode", target) is False
+
+ # delete_file
+ assert printer.delete_file("/model/test.gcode\r\nDELE /model/bad.gcode") is False
+ assert printer.delete_file("/model/test\0.gcode") is False
+
+ # list_files
+ assert printer.list_files("/model/\r\n") is None
+ assert printer.list_files("/model/\0") is None
diff --git a/tests/test_wizard_guided.py b/tests/test_wizard_guided.py
index ffc357e..58064e7 100644
--- a/tests/test_wizard_guided.py
+++ b/tests/test_wizard_guided.py
@@ -378,7 +378,7 @@ def test_guided_setup_mdns_discovery_error(tmp_path, monkeypatch):
"192.168.1.100", # manual IP fallback
"03000A222", # manual Serial fallback
"12341234", # access code
- "", # confirm model
+ "A1 mini", # printer model: nothing detected on the manual path, so no default
"", # confirm nozzle
"", # access code file
]
diff --git a/tests/test_xml_safety.py b/tests/test_xml_safety.py
new file mode 100644
index 0000000..ea46222
--- /dev/null
+++ b/tests/test_xml_safety.py
@@ -0,0 +1,47 @@
+"""Tests for XML entity expansion protection and parsing in slicer/estimate.py."""
+
+import zipfile
+
+from bambu_cli.slicer.estimate import _parse_slice_info, read_3mf_estimate
+
+
+def test_parse_slice_info_rejects_doctype_and_entity():
+ # DTD entity bomb attempt
+ malicious_xml = """
+
+
+
+ ]>
+
+
+ """
+
+ seconds, grams = _parse_slice_info(malicious_xml)
+ assert seconds is None
+ assert grams is None
+
+
+def test_parse_slice_info_valid_metadata():
+ valid_xml = """
+
+
+
+ """
+
+ seconds, grams = _parse_slice_info(valid_xml)
+ assert seconds == 3600
+ assert grams == 45.5
+
+
+def test_read_3mf_estimate_rejects_entity_bomb_in_3mf(tmp_path):
+ path = tmp_path / "entity_bomb.3mf"
+ bomb_xml = """]>
+ """
+
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("Metadata/slice_info.config", bomb_xml)
+
+ est = read_3mf_estimate(str(path))
+ assert est.seconds is None
+ assert est.grams is None
diff --git a/tests/test_zip_corrupt_member.py b/tests/test_zip_corrupt_member.py
new file mode 100644
index 0000000..2d1a3c3
--- /dev/null
+++ b/tests/test_zip_corrupt_member.py
@@ -0,0 +1,59 @@
+"""A ZIP whose compressed data is corrupt is an extract failure, not a crash.
+
+``zipfile`` raises ``zlib.error`` (not ``BadZipFile``) when a deflate stream is
+damaged but the headers are intact. Before the 2026-09-22 fix that escaped the
+extract handlers: ``plate job`` ended in "Unexpected error" with a traceback
+(exit 5) and lost the structured job summary the extract step is built to
+report.
+"""
+
+from __future__ import annotations
+
+import argparse
+import io
+import zipfile
+
+import pytest
+
+from bambu_cli.constants import EXIT_FILE_ERROR
+from bambu_cli.errors import BambuError
+
+
+def _corrupt_zip(path):
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("part.stl", b"solid x\n" + b"facet normal 0 0 0\n" * 5000 + b"endsolid x\n")
+ data = bytearray(buf.getvalue())
+ start = 30 + len("part.stl") # local file header + name: compressed data follows
+ for i in range(start + 10, start + 60):
+ data[i] ^= 0xFF
+ path.write_bytes(bytes(data))
+ return str(path)
+
+
+def test_extract_reports_corrupt_member_as_value_error(tmp_path):
+ from bambu_cli.download.extract import _extract_zip_model
+
+ archive = _corrupt_zip(tmp_path / "bad.zip")
+ out = tmp_path / "out"
+ out.mkdir()
+ with pytest.raises(ValueError, match="corrupt"):
+ _extract_zip_model(archive, str(out), argparse.Namespace(name=None, max_download_mb=100))
+ assert list(out.iterdir()) == [], "no partial or 0-byte model may be left behind"
+
+
+def test_job_keeps_its_structured_failure_for_a_corrupt_zip(tmp_path, capsys):
+ import json
+
+ from bambu_cli.cliparse import build_parser
+ from bambu_cli.commands import cmd_job
+
+ archive = _corrupt_zip(tmp_path / "bad.zip")
+ args = build_parser().parse_args(["--json", "job", archive, "--output", str(tmp_path / "work")])
+ with pytest.raises(BambuError) as caught:
+ cmd_job(args)
+ assert caught.value.exit_code == EXIT_FILE_ERROR
+ payload = json.loads(capsys.readouterr().out)
+ assert payload["failed_step"] == "extract"
+ assert payload["command"] == "job"
+ assert "corrupt" in payload["error"]
diff --git a/uv.lock b/uv.lock
index 00f9722..04a1d5d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -154,7 +154,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -411,7 +411,7 @@ wheels = [
[[package]]
name = "platecli"
-version = "0.5.0"
+version = "0.6.0.dev0"
source = { editable = "." }
dependencies = [
{ name = "paho-mqtt" },