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 @@ -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)'
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 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

Expand Down
159 changes: 159 additions & 0 deletions src/bambu_ai/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`.")
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)."""
Expand Down Expand Up @@ -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")
Expand Down
85 changes: 85 additions & 0 deletions src/bambu_ai/queue.py
Original file line number Diff line number Diff line change
@@ -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)
106 changes: 106 additions & 0 deletions tests/test_queue.py
Original file line number Diff line number Diff line change
@@ -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
Loading