From 2bb7306c4590698e3df18ba8032a0525c918d205 Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:58:11 -0400 Subject: [PATCH] fix: report size_verified when FTPS SIZE is missing Upload/job JSON carry size_verified. Missing SIZE is still success with a warning; mismatch remains a retryable failure. TOFU pin copy now says a hostile LAN can poison the pin. --sim is documented as a canned printer, not a protocol test. --- CHANGELOG.md | 3 +++ README.md | 2 +- bambu_cli/commands/doctor.py | 1 + bambu_cli/commands/files.py | 7 +++---- bambu_cli/contracts/models.py | 12 ++++++++++++ bambu_cli/job/orchestrate.py | 3 +++ bambu_cli/printer.py | 7 +++++++ bambu_cli/setup_cmd/wizard.py | 2 +- docs/api.md | 2 +- docs/manual.md | 2 +- docs/schemas/download.json | 4 ++++ docs/schemas/job_ok.json | 4 ++++ docs/schemas/upload.json | 4 ++++ docs/troubleshooting.md | 9 +++++++++ tests/test_upload_resume.py | 2 ++ 15 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a84db..17bd0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- `upload` / `job` `--json` include `size_verified` (`false` when the printer + omitted FTPS `SIZE` after the transfer). Mismatch is still a failure. + - Command handlers raise `BambuError` instead of emitting a JSON error and then aborting. `cli.main` is the sole error-envelope writer (`write_error_envelope`). Soft statuses (`confirmation_required`, etc.) diff --git a/README.md b/README.md index 97ffdad..82e2fa7 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ you can put in a shell script or hand to an agent, use this. ## Built for AI agents -Every command emits machine-readable `--json` output backed by published [JSON Schemas](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/), `--sim` provides a full fake printer for development without hardware, and the `--confirm` gate means physical actions never happen by accident. Two commands are deliberately human-only — the `go` wizard and the `tui` full-screen UI refuse `--json` and a non-TTY stdin with exit `5`; `plate job --confirm` is the machine path that does the same work. See the [user guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) and [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) for the JSON contracts and stability policy. +Every command emits machine-readable `--json` output backed by published [JSON Schemas](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/), `--sim` provides a canned printer (not a protocol test) for development without hardware, and the `--confirm` gate means physical actions never happen by accident. Two commands are deliberately human-only — the `go` wizard and the `tui` full-screen UI refuse `--json` and a non-TTY stdin with exit `5`; `plate job --confirm` is the machine path that does the same work. See the [user guide](https://github.com/DLANSAMA/platecli/blob/main/docs/manual.md) and [docs/api.md](https://github.com/DLANSAMA/platecli/blob/main/docs/api.md) for the JSON contracts and stability policy. ## Documentation diff --git a/bambu_cli/commands/doctor.py b/bambu_cli/commands/doctor.py index d81a445..c155c95 100644 --- a/bambu_cli/commands/doctor.py +++ b/bambu_cli/commands/doctor.py @@ -128,6 +128,7 @@ def log_pin_hint(): logger.info(" The printer uses a self-signed certificate. Pin it by adding to config.json:") logger.info(f' "cert_fingerprint": "{fp}"') logger.info(" then re-run doctor.") + logger.info(" Trust-on-first-use: a hostile LAN can poison this pin. Confirm it on the printer.") verbose = bool(_namespace_get(args, "verbose", False)) diff --git a/bambu_cli/commands/files.py b/bambu_cli/commands/files.py index 12f65de..f484345 100644 --- a/bambu_cli/commands/files.py +++ b/bambu_cli/commands/files.py @@ -25,8 +25,6 @@ def cmd_upload(args, ctx=None): """Upload a file to the printer via FTPS with binary retry/resume.""" - from bambu_cli.printer import get_printer - ctx = ctx or RuntimeContext.for_request(args) filepath = _expand_path(args.file) if filepath.startswith("-"): @@ -91,7 +89,7 @@ def cmd_upload(args, ctx=None): ) if getattr(args, "dry_run", False): logger.info(f"🔍 Dry Run: Validating printer connectivity for {filename}...") - printer = get_printer() + printer = ctx.printer() try: # Uploads go over FTPS, so the dry-run must exercise FTPS, not MQTT. with printer.get_ftp_client(timeout=5): @@ -138,7 +136,7 @@ def cmd_upload(args, ctx=None): logger.info(f"📤 Uploading {filename} ({filesize // 1024}KB)...") - printer = get_printer() + printer = ctx.printer() progress = None task_id = None @@ -193,6 +191,7 @@ def _cb(block): remote_name=filename, bytes=filesize, uploaded=True, + size_verified=printer.last_size_verified, ) ) return filename diff --git a/bambu_cli/contracts/models.py b/bambu_cli/contracts/models.py index 2d7e9e8..08785ac 100644 --- a/bambu_cli/contracts/models.py +++ b/bambu_cli/contracts/models.py @@ -280,6 +280,10 @@ class Upload(Contract): remote_name: str bytes: int = spec(required=True, minimum=0) uploaded: bool = spec(required=True) + size_verified: bool | None = spec( + default=None, + description="False when the printer did not report SIZE after STOR; True when SIZE matched.", + ) @dataclass(frozen=True) @@ -337,6 +341,10 @@ class Download(Contract): filename: str = spec(required=True, min_length=1, default="") bytes: int = spec(required=True, default=0) archive_entry: str | None = None + size_verified: bool | None = spec( + default=None, + description="Set on FTPS printer downloads when SIZE was checked; omitted for HTTP downloads.", + ) @dataclass(frozen=True) @@ -488,6 +496,10 @@ class JobOk(Contract): source: str | None = None local_path: str | None = None remote_path: str | None = None + size_verified: bool | None = spec( + default=None, + description="False when the printer omitted FTPS SIZE after upload.", + ) print_started: bool | None = None dry_run: bool | None = None copies_ignored: bool | None = None diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index ed5d013..068cc3f 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -521,6 +521,9 @@ def _run_job(ctx, args, steps=None): raise summary["remote_name"] = remote_name summary["uploaded"] = True + printer = getattr(ctx, "_printer", None) + if printer is not None and printer.last_size_verified is not None: + summary["size_verified"] = printer.last_size_verified if getattr(args, "upload_only", False): logger.info(f"✅ Job uploaded {remote_name}; print not started because --upload-only was set.") diff --git a/bambu_cli/printer.py b/bambu_cli/printer.py index caa02d2..440d9bd 100644 --- a/bambu_cli/printer.py +++ b/bambu_cli/printer.py @@ -53,6 +53,8 @@ def __init__( # TLS connection instead of opening a new one every 10s. self._mqtt_session: Any = None self._mqtt_hold_lock = threading.Lock() + # Last FTPS transfer: True if SIZE matched, False if SIZE was missing. + self.last_size_verified: bool | None = None def hold_mqtt(self, *, client_factory=None) -> None: """Keep one MQTT client for subsequent status/send_command/get_version. @@ -171,8 +173,10 @@ def upload_file( # Server doesn't support SIZE (or it failed) after a successful STOR. # Don't turn flaky SIZE support into a new failure mode. logger.warning(f"⚠️ Could not verify remote size for {remote_path}; assuming upload succeeded.") + self.last_size_verified = False return True if remote_size == filesize: + self.last_size_verified = True return True # Sizes disagree even though STOR didn't raise; treat as a failed @@ -270,6 +274,7 @@ def download_file( if remote_size is None: # Server doesn't support SIZE; don't invent a new failure mode. logger.warning(f"⚠️ Could not verify remote size for {remote_path}; assuming download succeeded.") + self.last_size_verified = False elif written != remote_size: logger.error(f"Download failed: size mismatch (local {written}, remote {remote_size})") with contextlib.suppress(OSError): @@ -277,6 +282,8 @@ def download_file( return False os.replace(partial_path, local_path) + if remote_size is not None: + self.last_size_verified = True return True except _FTP_SSL_OS_ERRORS as e: logger.error(f"Download failed: {e}") diff --git a/bambu_cli/setup_cmd/wizard.py b/bambu_cli/setup_cmd/wizard.py index 27ce48b..6c094de 100644 --- a/bambu_cli/setup_cmd/wizard.py +++ b/bambu_cli/setup_cmd/wizard.py @@ -412,7 +412,7 @@ def remove_service(self, zc, type_, name): # 990 (FTPS), and 6000 (camera) — true for Bambu firmware to date. cert_fingerprint = probe_cert_fingerprint(ip, 8883, timeout=5) logger.info(f" Fingerprint: {cert_fingerprint}") - logger.info(" (trust-on-first-use: verify this matches your printer if on an untrusted network)") + logger.info(" Trust-on-first-use: a hostile LAN can poison this pin. Confirm the fingerprint on the printer.") except Exception as e: logger.warning(f"⚠️ Could not fetch TLS certificate: {e}") logger.warning(" Connections may fail if the fingerprint is required.") diff --git a/docs/api.md b/docs/api.md index 25641ce..0f5d300 100644 --- a/docs/api.md +++ b/docs/api.md @@ -107,7 +107,7 @@ form when looking a code up; the human-readable log line shows both. | [`resume.json`](schemas/resume.json) | `resume` | | [`stop.json`](schemas/stop.json) | `stop` (incl. confirmation_required) | | [`snapshot.json`](schemas/snapshot.json) | `snapshot` | -| [`upload.json`](schemas/upload.json) | `upload` success / `--dry-run` | +| [`upload.json`](schemas/upload.json) | `upload` success / `--dry-run` (`size_verified` is `false` when the printer did not answer FTPS `SIZE`) | | [`files.json`](schemas/files.json) | `files` listing | | [`setup.json`](schemas/setup.json) | `setup` summary | | [`migrate_access_code.json`](schemas/migrate_access_code.json) | `setup --migrate-access-code` | diff --git a/docs/manual.md b/docs/manual.md index 33b6853..ecfaf6a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -32,7 +32,7 @@ pip install . `--json` is a global flag accepted by every command that produces structured output. Responses follow published JSON Schema files under [`docs/schemas/`](https://github.com/DLANSAMA/platecli/tree/main/docs/schemas/) — agents can validate against them or use them to understand the exact shape of each response. -`--sim` (simulation mode) replaces the real printer with a local stub, so an agent can develop, test, or exercise the full command surface without any hardware present. +`--sim` (simulation mode) replaces the real printer with a **canned** local stub — fixed status, files, and camera bytes. It is not a protocol test of MQTT/FTPS. Use it to develop agents and scripts without hardware. Destructive and physical actions — starting a print, pausing or resuming a print, stopping a job, deleting a file, or sending raw G-code — are gated behind an explicit `--confirm` flag. An agent that omits `--confirm` gets a refusal (exit code `5`, `"status": "confirmation_required"`) instead of a physical action, so accidental physical operations never happen. Note this is a gate against accidents, not an authorization boundary: anything that can run `plate` can also pass `--confirm`. diff --git a/docs/schemas/download.json b/docs/schemas/download.json index 0743f48..ee22f3d 100644 --- a/docs/schemas/download.json +++ b/docs/schemas/download.json @@ -46,6 +46,10 @@ }, "archive_entry": { "type": "string" + }, + "size_verified": { + "description": "Set on FTPS printer downloads when SIZE was checked; omitted for HTTP downloads.", + "type": "boolean" } }, "additionalProperties": true diff --git a/docs/schemas/job_ok.json b/docs/schemas/job_ok.json index f085af9..7426e11 100644 --- a/docs/schemas/job_ok.json +++ b/docs/schemas/job_ok.json @@ -28,6 +28,10 @@ "remote_path": { "type": "string" }, + "size_verified": { + "description": "False when the printer omitted FTPS SIZE after upload.", + "type": "boolean" + }, "print_started": { "type": "boolean" }, diff --git a/docs/schemas/upload.json b/docs/schemas/upload.json index 280b588..a56bba7 100644 --- a/docs/schemas/upload.json +++ b/docs/schemas/upload.json @@ -35,6 +35,10 @@ }, "uploaded": { "type": "boolean" + }, + "size_verified": { + "description": "False when the printer did not report SIZE after STOR; True when SIZE matched.", + "type": "boolean" } }, "additionalProperties": true diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7bd7657..f820b5b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,6 +16,7 @@ the `plate doctor` output — but check first that no access code is visible. - ["Connection failed: rc=5" (or any other rc)](#connection-failed-rc5-or-any-other-rc) - [LAN mode is off, or the access code rotated](#lan-mode-is-off-or-the-access-code-rotated) - [Certificate fingerprint mismatch](#certificate-fingerprint-mismatch) +- [Upload or download succeeded but `size_verified` is false](#upload-or-download-succeeded-but-size_verified-is-false) - [FTPS connection failed, or uploads hang at 0%](#ftps-connection-failed-or-uploads-hang-at-0) - [The printer is on the network but nothing reaches it (VLAN / AP isolation / guest Wi-Fi)](#the-printer-is-on-the-network-but-nothing-reaches-it-vlan--ap-isolation--guest-wi-fi) - [OrcaSlicer or its BBL profiles were not found](#orcaslicer-or-its-bbl-profiles-were-not-found) @@ -147,6 +148,14 @@ think you are on first. CLI warns whenever it is set, and it should never be your permanent answer to this error. +## Upload or download succeeded but `size_verified` is false + +Some firmware omits the FTPS `SIZE` reply after `STOR`/`RETR`. `plate` still +treats the transfer as success (a warning on stderr) and sets +`"size_verified": false` on `--json` `upload` / `job`. When `SIZE` is present +and disagrees with the local file, the transfer is a failure and is retried. +This is not a hang. + ## FTPS connection failed, or uploads hang at 0% Reported by `plate doctor` stage `[3/3]`, or during `plate upload` / diff --git a/tests/test_upload_resume.py b/tests/test_upload_resume.py index 59658bd..4ae16d6 100644 --- a/tests/test_upload_resume.py +++ b/tests/test_upload_resume.py @@ -120,6 +120,7 @@ def test_clean_success_with_size_verified(monkeypatch, local_file): printer = make_printer() assert printer.upload_file(path, "/model/job.gcode") is True + assert printer.last_size_verified is True assert ftp.deleted == ["/model/job.gcode"] assert len(ftp.stor_calls) == 1 assert ftp.stor_calls[0]["rest"] is None @@ -152,6 +153,7 @@ def test_size_raises_after_success_accepts_with_warning(monkeypatch, local_file, with caplog.at_level("WARNING", logger="bambu.printer"): result = printer.upload_file(path, "/model/job.gcode") assert result is True + assert printer.last_size_verified is False assert any("Could not verify remote size" in r.message for r in caplog.records)