Files
OrcaSlicer/resources/orca_plugins/BBLPrinterAgentPlugin.py
2026-07-02 17:49:36 +08:00

1244 lines
49 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["paho-mqtt"]
#
# [tool.orcaslicer.plugin]
# name = "BBL Printer Agent"
# description = "Barebones Bambu Lab printer-agent plugin."
# author = "OrcaSlicer"
# version = "0.0.1"
# type = "printer-connection"
# ///
import json
import ftplib
import queue
import select
import socket
import ssl
import sys
import threading
import uuid
from pathlib import Path
import orca
from typing import Any, Callable
try:
import paho.mqtt.client as mqtt
except ModuleNotFoundError:
mqtt = None
def _log(msg):
print(f"[BBLPrinterAgentPlugin] {msg}", file=sys.stderr, flush=True)
MQTT_CONNECT_TIMEOUT_SECONDS = 10
MQTT_KEEPALIVE_SECONDS = 60
MQTT_PORT = 1883
MQTT_SSL_PORT = 8883
FTP_SSL_PORT = 990
SSDP_GROUP = "239.255.255.250"
SSDP_PORTS = (1990, 2021)
SSDP_RECV_SIZE = 8192
BAMBU_DEFAULT_USERNAME = "bblp"
MQTT_CONNACK_MESSAGES = {
0: "accepted",
1: "unacceptable protocol version",
2: "identifier rejected",
3: "server unavailable",
4: "bad username or password",
5: "not authorized",
}
CONNECT_STATUS_OK = 0
CONNECT_STATUS_FAILED = 1
CONNECT_STATUS_LOST = 2
BAMBU_NETWORK_ERR_INVALID_HANDLE = -1
BAMBU_NETWORK_ERR_SEND_MSG_FAILED = -4
BAMBU_NETWORK_ERR_BIND_FAILED = -5
BAMBU_NETWORK_ERR_UNBIND_FAILED = -6
BAMBU_NETWORK_ERR_FILE_NOT_EXIST = -14
BAMBU_NETWORK_ERR_CANCELED = -18
BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED = -2130
BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED = -4020
BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED = -4030
BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED = -5010
PRINTING_STAGE_CREATE = 0
PRINTING_STAGE_UPLOAD = 1
PRINTING_STAGE_SENDING = 3
PRINTING_STAGE_FINISHED = 6
PRINTING_STAGE_ERROR = 7
INITIAL_PUSH_PAYLOAD = {
"pushing": {"command": "pushall"},
"info": {"command": "get_version"},
"upgrade": {"command": "get_history"},
}
class _ImplicitFTP_TLS(ftplib.FTP_TLS):
def __init__(self, *args, unwrap: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self._sock = None
self.unwrap = unwrap
def ntransfercmd(self, cmd, rest=None):
conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
if self._prot_p:
conn = self.context.wrap_socket(conn, server_hostname=self.host, session=self.sock.session)
return conn, size
@property
def sock(self):
return self._sock
@sock.setter
def sock(self, value):
if value is not None and not isinstance(value, ssl.SSLSocket):
value = self.context.wrap_socket(value)
self._sock = value
def storbinary(self, cmd, fp, blocksize=8192, callback=None, rest=None):
self.voidcmd("TYPE I")
conn = self.transfercmd(cmd, rest)
try:
while True:
buf = fp.read(blocksize)
if not buf:
break
conn.sendall(buf)
if callback:
callback(buf)
if isinstance(conn, ssl.SSLSocket) and self.unwrap:
conn.unwrap()
finally:
conn.close()
# Bambu's FTPS server may leave the control channel without the usual
# final 226 response after the data socket has been closed. At this
# point transfercmd/sendall has already succeeded, so accept the known
# non-standard completion forms instead of reporting a false upload
# failure to the UI.
old_timeout = self.sock.gettimeout() if self.sock else None
try:
if self.sock:
self.sock.settimeout(2)
return self.voidresp()
except TimeoutError:
_log(f"FTP upload completed for {cmd}; timed out waiting for final response")
return "226 Transfer complete"
except ftplib.error_reply as exc:
if str(exc).startswith("200"):
_log(f"FTP upload completed for {cmd}; accepted non-standard response: {exc}")
return str(exc)
raise
finally:
if self.sock and old_timeout is not None:
self.sock.settimeout(old_timeout)
class BBLPrinterAgentPlugin(orca.printer_agent.PrinterAgentBase):
def on_load(self):
self.on_server_err_fn: Callable[..., None] | None = None
self.on_printer_connected_fn: Callable[..., None] | None = None
self.on_subscribe_failure_fn: Callable[..., None] | None = None
self.on_message_fn: Callable[..., None] | None = None
self.on_user_message_fn: Callable[..., None] | None = None
self.on_local_connect_fn: Callable[..., None] | None = None
self.on_local_message_fn: Callable[..., None] | None = None
self.on_ssdp_msg_fn: Callable[..., None] | None = None
self.queue_on_main_fn: Callable[..., None] | None = None
self._selected_machine = ""
self._mqtt_lock = threading.RLock()
self._mqtt_client = None
self._mqtt_connected = threading.Event()
self._mqtt_dev_id = ""
self._mqtt_host = ""
self._mqtt_username = BAMBU_DEFAULT_USERNAME
self._mqtt_password = ""
self._mqtt_command_topic = ""
self._mqtt_manual_disconnect = False
self._printer_data: dict[str, Any] = {}
self._last_error = ""
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
self._sequence_id = 10000000
self._mqtt_command_queue = queue.Queue()
self._mqtt_command_worker_stop = threading.Event()
self._mqtt_command_worker = threading.Thread(target=self._mqtt_command_loop, daemon=True)
self._mqtt_command_worker.start()
self._ssdp_stop = threading.Event()
self._ssdp_thread = None
self._ssdp_sockets = []
self._ssdp_seen = {}
def get_name(self):
return "BBL Printer Agent"
def set_server_callback(self, on_server_err_fn):
self.on_server_err_fn = on_server_err_fn
return 0
def set_on_printer_connected_fn(self, on_printer_connected_fn):
self.on_printer_connected_fn = on_printer_connected_fn
return 0
def set_on_subscribe_failure_fn(self, on_subscribe_failure_fn):
self.on_subscribe_failure_fn = on_subscribe_failure_fn
return 0
def set_on_message_fn(self, on_message_fn):
self.on_message_fn = on_message_fn
return 0
def set_on_user_message_fn(self, on_user_message_fn):
self.on_user_message_fn = on_user_message_fn
return 0
def set_on_local_connect_fn(self, on_connect_fn):
self.on_local_connect_fn = on_connect_fn
return 0
def set_on_local_message_fn(self, on_message_fn):
self.on_local_message_fn = on_message_fn
return 0
def set_on_ssdp_msg_fn(self, on_ssdp_msg_fn):
self.on_ssdp_msg_fn = on_ssdp_msg_fn
return 0
def set_queue_on_main_fn(self, queue_on_main_fn):
self.queue_on_main_fn = queue_on_main_fn
return 0
def connect_printer(self, dev_id, dev_ip, username, password, use_ssl) -> int:
self._ensure_state()
dev_id = str(dev_id or "").strip()
dev_ip = str(dev_ip or "").strip()
username = str(username or BAMBU_DEFAULT_USERNAME).strip()
password = str(password or "").strip()
_log(
"connect_printer requested "
f"dev_id={dev_id or '<missing>'} host={dev_ip or '<missing>'} "
f"username={username or BAMBU_DEFAULT_USERNAME} ssl={bool(use_ssl)} "
f"password_length={len(password)}"
)
if not dev_ip:
return self._connection_failed(dev_id, "Missing printer IP address")
if mqtt is None:
return self._connection_failed(dev_id, "paho-mqtt is required for MQTT printer connections")
if not dev_id:
return self._connection_failed(dev_id, "Missing printer serial/device id")
if not password:
return self._connection_failed(dev_id, "Missing printer access code/password")
port = MQTT_SSL_PORT if use_ssl else MQTT_PORT
connected = threading.Event()
connect_result = {"rc": None}
client = self._create_mqtt_client(dev_id)
command_topic = f"device/{dev_id}/request"
report_topic = f"device/{dev_id}/report"
def on_connect(client, userdata, flags, rc, *extra):
connect_result["rc"] = self._mqtt_result_code(rc)
try:
_log(
f"MQTT on_connect dev_id={dev_id} rc={connect_result['rc']} "
f"message={MQTT_CONNACK_MESSAGES.get(connect_result['rc'], 'unknown')}"
)
if connect_result["rc"] == 0:
self._mqtt_connected.set()
client.subscribe(report_topic)
client.publish(command_topic, json.dumps(INITIAL_PUSH_PAYLOAD))
_log(f"MQTT subscribed dev_id={dev_id} topic={report_topic}; requested initial push")
finally:
connected.set()
def on_disconnect(client, userdata, *args):
reason = self._disconnect_reason(args)
with self._mqtt_lock:
is_current_client = client is self._mqtt_client
if is_current_client:
self._mqtt_connected.clear()
current_dev_id = self._mqtt_dev_id
manual_disconnect = self._mqtt_manual_disconnect
else:
current_dev_id = ""
manual_disconnect = True
_log(f"MQTT on_disconnect dev_id={current_dev_id or dev_id} reason={reason} manual={manual_disconnect}")
if self.on_local_connect_fn and current_dev_id and not manual_disconnect:
self.on_local_connect_fn(CONNECT_STATUS_LOST, current_dev_id, f"MQTT disconnected: {reason}")
def on_message(client, userdata, message):
with self._mqtt_lock:
if client is not self._mqtt_client:
return
payload = message.payload.decode("utf-8", errors="replace")
self._handle_message_payload(payload)
if self.on_local_message_fn:
self.on_local_message_fn(dev_id, payload)
def on_log(client, userdata, level, buf):
text = str(buf)
if any(token in text.lower() for token in ("connect", "ssl", "tls", "fail", "error", "refused")):
_log(f"MQTT paho log dev_id={dev_id} level={level}: {text}")
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.on_log = on_log
username = username or BAMBU_DEFAULT_USERNAME
client.username_pw_set(username, password)
if use_ssl:
client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE)
client.tls_insecure_set(True)
with self._mqtt_lock:
old_client = self._detach_mqtt_locked()
if old_client is not None:
_log("connect_printer replacing existing MQTT client")
self._stop_mqtt_client(old_client)
with self._mqtt_lock:
self._mqtt_client = client
self._mqtt_dev_id = dev_id
self._mqtt_host = dev_ip
self._mqtt_username = username
self._mqtt_password = password
self._mqtt_command_topic = command_topic
self._mqtt_manual_disconnect = False
self._printer_data = {}
self._last_error = ""
self._selected_machine = dev_id
try:
_log(f"MQTT connecting dev_id={dev_id} host={dev_ip} port={port}")
client.connect_async(dev_ip, port, keepalive=MQTT_KEEPALIVE_SECONDS)
client.loop_start()
_log(f"MQTT loop started dev_id={dev_id}; waiting up to {MQTT_CONNECT_TIMEOUT_SECONDS}s for CONNACK")
except Exception as exc:
client_to_stop = None
with self._mqtt_lock:
if self._mqtt_client is client:
client_to_stop = self._detach_mqtt_locked()
self._stop_mqtt_client(client_to_stop)
return self._connection_failed(dev_id, str(exc))
if not connected.wait(MQTT_CONNECT_TIMEOUT_SECONDS):
client_to_stop = None
with self._mqtt_lock:
if self._mqtt_client is client:
client_to_stop = self._detach_mqtt_locked()
self._stop_mqtt_client(client_to_stop)
return self._connection_failed(dev_id, f"Timed out connecting to MQTT broker at {dev_ip}:{port}")
with self._mqtt_lock:
is_current_client = client is self._mqtt_client
if not is_current_client:
return self._connection_failed(dev_id, "MQTT client was replaced before connection completed")
rc = connect_result.get("rc")
if rc == 0:
if self.on_local_connect_fn:
self.on_local_connect_fn(CONNECT_STATUS_OK, dev_id, "0")
if self.on_printer_connected_fn:
self.on_printer_connected_fn(report_topic)
_log(f"connect_printer succeeded dev_id={dev_id} host={dev_ip} port={port}")
return 0
reason = MQTT_CONNACK_MESSAGES.get(rc, "unknown")
message = (
f"MQTT broker rejected connection with code {rc} ({reason}); "
f"check LAN access code, username={username}, ssl={bool(use_ssl)}"
)
client_to_stop = None
with self._mqtt_lock:
if self._mqtt_client is client:
client_to_stop = self._detach_mqtt_locked()
self._stop_mqtt_client(client_to_stop)
return self._connection_failed(dev_id, message, callback_message=str(rc))
def disconnect_printer(self) -> int:
self._ensure_state()
with self._mqtt_lock:
client = self._detach_mqtt_locked()
self._stop_mqtt_client(client)
return 0
def start_discovery(self, start=True, sending=False) -> bool:
self._ensure_state()
if start:
if self._ssdp_thread and self._ssdp_thread.is_alive():
_log("SSDP discovery already running")
return True
self._ssdp_stop.clear()
self._ssdp_seen = {}
_log(f"Starting SSDP discovery sending={bool(sending)} ports={SSDP_PORTS}")
self._ssdp_thread = threading.Thread(
target=self._ssdp_loop,
args=(bool(sending),),
daemon=True,
)
self._ssdp_thread.start()
return True
if not self._ssdp_sockets and not (self._ssdp_thread and self._ssdp_thread.is_alive()):
return True
_log("Stopping SSDP discovery")
self._ssdp_stop.set()
for sock in list(self._ssdp_sockets):
try:
sock.close()
except OSError:
pass
self._ssdp_sockets = []
return True
def install_device_cert(self, dev_id, lan_only=True):
return
def get_agent_info(self):
return orca.printer_agent.AgentInfo(
"bbl",
"Bambu Lab",
"0.0.1",
"Barebones Bambu Lab printer-agent plugin",
)
def get_user_selected_machine(self):
self._ensure_state()
return self._selected_machine
def set_user_selected_machine(self, dev_id):
self._ensure_state()
self._selected_machine = str(dev_id or "")
return 0
def send_message(self, dev_id, json_str, qos=0, flag=0):
return self.send_message_to_printer(dev_id, json_str, qos, flag)
def send_message_to_printer(self, dev_id, json_str, qos=0, flag=0):
self._ensure_state()
del dev_id, flag
if self._publish_payload(json_str, qos=max(0, min(2, self._int_or_default(qos, 0)))):
return 0
self._last_code = BAMBU_NETWORK_ERR_SEND_MSG_FAILED
return self._last_code
def check_cert(self):
return 0
def ping_bind(self, ping_code):
self._last_error = "Printer binding is not supported by this plugin"
self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED
return self._last_code
def bind_detect(self, dev_ip, sec_link, detect):
detect.result_msg = "Printer binding is not supported by this plugin"
detect.command = "bind_detect"
detect.dev_id = ""
detect.model_id = ""
detect.dev_name = ""
detect.version = ""
detect.bind_state = "unsupported"
detect.connect_type = "lan"
self._last_error = detect.result_msg
self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED
return self._last_code
def bind(self, dev_ip, dev_id, sec_link, timezone, improved, update_fn):
del dev_ip, dev_id, sec_link, timezone, improved
self._last_error = "Printer binding is not supported by this plugin"
self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED
self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error)
return self._last_code
def unbind(self, dev_id):
del dev_id
self._last_error = "Printer unbinding is not supported by this plugin"
self._last_code = BAMBU_NETWORK_ERR_UNBIND_FAILED
return self._last_code
def request_bind_ticket(self):
return (BAMBU_NETWORK_ERR_BIND_FAILED, "")
def start_print(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None):
del wait_fn
return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn)
def start_local_print(self, params=None, update_fn=None, cancel_fn=None):
return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED, update_fn, cancel_fn)
def start_local_print_with_record(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None):
del wait_fn
return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn)
def start_sdcard_print(self, params=None, update_fn=None, cancel_fn=None):
self._ensure_state()
del update_fn
if self._cancel_requested(cancel_fn):
self._last_error = "Cancelled"
self._last_code = BAMBU_NETWORK_ERR_CANCELED
return self._last_code
if params is None:
self._last_error = "Missing print params"
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
return self._last_code
try:
payload = self._build_sdcard_project_file_payload(params)
except Exception as exc:
self._last_error = str(exc)
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
return self._last_code
if self._queue_payload(payload):
return 0
self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED
return self._last_code
def start_send_gcode_to_sdcard(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None):
self._ensure_state()
del wait_fn
if params is None:
self._last_error = "Missing print params"
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
return self._last_code
if self._upload_print_file(params, update_fn, cancel_fn):
return 0
return self._last_code or BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED
def get_filament_sync_mode(self):
return orca.printer_agent.FilamentSyncMode.Subscription
def fetch_filament_info(self, dev_id):
del dev_id
return self._publish_payload({"pushing": {"command": "pushall"}}, require_connected=False)
def send_gcode(self, gcode, sequence_id=None):
self._ensure_state()
gcode = str(gcode or "").strip()
if not gcode:
self._last_error = "Missing G-code command"
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
return self._last_code
payload = {
"print": {
"command": "gcode_line",
"param": gcode,
"sequence_id": str(sequence_id or self._next_sequence_id()),
}
}
if self._queue_payload(payload):
return 0
self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED
return self._last_code
def send_command(self, request_json):
return self._send_command_impl(request_json, None, None)
def send_command_with_progress(self, request_json, update_fn, cancel_fn):
return self._send_command_impl(request_json, update_fn, cancel_fn)
def _send_command_impl(self, request_json, update_fn=None, cancel_fn=None):
try:
request = json.loads(request_json)
except json.JSONDecodeError:
return json.dumps({
"status": "error",
"message": "Invalid printer-agent request",
})
command = request.get("command")
payload = request.get("payload") or {}
if not isinstance(payload, dict):
return json.dumps({
"status": "error",
"message": "Invalid printer-agent payload",
})
if command == "connect_printer":
dev_id = request.get("dev_id") or payload.get("dev_id") or ""
result = self.connect_printer(
dev_id,
payload.get("dev_ip") or "",
payload.get("username") or "",
payload.get("password") or "",
self._as_bool(payload.get("use_ssl")),
)
if result == 0:
return json.dumps({"status": "ok"})
return json.dumps({"status": "error", "message": self._last_error or "MQTT connection failed"})
if command == "disconnect_printer":
self.disconnect_printer()
return json.dumps({"status": "ok"})
if command == "get_filament_info":
self._queue_payload({"pushing": {"command": "pushall"}}, require_connected=False)
return json.dumps(self._build_filament_info())
if command in ("send_message", "send_message_to_printer"):
outbound = payload.get("message") if "message" in payload else payload
qos = self._int_or_default(payload.get("qos"), 0)
qos = max(0, min(2, qos))
return self._status_response(self._queue_payload(outbound, qos=qos))
if command == "start_local_print_with_record":
return self._status_response(
self._start_local_print(payload, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn)
)
if command == "start_local_print":
return self._status_response(
self._start_local_print(payload, BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED, update_fn, cancel_fn)
)
if command == "start_send_gcode_to_sdcard":
result = 0 if self._upload_print_file(payload, update_fn, cancel_fn) else self._last_code
if result is None:
result = BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED
return self._status_response(result)
if command == "start_sdcard_print":
return self._status_response(self.start_sdcard_print(payload))
if command == "send_gcode":
gcode = payload.get("gcode") or payload.get("param") or payload.get("message")
return self._status_response(self.send_gcode(gcode, payload.get("sequence_id")))
if self._looks_like_bambu_payload(request):
return self._status_response(self._queue_payload(request, qos=0))
return json.dumps({
"status": "error",
"message": f"Unsupported printer-agent command: {command or '<missing>'}",
})
def on_unload(self):
self.start_discovery(False, False)
self._mqtt_command_worker_stop.set()
self.disconnect_printer()
if self._mqtt_command_worker and self._mqtt_command_worker.is_alive():
self._mqtt_command_worker.join(timeout=1.0)
self.on_server_err_fn: Callable[..., None] | None = None
self.on_printer_connected_fn: Callable[..., None] | None = None
self.on_subscribe_failure_fn: Callable[..., None] | None = None
self.on_message_fn: Callable[..., None] | None = None
self.on_user_message_fn: Callable[..., None] | None = None
self.on_local_connect_fn: Callable[..., None] | None = None
self.on_local_message_fn: Callable[..., None] | None = None
self.on_ssdp_msg_fn: Callable[..., None] | None = None
self.queue_on_main_fn: Callable[..., None] | None = None
def _ensure_state(self):
if hasattr(self, "_mqtt_lock"):
return
self.on_load()
def _create_mqtt_client(self, dev_id):
client_id = f"OrcaSlicer-{uuid.uuid4().hex[:12]}"
_log(f"MQTT creating client dev_id={dev_id or '<missing>'} client_id={client_id}")
kwargs = {
"client_id": client_id,
"clean_session": True,
"protocol": mqtt.MQTTv311,
}
callback_api_version = getattr(mqtt, "CallbackAPIVersion", None)
if callback_api_version is not None:
kwargs["callback_api_version"] = callback_api_version.VERSION1
return mqtt.Client(**kwargs)
def _detach_mqtt_locked(self):
client = self._mqtt_client
self._mqtt_manual_disconnect = True
self._mqtt_client = None
self._mqtt_dev_id = ""
self._mqtt_host = ""
self._mqtt_command_topic = ""
self._mqtt_connected.clear()
return client
def _stop_mqtt_client(self, client):
if client is None:
return
try:
client.disconnect()
except Exception:
pass
try:
client.loop_stop()
except Exception:
pass
def _stop_mqtt_client_async(self, client):
if client is None:
return
threading.Thread(target=self._stop_mqtt_client, args=(client,), daemon=True).start()
def _ssdp_loop(self, sending=False):
sockets = []
try:
for port in SSDP_PORTS:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
except OSError:
pass
sock.bind(("", port))
membership = socket.inet_aton(SSDP_GROUP) + socket.inet_aton("0.0.0.0")
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
sockets.append(sock)
_log(f"SSDP listening on {SSDP_GROUP}:{port}")
except OSError as exc:
self._last_error = f"SSDP listen failed on port {port}: {exc}"
_log(self._last_error)
self._ssdp_sockets = sockets
if not sockets:
_log("SSDP discovery has no listening sockets")
return
if sending:
_log("Sending SSDP M-SEARCH")
for port in SSDP_PORTS:
request = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {SSDP_GROUP}:{port}\r\n"
'MAN: "ssdp:discover"\r\n'
"MX: 1\r\n"
"ST: ssdp:all\r\n"
"\r\n"
).encode("utf-8")
for sock in sockets:
try:
sock.sendto(request, (SSDP_GROUP, port))
except OSError as exc:
_log(f"SSDP M-SEARCH send failed port={port}: {exc}")
while not self._ssdp_stop.is_set():
try:
readable, _, _ = select.select(sockets, [], [], 1.0)
except OSError as exc:
_log(f"SSDP select failed: {exc}")
break
for sock in readable:
try:
packet, addr = sock.recvfrom(SSDP_RECV_SIZE)
except OSError as exc:
_log(f"SSDP recv failed: {exc}")
continue
text = packet.decode("utf-8", errors="replace")
headers = {}
for line in text.replace("\r\n", "\n").split("\n"):
if ":" not in line:
continue
key, value = line.split(":", 1)
key = "".join(ch for ch in key.lower() if ch.isalnum())
value = value.strip()
headers[key] = value
for suffix in ("bambucom", "bambulabcom"):
if key.endswith(suffix):
headers.setdefault(key[:-len(suffix)], value)
def header(*names, default=""):
for name in names:
key = "".join(ch for ch in name.lower() if ch.isalnum())
value = headers.get(key)
if value:
return value
return default
dev_id = header("dev_id", "devid", "serial", "serial_number", "usn")
if dev_id.startswith("uuid:"):
dev_id = dev_id[5:]
if "::" in dev_id:
dev_id = dev_id.split("::", 1)[0]
if not dev_id:
continue
dev_name = header("dev_name", "devname", "friendly_name", "name", default=dev_id)
device = {
"dev_name": dev_name,
"dev_id": dev_id,
"dev_ip": addr[0],
"dev_type": header("dev_type", "dev_model", "devmodel", "model", "printer_type", default=""),
"dev_signal": header("dev_signal", "devsignal", "signal", default="0"),
"connect_type": header("connect_type", "connection_type", "dev_connect", "devconnect", default="lan"),
"bind_state": header("bind_state", "dev_bind", "devbind", "bind", default="free"),
"sec_link": header("sec_link", "dev_sec_link", "devseclink", "secure_link", default=""),
"ssdp_version": header("ssdp_version", "dev_version", "devversion", "version", default=""),
"connection_name": header("connection_name", default=dev_name),
}
seen_key = device["dev_id"]
seen_value = (
device["dev_ip"],
device["dev_name"],
device["dev_type"],
device["connect_type"],
device["bind_state"],
device["sec_link"],
device["ssdp_version"],
)
if self._ssdp_seen.get(seen_key) != seen_value:
self._ssdp_seen[seen_key] = seen_value
_log(f"SSDP discovered printer {json.dumps(device, sort_keys=True)}")
if self.on_ssdp_msg_fn:
self.on_ssdp_msg_fn(json.dumps(device))
finally:
for sock in sockets:
try:
sock.close()
except OSError:
pass
self._ssdp_sockets = []
_log("SSDP discovery loop exited")
def _connection_failed(self, dev_id, message, callback_message=None) -> int:
self._last_error = message
_log(f"connect_printer failed dev_id={dev_id or '<missing>'}: {message}")
if self.on_local_connect_fn:
self.on_local_connect_fn(CONNECT_STATUS_FAILED, dev_id, callback_message or message)
return -1
def _queue_payload(self, payload, qos=1, require_connected=True) -> bool:
try:
self._mqtt_command_queue.put_nowait((payload, qos, require_connected))
return True
except Exception as exc:
self._last_error = str(exc)
return False
def _mqtt_command_loop(self):
while not self._mqtt_command_worker_stop.is_set():
try:
payload, qos, require_connected = self._mqtt_command_queue.get(timeout=0.25)
except queue.Empty:
continue
try:
if not self._publish_payload(payload, qos=qos, require_connected=require_connected):
_log(f"MQTT queued command publish failed: {self._last_error}")
finally:
self._mqtt_command_queue.task_done()
def _publish_payload(self, payload, qos=1, require_connected=True) -> bool:
if isinstance(payload, str):
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
with self._mqtt_lock:
client = self._mqtt_client
topic = self._mqtt_command_topic
connected = self._mqtt_connected.is_set()
if client is None or not topic:
self._last_error = "MQTT client is not connected"
return False
if require_connected and not connected:
self._last_error = "MQTT client is not connected"
return False
try:
message = payload if isinstance(payload, str) else json.dumps(payload)
info = client.publish(topic, message, qos=qos)
if getattr(info, "rc", 0) != 0:
self._last_error = f"MQTT publish failed with code {info.rc}"
return False
return True
except Exception as exc:
self._last_error = str(exc)
return False
def _handle_message_payload(self, payload: str):
try:
doc = json.loads(payload)
except json.JSONDecodeError:
return
if not isinstance(doc, dict):
return
with self._mqtt_lock:
self._merge_printer_data(doc)
def _merge_printer_data(self, doc: dict[str, Any]):
for key, value in doc.items():
if isinstance(value, dict) and isinstance(self._printer_data.get(key), dict):
self._printer_data[key].update(value)
else:
self._printer_data[key] = value
def _build_filament_info(self) -> dict[str, Any]:
with self._mqtt_lock:
data = json.loads(json.dumps(self._printer_data))
print_data = data.get("print", {})
if not isinstance(print_data, dict):
return {"units": [], "external": []}
return {
"units": self._build_ams_units(print_data.get("ams", {})),
"external": self._build_external_filaments(print_data.get("vt_tray")),
}
def _build_ams_units(self, ams_data) -> list[dict[str, Any]]:
if not isinstance(ams_data, dict):
return []
units = []
for unit_index, unit in enumerate(ams_data.get("ams", []) or []):
if not isinstance(unit, dict):
continue
unit_id = str(unit.get("id", unit_index))
slots = []
for slot_index, tray in enumerate(unit.get("tray", []) or []):
if not isinstance(tray, dict):
continue
slots.append(self._convert_filament_tray(tray, slot_index))
units.append({
"id": unit_id,
"type": self._ams_type_name(unit),
"extruder": self._int_or_default(unit.get("ext_id"), 0),
"temperature": self._float_or_default(unit.get("temp"), 0.0),
"humidity_percent": self._int_or_default(unit.get("humidity_raw"), -1),
"dry_time_min": self._int_or_default(unit.get("dry_time"), 0),
"slots": slots,
})
return units
def _build_external_filaments(self, vt_tray) -> list[dict[str, Any]]:
if not isinstance(vt_tray, dict):
return []
return [self._convert_filament_tray(vt_tray, 0, extruder=0)]
def _convert_filament_tray(self, tray, index, extruder=None) -> dict[str, Any]:
color = self._normalize_color(tray.get("tray_color") or tray.get("color") or "00000000")
material = str(tray.get("tray_type") or tray.get("type") or "")
preset_id = str(tray.get("tray_info_idx") or tray.get("preset_id") or "")
converted = {
"index": self._int_or_default(tray.get("id"), index),
"loaded": bool(material or preset_id or tray.get("n")),
"material": material,
"preset_id": preset_id,
"color": color,
"nozzle_temp_min": self._int_or_default(tray.get("nozzle_temp_min"), 0),
"nozzle_temp_max": self._int_or_default(tray.get("nozzle_temp_max"), 0),
"remain_percent": self._int_or_default(tray.get("remain"), -1),
"k": self._float_or_default(tray.get("k"), 0.0),
}
if extruder is not None:
converted["extruder"] = extruder
diameter = self._float_or_none(tray.get("tray_diameter") or tray.get("diameter"))
if diameter is not None:
converted["diameter_mm"] = diameter
weight = self._int_or_none(tray.get("tray_weight") or tray.get("weight"))
if weight is not None:
converted["weight_g"] = weight
return converted
def _start_local_print(self, params, upload_error_code, update_fn=None, cancel_fn=None):
self._ensure_state()
if params is None:
self._last_error = "Missing print params"
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
return self._last_code
if self._cancel_requested(cancel_fn):
self._last_error = "Cancelled"
self._last_code = BAMBU_NETWORK_ERR_CANCELED
return self._last_code
self._update_progress(update_fn, PRINTING_STAGE_CREATE, 0, "Preparing...")
if not self._upload_print_file(params, update_fn, cancel_fn, upload_error_code):
self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error)
return self._last_code
if self._cancel_requested(cancel_fn):
self._last_error = "Cancelled"
self._last_code = BAMBU_NETWORK_ERR_CANCELED
return self._last_code
try:
payload = self._build_project_file_payload(params)
except Exception as exc:
self._last_error = str(exc)
self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE
self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error)
return self._last_code
self._update_progress(update_fn, PRINTING_STAGE_SENDING, 0, "Starting print...")
if not self._queue_payload(payload):
self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED
self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error)
return self._last_code
self._update_progress(update_fn, PRINTING_STAGE_FINISHED, 100, "1")
return 0
def _build_project_file_payload(self, params) -> dict[str, Any]:
filename = self._remote_print_name(params)
if not filename:
raise ValueError("Missing print filename")
plate = self._param(params, "plate_index", 1)
try:
plate = int(plate)
except (TypeError, ValueError):
plate = 1
ams_mapping = self._json_or_default(self._param(params, "ams_mapping"), [0])
if not isinstance(ams_mapping, list):
ams_mapping = [ams_mapping]
return {
"print": {
"command": "project_file",
"param": self._param(params, "plate_location") or f"Metadata/plate_{plate}.gcode",
"file": filename,
"bed_leveling": self._as_bool(self._param(params, "task_bed_leveling", True)),
"bed_type": self._param(params, "task_bed_type", "textured_plate"),
"flow_cali": self._as_bool(self._param(params, "task_flow_cali", True)),
"vibration_cali": self._as_bool(self._param(params, "task_vibration_cali", True)),
"url": self._param(params, "url") or f"ftp:///{filename}",
"layer_inspect": self._as_bool(self._param(params, "task_layer_inspect", False)),
"sequence_id": str(self._param(params, "sequence_id") or self._next_sequence_id()),
"timelapse": self._as_bool(self._param(params, "task_record_timelapse", False)),
"use_ams": self._as_bool(self._param(params, "task_use_ams", True)),
"ams_mapping": ams_mapping,
"skip_objects": self._json_or_default(self._param(params, "skip_objects"), None),
}
}
def _build_sdcard_project_file_payload(self, params) -> dict[str, Any]:
remote = (
self._param(params, "dst_file")
or self._param(params, "ftp_file")
or self._param(params, "file")
or self._param(params, "filename")
)
if not remote:
raise ValueError("Missing printer storage file")
remote = str(remote)
if remote.startswith("file://"):
file_url = remote
elif remote.startswith("/"):
file_url = f"file://{remote}"
else:
file_url = f"file:///{remote}"
payload_params = dict(params) if isinstance(params, dict) else {}
payload_params["ftp_file"] = Path(remote).name
payload_params["url"] = self._param(params, "url") or file_url
return self._build_project_file_payload(payload_params)
def _upload_print_file(self, params, update_fn=None, cancel_fn=None, error_code=BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED) -> bool:
source = (
self._param(params, "local_path")
or self._param(params, "source")
or self._param(params, "filename")
or self._param(params, "dst_file")
)
if not source:
self._last_error = "Missing local file path"
self._last_code = BAMBU_NETWORK_ERR_FILE_NOT_EXIST
return False
path = Path(source)
if not path.is_file():
self._last_error = f"Local file does not exist: {source}"
self._last_code = BAMBU_NETWORK_ERR_FILE_NOT_EXIST
return False
remote = self._remote_print_name(params, path)
host = self._param(params, "dev_ip") or self._mqtt_host
username = self._param(params, "username") or self._mqtt_username or BAMBU_DEFAULT_USERNAME
password = self._param(params, "password") or self._mqtt_password
use_ssl = self._as_bool(self._param(params, "use_ssl_for_ftp", True))
port = FTP_SSL_PORT if use_ssl else 21
if not host or not password:
self._last_error = "Missing FTP host or password"
self._last_code = error_code
return False
try:
self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, 0, "Uploading...")
ftp = _ImplicitFTP_TLS() if use_ssl else ftplib.FTP()
_log(
"FTP upload starting "
f"host={host} port={port} ssl={use_ssl} source={path} remote={remote} "
f"size={path.stat().st_size}"
)
ftp.connect(host=host, port=port, timeout=30)
ftp.login(username, password)
if use_ssl:
ftp.prot_p()
total = path.stat().st_size
uploaded = 0
def on_upload(block):
nonlocal uploaded
if self._cancel_requested(cancel_fn):
raise InterruptedError("Cancelled")
uploaded += len(block)
if total > 0:
self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, min(100, int(uploaded * 100 / total)), "Uploading...")
with path.open("rb") as fh:
ftp.storbinary(f"STOR {remote}", fh, blocksize=32768, callback=on_upload)
ftp.close()
self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, 100, "Uploading...")
_log(f"FTP upload succeeded remote={remote} bytes={uploaded}")
return True
except InterruptedError as exc:
self._last_error = str(exc)
self._last_code = BAMBU_NETWORK_ERR_CANCELED
_log(f"FTP upload cancelled remote={remote}: {exc}")
return False
except Exception as exc:
self._last_error = str(exc)
self._last_code = error_code
_log(f"FTP upload failed remote={remote} code={error_code}: {type(exc).__name__}: {exc}")
return False
def _remote_print_name(self, params, source_path: Path | None = None) -> str:
remote = self._param(params, "ftp_file") or self._param(params, "file")
if remote:
return Path(str(remote)).name
candidate = self._param(params, "filename") or self._param(params, "dst_file") or self._param(params, "source") or self._param(params, "local_path")
if candidate:
return Path(str(candidate)).name
project_name = self._param(params, "project_name")
if project_name:
return Path(str(project_name)).name
return source_path.name if source_path else ""
def _status_response(self, result):
if isinstance(result, bool):
result = 0 if result else (self._last_code or BAMBU_NETWORK_ERR_INVALID_HANDLE)
if result == 0:
return json.dumps({"status": "ok"})
status = "cancelled" if result == BAMBU_NETWORK_ERR_CANCELED else "error"
return json.dumps({
"status": status,
"code": result,
"message": self._last_error or "operation failed",
})
@staticmethod
def _looks_like_bambu_payload(request) -> bool:
return isinstance(request, dict) and any(key in request for key in ("print", "system", "pushing", "info", "upgrade", "xcam", "camera"))
@staticmethod
def _disconnect_reason(args) -> int:
if not args:
return 0
if len(args) >= 2:
return BBLPrinterAgentPlugin._mqtt_result_code(args[1])
return BBLPrinterAgentPlugin._mqtt_result_code(args[0])
@staticmethod
def _ams_type_name(unit) -> str:
ams_type = str(unit.get("ams_type", unit.get("type", "ams"))).lower()
if ams_type in {"2", "ams_lite", "ams-lite"}:
return "ams_lite"
if ams_type in {"3", "n3f"}:
return "n3f"
if ams_type in {"4", "n3s"}:
return "n3s"
return "ams"
@staticmethod
def _normalize_color(value) -> str:
color = str(value or "").strip().lstrip("#")
if not color:
return "00000000"
if len(color) == 6:
color += "FF"
return color.upper()
@staticmethod
def _param(params, key, default=None):
if isinstance(params, dict):
return params.get(key, default)
return getattr(params, key, default)
def _next_sequence_id(self):
with self._mqtt_lock:
self._sequence_id += 1
return self._sequence_id
@staticmethod
def _update_progress(update_fn, stage, code, info):
if not update_fn:
return
try:
update_fn(stage, code, info)
except Exception as exc:
_log(f"progress callback failed: {exc}")
@staticmethod
def _cancel_requested(cancel_fn) -> bool:
if not cancel_fn:
return False
try:
return bool(cancel_fn())
except Exception as exc:
_log(f"cancel callback failed: {exc}")
return False
@staticmethod
def _json_or_default(value, default):
if value in (None, ""):
return default
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return default
return value
@staticmethod
def _int_or_default(value, default):
result = BBLPrinterAgentPlugin._int_or_none(value)
return default if result is None else result
@staticmethod
def _int_or_none(value):
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _float_or_default(value, default):
result = BBLPrinterAgentPlugin._float_or_none(value)
return default if result is None else result
@staticmethod
def _float_or_none(value):
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _mqtt_result_code(rc) -> int:
try:
return int(rc)
except (TypeError, ValueError):
value = getattr(rc, "value", None)
return int(value) if value is not None else -1
@staticmethod
def _as_bool(value) -> bool:
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@orca.plugin
class BBLPrinterAgentPackage(orca.base):
def register_capabilities(self):
orca.register_capability(BBLPrinterAgentPlugin)