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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

### Changed

- 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.)
still emit their own contracts.

- Tests import the real `paho-mqtt` package instead of stubbing it in
`sys.modules`. Audit-named test files are renamed to topic names.

Expand Down
16 changes: 8 additions & 8 deletions bambu_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
# and the help/workflow smokes plus several tests import it from this module.
from .contracts import ErrorEnvelope, Version
from .jsonio import json_mode_requested as _json_mode_requested
from .utils import emit_json, emit_json_error
from .utils import emit_json, write_error_envelope


def setup_logging(verbose=False, json_mode=False):
Expand Down Expand Up @@ -191,12 +191,12 @@ def _handle_bambu_error(exc, command_name):
# Emit the machine-readable envelope FIRST: stdout must stay parseable even if the
# human-readable log line below fails to render.
if _json_mode_requested(args) and not utils._JSON_EMITTED:
extra = {}
extra = dict(getattr(exc, "extra", None) or {})
if exc.detail:
extra["detail"] = exc.detail
if exc.next_command:
extra["next_command"] = exc.next_command
emit_json_error(
write_error_envelope(
args,
command_name,
exc.exit_code,
Expand All @@ -214,7 +214,7 @@ def _handle_interrupt(interrupt_args, command_name):
# failure branch) and send the human line to stderr, not stdout.
message = "Operation cancelled by user."
if _json_mode_requested(interrupt_args) and not utils._JSON_EMITTED:
emit_json_error(
write_error_envelope(
interrupt_args,
command_name,
EXIT_COMMAND_ERROR,
Expand All @@ -236,14 +236,14 @@ def _handle_interrupt(interrupt_args, command_name):
printer_ip = _context.current_settings().printer_ip
if printer_ip == "0.0.0.0":
message = "Printer IP is not configured. Please run `plate setup` first."
emit_json_error(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
write_error_envelope(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
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}"
emit_json_error(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
write_error_envelope(args, args.cmd or "main", EXIT_CONFIG_ERROR, message, failed_step="config")
logger.error(message)
sys.exit(EXIT_CONFIG_ERROR)

Expand All @@ -254,7 +254,7 @@ def _handle_interrupt(interrupt_args, command_name):
except SystemExit as exc:
exit_code = _exit_code_from_system_exit(exc)
if exit_code != EXIT_SUCCESS and _json_mode_requested(args) and not utils._JSON_EMITTED:
emit_json_error(
write_error_envelope(
args,
args.cmd,
exit_code,
Expand All @@ -268,7 +268,7 @@ def _handle_interrupt(interrupt_args, command_name):
except Exception as exc:
# Envelope first — see _safe_log_error: a logging failure must not eat stdout.
if _json_mode_requested(args) and not utils._JSON_EMITTED:
emit_json_error(
write_error_envelope(
args,
args.cmd,
EXIT_COMMAND_ERROR,
Expand Down
38 changes: 29 additions & 9 deletions bambu_cli/commands/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from bambu_cli.contracts import Light, Pause, Resume, Stop
from bambu_cli.errors import abort
from bambu_cli.logging_utils import logger, safe_log_error
from bambu_cli.utils import emit_json, emit_json_error, get_sequence_id
from bambu_cli.utils import emit_json, get_sequence_id


def cmd_light(args, ctx=None):
Expand All @@ -32,9 +32,14 @@ def cmd_light(args, ctx=None):
printer = ctx.printer()
if not printer.send_command(payload):
message = "Failed to send light command."
emit_json_error(args, "light", EXIT_NETWORK_ERROR, message, failed_step="mqtt", action=action, changed=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="mqtt",
extra={"action": action, "changed": False},
command="light",
)
logger.info(f"💡 Light turned {action}")
if bool(_namespace_get(args, "json", False)):
emit_json(Light(status="light_changed", command="light", action=action, changed=True))
Expand All @@ -60,9 +65,14 @@ def cmd_pause(args, ctx=None):
printer = ctx.printer()
if not printer.send_command(payload):
message = "Failed to send pause command."
emit_json_error(args, "pause", EXIT_NETWORK_ERROR, message, failed_step="mqtt", paused=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="mqtt",
extra={"paused": False},
command="pause",
)
logger.info("⏸️ Print paused")
if bool(_namespace_get(args, "json", False)):
emit_json(Pause(status="paused", command="pause", paused=True))
Expand All @@ -88,9 +98,14 @@ def cmd_resume(args, ctx=None):
printer = ctx.printer()
if not printer.send_command(payload):
message = "Failed to send resume command."
emit_json_error(args, "resume", EXIT_NETWORK_ERROR, message, failed_step="mqtt", resumed=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="mqtt",
extra={"resumed": False},
command="resume",
)
logger.info("▶️ Print resumed")
if bool(_namespace_get(args, "json", False)):
emit_json(Resume(status="resumed", command="resume", resumed=True))
Expand All @@ -116,9 +131,14 @@ def cmd_stop(args, ctx=None):
printer = ctx.printer()
if not printer.send_command(payload):
message = "Failed to send stop command."
emit_json_error(args, "stop", EXIT_NETWORK_ERROR, message, failed_step="mqtt", stopped=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="mqtt",
extra={"stopped": False},
command="stop",
)
logger.info("⏹️ Print stopped")
if bool(_namespace_get(args, "json", False)):
emit_json(Stop(status="stopped", command="stop", stopped=True))
118 changes: 64 additions & 54 deletions bambu_cli/commands/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
)
from bambu_cli.errors import abort
from bambu_cli.fsutil import _portable_basename
from bambu_cli.logging_utils import logger, safe_log_error
from bambu_cli.logging_utils import logger
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.slicer import _directory_input_message, _is_directory_input
from bambu_cli.utils import emit_json, emit_json_error
from bambu_cli.utils import emit_json


def cmd_upload(args, ctx=None):
Expand All @@ -31,59 +31,64 @@ def cmd_upload(args, ctx=None):
filepath = _expand_path(args.file)
if filepath.startswith("-"):
message = f"Invalid filepath: {_path_for_message(filepath)}"
emit_json_error(args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath},
)
if not os.path.exists(filepath):
message = f"File not found: {_path_for_message(filepath)}"
emit_json_error(args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath},
)
if _is_directory_input(filepath):
message = _directory_input_message(filepath)
emit_json_error(args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath},
)

filename = _portable_basename(filepath)
if _safe_remote_name(filename) is None:
message = f"Refusing to upload file with unsafe name: {_name_for_message(filename)!r}"
emit_json_error(
args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath, remote_name=filename
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath, "remote_name": filename},
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
if not _is_print_ready_name(filename):
message = _print_ready_error_message(filename, "upload")
emit_json_error(
args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath, remote_name=filename
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath, "remote_name": filename},
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
try:
filesize = os.path.getsize(filepath)
except OSError as exc:
message = f"Could not read file size for {_path_for_message(filepath)}: {_exception_for_message(exc)}"
emit_json_error(
args, "upload", EXIT_FILE_ERROR, message, failed_step="validate", file=filepath, remote_name=filename
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filepath, "remote_name": filename},
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
if filesize <= 0:
message = f"Refusing to upload empty file: {_path_for_message(filepath)}"
emit_json_error(
args,
"upload",
EXIT_FILE_ERROR,
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
file=filepath,
remote_name=filename,
bytes=filesize,
extra={"file": filepath, "remote_name": filename, "bytes": filesize},
)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)

if getattr(args, "dry_run", False):
logger.info(f"🔍 Dry Run: Validating printer connectivity for {filename}...")
printer = get_printer()
Expand All @@ -108,11 +113,12 @@ def cmd_upload(args, ctx=None):
" (a TLS error can mean the camera/FTPS certificate no longer matches a "
"configured cert_fingerprint pin — verify the printer's certificate)"
)
emit_json_error(
args, "upload", EXIT_NETWORK_ERROR, message, failed_step="dry_run", file=filepath, remote_name=filename
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="dry_run",
extra={"file": filepath, "remote_name": filename},
)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)

logger.info(f" ✅ Local file {_path_for_message(filepath)} exists ({filesize // 1024}KB)")
if bool(_namespace_get(args, "json", False)):
Expand Down Expand Up @@ -193,17 +199,12 @@ def _cb(block):
else:
# 4 attempts mirrors upload_file.max_retries (3 retries + initial try)
message = "Upload failed after 4 attempts."
emit_json_error(
args,
"upload",
EXIT_NETWORK_ERROR,
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="upload",
file=filepath,
remote_name=filename,
extra={"file": filepath, "remote_name": filename},
)
safe_log_error(f"❌ {message}")
abort("", exit_code=EXIT_NETWORK_ERROR)


def cmd_files(args, ctx=None):
Expand Down Expand Up @@ -238,9 +239,12 @@ def cmd_files(args, ctx=None):
logger.info(f" {f}")
except Exception as e:
message = f"Error listing files: {e}"
emit_json_error(args, "files", EXIT_NETWORK_ERROR, message, failed_step="ftps", files=[])
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="ftps",
extra={"files": []},
)


def cmd_delete(args, ctx=None):
Expand All @@ -251,9 +255,12 @@ def cmd_delete(args, ctx=None):
filename = str(args.file or "")
if _safe_remote_name(filename) is None:
message = f"Refusing to delete file with unsafe name: {_name_for_message(filename)!r}"
emit_json_error(args, "delete", EXIT_FILE_ERROR, message, failed_step="validate", file=filename, deleted=False)
safe_log_error(message)
abort("", exit_code=EXIT_FILE_ERROR)
abort(
message,
exit_code=EXIT_FILE_ERROR,
failed_step="validate",
extra={"file": filename, "deleted": False},
)
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)):
Expand Down Expand Up @@ -282,6 +289,9 @@ def cmd_delete(args, ctx=None):
raise Exception("Delete operation failed in printer client.")
except Exception as e:
message = f"Delete failed: {e}"
emit_json_error(args, "delete", EXIT_NETWORK_ERROR, message, failed_step="ftps", file=filename, deleted=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
message,
exit_code=EXIT_NETWORK_ERROR,
failed_step="ftps",
extra={"file": filename, "deleted": False},
)
24 changes: 14 additions & 10 deletions bambu_cli/commands/gcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
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
from bambu_cli.utils import emit_json, emit_json_error, get_sequence_id
from bambu_cli.logging_utils import logger
from bambu_cli.utils import emit_json, get_sequence_id


