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
1 change: 1 addition & 0 deletions completions/_bambu
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
2 changes: 1 addition & 1 deletion completions/bambu.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions src/bambu_ai/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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)")
Expand Down
53 changes: 53 additions & 0 deletions src/bambu_ai/printing.py
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions tests/test_printing.py
Original file line number Diff line number Diff line change
@@ -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()
Loading