From 0ba9c27b7f9ebadf6498e6c7a5ca67d9cc2e9f0c Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:40:50 -0400 Subject: [PATCH] refactor: emit --json through contracts; stop flattening status `--json` emitters construct bambu_cli.contracts objects. status --json no longer copies firmware fields onto the envelope; they stay under printer. Errors go through ErrorEnvelope; job success/failure through JobOk / JobError. Nested dataclasses serialize via to_payload. --- CHANGELOG.md | 5 ++ bambu_cli/cli.py | 16 +++--- bambu_cli/commands/doctor.py | 22 +++++---- bambu_cli/commands/files.py | 77 +++++++++++++++-------------- bambu_cli/commands/gcode.py | 24 ++++----- bambu_cli/commands/print_cmd.py | 29 +++++------ bambu_cli/commands/snapshot.py | 42 +++++++++------- bambu_cli/commands/status.py | 32 ++++++++---- bambu_cli/contracts/base.py | 30 ++++++++--- bambu_cli/contracts/models.py | 15 +++--- bambu_cli/job/orchestrate.py | 16 +++--- bambu_cli/job/support.py | 24 ++++++++- bambu_cli/protocols/mqtt_monitor.py | 30 +++++------ bambu_cli/setup_cmd/config_cmd.py | 42 +++++++++------- bambu_cli/slicer/cmd.py | 18 ++++--- bambu_cli/slicer/output.py | 20 ++++---- bambu_cli/utils.py | 32 ++++++++---- docs/api.md | 4 +- docs/schemas/status.json | 12 ++++- tests/agent_cli_smoke.py | 4 +- tests/test_cmd_status.py | 3 +- tests/test_config_and_logging.py | 4 ++ tests/test_json_contract_query.py | 1 - tests/test_snapshot_output.py | 10 +++- 24 files changed, 310 insertions(+), 202 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2668d..1b0bab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- `--json` emitters construct `bambu_cli.contracts` objects. `status --json` + no longer flattens firmware fields onto the envelope; they stay under + `printer`. Errors go through `ErrorEnvelope`; `job` success/failure through + `JobOk` / `JobError`. + - MQTT client construction, command/status, print-ack, and the monitor loop live in separate `protocols/mqtt_*.py` modules. TLS pinning uses a real `SSLContext` subclass (`PinningSSLContext`) instead of patching diff --git a/bambu_cli/cli.py b/bambu_cli/cli.py index 26bf17c..a0f1bb2 100644 --- a/bambu_cli/cli.py +++ b/bambu_cli/cli.py @@ -35,7 +35,7 @@ # namespace without importing this entrypoint (audit item A1). Re-exported here # because build_parser() is the documented source of truth for the command set, # and the help/workflow smokes plus several tests import it from this module. -from .contracts import Version +from .contracts import ErrorEnvelope, Version from .jsonio import json_mode_requested as _json_mode_requested from .utils import emit_json, emit_json_error @@ -150,13 +150,13 @@ def main(): return if not args.cmd and bool(getattr(args, "json", False)): emit_json( - { - "status": "error", - "command": "main", - "failed_step": "parse", - "exit_code": EXIT_COMMAND_ERROR, - "error": "Missing subcommand. Put --json with a command that supports it.", - } + ErrorEnvelope( + status="error", + command="main", + failed_step="parse", + exit_code=EXIT_COMMAND_ERROR, + error="Missing subcommand. Put --json with a command that supports it.", + ) ) sys.exit(EXIT_COMMAND_ERROR) diff --git a/bambu_cli/commands/doctor.py b/bambu_cli/commands/doctor.py index 1e93894..d81a445 100644 --- a/bambu_cli/commands/doctor.py +++ b/bambu_cli/commands/doctor.py @@ -237,14 +237,18 @@ def shown_ip(): if json_mode: # Mask IP address inside doctor capabilities report unless --verbose is checked (A0530-SEC-16) reported_ip = ctx.settings.printer_ip if verbose else "" + from bambu_cli.contracts import Doctor + emit_json( - { - "command": "doctor", - "ok": True, - "status": "ok", - "output": cap_path, - "printer_ip": reported_ip, - "certificate_fingerprint": fp, - "capabilities": capabilities, - } + Doctor( + status="ok", + command="doctor", + certificate_fingerprint=fp, + printer_reachable=True, + ).to_payload( + ok=True, + output=cap_path, + printer_ip=reported_ip, + capabilities=capabilities, + ) ) diff --git a/bambu_cli/commands/files.py b/bambu_cli/commands/files.py index e1f080d..284b1af 100644 --- a/bambu_cli/commands/files.py +++ b/bambu_cli/commands/files.py @@ -116,15 +116,17 @@ def cmd_upload(args, ctx=None): logger.info(f" โœ… Local file {_path_for_message(filepath)} exists ({filesize // 1024}KB)") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Upload + emit_json( - { - "status": "dry_run_ok", - "command": "upload", - "file": filepath, - "remote_name": filename, - "bytes": filesize, - "uploaded": False, - } + Upload( + status="dry_run_ok", + command="upload", + file=filepath, + remote_name=filename, + bytes=filesize, + uploaded=False, + ) ) return filename @@ -175,15 +177,17 @@ def _cb(block): if success: logger.info(f"โœ… Uploaded {filename} to printer") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Upload + emit_json( - { - "status": "uploaded", - "command": "upload", - "file": filepath, - "remote_name": filename, - "bytes": filesize, - "uploaded": True, - } + Upload( + status="uploaded", + command="upload", + file=filepath, + remote_name=filename, + bytes=filesize, + uploaded=True, + ) ) return filename else: @@ -215,13 +219,15 @@ def cmd_files(args, ctx=None): raise Exception("Failed to list files via printer API") remote_files = [{"name": _portable_basename(path), "path": path} for path in files] if json_mode: + from bambu_cli.contracts import Files, RemoteFile + emit_json( - { - "status": "ok", - "command": "files", - "count": len(remote_files), - "files": remote_files, - } + Files( + status="ok", + command="files", + count=len(remote_files), + files=[RemoteFile(name=item["name"], path=item["path"]) for item in remote_files], + ) ) return if not files: @@ -251,14 +257,16 @@ def cmd_delete(args, ctx=None): if not args.confirm: logger.warning(f"โš ๏ธ This will DELETE '{filename}' from the printer. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Delete + emit_json( - { - "status": "confirmation_required", - "command": "delete", - "file": filename, - "deleted": False, - "next_command": ["delete", filename, "--confirm", "--json"], - } + Delete( + status="confirmation_required", + command="delete", + file=filename, + deleted=False, + next_command=["delete", filename, "--confirm", "--json"], + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) @@ -267,14 +275,9 @@ def cmd_delete(args, ctx=None): if printer.delete_file(f"/model/{filename}"): logger.info(f"๐Ÿ—‘๏ธ Deleted {filename} from printer") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "deleted", - "command": "delete", - "file": filename, - "deleted": True, - } - ) + from bambu_cli.contracts import Delete + + emit_json(Delete(status="deleted", command="delete", file=filename, deleted=True)) else: raise Exception("Delete operation failed in printer client.") except Exception as e: diff --git a/bambu_cli/commands/gcode.py b/bambu_cli/commands/gcode.py index 24f6cd5..62fdc76 100644 --- a/bambu_cli/commands/gcode.py +++ b/bambu_cli/commands/gcode.py @@ -5,6 +5,7 @@ from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_NETWORK_ERROR 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.logging_utils import logger, safe_log_error @@ -29,13 +30,13 @@ def cmd_gcode(args, ctx=None): logger.warning("โš ๏ธ This will SEND raw G-code to the printer. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "confirmation_required", - "command": "gcode", - "gcode": gcode, - "sent": False, - "next_command": ["gcode", gcode, "--confirm", "--json"], - } + Gcode( + status="confirmation_required", + command="gcode", + gcode=gcode, + sent=False, + next_command=["gcode", gcode, "--confirm", "--json"], + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) @@ -48,11 +49,4 @@ def cmd_gcode(args, ctx=None): abort("", exit_code=EXIT_NETWORK_ERROR) logger.info(f"๐Ÿ“ก Sent: {gcode}") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "sent", - "command": "gcode", - "gcode": gcode, - "sent": True, - } - ) + emit_json(Gcode(status="sent", command="gcode", gcode=gcode, sent=True)) diff --git a/bambu_cli/commands/print_cmd.py b/bambu_cli/commands/print_cmd.py index 10ccd58..d790cdd 100644 --- a/bambu_cli/commands/print_cmd.py +++ b/bambu_cli/commands/print_cmd.py @@ -5,6 +5,7 @@ from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_FILE_ERROR from bambu_cli.context import RuntimeContext +from bambu_cli.contracts import Print from bambu_cli.download.naming import ( _is_print_ready_name, _name_for_message, @@ -45,13 +46,13 @@ def cmd_print(args, ctx=None): logger.warning("โš ๏ธ This will START a print. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "confirmation_required", - "command": "print", - "file": basename, - "printed": False, - "next_command": _print_next_command(args, basename), - } + Print( + status="confirmation_required", + command="print", + file=basename, + printed=False, + next_command=_print_next_command(args, basename), + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) @@ -88,12 +89,12 @@ def cmd_print(args, ctx=None): raise if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "dry_run_ok" if dry_run else "print_started", - "command": "print", - "file": basename, - "printed": not dry_run, - "dry_run": bool(dry_run), - } + Print( + status="dry_run_ok" if dry_run else "print_started", + command="print", + file=basename, + printed=not dry_run, + dry_run=bool(dry_run), + ) ) return basename diff --git a/bambu_cli/commands/snapshot.py b/bambu_cli/commands/snapshot.py index d0d085a..a77071b 100644 --- a/bambu_cli/commands/snapshot.py +++ b/bambu_cli/commands/snapshot.py @@ -274,16 +274,18 @@ def cmd_snapshot( sha256 = hashlib.sha256(_frame).hexdigest() logger.info(f"\U0001f4f8 Snapshot saved: {_path_for_message(outpath)} ({size // 1024}KB)") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Snapshot + emit_json( - { - "status": "saved", - "command": "snapshot", - "output": outpath, - "size_bytes": size, - "captured_at": captured_at, - "sha256": sha256, - "method": "direct", - } + Snapshot( + status="saved", + command="snapshot", + output=outpath, + size_bytes=size, + captured_at=captured_at, + sha256=sha256, + method="direct", + ) ) return @@ -416,17 +418,19 @@ def cmd_snapshot( sha256 = hashlib.sha256(data).hexdigest() logger.info(f"โœ… Snapshot saved: {_path_for_message(outpath)} ({size // 1024}KB)") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Snapshot + emit_json( - { - "status": "saved", - "command": "snapshot", - "output": outpath, - "size_bytes": size, - "captured_at": captured_at, - "sha256": sha256, - "camera_image": camera_image, - "docker_container": "bambu_camera", - } + Snapshot( + status="saved", + command="snapshot", + output=outpath, + size_bytes=size, + captured_at=captured_at, + sha256=sha256, + camera_image=camera_image, + docker_container="bambu_camera", + ) ) except urllib.error.URLError as e: message = f"Snapshot network error: {e}" diff --git a/bambu_cli/commands/status.py b/bambu_cli/commands/status.py index d5f0b88..01a65f8 100644 --- a/bambu_cli/commands/status.py +++ b/bambu_cli/commands/status.py @@ -1,11 +1,32 @@ """Printer status command.""" +import dataclasses + from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.context import RuntimeContext +from bambu_cli.contracts import PrinterState, Status from bambu_cli.errors import PrinterConnectionError from bambu_cli.logging_utils import logger from bambu_cli.utils import emit_json +_PRINTER_STATE_FIELDS = {f.name for f in dataclasses.fields(PrinterState)} + + +def _status_payload(data, ams): + """Build the Status contract. Firmware extras stay on ``printer``, never the envelope.""" + known = {key: data[key] for key in _PRINTER_STATE_FIELDS if key in data and key != "ams"} + extras = { + key: value + for key, value in data.items() + if key not in _PRINTER_STATE_FIELDS and key not in {"status", "command", "printer", "ams"} + } + payload = Status(status="ok", command="status", printer=PrinterState(**known), ams=ams).to_payload() + if extras: + printer = dict(payload["printer"]) + printer.update(extras) + payload["printer"] = printer + return payload + def cmd_status(args, ctx=None): """Query and display the printer's current status.""" @@ -30,16 +51,7 @@ def cmd_status(args, ctx=None): ams = parse_ams(data) if bool(_namespace_get(args, "json", False)): - payload = { - "status": "ok", - "command": "status", - "printer": data, - } - payload.update({k: v for k, v in data.items() if k not in ("status", "command")}) - # Normalized AMS view (trays/filaments) for agents building --ams-mapping; - # None on printers without an AMS. - payload["ams"] = ams - emit_json(payload) + emit_json(_status_payload(data, ams)) return state = data.get("gcode_state", "UNKNOWN") diff --git a/bambu_cli/contracts/base.py b/bambu_cli/contracts/base.py index aebacd3..49c52f2 100644 --- a/bambu_cli/contracts/base.py +++ b/bambu_cli/contracts/base.py @@ -100,23 +100,41 @@ class Contract: def to_payload(self, **extra: Any) -> dict[str, Any]: """Render to the dict ``emit_json`` takes. - ``extra`` carries command-specific keys that are not part of the - guaranteed contract โ€” legal because the schemas allow additional - properties. Redaction still happens downstream in ``emit_json``; this - method deliberately does no escaping or scrubbing of its own. + Nested dataclasses (and nested ``Contract`` instances) become plain + dicts so ``json.dumps`` never sees a model object. ``extra`` carries + command-specific keys that are not part of the guaranteed contract โ€” + legal because the schemas allow additional properties. Redaction still + happens downstream in ``emit_json``. """ payload: dict[str, Any] = {} for field in dataclasses.fields(self): value = getattr(self, field.name) if value is None and field.name not in self.keep_none: continue - payload[field.name] = value + payload[field.name] = _to_jsonable(value) for key, value in extra.items(): if value is not None or key in self.keep_none: - payload[key] = value + payload[key] = _to_jsonable(value) return payload +def _to_jsonable(value: Any) -> Any: + if isinstance(value, Contract): + return value.to_payload() + if dataclasses.is_dataclass(value) and not isinstance(value, type): + out: dict[str, Any] = {} + for key, item in dataclasses.asdict(value).items(): + if item is None: + continue + out[key] = _to_jsonable(item) + return out + if isinstance(value, list): + return [_to_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: _to_jsonable(item) for key, item in value.items()} + return value + + def all_contracts() -> list[type[Contract]]: """Every concrete contract, discovered from the registry module. diff --git a/bambu_cli/contracts/models.py b/bambu_cli/contracts/models.py index 7f579f3..f05e983 100644 --- a/bambu_cli/contracts/models.py +++ b/bambu_cli/contracts/models.py @@ -130,10 +130,6 @@ class ErrorEnvelope(Contract): schema_name: ClassVar[str] = "error_envelope" schema_title: ClassVar[str] = "platecli JSON error envelope" - # `job`/`send` build their summary up front and emit `next_command: null` - # when there is no recovery step, so null is a real value here. The old - # hand-written schema typed this `{}` (anything), which hid that; the - # generated one is explicit. keep_none: ClassVar[frozenset[str]] = frozenset({"next_command"}) status: Literal["error"] @@ -157,8 +153,11 @@ class Status(Contract): schema_name: ClassVar[str] = "status" schema_title: ClassVar[str] = "platecli status success envelope" schema_description: ClassVar[str] = ( - "JSON output of `plate status --json`. Printer fields appear both at the top level (raw MQTT data) and normalised under the `printer` key." + "JSON output of `plate status --json`. Guaranteed printer fields live under `printer` " + "(never flattened onto the envelope). Firmware extras stay on `printer` as additional " + "properties. `ams` is the normalised tray view for --ams-mapping." ) + keep_none: ClassVar[frozenset[str]] = frozenset({"ams"}) status: Literal["ok"] command: Literal["status"] @@ -168,10 +167,14 @@ class Status(Contract): 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." + "an incomplete object. Extra firmware keys stay here, not on the envelope." ), requires_keys=("gcode_state", "mc_percent", "bed_temper", "nozzle_temper"), ) + ams: dict[str, Any] | None = spec( + default=None, + description="Normalised AMS trays for --ams-mapping; null when the printer has no AMS.", + ) @dataclass(frozen=True) diff --git a/bambu_cli/job/orchestrate.py b/bambu_cli/job/orchestrate.py index 4f1378c..95d6f25 100644 --- a/bambu_cli/job/orchestrate.py +++ b/bambu_cli/job/orchestrate.py @@ -48,6 +48,7 @@ from bambu_cli.job.steps import JobSteps from bambu_cli.job.support import ( _emit_job_failure, + _emit_job_ok, _exit_code_from_error, _job_fail, _last_error_for, @@ -61,7 +62,6 @@ from bambu_cli.paths import path_for_message as _path_for_message from bambu_cli.printables import is_printables_url from bambu_cli.slicer import _directory_input_message, _is_directory_input, _validate_slice_options -from bambu_cli.utils import emit_json def _cmd_job(args, steps): @@ -197,7 +197,7 @@ def _run_job(ctx, args, steps=None): getattr(args, "upload_only", False) ) if getattr(args, "json", False): - emit_json(summary) + _emit_job_ok(summary) return None if _is_http_url(source): @@ -345,7 +345,7 @@ def _run_job(ctx, args, steps=None): getattr(args, "upload_only", False) ) if getattr(args, "json", False): - emit_json(summary) + _emit_job_ok(summary) return source_path try: extracted_path, extracted_filename, archive_entry, _ = _extract_zip_model( @@ -416,7 +416,7 @@ def _run_job(ctx, args, steps=None): getattr(args, "upload_only", False) ) if getattr(args, "json", False): - emit_json(summary) + _emit_job_ok(summary) return source_path try: utils._LAST_ERROR_PAYLOAD = None @@ -485,7 +485,7 @@ def _run_job(ctx, args, steps=None): getattr(args, "upload_only", False) ) if getattr(args, "json", False): - emit_json(summary) + _emit_job_ok(summary) return source_path printable_path = source_path else: @@ -527,7 +527,7 @@ def _run_job(ctx, args, steps=None): if getattr(args, "json", False): summary["status"] = "uploaded" summary["next_command"] = _print_next_command(args, remote_name) - emit_json(summary) + _emit_job_ok(summary) return printable_path if not getattr(args, "confirm", False): @@ -535,7 +535,7 @@ def _run_job(ctx, args, steps=None): if getattr(args, "json", False): summary["status"] = "uploaded_not_printed" summary["next_command"] = _print_next_command(args, remote_name) - emit_json(summary) + _emit_job_ok(summary) return printable_path summary["would_print"] = True @@ -572,7 +572,7 @@ def _run_job(ctx, args, steps=None): summary["printed"] = True summary["status"] = "printed" if getattr(args, "json", False): - emit_json(summary) + _emit_job_ok(summary) return printable_path finally: if os.environ.get("BAMBU_KEEP_WORKDIR") != "1": diff --git a/bambu_cli/job/support.py b/bambu_cli/job/support.py index f9132df..2349766 100644 --- a/bambu_cli/job/support.py +++ b/bambu_cli/job/support.py @@ -33,10 +33,32 @@ def _exit_code_from_error(exc, default=EXIT_COMMAND_ERROR): from bambu_cli.utils import emit_json +def _contract_from_mapping(cls, mapping, **overrides): + """Build ``cls`` from a dict, passing unknown keys through ``to_payload`` extra.""" + import dataclasses + + data = dict(mapping) + data.update(overrides) + known = {field.name for field in dataclasses.fields(cls)} + kwargs = {key: data[key] for key in known if key in data} + extras = {key: value for key, value in data.items() if key not in known} + payload = cls(**kwargs).to_payload() + payload.update(extras) + return payload + + +def _emit_job_ok(summary): + from bambu_cli.contracts import JobOk + + emit_json(_contract_from_mapping(JobOk, summary)) + + def _emit_job_failure(args, summary, failed_step, exit_code, error=None, detail=None): """Emit a single machine-readable failure summary for job/send --json.""" if not bool(_namespace_get(args, "json", False)): return + from bambu_cli.contracts import JobError + payload = dict(summary) payload.update( { @@ -48,7 +70,7 @@ def _emit_job_failure(args, summary, failed_step, exit_code, error=None, detail= ) if detail: payload[f"{failed_step}_error"] = detail - emit_json(payload) + emit_json(_contract_from_mapping(JobError, payload)) def _job_fail(args, summary, failed_step, exit_code, message): diff --git a/bambu_cli/protocols/mqtt_monitor.py b/bambu_cli/protocols/mqtt_monitor.py index 62ee66c..053463a 100644 --- a/bambu_cli/protocols/mqtt_monitor.py +++ b/bambu_cli/protocols/mqtt_monitor.py @@ -6,6 +6,7 @@ import sys import threading +from bambu_cli.contracts import StatusEvent from bambu_cli.logging_utils import logger from bambu_cli.protocols.mqtt_cmd import ( TERMINAL_GCODE_STATES, @@ -25,20 +26,21 @@ def _int(value, default=0): except (TypeError, ValueError): return default - return { - "event": event, - "command": "status", - "gcode_state": p.get("gcode_state", "UNKNOWN"), - "mc_percent": _int(p.get("mc_percent", 0)), - "layer_num": _int(p.get("layer_num", 0)), - "total_layer_num": _int(p.get("total_layer_num", 0)), - "mc_remaining_time": _int(p.get("mc_remaining_time", 0)), - "nozzle_temper": p.get("nozzle_temper"), - "nozzle_target_temper": p.get("nozzle_target_temper"), - "bed_temper": p.get("bed_temper"), - "bed_target_temper": p.get("bed_target_temper"), - "gcode_file": p.get("gcode_file", ""), - } + return StatusEvent( + event=event, + command="status", + gcode_state=p.get("gcode_state", "UNKNOWN"), + mc_percent=_int(p.get("mc_percent", 0)), + ).to_payload( + layer_num=_int(p.get("layer_num", 0)), + total_layer_num=_int(p.get("total_layer_num", 0)), + mc_remaining_time=_int(p.get("mc_remaining_time", 0)), + nozzle_temper=p.get("nozzle_temper"), + nozzle_target_temper=p.get("nozzle_target_temper"), + bed_temper=p.get("bed_temper"), + bed_target_temper=p.get("bed_target_temper"), + gcode_file=p.get("gcode_file", ""), + ) def monitor_status(args, printer): diff --git a/bambu_cli/setup_cmd/config_cmd.py b/bambu_cli/setup_cmd/config_cmd.py index d69ec3b..106a69b 100644 --- a/bambu_cli/setup_cmd/config_cmd.py +++ b/bambu_cli/setup_cmd/config_cmd.py @@ -77,14 +77,16 @@ def _cmd_config_show(args): redacted = _redacted_config(config) if _namespace_get(args, "json", False): + from bambu_cli.contracts import ConfigCmd + emit_json( - { - "status": "ok", - "command": "config", - "action": "show", - "config_path": _display_path(config_path), - "config": redacted, - } + ConfigCmd( + status="ok", + command="config", + action="show", + config_path=_display_path(config_path), + config=redacted, + ) ) return logger.info(f"๐Ÿ“„ Config: {_display_path(config_path)}") @@ -106,19 +108,21 @@ def _cmd_config_validate(args): status = "warning" if _namespace_get(args, "json", False): + from bambu_cli.contracts import ConfigCmd + emit_json( - { - "status": status, - "command": "config", - "action": "validate", - "exit_code": exit_code, - "ok": ok, - "errors": error_count, - "warnings": warning_count, - "strict": bool(_namespace_get(args, "strict", False)), - "config_path": _display_path(_config_path()), - "checks": checks, - } + ConfigCmd( + status=status, + command="config", + action="validate", + exit_code=exit_code, + ok=ok, + errors=error_count, + warnings=warning_count, + strict=bool(_namespace_get(args, "strict", False)), + config_path=_display_path(_config_path()), + checks=checks, + ) ) else: logger.info(f"๐Ÿงช Validating {_display_path(_config_path())}") diff --git a/bambu_cli/slicer/cmd.py b/bambu_cli/slicer/cmd.py index d43ea3a..21ff1aa 100644 --- a/bambu_cli/slicer/cmd.py +++ b/bambu_cli/slicer/cmd.py @@ -55,15 +55,17 @@ def _list_settings(args: argparse.Namespace, settings) -> str: "set 'profiles_dir' in config.json (see 'preflight')." ) if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import FilamentSettings, ProcessSettings, SliceListSettings + emit_json( - { - "status": "ok", - "command": "slice", - "action": "list_settings", - "profiles_dir": profiles_dir, - "process": {"count": len(process), "settings": process}, - "filament": {"count": len(filament), "settings": filament}, - } + SliceListSettings( + status="ok", + command="slice", + action="list_settings", + profiles_dir=profiles_dir, + process=ProcessSettings(count=len(process), settings=process), + filament=FilamentSettings(count=len(filament), settings=filament), + ) ) else: # The settings list is DATA, so it goes to stdout while the header and the diff --git a/bambu_cli/slicer/output.py b/bambu_cli/slicer/output.py index 8beb6fe..59cf1c9 100644 --- a/bambu_cli/slicer/output.py +++ b/bambu_cli/slicer/output.py @@ -171,16 +171,18 @@ def _finalize_slice( abort("", exit_code=EXIT_FILE_ERROR) logger.info(f"โœ… Sliced: {_path_for_message(outpath)} ({size // 1024}KB)") if bool(_namespace_get(args, "json", False)): + from bambu_cli.contracts import Slice + emit_json( - { - "status": "sliced", - "command": "slice", - "file": _expand_path(args.file), - "path": outpath, - "filename": os.path.basename(outpath), - "bytes": size, - "step_converted": step_converted, - } + Slice( + status="sliced", + command="slice", + file=_expand_path(args.file), + path=outpath, + filename=os.path.basename(outpath), + bytes=size, + step_converted=step_converted, + ) ) return outpath else: diff --git a/bambu_cli/utils.py b/bambu_cli/utils.py index 1a6c322..c9bc1ec 100644 --- a/bambu_cli/utils.py +++ b/bambu_cli/utils.py @@ -154,15 +154,20 @@ def emit_json_error(args, command, exit_code, error, failed_step=None, **extra): global _JSON_EMITTED _JSON_EMITTED = True global _LAST_ERROR_PAYLOAD - payload = { - "status": "error", - "command": command, - "exit_code": exit_code, - "error": error, - } - if failed_step: - payload["failed_step"] = failed_step - payload.update(extra) + from bambu_cli.contracts import ErrorEnvelope + + envelope = ErrorEnvelope( + status="error", + command=command, + exit_code=exit_code, + error=error, + failed_step=failed_step, + printer_error_code=extra.pop("printer_error_code", None), + printer_error_code_hex=extra.pop("printer_error_code_hex", None), + next_command=extra.pop("next_command", None), + detail=extra.pop("detail", None), + ) + payload = envelope.to_payload(**extra) _LAST_ERROR_PAYLOAD = payload if not bool(_namespace_get(args, "json", False)): return @@ -185,6 +190,15 @@ def record_error_detail(command, exit_code, error, failed_step=None, **extra): def _record_download_success(args, payload): global _LAST_DOWNLOAD_PAYLOAD + from bambu_cli.contracts import Download + + if isinstance(payload, dict): + import dataclasses + + known = {field.name for field in dataclasses.fields(Download)} + kwargs = {key: payload[key] for key in known if key in payload} + extras = {key: value for key, value in payload.items() if key not in known} + payload = Download(**kwargs).to_payload(**extras) _LAST_DOWNLOAD_PAYLOAD = payload if bool(_namespace_get(args, "json", False)): emit_json(payload) diff --git a/docs/api.md b/docs/api.md index 24f3209..bdd7a5e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -169,9 +169,7 @@ One-shot query returns printer state, temperatures, and a normalized AMS block: } ``` -Top-level keys also mirror common fields from the raw printer map for convenience. - -`printer` is always a complete state snapshot. The printer publishes incremental +`printer` is always a complete state snapshot. Envelope keys stay closed: firmware extras live under `printer`, not as siblings of `status` / `command`. The printer publishes incremental deltas on its MQTT report topic and answers `pushall` with the whole state, so `status` merges report messages and keeps waiting (re-requesting on each retry) until `gcode_state`, `mc_percent`, `bed_temper`, and `nozzle_temper` are all diff --git a/docs/schemas/status.json b/docs/schemas/status.json index dd27227..e137a39 100644 --- a/docs/schemas/status.json +++ b/docs/schemas/status.json @@ -3,7 +3,7 @@ "$id": "https://platecli.local/schemas/status.json", "title": "platecli status success envelope", "type": "object", - "description": "JSON output of `plate status --json`. Printer fields appear both at the top level (raw MQTT data) and normalised under the `printer` key.", + "description": "JSON output of `plate status --json`. Guaranteed printer fields live under `printer` (never flattened onto the envelope). Firmware extras stay on `printer` as additional properties. `ams` is the normalised tray view for --ams-mapping.", "required": [ "status", "command", @@ -105,7 +105,15 @@ "nozzle_temper" ], "additionalProperties": true, - "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." + "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, + "type": [ + "object", + "null" + ] } }, "additionalProperties": true diff --git a/tests/agent_cli_smoke.py b/tests/agent_cli_smoke.py index e92b776..c16c55a 100644 --- a/tests/agent_cli_smoke.py +++ b/tests/agent_cli_smoke.py @@ -672,8 +672,10 @@ def smoke_sim_lower_level_json(root): status = json_stdout(run_cli(["--sim", "status", "--json"], env)) if status.get("status") != "ok" or status.get("command") != "status": assert False, f"status JSON is not self-describing: {status}" - if status.get("printer", {}).get("gcode_state") != "IDLE" or status.get("gcode_state") != "IDLE": + if status.get("printer", {}).get("gcode_state") != "IDLE": assert False, f"status JSON did not preserve printer state fields: {status}" + if "gcode_state" in status: + assert False, f"status JSON flattened firmware fields onto the envelope: {status}" files = json_stdout(run_cli(["--sim", "files", "--json"], env)) if files.get("status") != "ok" or files.get("command") != "files": diff --git a/tests/test_cmd_status.py b/tests/test_cmd_status.py index 26ecdbf..b175e95 100644 --- a/tests/test_cmd_status.py +++ b/tests/test_cmd_status.py @@ -75,7 +75,8 @@ def test_cmd_status_json_output(self, mock_logger, mock_get_status, mock_emit_js payload = mock_emit_json.call_args[0][0] self.assertEqual(payload["status"], "ok") self.assertEqual(payload["command"], "status") - self.assertEqual(payload["gcode_state"], "IDLE") + self.assertNotIn("gcode_state", payload) + self.assertEqual(payload["printer"]["gcode_state"], "IDLE") @patch("bambu_cli.commands.status.emit_json") @patch("bambu_cli.protocols.mqtt.get_status") diff --git a/tests/test_config_and_logging.py b/tests/test_config_and_logging.py index 19ea897..2916b52 100644 --- a/tests/test_config_and_logging.py +++ b/tests/test_config_and_logging.py @@ -224,6 +224,8 @@ def test_config_show_json_payload(self): with patch("bambu_cli.setup_cmd.config_cmd.emit_json") as mock_emit: _cmd_config(self._args("show", json_mode=True)) payload = mock_emit.call_args[0][0] + if hasattr(payload, "to_payload"): + payload = payload.to_payload() self.assertEqual(payload["command"], "config") self.assertEqual(payload["action"], "show") self.assertEqual(payload["config"]["access_code"], "") @@ -256,6 +258,8 @@ def test_config_validate_filters_to_config_checks(self): ): _cmd_config(self._args("validate", json_mode=True)) payload = mock_emit.call_args[0][0] + if hasattr(payload, "to_payload"): + payload = payload.to_payload() self.assertEqual(payload["action"], "validate") self.assertEqual({c["name"] for c in payload["checks"]}, {"printer-ip", "access-code"}) # Warnings without --strict still validate (same semantics as preflight). diff --git a/tests/test_json_contract_query.py b/tests/test_json_contract_query.py index 4e35648..341c90e 100644 --- a/tests/test_json_contract_query.py +++ b/tests/test_json_contract_query.py @@ -20,7 +20,6 @@ def test_status_success_shape(monkeypatch, tmp_path, capsys): "status": {"enum": ["ok"]}, "command": {"enum": ["status"]}, "printer": DICT, - "gcode_state": STR, }, }, ) diff --git a/tests/test_snapshot_output.py b/tests/test_snapshot_output.py index 8c93ef0..dd024d9 100644 --- a/tests/test_snapshot_output.py +++ b/tests/test_snapshot_output.py @@ -134,7 +134,10 @@ def test_direct_path_json_includes_captured_at_and_sha256(self, capsys=None): patch("bambu_cli.logging_utils._BACKEND", MagicMock()), patch("os.path.getsize", return_value=len(frame_data)), patch("bambu_cli.commands.snapshot._ensure_parent_dir"), - patch("bambu_cli.commands.snapshot.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), + patch( + "bambu_cli.commands.snapshot.emit_json", + side_effect=lambda d: buf.write(json.dumps(d.to_payload() if hasattr(d, "to_payload") else d)), + ), ): cmd_snapshot( args, @@ -170,7 +173,10 @@ def test_docker_path_json_includes_captured_at_and_sha256(self): patch("bambu_cli.logging_utils._BACKEND", MagicMock()), patch("os.path.getsize", return_value=len(frame_data)), patch("bambu_cli.commands.snapshot._ensure_parent_dir"), - patch("bambu_cli.commands.snapshot.emit_json", side_effect=lambda d: buf.write(json.dumps(d))), + patch( + "bambu_cli.commands.snapshot.emit_json", + side_effect=lambda d: buf.write(json.dumps(d.to_payload() if hasattr(d, "to_payload") else d)), + ), settings_ctx(camera_allow_streamer=True), ): cmd_snapshot(