diff --git a/completions/_bambu b/completions/_bambu index 0dfdd20..5eaed4f 100644 --- a/completions/_bambu +++ b/completions/_bambu @@ -10,6 +10,7 @@ _bambu() { 'pause:pause the running print' 'resume:resume a paused print' 'cancel:cancel the running print (requires --yes)' + 'print:upload a .3mf/.gcode and start a print' 'home:home the print head' 'light:turn chamber light on or off' 'schedule:start a print at a specified time (HH:MM)' diff --git a/completions/bambu.bash b/completions/bambu.bash index 086d119..d870f30 100644 --- a/completions/bambu.bash +++ b/completions/bambu.bash @@ -12,7 +12,7 @@ _bambu() { } if [ "${cword}" -eq 1 ]; then - COMPREPLY=( $(compgen -W "status pause resume cancel home light schedule quiet auto-off snap stream vision" -- "${cur}") ) + COMPREPLY=( $(compgen -W "status pause resume cancel home light print schedule quiet auto-off snap stream vision" -- "${cur}") ) return fi diff --git a/src/bambu_ai/cli.py b/src/bambu_ai/cli.py index 25d8b2c..3172fee 100644 --- a/src/bambu_ai/cli.py +++ b/src/bambu_ai/cli.py @@ -87,6 +87,36 @@ def _cmd_light(p, args): # ----------------------------------------------------------------- new subcommands +@_with_printer +def _cmd_print(p, args): + """Upload a .3mf/.gcode and start the print (issue #3).""" + from pathlib import Path + + from .printing import upload_and_start, validate_print_file + + path = Path(args.file) + try: + validate_print_file(path) + except ValueError as e: + sys.exit(f"[print] {e}") + + if args.upload_only: + with path.open("rb") as f: + p.upload_file(f, args.name or path.name) + print(f"[print] uploaded {args.name or path.name}") + return + + name, ok = upload_and_start( + p, + path, + remote_name=args.name, + plate_number=args.plate, + use_ams=args.ams, + flow_calibration=args.flow_calibration, + ) + print(f"[print] uploaded={name} start_print={ok}") + + @_with_printer def _cmd_snap(p, args): """Capture one frame from the chamber camera to a file (issue #4).""" @@ -289,6 +319,16 @@ def main() -> None: ) autooff.set_defaults(func=_cmd_autooff) + # print (issue #3) — upload a .3mf/.gcode and start + pr = sub.add_parser("print", help="upload a .3mf/.gcode and start a print") + pr.add_argument("file", help="local path to the .3mf or .gcode") + pr.add_argument("--name", default=None, help="remote filename (default: basename of file)") + pr.add_argument("--plate", type=int, default=1, help="plate number to print (default: 1)") + pr.add_argument("--ams", action="store_true", help="use AMS (default: off)") + pr.add_argument("--no-flow-calibration", action="store_false", dest="flow_calibration") + pr.add_argument("--upload-only", action="store_true", help="upload but don't start the print") + pr.set_defaults(func=_cmd_print) + # snap / stream (issue #4) — chamber camera frame capture snap = sub.add_parser("snap", help="capture one frame from the chamber camera") snap.add_argument("out", help="output path (e.g. frame.jpg)") diff --git a/src/bambu_ai/printing.py b/src/bambu_ai/printing.py new file mode 100644 index 0000000..1adffdf --- /dev/null +++ b/src/bambu_ai/printing.py @@ -0,0 +1,53 @@ +"""Upload-and-start a .3mf or .gcode file (issue #3). + +Wraps bambulabs_api's ``upload_file`` (FTP over implicit TLS, port 990) and +``start_print``. Keeps the file handling testable by separating the upload +step from the start step. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def validate_print_file(path: Path) -> None: + """Reject files we don't know how to print. Raises ValueError on failure.""" + if not path.is_file(): + raise ValueError(f"not a file: {path}") + if path.suffix.lower() not in (".3mf", ".gcode"): + raise ValueError(f"unsupported extension {path.suffix!r}; expected .3mf or .gcode") + if path.stat().st_size == 0: + raise ValueError(f"file is empty: {path}") + + +def upload_and_start( + printer: Any, + local_path: Path, + *, + remote_name: str | None = None, + plate_number: int = 1, + use_ams: bool = False, + flow_calibration: bool = True, +) -> tuple[str, bool]: + """Upload ``local_path`` to the printer and start the print. + + Returns ``(remote_name, start_result)``. ``start_result`` is what + bambulabs_api's ``start_print`` returns (typically a bool). + + Defaults are sensible for an A1 mini without AMS: ``use_ams=False``, + ``plate_number=1``, ``flow_calibration=True``. + """ + validate_print_file(local_path) + name = remote_name or local_path.name + + with local_path.open("rb") as f: + printer.upload_file(f, name) + + result = printer.start_print( + name, + plate_number, + use_ams=use_ams, + flow_calibration=flow_calibration, + ) + return name, bool(result) diff --git a/tests/test_printing.py b/tests/test_printing.py new file mode 100644 index 0000000..6a7c458 --- /dev/null +++ b/tests/test_printing.py @@ -0,0 +1,93 @@ +"""Tests for upload-and-start (issue #3).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from bambu_ai.printing import upload_and_start, validate_print_file + + +class TestValidatePrintFile: + def test_accepts_3mf(self, tmp_path) -> None: + p = tmp_path / "x.3mf" + p.write_bytes(b"abc") + validate_print_file(p) + + def test_accepts_gcode(self, tmp_path) -> None: + p = tmp_path / "x.gcode" + p.write_bytes(b"G28\n") + validate_print_file(p) + + def test_rejects_missing(self, tmp_path) -> None: + with pytest.raises(ValueError, match="not a file"): + validate_print_file(tmp_path / "nope.3mf") + + def test_rejects_unsupported_extension(self, tmp_path) -> None: + p = tmp_path / "x.stl" + p.write_bytes(b"abc") + with pytest.raises(ValueError, match="unsupported extension"): + validate_print_file(p) + + def test_rejects_empty(self, tmp_path) -> None: + p = tmp_path / "x.3mf" + p.write_bytes(b"") + with pytest.raises(ValueError, match="empty"): + validate_print_file(p) + + +class TestUploadAndStart: + def test_uploads_then_starts_with_defaults(self, tmp_path) -> None: + p = tmp_path / "calibration_cube.3mf" + p.write_bytes(b"FAKE_3MF_DATA") + printer = MagicMock() + printer.start_print.return_value = True + + name, ok = upload_and_start(printer, p) + assert name == "calibration_cube.3mf" + assert ok is True + + printer.upload_file.assert_called_once() + call_args = printer.upload_file.call_args + # second positional arg is the remote filename + assert call_args.args[1] == "calibration_cube.3mf" + + printer.start_print.assert_called_once_with( + "calibration_cube.3mf", + 1, + use_ams=False, + flow_calibration=True, + ) + + def test_remote_name_overrides_basename(self, tmp_path) -> None: + p = tmp_path / "local.3mf" + p.write_bytes(b"x") + printer = MagicMock() + printer.start_print.return_value = True + + name, _ = upload_and_start(printer, p, remote_name="job_001.3mf") + assert name == "job_001.3mf" + assert printer.upload_file.call_args.args[1] == "job_001.3mf" + assert printer.start_print.call_args.args[0] == "job_001.3mf" + + def test_passes_through_plate_ams_flow_flags(self, tmp_path) -> None: + p = tmp_path / "x.gcode" + p.write_bytes(b"G28\n") + printer = MagicMock() + printer.start_print.return_value = True + + upload_and_start(printer, p, plate_number=3, use_ams=True, flow_calibration=False) + printer.start_print.assert_called_once_with( + "x.gcode", + 3, + use_ams=True, + flow_calibration=False, + ) + + def test_raises_for_invalid_file(self, tmp_path) -> None: + printer = MagicMock() + with pytest.raises(ValueError): + upload_and_start(printer, tmp_path / "missing.3mf") + printer.upload_file.assert_not_called() + printer.start_print.assert_not_called()