from __future__ import annotations import collections from dataclasses import dataclass, field import math from pathlib import Path import queue import re import threading import time from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView from octoprint_bambu_printer.printer.file_system.file_info import FileInfo from octoprint_bambu_printer.printer.print_job import PrintJob from pybambu import BambuClient, commands import logging import logging.handlers import json import paho.mqtt.client as mqtt from octoprint.util import RepeatedTimer from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState from octoprint_bambu_printer.printer.states.idle_state import IdleState from .printer_serial_io import PrinterSerialIO from .states.paused_state import PausedState from .states.printing_state import PrintingState from .gcode_executor import GCodeExecutor from .file_system.remote_sd_card_file_list import RemoteSDCardFileList AMBIENT_TEMPERATURE: float = 21.3 @dataclass class BambuPrinterTelemetry: temp: list[float] = field(default_factory=lambda: [AMBIENT_TEMPERATURE]) targetTemp: list[float] = field(default_factory=lambda: [0.0]) bedTemp: float = AMBIENT_TEMPERATURE bedTargetTemp = 0.0 hasChamber: bool = False chamberTemp: float = AMBIENT_TEMPERATURE chamberTargetTemp: float = 0.0 lastTempAt: float = time.monotonic() firmwareName: str = "Bambu" extruderCount: int = 1 class BambuMqttBridgeClient: """ Implements compatible interface with BambuClient but uses Paho MQTT to connect to a MQTT broker that bridges Bambu topics """ def __init__(self, device_type, serial, host, mqtt_port=1883, **kwargs): self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuMqttBridge") self._device_type = device_type self._serial = serial self._host = host self._mqtt_port = mqtt_port self.connected = False self._mqtt_client = mqtt.Client() self._device_data = self._create_empty_device_data() self._callbacks = {} # Setup callbacks self._mqtt_client.on_connect = self._on_connect self._mqtt_client.on_message = self._on_message self._mqtt_client.on_disconnect = self._on_disconnect def _create_empty_device_data(self): """Creates empty device data structure compatible with BambuClient""" from types import SimpleNamespace # Create basic structure matching BambuClient device = SimpleNamespace() device.print_job = SimpleNamespace() device.print_job.gcode_state = "IDLE" device.temperature = SimpleNamespace() device.temperature.nozzle_temp = AMBIENT_TEMPERATURE device.temperature.target_nozzle_temp = 0.0 device.temperature.bed_temp = AMBIENT_TEMPERATURE device.temperature.target_bed_temp = 0.0 device.temperature.chamber_temp = AMBIENT_TEMPERATURE device.hms = SimpleNamespace() device.hms.errors = {"Count": 0} return device def _on_connect(self, client, userdata, flags, rc): if rc == 0: self._log.info(f"Connected to MQTT broker at {self._host}:{self._mqtt_port}") self.connected = True # Subscribe to Bambu topics topic_base = f"device/{self._device_type}/{self._serial}" self._mqtt_client.subscribe(f"{topic_base}/report") self._mqtt_client.subscribe(f"{topic_base}/report_hms") if 'callback' in self._callbacks: self._callbacks['callback']("event_printer_data_update") if hasattr(self, 'on_connect') and callable(self.on_connect): self.on_connect(client, userdata, flags, rc) else: self._log.error(f"Failed to connect to MQTT broker, return code: {rc}") def _on_disconnect(self, client, userdata, rc): self._log.warning(f"Disconnected from MQTT broker with code: {rc}") self.connected = False if hasattr(self, 'on_disconnect') and callable(self.on_disconnect): self.on_disconnect(client, userdata, rc) def _on_message(self, client, userdata, msg): try: payload = json.loads(msg.payload.decode('utf-8')) self._log.debug(f"Received message on topic {msg.topic}: {payload}") if msg.topic.endswith('/report'): self._process_report_message(payload) if 'callback' in self._callbacks: self._callbacks['callback']("event_printer_data_update") elif msg.topic.endswith('/report_hms'): self._process_hms_message(payload) if 'callback' in self._callbacks: self._callbacks['callback']("event_hms_errors") except json.JSONDecodeError: self._log.error(f"Failed to decode JSON from message: {msg.payload}") except Exception as e: self._log.error(f"Error processing message: {str(e)}") def _process_report_message(self, data): """Process printer status report messages""" if 'print' in data and 'gcode_state' in data['print']: self._device_data.print_job.gcode_state = data['print']['gcode_state'] if 'temperature' in data: temp = self._device_data.temperature temp_data = data['temperature'] if 'nozzle_temp' in temp_data: temp.nozzle_temp = temp_data['nozzle_temp'] if 'target_nozzle_temp' in temp_data: temp.target_nozzle_temp = temp_data['target_nozzle_temp'] if 'bed_temp' in temp_data: temp.bed_temp = temp_data['bed_temp'] if 'target_bed_temp' in temp_data: temp.target_bed_temp = temp_data['target_bed_temp'] if 'chamber_temp' in temp_data: temp.chamber_temp = temp_data['chamber_temp'] def _process_hms_message(self, data): """Process HMS error messages""" if 'hms' in data: error_count = 0 hms_errors = {"Count": 0} for error in data['hms']: error_count += 1 hms_errors[f"{error_count}-Error"] = error['msg'] hms_errors["Count"] = error_count self._device_data.hms.errors = hms_errors def connect(self, callback=None): """Connect to MQTT broker""" if callback: self._callbacks['callback'] = callback try: self._mqtt_client.connect(self._host, self._mqtt_port) self._mqtt_client.loop_start() return True except Exception as e: self._log.error(f"Failed to connect to MQTT broker: {str(e)}") return False def disconnect(self): """Disconnect from MQTT broker""" if self.connected: self._mqtt_client.loop_stop() self._mqtt_client.disconnect() self.connected = False def get_device(self): """Returns device data structure""" return self._device_data def publish(self, command): """Publishes command to device""" if not self.connected: return False try: topic_base = f"device/{self._device_type}/{self._serial}" if 'print' in command and 'param' in command['print']: # Assuming commands go to command topic message = json.dumps(command) self._mqtt_client.publish(f"{topic_base}/cmd", message) return True except Exception as e: self._log.error(f"Failed to publish command: {str(e)}") return False # noinspection PyBroadException class BambuVirtualPrinter: gcode_executor = GCodeExecutor() def __init__( self, settings, printer_profile_manager, data_folder, serial_log_handler=None, read_timeout=5.0, faked_baudrate=115200, ): self._settings = settings self._printer_profile_manager = printer_profile_manager self._faked_baudrate = faked_baudrate self._data_folder = data_folder self._last_hms_errors = None self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") self._state_idle = IdleState(self) self._state_printing = PrintingState(self) self._state_paused = PausedState(self) self._current_state = self._state_idle self._running = True self._print_status_reporter = None self._print_temp_reporter = None self._printer_thread = threading.Thread( target=self._printer_worker, name="octoprint.plugins.bambu_printer.printer_state", ) self._state_change_queue = queue.Queue() self._current_print_job: PrintJob | None = None self._serial_io = PrinterSerialIO( handle_command_callback=self._process_gcode_serial_command, settings=settings, serial_log_handler=serial_log_handler, read_timeout=read_timeout, write_timeout=10.0, ) self._telemetry = BambuPrinterTelemetry() self._telemetry.hasChamber = printer_profile_manager.get_current().get( "heatedChamber" ) self.file_system = RemoteSDCardFileList(settings) self._selected_project_file: FileInfo | None = None self._project_files_view = ( CachedFileView(self.file_system, on_update=self._list_cached_project_files) .with_filter("", ".3mf") .with_filter("cache/", ".3mf") ) self._serial_io.start() self._printer_thread.start() self._bambu_client: BambuClient = self._create_client_connection_async() @property def bambu_client(self): return self._bambu_client @property def is_running(self): return self._running @property def current_state(self): return self._current_state @property def current_print_job(self): return self._current_print_job @current_print_job.setter def current_print_job(self, value): self._current_print_job = value @property def selected_file(self): return self._selected_project_file @property def has_selected_file(self): return self._selected_project_file is not None @property def timeout(self): return self._serial_io._read_timeout @timeout.setter def timeout(self, value): self._log.debug(f"Setting read timeout to {value}s") self._serial_io._read_timeout = value @property def write_timeout(self): return self._serial_io._write_timeout @write_timeout.setter def write_timeout(self, value): self._log.debug(f"Setting write timeout to {value}s") self._serial_io._write_timeout = value @property def port(self): return "BAMBU" @property def baudrate(self): return self._faked_baudrate @property def project_files(self): return self._project_files_view def change_state(self, new_state: APrinterState): self._state_change_queue.put(new_state) def new_update(self, event_type): if event_type == "event_hms_errors": self._update_hms_errors() elif event_type == "event_printer_data_update": self._update_printer_info() def _update_printer_info(self): device_data = self.bambu_client.get_device() print_job_state = device_data.print_job.gcode_state temperatures = device_data.temperature self.lastTempAt = time.monotonic() self._telemetry.temp[0] = temperatures.nozzle_temp self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp self._telemetry.bedTemp = temperatures.bed_temp self._telemetry.bedTargetTemp = temperatures.target_bed_temp self._telemetry.chamberTemp = temperatures.chamber_temp self._log.debug(f"Received printer state update: {print_job_state}") if ( print_job_state == "IDLE" or print_job_state == "FINISH" or print_job_state == "FAILED" ): self.change_state(self._state_idle) elif print_job_state == "RUNNING" or print_job_state == "PREPARE": self.change_state(self._state_printing) elif print_job_state == "PAUSE": self.change_state(self._state_paused) else: self._log.warn(f"Unknown print job state: {print_job_state}") def _update_hms_errors(self): bambu_printer = self.bambu_client.get_device() if ( bambu_printer.hms.errors != self._last_hms_errors and bambu_printer.hms.errors["Count"] > 0 ): self._log.debug(f"HMS Error: {bambu_printer.hms.errors}") for n in range(1, bambu_printer.hms.errors["Count"] + 1): error = bambu_printer.hms.errors[f"{n}-Error"].strip() self.sendIO(f"// action:notification {error}") self._last_hms_errors = bambu_printer.hms.errors def on_disconnect(self, on_disconnect): self._log.debug(f"on disconnect called") return on_disconnect def on_connect(self, on_connect): self._log.debug(f"on connect called") return on_connect def _create_client_connection_async(self): self._create_client_connection() if self._bambu_client is None: raise RuntimeError("Connection with Bambu Client not established") return self._bambu_client def _create_client_connection(self): if ( self._settings.get(["device_type"]) == "" or self._settings.get(["serial"]) == "" ): msg = "invalid settings to start connection with Bambu Printer" self._log.debug(msg) raise ValueError(msg) # Check if we should use MQTT bridge mode use_mqtt_bridge = self._settings.get_boolean(["use_mqtt_bridge"]) if use_mqtt_bridge: self._log.debug( f"connecting via mqtt bridge: {self._settings.get(['mqtt_host'])}:{self._settings.get(['mqtt_port'])}" ) # Create MQTT bridge client bambu_client = BambuMqttBridgeClient( device_type=self._settings.get(["device_type"]), serial=self._settings.get(["serial"]), host=self._settings.get(["mqtt_host"]), mqtt_port=int(self._settings.get(["mqtt_port"]) or 1883) ) else: # Use standard BambuClient self._log.debug( f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" ) bambu_client = BambuClient( device_type=self._settings.get(["device_type"]), serial=self._settings.get(["serial"]), host=self._settings.get(["host"]), username=( "bblp" if self._settings.get_boolean(["local_mqtt"]) else self._settings.get(["username"]) ), access_code=self._settings.get(["access_code"]), local_mqtt=self._settings.get_boolean(["local_mqtt"]), region=self._settings.get(["region"]), email=self._settings.get(["email"]), auth_token=self._settings.get(["auth_token"]), ) bambu_client.on_disconnect = self.on_disconnect(bambu_client.on_disconnect) bambu_client.on_connect = self.on_connect(bambu_client.on_connect) bambu_client.connect(callback=self.new_update) self._log.info(f"bambu connection status: {bambu_client.connected}") self.sendOk() self._bambu_client = bambu_client def __str__(self): return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( read_timeout=self.timeout, write_timeout=self.write_timeout, options={ "device_type": self._settings.get(["device_type"]), "host": self._settings.get(["host"]), }, ) def _reset(self): with self._serial_io.incoming_lock: self.lastN = 0 self._running = False if self._print_status_reporter is not None: self._print_status_reporter.cancel() self._print_status_reporter = None if self._settings.get_boolean(["simulateReset"]): for item in self._settings.get(["resetLines"]): self.sendIO(item + "\n") self._serial_io.reset() def write(self, data: bytes) -> int: return self._serial_io.write(data) def readline(self) -> bytes: return self._serial_io.readline() def readlines(self) -> list[bytes]: return self._serial_io.readlines() def sendIO(self, line: str): self._serial_io.send(line) def sendOk(self): self._serial_io.sendOk() def flush(self): self._serial_io.flush() self._wait_for_state_change() ##~~ project file functions def remove_project_selection(self): self._selected_project_file = None def select_project_file(self, file_path: str) -> bool: self._log.debug(f"Select project file: {file_path}") file_info = self._project_files_view.get_file_by_stem( file_path, [".gcode", ".3mf"] ) if ( self._selected_project_file is not None and file_info is not None and self._selected_project_file.path == file_info.path ): return True if file_info is None: self._log.error(f"Cannot select not existing file: {file_path}") return False self._selected_project_file = file_info self._send_file_selected_message() return True ##~~ command implementations @gcode_executor.register_no_data("M21") def _sd_status(self) -> None: self.sendIO("SD card ok") @gcode_executor.register("M23") def _select_sd_file(self, data: str) -> bool: filename = data.split(maxsplit=1)[1].strip() return self.select_project_file(filename) def _send_file_selected_message(self): if self.selected_file is None: return self.sendIO( f"File opened: {self.selected_file.file_name} " f"Size: {self.selected_file.size}" ) self.sendIO("File selected") @gcode_executor.register("M26") def _set_sd_position(self, data: str) -> bool: if data == "M26 S0": return self._cancel_print() else: self._log.debug("ignoring M26 command.") self.sendIO("M26 disabled for Bambu") return True @gcode_executor.register("M27") def _report_sd_print_status(self, data: str) -> bool: matchS = re.search(r"S([0-9]+)", data) if matchS: interval = int(matchS.group(1)) if interval > 0: self.start_continuous_status_report(interval) return False else: self.stop_continuous_status_report() return False self.report_print_job_status() return True def start_continuous_status_report(self, interval: int): if self._print_status_reporter is not None: self._print_status_reporter.cancel() self._print_status_reporter = RepeatedTimer( interval, self.report_print_job_status ) self._print_status_reporter.start() def stop_continuous_status_report(self): if self._print_status_reporter is not None: self._print_status_reporter.cancel() self._print_status_reporter = None @gcode_executor.register("M30") def _delete_project_file(self, data: str) -> bool: file_path = data.split(maxsplit=1)[1].strip() file_info = self.project_files.get_file_data(file_path) if file_info is not None: self.file_system.delete_file(file_info.path) self._update_project_file_list() else: self._log.error(f"File not found to delete {file_path}") return True @gcode_executor.register("M105") def _report_temperatures(self, data: str) -> bool: self._processTemperatureQuery() return True @gcode_executor.register("M155") def _auto_report_temperatures(self, data: str) -> bool: matchS = re.search(r"S([0-9]+)", data) if matchS: interval = int(matchS.group(1)) if interval > 0: self.start_continuous_temp_report(interval) else: self.stop_continuous_temp_report() self.report_print_job_status() return True def start_continuous_temp_report(self, interval: int): if self._print_temp_reporter is not None: self._print_temp_reporter.cancel() self._print_temp_reporter = RepeatedTimer( interval, self._processTemperatureQuery ) self._print_temp_reporter.start() def stop_continuous_temp_report(self): if self._print_temp_reporter is not None: self._print_temp_reporter.cancel() self._print_temp_reporter = None # noinspection PyUnusedLocal @gcode_executor.register_no_data("M115") def _report_firmware_info(self) -> bool: self.sendIO("Bambu Printer Integration") self.sendIO("Cap:AUTOREPORT_SD_STATUS:1") self.sendIO("Cap:AUTOREPORT_TEMP:1") self.sendIO("Cap:EXTENDED_M20:1") self.sendIO("Cap:LFN_WRITE:1") return True @gcode_executor.register("M117") def _get_lcd_message(self, data: str) -> bool: result = re.search(r"M117\s+(.*)", data).group(1) self.sendIO(f"echo:{result}") return True @gcode_executor.register("M118") def _serial_print(self, data: str) -> bool: match = re.search(r"M118 (?:(?PA1|E1|Pn[012])\s)?(?P.*)", data) if not match: self.sendIO("Unrecognized command parameters for M118") else: result = match.groupdict() text = result["text"] parameter = result["parameter"] if parameter == "A1": self.sendIO(f"//{text}") elif parameter == "E1": self.sendIO(f"echo:{text}") else: self.sendIO(text) return True # noinspection PyUnusedLocal @gcode_executor.register("M220") def _set_feedrate_percent(self, data: str) -> bool: if self.bambu_client.connected: gcode_command = commands.SEND_GCODE_TEMPLATE percent = int(data.replace("M220 S", "")) def speed_fraction(speed_percent): return math.floor(10000 / speed_percent) / 100 def acceleration_magnitude(speed_percent): return math.exp((speed_fraction(speed_percent) - 1.0191) / -0.8139) def feed_rate(speed_percent): return 6.426e-5 * speed_percent ** 2 - 2.484e-3 * speed_percent + 0.654 def linear_interpolate(x, x_points, y_points): if x <= x_points[0]: return y_points[0] if x >= x_points[-1]: return y_points[-1] for i in range(len(x_points) - 1): if x_points[i] <= x < x_points[i + 1]: t = (x - x_points[i]) / (x_points[i + 1] - x_points[i]) return y_points[i] * (1 - t) + y_points[i + 1] * t def scale_to_data_points(func, data_points): data_points.sort(key=lambda x: x[0]) speeds, values = zip(*data_points) scaling_factors = [v / func(s) for s, v in zip(speeds, values)] return lambda x: func(x) * linear_interpolate(x, speeds, scaling_factors) def speed_adjust(speed_percentage): if not 30 <= speed_percentage <= 180: speed_percentage = 100 bambu_params = { "speed": [50, 100, 124, 166], "acceleration": [0.3, 1.0, 1.4, 1.6], "feed_rate": [0.7, 1.0, 1.4, 2.0] } acc_mag_scaled = scale_to_data_points(acceleration_magnitude, list(zip(bambu_params["speed"], bambu_params["acceleration"]))) feed_rate_scaled = scale_to_data_points(feed_rate, list(zip(bambu_params["speed"], bambu_params["feed_rate"]))) speed_frac = speed_fraction(speed_percentage) acc_mag = acc_mag_scaled(speed_percentage) feed = feed_rate_scaled(speed_percentage) # speed_level = 1.539 * (acc_mag**2) - 0.7032 * acc_mag + 4.0834 return f"M204.2 K{acc_mag:.2f}\nM220 K{feed:.2f}\nM73.2 R{speed_frac:.2f}\n" # M1002 set_gcode_claim_speed_level ${speed_level:.0f}\n speed_command = speed_adjust(percent) gcode_command["print"]["param"] = speed_command if self.bambu_client.publish(gcode_command): self._log.info(f"{percent}% speed adjustment command sent successfully") return True def _process_gcode_serial_command(self, gcode: str, full_command: str): self._log.debug(f"processing gcode {gcode} command = {full_command}") handled = self.gcode_executor.execute(self, gcode, full_command) if handled: self.sendOk() return # post gcode to printer otherwise if self.bambu_client.connected: GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE GCODE_COMMAND["print"]["param"] = full_command + "\n" if self.bambu_client.publish(GCODE_COMMAND): self._log.info("command sent successfully") self.sendOk() @gcode_executor.register_no_data("M112") def _shutdown(self): self._running = True if self.bambu_client.connected: self.bambu_client.disconnect() self.sendIO("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") self._serial_io.close() return True @gcode_executor.register("M20") def _update_project_file_list(self, data: str = ""): self._project_files_view.update() # internally sends list to serial io return True def _list_cached_project_files(self): self.sendIO("Begin file list") for item in map( FileInfo.get_gcode_info, self._project_files_view.get_all_cached_info() ): self.sendIO(item) self.sendIO("End file list") self.sendOk() @gcode_executor.register_no_data("M24") def _start_resume_sd_print(self): self._current_state.start_new_print() return True @gcode_executor.register_no_data("M25") def _pause_print(self): self._current_state.pause_print() return True @gcode_executor.register("M524") def _cancel_print(self): self._current_state.cancel_print() return True def report_print_job_status(self): if self.current_print_job is not None: file_position = 1 if self.current_print_job.file_position == 0 else self.current_print_job.file_position self.sendIO( f"SD printing byte {file_position}" f"/{self.current_print_job.file_info.size}" ) else: self.sendIO("Not SD printing") def report_print_finished(self): if self.current_print_job is None: return self._log.debug( f"SD File Print finishing: {self.current_print_job.file_info.file_name}" ) self.sendIO("Done printing file") def finalize_print_job(self): if self.current_print_job is not None: self.report_print_job_status() self.report_print_finished() self.current_print_job = None self.report_print_job_status() self.change_state(self._state_idle) def _create_temperature_message(self) -> str: template = "{heater}:{actual:.2f}/ {target:.2f}" temps = collections.OrderedDict() temps["T"] = (self._telemetry.temp[0], self._telemetry.targetTemp[0]) temps["B"] = (self._telemetry.bedTemp, self._telemetry.bedTargetTemp) if self._telemetry.hasChamber: temps["C"] = ( self._telemetry.chamberTemp, self._telemetry.chamberTargetTemp, ) output = " ".join( map( lambda x: template.format(heater=x[0], actual=x[1][0], target=x[1][1]), temps.items(), ) ) output += " @:64\n" return output def _processTemperatureQuery(self) -> bool: # includeOk = not self._okBeforeCommandOutput if self.bambu_client.connected: output = self._create_temperature_message() self.sendIO(output) return True else: return False def close(self): if self.bambu_client.connected: self.bambu_client.disconnect() self.change_state(self._state_idle) self._serial_io.close() self.stop() def stop(self): self._running = False self._printer_thread.join() def _wait_for_state_change(self): self._state_change_queue.join() def _printer_worker(self): self._create_client_connection_async() self.sendIO("Printer connection complete") while self._running: try: next_state = self._state_change_queue.get(timeout=0.01) self._trigger_change_state(next_state) self._state_change_queue.task_done() except queue.Empty: continue except Exception as e: self._state_change_queue.task_done() raise e self._current_state.finalize() def _trigger_change_state(self, new_state: APrinterState): if self._current_state == new_state: return self._log.debug( f"Changing state from {self._current_state.__class__.__name__} to {new_state.__class__.__name__}" ) self._current_state.finalize() self._current_state = new_state self._current_state.init() def _showPrompt(self, text, choices): self._hidePrompt() self.sendIO(f"//action:prompt_begin {text}") for choice in choices: self.sendIO(f"//action:prompt_button {choice}") self.sendIO("//action:prompt_show") def _hidePrompt(self): self.sendIO("//action:prompt_end")