Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> --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 <url> --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

Expand Down
1 change: 1 addition & 0 deletions bambu_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
7 changes: 3 additions & 4 deletions bambu_cli/commands/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("-"):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -193,6 +191,7 @@ def _cb(block):
remote_name=filename,
bytes=filesize,
uploaded=True,
size_verified=printer.last_size_verified,
)
)
return filename
Expand Down
12 changes: 12 additions & 0 deletions bambu_cli/contracts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions bambu_cli/job/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
7 changes: 7 additions & 0 deletions bambu_cli/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -270,13 +274,16 @@ 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):
os.remove(partial_path)
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}")
Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/setup_cmd/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
4 changes: 4 additions & 0 deletions docs/schemas/download.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/schemas/job_ok.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions docs/schemas/upload.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` /
Expand Down
2 changes: 2 additions & 0 deletions tests/test_upload_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down