def cmd_gcode(args, ctx=None):
Expand All @@ -21,10 +21,12 @@ def cmd_gcode(args, ctx=None):
# Reject empty/whitespace-only and CR/LF/NUL (shared helper with remote-name
# sanitization — same command-injection risk on MQTT as on FTP lines).
if not gcode.strip() or _has_command_injection_chars(gcode):
message = "Invalid G-code: must be non-empty and must not contain control characters (CR/LF/NUL)."
emit_json_error(args, "gcode", EXIT_COMMAND_ERROR, message, failed_step="validate", gcode=gcode, sent=False)
safe_log_error(message)
abort("", exit_code=EXIT_COMMAND_ERROR)
abort(
"Invalid G-code: must be non-empty and must not contain control characters (CR/LF/NUL).",
exit_code=EXIT_COMMAND_ERROR,
failed_step="validate",
extra={"gcode": gcode, "sent": False},
)

if not args.confirm:
logger.warning("⚠️ This will SEND raw G-code to the printer. Add --confirm to proceed.")
Expand All @@ -43,10 +45,12 @@ 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):
message = "Failed to send G-code command."
emit_json_error(args, "gcode", EXIT_NETWORK_ERROR, message, failed_step="mqtt", gcode=gcode, sent=False)
safe_log_error(message)
abort("", exit_code=EXIT_NETWORK_ERROR)
abort(
"Failed to send G-code command.",
exit_code=EXIT_NETWORK_ERROR,
failed_step="mqtt",
extra={"gcode": gcode, "sent": False},
)
logger.info(f"📡 Sent: {gcode}")
if bool(_namespace_get(args, "json", False)):
emit_json(Gcode(status="sent", command="gcode", gcode=gcode, sent=True))
Loading
Loading