From 9daef9f05fb4046bb70968d82da95c8c636cd128 Mon Sep 17 00:00:00 2001 From: "Abe Diaz (@abe238)" Date: Tue, 26 May 2026 02:03:15 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20persistent=20print=20queue=20=E2=80=94?= =?UTF-8?q?=20bambu=20queue=20add/list/start/clear=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent JSON-backed FIFO at ~/.bambu-ai/queue.json (override via $BAMBU_AI_QUEUE). Daemon watches MQTT for IDLE/FINISH and auto-dispatches the next item via upload+start. 11 new tests, 128 total. Closes #13. --- completions/_bambu | 1 + completions/bambu.bash | 2 +- src/bambu_ai/cli.py | 159 +++++++++++++++++++++++++++++++++++++++++ src/bambu_ai/queue.py | 85 ++++++++++++++++++++++ tests/test_queue.py | 106 +++++++++++++++++++++++++++ 5 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 src/bambu_ai/queue.py create mode 100644 tests/test_queue.py diff --git a/completions/_bambu b/completions/_bambu index 5eaed4f..e281329 100644 --- a/completions/_bambu +++ b/completions/_bambu @@ -16,6 +16,7 @@ _bambu() { 'schedule:start a print at a specified time (HH:MM)' 'quiet:cap aux fan during quiet hours' 'auto-off:fire a hook on FINISH/FAILED' + 'queue:persistent print queue (add/list/clear/start)' 'snap:capture one frame from the chamber camera' 'stream:capture frames periodically into a folder' 'vision:AI failure detection (watch / classify)' diff --git a/completions/bambu.bash b/completions/bambu.bash index d870f30..c800b22 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 print schedule quiet auto-off snap stream vision" -- "${cur}") ) + COMPREPLY=( $(compgen -W "status pause resume cancel home light print queue 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 3172fee..28ad3b6 100644 --- a/src/bambu_ai/cli.py +++ b/src/bambu_ai/cli.py @@ -87,6 +87,150 @@ def _cmd_light(p, args): # ----------------------------------------------------------------- new subcommands +def _cmd_queue_add(args) -> None: + from pathlib import Path + + from .printing import validate_print_file + from .queue import PrintQueue, QueueItem + + path = Path(args.file).resolve() + try: + validate_print_file(path) + except ValueError as e: + sys.exit(f"[queue] {e}") + q = PrintQueue() + item = QueueItem(file=str(path), name=args.name, plate=args.plate, use_ams=args.ams) + q.add(item) + print(f"[queue] added {path.name} (position {len(q)})") + + +def _cmd_queue_list(_args) -> None: + from .queue import PrintQueue + + q = PrintQueue() + items = q.list() + if not items: + print("[queue] empty") + return + for i, item in enumerate(items, 1): + from pathlib import Path + + name = item.name or Path(item.file).name + print(f" {i}. {name} (plate={item.plate} ams={item.use_ams} added {item.added_at})") + + +def _cmd_queue_clear(_args) -> None: + from .queue import PrintQueue + + q = PrintQueue() + n = len(q) + q.clear() + print(f"[queue] cleared {n} item(s)") + + +def _cmd_queue_start(_args) -> None: + """Daemon: when printer is IDLE/FINISH and queue is non-empty, start the next job (issue #13).""" + import json as _json + import ssl + import time + + import paho.mqtt.client as mqtt + + from .config import load_config + from .printing import upload_and_start + from .queue import PrintQueue + + cfg = load_config() + q = PrintQueue() + if len(q) == 0: + print("[queue] queue is empty — nothing to do. Add items with `bambu queue add `.") + return + + import bambulabs_api as bl + + topic_report = f"device/{cfg['SERIAL']}/report" + topic_request = f"device/{cfg['SERIAL']}/request" + snap: dict = {} + last_state: str | None = None + + printer = bl.Printer(cfg["IP"], cfg["ACCESS_CODE"], cfg["SERIAL"]) + printer.connect() + for _ in range(50): + if printer.mqtt_client_ready(): + break + time.sleep(0.1) + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"bambu-queue-{int(time.time())}") + client.username_pw_set("bblp", cfg["ACCESS_CODE"]) + client.tls_set(cert_reqs=ssl.CERT_NONE, tls_version=ssl.PROTOCOL_TLS_CLIENT) + client.tls_insecure_set(True) + + def on_connect(c, _u, _f, rc, _p=None): + if rc == 0: + print(f"[queue] daemon up. {len(q)} job(s) waiting.") + c.subscribe(topic_report) + c.publish(topic_request, _json.dumps({"pushing": {"sequence_id": "0", "command": "pushall"}})) + else: + print(f"[queue] connect failed: {rc}") + + def try_dispatch_next() -> None: + nonlocal q + from pathlib import Path + + if len(q) == 0: + print("[queue] queue empty — daemon idle (Ctrl-C to stop)") + return + item = q.pop_next() + if item is None: + return + path = Path(item.file) + if not path.is_file(): + print(f"[queue] WARNING: file {path} no longer exists; skipping") + return + print(f"[queue] starting next: {path.name}") + try: + name, ok = upload_and_start( + printer, + path, + remote_name=item.name, + plate_number=item.plate, + use_ams=item.use_ams, + ) + print(f"[queue] uploaded={name} start_print={ok}; remaining={len(q)}") + except Exception as e: # noqa: BLE001 + print(f"[queue] dispatch failed for {path.name}: {e}") + + def on_message(_c, _u, msg): + nonlocal last_state + try: + payload = _json.loads(msg.payload.decode()) + except Exception: + return + p_payload = payload.get("print") + if not p_payload: + return + snap.update({k: v for k, v in p_payload.items() if v not in (None, "")}) + curr = snap.get("gcode_state") + if curr and curr != last_state: + print(f"[queue] state {last_state} -> {curr}") + if curr in ("IDLE", "FINISH") and last_state is not None: + try_dispatch_next() + last_state = curr + + client.on_connect = on_connect + client.on_message = on_message + client.connect(cfg["IP"], 8883, keepalive=60) + try: + client.loop_forever() + except KeyboardInterrupt: + print("\n[queue] bye") + client.disconnect() + try: + printer.disconnect() + except Exception: # noqa: BLE001 + pass + + @_with_printer def _cmd_print(p, args): """Upload a .3mf/.gcode and start the print (issue #3).""" @@ -319,6 +463,21 @@ def main() -> None: ) autooff.set_defaults(func=_cmd_autooff) + # queue (issue #13) — persistent print queue + queue = sub.add_parser("queue", help="persistent print queue") + qsub = queue.add_subparsers(dest="queue_cmd", required=True) + qadd = qsub.add_parser("add", help="add a file to the queue") + qadd.add_argument("file", help="local path to a .3mf or .gcode") + qadd.add_argument("--name", default=None, help="remote filename (default: basename)") + qadd.add_argument("--plate", type=int, default=1) + qadd.add_argument("--ams", action="store_true") + qadd.set_defaults(func=_cmd_queue_add) + qsub.add_parser("list", help="show queued jobs").set_defaults(func=_cmd_queue_list) + qsub.add_parser("clear", help="empty the queue").set_defaults(func=_cmd_queue_clear) + qsub.add_parser("start", help="run the queue daemon (auto-start next on IDLE/FINISH)").set_defaults( + func=_cmd_queue_start + ) + # 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") diff --git a/src/bambu_ai/queue.py b/src/bambu_ai/queue.py new file mode 100644 index 0000000..d0632a8 --- /dev/null +++ b/src/bambu_ai/queue.py @@ -0,0 +1,85 @@ +"""Persistent print queue (issue #13). + +A simple JSON-backed FIFO. Lives at ``~/.bambu-ai/queue.json`` by default +(override with ``$BAMBU_AI_QUEUE``). Each entry is a single .3mf/.gcode path +plus optional metadata. +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +def default_queue_path() -> Path: + if override := os.environ.get("BAMBU_AI_QUEUE"): + return Path(override).expanduser() + return Path.home() / ".bambu-ai" / "queue.json" + + +@dataclass +class QueueItem: + """One queued job.""" + + file: str + name: str | None = None + plate: int = 1 + use_ams: bool = False + added_at: str = field(default_factory=lambda: dt.datetime.now().isoformat(timespec="seconds")) + + def to_dict(self) -> dict: + return asdict(self) + + +class PrintQueue: + """A persistent FIFO of QueueItems. + + All mutations write through to disk. Multi-process safety is best-effort + (no locking) — fine for a single daemon + occasional CLI adds. + """ + + def __init__(self, path: Path | None = None) -> None: + self.path = path or default_queue_path() + self._items: list[QueueItem] = [] + self.load() + + # ---------------------------------------------------------------- I/O + + def load(self) -> None: + if not self.path.is_file(): + self._items = [] + return + raw = json.loads(self.path.read_text() or "[]") + self._items = [QueueItem(**d) for d in raw] + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps([i.to_dict() for i in self._items], indent=2)) + + # ------------------------------------------------------------- mutations + + def add(self, item: QueueItem) -> None: + self._items.append(item) + self.save() + + def pop_next(self) -> QueueItem | None: + if not self._items: + return None + item = self._items.pop(0) + self.save() + return item + + def clear(self) -> None: + self._items.clear() + self.save() + + # --------------------------------------------------------------- views + + def list(self) -> list[QueueItem]: + return list(self._items) + + def __len__(self) -> int: + return len(self._items) diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..1ecdab7 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,106 @@ +"""Tests for the persistent print queue (issue #13).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bambu_ai.queue import PrintQueue, QueueItem, default_queue_path + + +@pytest.fixture +def queue_in_tmp(tmp_path, monkeypatch): + """Point the default queue path to a tmp_path file.""" + path = tmp_path / "queue.json" + monkeypatch.setenv("BAMBU_AI_QUEUE", str(path)) + return path + + +class TestDefaultPath: + def test_env_var_override(self, tmp_path, monkeypatch) -> None: + monkeypatch.setenv("BAMBU_AI_QUEUE", str(tmp_path / "elsewhere.json")) + assert default_queue_path() == tmp_path / "elsewhere.json" + + def test_falls_back_to_homedir(self, monkeypatch) -> None: + monkeypatch.delenv("BAMBU_AI_QUEUE", raising=False) + p = default_queue_path() + assert p.name == "queue.json" + assert ".bambu-ai" in p.parts + + +class TestPersistence: + def test_add_and_list(self, queue_in_tmp) -> None: + q = PrintQueue() + q.add(QueueItem(file="/tmp/a.3mf")) + q.add(QueueItem(file="/tmp/b.gcode", plate=2)) + assert len(q) == 2 + items = q.list() + assert items[0].file == "/tmp/a.3mf" + assert items[1].plate == 2 + + def test_round_trip_through_disk(self, queue_in_tmp) -> None: + q1 = PrintQueue() + q1.add(QueueItem(file="/tmp/persist.3mf", name="job_001.3mf")) + # New instance reads from disk. + q2 = PrintQueue() + items = q2.list() + assert len(items) == 1 + assert items[0].file == "/tmp/persist.3mf" + assert items[0].name == "job_001.3mf" + + def test_pop_next_fifo(self, queue_in_tmp) -> None: + q = PrintQueue() + q.add(QueueItem(file="first")) + q.add(QueueItem(file="second")) + first = q.pop_next() + second = q.pop_next() + empty = q.pop_next() + assert first.file == "first" + assert second.file == "second" + assert empty is None + assert len(q) == 0 + + def test_pop_persists(self, queue_in_tmp) -> None: + q1 = PrintQueue() + q1.add(QueueItem(file="a")) + q1.add(QueueItem(file="b")) + q1.pop_next() + q2 = PrintQueue() + assert len(q2) == 1 + assert q2.list()[0].file == "b" + + def test_clear(self, queue_in_tmp) -> None: + q = PrintQueue() + q.add(QueueItem(file="a")) + q.add(QueueItem(file="b")) + q.clear() + assert len(q) == 0 + # Persisted. + assert len(PrintQueue()) == 0 + + def test_load_from_missing_file_is_empty(self, queue_in_tmp) -> None: + q = PrintQueue() + assert len(q) == 0 + assert not Path(queue_in_tmp).exists() + + def test_load_from_empty_file_is_empty(self, queue_in_tmp) -> None: + Path(queue_in_tmp).parent.mkdir(parents=True, exist_ok=True) + Path(queue_in_tmp).write_text("") + q = PrintQueue() + assert len(q) == 0 + + +class TestQueueItem: + def test_added_at_is_iso_format(self) -> None: + item = QueueItem(file="x") + assert "T" in item.added_at # ISO-8601 + + def test_to_dict_round_trip(self) -> None: + item = QueueItem(file="x", name="y", plate=3, use_ams=True) + d = item.to_dict() + assert d["file"] == "x" + assert d["plate"] == 3 + # Re-construct. + item2 = QueueItem(**d) + assert item2 == item