diff --git a/octoprint_bambu_printer/bambu_virtual_printer.py b/octoprint_bambu_printer/bambu_virtual_printer.py deleted file mode 100644 index 7b87349..0000000 --- a/octoprint_bambu_printer/bambu_virtual_printer.py +++ /dev/null @@ -1,894 +0,0 @@ -__author__ = "Gina Häußge " -__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" - - -import collections -import math -import os -import queue -import re -import threading -import time -import asyncio -from octoprint_bambu_printer.remote_sd_card_file_list import RemoteSDCardFileList -from pybambu import BambuClient, commands -import logging -import logging.handlers - -from serial import SerialTimeoutException -from octoprint.util import RepeatedTimer, to_bytes, to_unicode - -from octoprint_bambu_printer.gcode_executor import GCodeExecutor - -from .char_counting_queue import CharCountingQueue - - -# noinspection PyBroadException -class BambuVirtualPrinter: - gcode_executor = GCodeExecutor() - command_regex = re.compile(r"^([GM])(\d+)") - - def __init__( - self, - settings, - printer_profile_manager, - data_folder, - seriallog_handler=None, - read_timeout=5.0, - write_timeout=10.0, - faked_baudrate=115200, - ): - self._busyInterval = 2.0 - self.tick_rate = 2.0 - self._errors = { - "checksum_mismatch": "Checksum mismatch", - "checksum_missing": "Missing checksum", - "lineno_mismatch": "expected line {} got {}", - "lineno_missing": "No Line Number with checksum, Last Line: {}", - "maxtemp": "MAXTEMP triggered!", - "mintemp": "MINTEMP triggered!", - "command_unknown": "Unknown command {}", - } - self._sendBusy = False - self._ambient_temperature = 21.3 - self.temp = [self._ambient_temperature] - self.targetTemp = [0.0] - self.bedTemp = self._ambient_temperature - self.bedTargetTemp = 0.0 - self._hasChamber = printer_profile_manager.get_current().get("heatedChamber") - self.chamberTemp = self._ambient_temperature - self.chamberTargetTemp = 0.0 - self.lastTempAt = time.monotonic() - self._firmwareName = "Bambu" - self._m115FormatString = "FIRMWARE_NAME:{firmware_name} PROTOCOL_VERSION:1.0" - self._received_lines = 0 - self.extruderCount = 1 - self._waitInterval = 5.0 - self._killed = False - self._heatingUp = False - self.current_line = 0 - self._writingToSd = False - - self._sdPrinter = None - self._sdPrinting = False - self._sdPrintStarting = False - self._sdPrintingSemaphore = threading.Event() - self._sdPrintingPausedSemaphore = threading.Event() - self._sdCardFileSystem = RemoteSDCardFileList(settings) - - self._busy = None - self._busy_loop = None - - self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") - - self._settings = settings - self._printer_profile_manager = printer_profile_manager - self._faked_baudrate = faked_baudrate - self._plugin_data_folder = data_folder - - self._serial_log = logging.getLogger( - "octoprint.plugins.bambu_printer.BambuPrinter.serial" - ) - self._serial_log.setLevel(logging.CRITICAL) - self._serial_log.propagate = False - - if seriallog_handler is not None: - self._serial_log.addHandler(seriallog_handler) - self._serial_log.setLevel(logging.INFO) - - self._serial_log.debug("-" * 78) - - self._read_timeout = read_timeout - self._write_timeout = write_timeout - - self._rx_buffer_size = 64 - self._incoming_lock = threading.RLock() - - self.incoming = CharCountingQueue(self._rx_buffer_size, name="RxBuffer") - self.outgoing = queue.Queue() - self.buffered = queue.Queue(maxsize=4) - - self._last_hms_errors = None - - self._bambu: BambuClient = None - - readThread = threading.Thread( - target=self._processIncoming, - name="octoprint.plugins.bambu_printer.wait_thread", - daemon=True, - ) - readThread.start() - - connectionThread = threading.Thread( - target=self._create_connection, - name="octoprint.plugins.bambu_printer.connection_thread", - daemon=True, - ) - connectionThread.start() - - @property - def bambu(self): - if self._bambu is None: - raise ValueError("No connection to Bambulab was established") - return self._bambu - - def new_update(self, event_type): - if event_type == "event_hms_errors": - bambu_printer = self.bambu.get_device() - if ( - bambu_printer.hms.errors != self._last_hms_errors - and bambu_printer.hms.errors["Count"] > 0 - ): - self._logger.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._send(f"// action:notification {error}") - self._last_hms_errors = bambu_printer.hms.errors - elif event_type == "event_printer_data_update": - device_data = self.bambu.get_device() - ams = device_data.ams.__dict__ - print_job = device_data.print_job.__dict__ - temperatures = device_data.temperature.__dict__ - lights = device_data.lights.__dict__ - fans = device_data.fans.__dict__ - speed = device_data.speed.__dict__ - - # self._logger.debug(device_data) - - self.lastTempAt = time.monotonic() - self.temp[0] = temperatures.get("nozzle_temp", 0.0) - self.targetTemp[0] = temperatures.get("target_nozzle_temp", 0.0) - self.bedTemp = temperatures.get("bed_temp", 0.0) - self.bedTargetTemp = temperatures.get("target_bed_temp", 0.0) - self.chamberTemp = temperatures.get("chamber_temp", 0.0) - - if print_job.get("gcode_state") == "RUNNING": - if not self._sdPrintingSemaphore.is_set(): - self._sdPrintingSemaphore.set() - if self._sdPrintingPausedSemaphore.is_set(): - self._sdPrintingPausedSemaphore.clear() - self._sdPrintStarting = False - if not self._sdPrinting: - filename: str = print_job.get("subtask_name") - project_file = self._sdCardFileSystem.search_by_stem( - filename, [".3mf", ".gcode.3mf"] - ) - if project_file is None: - self._logger.debug(f"No 3mf file found for {print_job}") - - if self._sdCardFileSystem.select_file(filename): - self._sendOk() - self._startSdPrint(from_printer=True) - - # fuzzy math here to get print percentage to match BambuStudio - self._selectedSdFilePos = int( - self._selectedSdFileSize - * ((print_job.get("print_percentage") + 1) / 100) - ) - - if print_job.get("gcode_state") == "PAUSE": - if not self._sdPrintingPausedSemaphore.is_set(): - self._sdPrintingPausedSemaphore.set() - if self._sdPrintingSemaphore.is_set(): - self._sdPrintingSemaphore.clear() - self._send("// action:paused") - self._sendPaused() - - if ( - print_job.get("gcode_state") == "FINISH" - or print_job.get("gcode_state") == "FAILED" - ): - if self._sdPrintStarting is False: - self._sdPrinting = False - if self._sdPrintingSemaphore.is_set(): - self._selectedSdFilePos = self._selectedSdFileSize - self._finishSdPrint() - - def _create_connection(self): - if ( - self._settings.get(["device_type"]) != "" - and self._settings.get(["serial"]) != "" - and self._settings.get(["serial"]) != "" - and self._settings.get(["username"]) != "" - and self._settings.get(["access_code"]) != "" - ): - asyncio.run(self._create_connection_async()) - - def on_disconnect(self, on_disconnect): - self._logger.debug(f"on disconnect called") - return on_disconnect - - def on_connect(self, on_connect): - self._logger.debug(f"on connect called") - return on_connect - - async def _create_connection_async(self): - self._logger.debug( - f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" - ) - self._bambu = 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"]), - ) - self._bambu.on_disconnect = self.on_disconnect(self._bambu.on_disconnect) - self._bambu.on_connect = self.on_connect(self._bambu.on_connect) - self._bambu.connect(callback=self.new_update) - self._logger.info(f"bambu connection status: {self._bambu.connected}") - self._sendOk() - - def __str__(self): - return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( - read_timeout=self._read_timeout, - write_timeout=self._write_timeout, - options={ - "device_type": self._settings.get(["device_type"]), - "host": self._settings.get(["host"]), - }, - ) - - def _calculate_resend_every_n(self, resend_ratio): - self._resend_every_n = (100 // resend_ratio) if resend_ratio else 0 - - def _reset(self): - with self._incoming_lock: - self._relative = True - self._lastX = 0.0 - self._lastY = 0.0 - self._lastZ = 0.0 - self._lastE = [0.0] * self.extruderCount - self._lastF = 200 - - self._unitModifier = 1 - self._feedrate_multiplier = 100 - self._flowrate_multiplier = 100 - - self._sdPrinting = False - self._sdPrintStarting = False - if self._sdPrinter: - self._sdPrinting = False - self._sdPrintingSemaphore.clear() - self._sdPrintingPausedSemaphore.clear() - self._sdPrinter = None - self._selectedSdFile = None - self._selectedSdFileSize = None - self._selectedSdFilePos = None - - if self._writingToSdHandle: - try: - self._writingToSdHandle.close() - except Exception: - pass - self._writingToSd = False - self._writingToSdHandle = None - self._writingToSdFile = None - self._newSdFilePos = None - - self._heatingUp = False - - self.current_line = 0 - self.lastN = 0 - - self._debug_awol = False - self._debug_sleep = 0 - # self._sleepAfterNext.clear() - # self._sleepAfter.clear() - - self._dont_answer = False - self._broken_klipper_connection = False - - self._debug_drop_connection = False - - self._killed = False - - if self._sdstatus_reporter is not None: - self._sdstatus_reporter.cancel() - self._sdstatus_reporter = None - - self._clearQueue(self.incoming) - self._clearQueue(self.outgoing) - # self._clearQueue(self.buffered) - - if self._settings.get_boolean(["simulateReset"]): - for item in self._settings.get(["resetLines"]): - self._send(item + "\n") - - self._locked = self._settings.get_boolean(["locked"]) - - @property - def timeout(self): - return self._read_timeout - - @timeout.setter - def timeout(self, value): - self._logger.debug(f"Setting read timeout to {value}s") - self._read_timeout = value - - @property - def write_timeout(self): - return self._write_timeout - - @write_timeout.setter - def write_timeout(self, value): - self._logger.debug(f"Setting write timeout to {value}s") - self._write_timeout = value - - @property - def port(self): - return "BAMBU" - - @property - def baudrate(self): - return self._faked_baudrate - - # noinspection PyMethodMayBeStatic - def _clearQueue(self, q): - try: - while q.get(block=False): - q.task_done() - continue - except queue.Empty: - pass - - def _processIncoming(self): - linenumber = 0 - next_wait_timeout = 0 - - def recalculate_next_wait_timeout(): - nonlocal next_wait_timeout - next_wait_timeout = time.monotonic() + self._waitInterval - - recalculate_next_wait_timeout() - - data = None - - buf = b"" - while self.incoming is not None and not self._killed: - try: - data = self.incoming.get(timeout=0.01) - data = to_bytes(data, encoding="ascii", errors="replace") - self.incoming.task_done() - except queue.Empty: - continue - except Exception: - if self.incoming is None: - # just got closed - break - - if data is not None: - buf += data - nl = buf.find(b"\n") + 1 - if nl > 0: - data = buf[:nl] - buf = buf[nl:] - else: - continue - - recalculate_next_wait_timeout() - - if data is None: - continue - - self._received_lines += 1 - - # strip checksum - if b"*" in data: - checksum = int(data[data.rfind(b"*") + 1 :]) - data = data[: data.rfind(b"*")] - if not checksum == self._calculate_checksum(data): - self._triggerResend(expected=self.current_line + 1) - continue - - self.current_line += 1 - elif self._settings.get_boolean(["forceChecksum"]): - self._send(self._format_error("checksum_missing")) - continue - - # track N = N + 1 - if data.startswith(b"N") and b"M110" in data: - linenumber = int(re.search(b"N([0-9]+)", data).group(1)) - self.lastN = linenumber - self.current_line = linenumber - self._sendOk() - continue - - elif data.startswith(b"N"): - linenumber = int(re.search(b"N([0-9]+)", data).group(1)) - expected = self.lastN + 1 - if linenumber != expected: - self._triggerResend(actual=linenumber) - continue - else: - self.lastN = linenumber - - data = data.split(None, 1)[1].strip() - - data += b"\n" - - command = to_unicode(data, encoding="ascii", errors="replace").strip() - - # actual command handling - command_match = BambuVirtualPrinter.command_regex.match(command) - if command_match is not None: - gcode = command_match.group(0) - gcode_letter = command_match.group(1) - - if gcode_letter in self.gcode_executor: - handled = self.run_gcode_handler(gcode_letter, data) - else: - handled = self.run_gcode_handler(gcode, data) - if handled: - self._sendOk() - continue - - if self.bambu.connected: - GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE - GCODE_COMMAND["print"]["param"] = data + "\n" - if self.bambu.publish(GCODE_COMMAND): - self._logger.info("command sent successfully") - self._sendOk() - continue - self._logger.debug(f"{data}") - - self._logger.debug("Closing down read loop") - - ##~~ command implementations - def run_gcode_handler(self, gcode, data): - self.gcode_executor.execute(self, gcode, data) - - @gcode_executor.register("M21") - def _gcode_M21(self, data: str) -> bool: - self._send("SD card ok") - return True - - @gcode_executor.register("M23") - def _gcode_M23(self, data: str) -> bool: - filename = data.split(maxsplit=1)[1].strip() - self._selectSdFile(filename) - return True - - @gcode_executor.register("M26") - def _gcode_M26(self, data: str) -> bool: - if data == "M26 S0": - return self._cancelSdPrint() - else: - self._logger.debug("ignoring M26 command.") - self._send("M26 disabled for Bambu") - return True - - @gcode_executor.register("M27") - def _gcode_M27(self, data: str) -> bool: - matchS = re.search(r"S([0-9]+)", data) - if matchS: - interval = int(matchS.group(1)) - if self._sdstatus_reporter is not None: - self._sdstatus_reporter.cancel() - - if interval > 0: - self._sdstatus_reporter = RepeatedTimer(interval, self._reportSdStatus) - self._sdstatus_reporter.start() - else: - self._sdstatus_reporter = None - - self._reportSdStatus() - return True - - @gcode_executor.register("M30") - def _gcode_M30(self, data: str) -> bool: - filename = data.split(None, 1)[1].strip() - self._deleteSdFile(filename) - return True - - @gcode_executor.register("M105") - def _gcode_M105(self, data: str) -> bool: - return self._processTemperatureQuery() - - # noinspection PyUnusedLocal - @gcode_executor.register("M115") - def _gcode_M115(self, data: str) -> bool: - self._send("Bambu Printer Integration") - self._send("Cap:EXTENDED_M20:1") - self._send("Cap:LFN_WRITE:1") - self._send("Cap:LFN_WRITE:1") - return True - - @gcode_executor.register("M117") - def _gcode_M117(self, data: str) -> bool: - # we'll just use this to echo a message, to allow playing around with pause triggers - result = re.search(r"M117\s+(.*)", data).group(1) - self._send(f"echo:{result}") - return False - - @gcode_executor.register("M118") - def _gcode_M118(self, data: str) -> bool: - match = re.search(r"M118 (?:(?PA1|E1|Pn[012])\s)?(?P.*)", data) - if not match: - self._send("Unrecognized command parameters for M118") - else: - result = match.groupdict() - text = result["text"] - parameter = result["parameter"] - - if parameter == "A1": - self._send(f"//{text}") - elif parameter == "E1": - self._send(f"echo:{text}") - else: - self._send(text) - return True - - # noinspection PyUnusedLocal - @gcode_executor.register("M220") - def _gcode_M220(self, data: str) -> bool: - if self.bambu.connected: - gcode_command = commands.SEND_GCODE_TEMPLATE - percent = int(data[1:]) - - if percent is None or percent < 1 or percent > 166: - return True - - speed_fraction = 100 / percent - acceleration = math.exp((speed_fraction - 1.0191) / -0.814) - feed_rate = ( - 2.1645 * (acceleration**3) - - 5.3247 * (acceleration**2) - + 4.342 * acceleration - - 0.181 - ) - speed_level = 1.539 * (acceleration**2) - 0.7032 * acceleration + 4.0834 - speed_command = f"M204.2 K${acceleration:.2f} \nM220 K${feed_rate:.2f} \nM73.2 R${speed_fraction:.2f} \nM1002 set_gcode_claim_speed_level ${speed_level:.0f}\n" - - gcode_command["print"]["param"] = speed_command - if self.bambu.publish(gcode_command): - self._logger.info( - f"{percent}% speed adjustment command sent successfully" - ) - return True - - @staticmethod - def _check_param_letters(letters, data): - # Checks if any of the params (letters) are included in data - # Purely for saving typing :) - for param in list(letters): - if param in data: - return True - - ##~~ further helpers - - # noinspection PyMethodMayBeStatic - def _calculate_checksum(self, line: bytes) -> int: - checksum = 0 - for c in bytearray(line): - checksum ^= c - return checksum - - def _kill(self): - self._killed = True - if self.bambu.connected: - self.bambu.disconnect() - self._send("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") - - def _triggerResend( - self, expected: int = None, actual: int = None, checksum: int = None - ) -> None: - with self._incoming_lock: - if expected is None: - expected = self.lastN + 1 - else: - self.lastN = expected - 1 - - if actual is None: - if checksum: - self._send(self._format_error("checksum_mismatch")) - else: - self._send(self._format_error("checksum_missing")) - else: - self._send(self._format_error("lineno_mismatch", expected, actual)) - - def request_resend(): - self._send("Resend:%d" % expected) - # if not self._brokenResend: - self._sendOk() - - request_resend() - - @gcode_executor.register_no_data("M20") - def _listSd(self): - self._send("Begin file list") - for item in map( - lambda f: f.get_log_info(), self._sdCardFileSystem.get_all_files() - ): - self._send(item) - self._send("End file list") - - @gcode_executor.register_no_data("M24") - def _startSdPrint(self, from_printer: bool = False) -> bool: - self._logger.debug(f"_startSdPrint: from_printer={from_printer}") - if self._selectedSdFile is not None: - if self._sdPrinter is None: - self._sdPrinting = True - self._sdPrintStarting = True - self._sdPrinter = threading.Thread( - target=self._sdPrintingWorker, kwargs={"from_printer": from_printer} - ) - self._sdPrinter.start() - - if self._sdPrinter is not None: - if self.bambu.connected: - if self.bambu.publish(commands.RESUME): - self._logger.info("print resumed") - else: - self._logger.info("print resume failed") - return True - - @gcode_executor.register_no_data("M25") - def _pauseSdPrint(self): - if self.bambu.connected: - if self.bambu.publish(commands.PAUSE): - self._logger.info("print paused") - else: - self._logger.info("print pause failed") - - @gcode_executor.register("M524") - def _cancelSdPrint(self) -> bool: - if self.bambu.connected: - if self.bambu.publish(commands.STOP): - self._logger.info("print cancelled") - self._finishSdPrint() - return True - else: - self._logger.info("print cancel failed") - return False - return False - - def _setSdPos(self, pos): - self._newSdFilePos = pos - - def _reportSdStatus(self): - if ( - self._sdPrinter is not None or self._sdPrintStarting is True - ) and self._selectedSdFileSize > 0: - self._send( - f"SD printing byte {self._selectedSdFilePos}/{self._selectedSdFileSize}" - ) - else: - self._send("Not SD printing") - - def _generateTemperatureOutput(self) -> str: - template = "{heater}:{actual:.2f}/ {target:.2f}" - temps = collections.OrderedDict() - temps["T"] = (self.temp[0], self.targetTemp[0]) - temps["B"] = (self.bedTemp, self.bedTargetTemp) - if self._hasChamber: - temps["C"] = (self.chamberTemp, self.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.connected: - output = self._generateTemperatureOutput() - self._send(output) - return True - else: - return False - - def _writeSdFile(self, filename: str) -> None: - self._send(f"Writing to file: {filename}") - - def _finishSdFile(self): - try: - self._writingToSdHandle.close() - except Exception: - pass - finally: - self._writingToSdHandle = None - self._writingToSd = False - self._selectedSdFile = None - # Most printers don't have RTC and set some ancient date - # by default. Emulate that using 2000-01-01 01:00:00 - # (taken from prusa firmware behaviour) - st = os.stat(self._writingToSdFile) - os.utime(self._writingToSdFile, (st.st_atime, 946684800)) - self._writingToSdFile = None - self._send("Done saving file") - - def _sdPrintingWorker(self, from_printer: bool = False): - self._selectedSdFilePos = 0 - try: - if not from_printer and self.bambu.connected: - print_command = { - "print": { - "sequence_id": 0, - "command": "project_file", - "param": "Metadata/plate_1.gcode", - "md5": "", - "profile_id": "0", - "project_id": "0", - "subtask_id": "0", - "task_id": "0", - "subtask_name": f"{self._selectedSdFile}", - "file": f"{self._selectedSdFile}", - "url": ( - f"file:///mnt/sdcard/{self._selectedSdFile}" - if self._settings.get_boolean(["device_type"]) - in ["X1", "X1C"] - else f"file:///sdcard/{self._selectedSdFile}" - ), - "timelapse": self._settings.get_boolean(["timelapse"]), - "bed_leveling": self._settings.get_boolean(["bed_leveling"]), - "flow_cali": self._settings.get_boolean(["flow_cali"]), - "vibration_cali": self._settings.get_boolean( - ["vibration_cali"] - ), - "layer_inspect": self._settings.get_boolean(["layer_inspect"]), - "use_ams": self._settings.get_boolean(["use_ams"]), - } - } - self.bambu.publish(print_command) - - while self._selectedSdFilePos < self._selectedSdFileSize: - if self._killed or not self._sdPrinting: - break - - # if we are paused, wait for resuming - self._sdPrintingSemaphore.wait() - self._reportSdStatus() - time.sleep(3) - self._logger.debug(f"SD File Print: {self._selectedSdFile}") - except AttributeError: - if self.outgoing is not None: - raise - - self._finishSdPrint() - - def _finishSdPrint(self): - if not self._killed: - self._sdPrintingSemaphore.clear() - self._sdPrintingPausedSemaphore.clear() - self._send("Done printing file") - self._selectedSdFilePos = 0 - self._selectedSdFileSize = 0 - self._sdPrinting = False - self._sdPrintStarting = False - self._sdPrinter = None - - def _setBusy(self, reason="processing"): - if not self._sendBusy: - return - - def loop(): - while self._busy: - self._send(f"echo:busy {self._busy}") - time.sleep(self._busyInterval) - self._sendOk() - - self._busy = reason - self._busy_loop = threading.Thread(target=loop) - self._busy_loop.daemon = True - self._busy_loop.start() - - def _setUnbusy(self): - self._busy = None - - def _showPrompt(self, text, choices): - self._hidePrompt() - self._send(f"//action:prompt_begin {text}") - for choice in choices: - self._send(f"//action:prompt_button {choice}") - self._send("//action:prompt_show") - - def _hidePrompt(self): - self._send("//action:prompt_end") - - def write(self, data: bytes) -> int: - data = to_bytes(data, errors="replace") - u_data = to_unicode(data, errors="replace") - - with self._incoming_lock: - if self.incoming is None or self.outgoing is None: - return 0 - - if b"M112" in data: - self._serial_log.debug(f"<<< {u_data}") - self._kill() - return len(data) - - try: - written = self.incoming.put( - data, timeout=self._write_timeout, partial=True - ) - self._serial_log.debug(f"<<< {u_data}") - return written - except queue.Full: - self._logger.info( - "Incoming queue is full, raising SerialTimeoutException" - ) - raise SerialTimeoutException() - - def readline(self) -> bytes: - assert self.outgoing is not None - timeout = self._read_timeout - - try: - # fetch a line from the queue, wait no longer than timeout - line = to_unicode(self.outgoing.get(timeout=timeout), errors="replace") - self._serial_log.debug(f">>> {line.strip()}") - self.outgoing.task_done() - return to_bytes(line) - except queue.Empty: - # queue empty? return empty line - return b"" - - def close(self): - if self.bambu.connected: - self.bambu.disconnect() - self._killed = True - self.incoming = None - self.outgoing = None - self.buffered = None - - def _sendOk(self): - if self.outgoing is None: - return - self._send("ok") - - def _isPaused(self): - return self._sdPrintingPausedSemaphore.is_set() - - def _sendPaused(self): - paused_timer = RepeatedTimer( - interval=3.0, - function=self._send, - args=[ - f"SD printing byte {self._selectedSdFilePos}/{self._selectedSdFileSize}" - ], - daemon=True, - run_first=True, - condition=self._isPaused, - ) - paused_timer.start() - - def _send(self, line: str) -> None: - if self.outgoing is not None: - self.outgoing.put(line) - - def _format_error(self, error: str, *args, **kwargs) -> str: - return f"Error: {self._errors.get(error).format(*args, **kwargs)}" diff --git a/octoprint_bambu_printer/printer/__init__.py b/octoprint_bambu_printer/printer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/octoprint_bambu_printer/printer/bambu_virtual_printer.py b/octoprint_bambu_printer/printer/bambu_virtual_printer.py new file mode 100644 index 0000000..b1e6933 --- /dev/null +++ b/octoprint_bambu_printer/printer/bambu_virtual_printer.py @@ -0,0 +1,521 @@ +__author__ = "Gina Häußge " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" + + +import collections +from dataclasses import dataclass, field +import math +import os +import queue +import re +import threading +import time +import asyncio +from pybambu import BambuClient, commands +import logging +import logging.handlers + +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.print_finished_state import PrintFinishedState +from .states.paused_state import PausedState +from .states.printing_state import PrintingState + +from .gcode_executor import GCodeExecutor +from .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 + + +# noinspection PyBroadException +class BambuVirtualPrinter: + gcode_executor = GCodeExecutor() + + def __init__( + self, + settings, + printer_profile_manager, + data_folder, + serial_log_handler=None, + faked_baudrate=115200, + ): + 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._state_finished = PrintFinishedState(self) + self._current_state = self._state_idle + self._serial_io = PrinterSerialIO( + self._process_gcode_serial_command, + serial_log_handler, + read_timeout=5.0, + write_timeout=10.0, + ) + + self.tick_rate = 2.0 + self._telemetry = BambuPrinterTelemetry() + self._telemetry.hasChamber = printer_profile_manager.get_current().get( + "heatedChamber" + ) + + self._running = True + self.file_system = RemoteSDCardFileList(settings) + + self._busy_reason = None + self._busy_loop = None + self._busy_interval = 2.0 + + self._settings = settings + self._printer_profile_manager = printer_profile_manager + self._faked_baudrate = faked_baudrate + self._plugin_data_folder = data_folder + + self._last_hms_errors = None + + self._serial_io.start() + + self._bambu_client: BambuClient = None + asyncio.get_event_loop().run_until_complete(self._create_connection_async()) + + @property + def bambu_client(self): + if self._bambu_client is None: + raise ValueError("No connection to Bambulab was established") + return self._bambu_client + + @property + def is_running(self): + return self._running + + @property + def current_print_job(self): + if isinstance(self._current_state, PrintingState): + return self._current_state.print_job + return None + + def 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 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() + ams = device_data.ams.__dict__ + print_job = device_data.print_job + temperatures = device_data.temperature.__dict__ + lights = device_data.lights.__dict__ + fans = device_data.fans.__dict__ + speed = device_data.speed.__dict__ + + self.lastTempAt = time.monotonic() + self._telemetry.temp[0] = temperatures.get("nozzle_temp", 0.0) + self._telemetry.targetTemp[0] = temperatures.get("target_nozzle_temp", 0.0) + self.bedTemp = temperatures.get("bed_temp", 0.0) + self.bedTargetTemp = temperatures.get("target_bed_temp", 0.0) + self.chamberTemp = temperatures.get("chamber_temp", 0.0) + + if print_job.gcode_state == "RUNNING": + self.change_state(self._state_printing) + self._state_printing.set_print_job_info(print_job) + if print_job.gcode_state == "PAUSE": + self.change_state(self._state_paused) + if print_job.gcode_state == "FINISH" or print_job.gcode_state == "FAILED": + self.change_state(self._state_finished) + + 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 + + async def _create_connection_async(self): + if ( + self._settings.get(["device_type"]) == "" + or self._settings.get(["serial"]) == "" + or self._settings.get(["username"]) == "" + or self._settings.get(["access_code"]) == "" + ): + self._log.debug("invalid settings to start connection with Bambu Printer") + return + + self._log.debug( + f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" + ) + self._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"]), + ) + self._bambu_client.on_disconnect = self.on_disconnect( + self._bambu_client.on_disconnect + ) + self._bambu_client.on_connect = self.on_connect(self._bambu_client.on_connect) + self._bambu_client.connect(callback=self.new_update) + self._log.info(f"bambu connection status: {self._bambu_client.connected}") + self._serial_io.sendOk() + + def __str__(self): + return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( + read_timeout=self._read_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._debug_awol = False + self._debug_sleep = 0 + # self._sleepAfterNext.clear() + # self._sleepAfter.clear() + + self._dont_answer = False + self._broken_klipper_connection = False + + self._debug_drop_connection = False + + self._running = False + + if self._sdstatus_reporter is not None: + self._sdstatus_reporter.cancel() + self._sdstatus_reporter = None + + if self._settings.get_boolean(["simulateReset"]): + for item in self._settings.get(["resetLines"]): + self.sendIO(item + "\n") + + self._locked = self._settings.get_boolean(["locked"]) + self._serial_io.reset() + + @property + def timeout(self): + return self._read_timeout + + @timeout.setter + def timeout(self, value): + self._log.debug(f"Setting read timeout to {value}s") + self._read_timeout = value + + @property + def write_timeout(self): + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, value): + self._log.debug(f"Setting write timeout to {value}s") + self._write_timeout = value + + @property + def port(self): + return "BAMBU" + + @property + def baudrate(self): + return self._faked_baudrate + + def write(self, data: bytes) -> int: + return self._serial_io.write(data) + + def readline(self) -> bytes: + return self._serial_io.readline() + + def sendIO(self, line: str): + self.sendIO(line) + + def sendOk(self): + self._serial_io.sendOk() + + ##~~ command implementations + def run_gcode_handler(self, gcode, data): + self.gcode_executor.execute(self, gcode, data) + + @gcode_executor.register("M23") + def _gcode_M23(self, data: str) -> bool: + filename = data.split(maxsplit=1)[1].strip() + self.file_system.select_file(filename) + return True + + @gcode_executor.register("M26") + def _gcode_M26(self, data: str) -> bool: + if data == "M26 S0": + return self._cancelSdPrint() + else: + self._log.debug("ignoring M26 command.") + self.sendIO("M26 disabled for Bambu") + return True + + @gcode_executor.register("M27") + def _gcode_M27(self, data: str) -> bool: + matchS = re.search(r"S([0-9]+)", data) + if matchS: + interval = int(matchS.group(1)) + if self._sdstatus_reporter is not None: + self._sdstatus_reporter.cancel() + + if interval > 0: + self._sdstatus_reporter = RepeatedTimer( + interval, self.report_print_job_status + ) + self._sdstatus_reporter.start() + else: + self._sdstatus_reporter = None + + self.report_print_job_status() + return True + + @gcode_executor.register("M30") + def _gcode_M30(self, data: str) -> bool: + filename = data.split(None, 1)[1].strip() + self.file_system.delete_file(filename) + return True + + @gcode_executor.register("M105") + def _gcode_M105(self, data: str) -> bool: + return self._processTemperatureQuery() + + # noinspection PyUnusedLocal + @gcode_executor.register("M115") + def _gcode_M115(self, data: str) -> bool: + self.sendIO("Bambu Printer Integration") + self.sendIO("Cap:EXTENDED_M20:1") + self.sendIO("Cap:LFN_WRITE:1") + self.sendIO("Cap:LFN_WRITE:1") + return True + + @gcode_executor.register("M117") + def _gcode_M117(self, data: str) -> bool: + # we'll just use this to echo a message, to allow playing around with pause triggers + result = re.search(r"M117\s+(.*)", data).group(1) + self.sendIO(f"echo:{result}") + return False + + @gcode_executor.register("M118") + def _gcode_M118(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 _gcode_M220(self, data: str) -> bool: + if self.bambu_client.connected: + gcode_command = commands.SEND_GCODE_TEMPLATE + percent = int(data[1:]) + + if percent is None or percent < 1 or percent > 166: + return True + + speed_fraction = 100 / percent + acceleration = math.exp((speed_fraction - 1.0191) / -0.814) + feed_rate = ( + 2.1645 * (acceleration**3) + - 5.3247 * (acceleration**2) + + 4.342 * acceleration + - 0.181 + ) + speed_level = 1.539 * (acceleration**2) - 0.7032 * acceleration + 4.0834 + speed_command = f"M204.2 K${acceleration:.2f} \nM220 K${feed_rate:.2f} \nM73.2 R${speed_fraction:.2f} \nM1002 set_gcode_claim_speed_level ${speed_level:.0f}\n" + + 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_letter: str, gcode: str, data: bytes): + self._log.debug(f"processing gcode command {gcode_letter} {gcode} {data}") + if gcode_letter in self.gcode_executor: + handled = self.run_gcode_handler(gcode_letter, data) + else: + handled = self.run_gcode_handler(gcode, data) + if handled: + self._serial_io.sendOk() + return + + # post gcode to printer otherwise + if self.bambu_client.connected: + GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE + GCODE_COMMAND["print"]["param"] = data + "\n" + if self.bambu_client.publish(GCODE_COMMAND): + self._log.info("command sent successfully") + self._serial_io.sendOk() + + ##~~ further helpers + + @gcode_executor.register_no_data("M112") + def _kill(self): + self._running = True + if self.bambu_client.connected: + self.bambu_client.disconnect() + self.sendIO("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") + self._serial_io.stop() + + @gcode_executor.register_no_data("M20") + def _listSd(self): + self.sendIO("Begin file list") + for item in map(lambda f: f.get_log_info(), self.file_system.get_all_files()): + self.sendIO(item) + self.sendIO("End file list") + + @gcode_executor.register_no_data("M24") + def _startSdPrint(self, from_printer: bool = False) -> bool: + self._log.debug(f"_startSdPrint: from_printer={from_printer}") + self.change_state(self._state_printing) + + @gcode_executor.register_no_data("M25") + def _pauseSdPrint(self): + if self.bambu_client.connected: + if self.bambu_client.publish(commands.PAUSE): + self._log.info("print paused") + else: + self._log.info("print pause failed") + + @gcode_executor.register("M524") + def _cancelSdPrint(self) -> bool: + self._current_state.cancel() + + def report_print_job_status(self): + print_job = self.current_print_job + if print_job is not None: + self.sendIO( + f"SD printing byte {print_job.file_position}/{print_job.file_info.size}" + ) + else: + self.sendIO("Not SD printing") + + def _generateTemperatureOutput(self) -> str: + template = "{heater}:{actual:.2f}/ {target:.2f}" + temps = collections.OrderedDict() + temps["T"] = (self._telemetry.temp[0], self._telemetry.targetTemp[0]) + temps["B"] = (self.bedTemp, self.bedTargetTemp) + if self._telemetry.hasChamber: + temps["C"] = (self.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._generateTemperatureOutput() + self.sendIO(output) + return True + else: + return False + + def _writeSdFile(self, filename: str) -> None: + self.sendIO(f"Writing to file: {filename}") + + def _finishSdFile(self): + try: + self._writingToSdHandle.close() + except Exception: + pass + finally: + self._writingToSdHandle = None + self._writingToSd = False + self._selectedSdFile = None + # Most printers don't have RTC and set some ancient date + # by default. Emulate that using 2000-01-01 01:00:00 + # (taken from prusa firmware behaviour) + st = os.stat(self._writingToSdFile) + os.utime(self._writingToSdFile, (st.st_atime, 946684800)) + self._writingToSdFile = None + self.sendIO("Done saving file") + + def _setMainThreadBusy(self, reason="processing"): + def loop(): + while self._busy_reason is not None: + self.sendIO(f"echo:busy {self._busy_reason}") + time.sleep(self._busy_interval) + self._serial_io.sendOk() + + self._busy_reason = reason + self._busy_loop = threading.Thread(target=loop) + self._busy_loop.daemon = True + self._busy_loop.start() + + def _setMainThreadIdle(self): + self._busy_reason = None + + def close(self): + if self.bambu_client.connected: + self.bambu_client.disconnect() + self._serial_io.stop() diff --git a/octoprint_bambu_printer/char_counting_queue.py b/octoprint_bambu_printer/printer/char_counting_queue.py similarity index 100% rename from octoprint_bambu_printer/char_counting_queue.py rename to octoprint_bambu_printer/printer/char_counting_queue.py diff --git a/octoprint_bambu_printer/ftpsclient/__init__.py b/octoprint_bambu_printer/printer/ftpsclient/__init__.py similarity index 100% rename from octoprint_bambu_printer/ftpsclient/__init__.py rename to octoprint_bambu_printer/printer/ftpsclient/__init__.py diff --git a/octoprint_bambu_printer/ftpsclient/ftpsclient.py b/octoprint_bambu_printer/printer/ftpsclient/ftpsclient.py similarity index 100% rename from octoprint_bambu_printer/ftpsclient/ftpsclient.py rename to octoprint_bambu_printer/printer/ftpsclient/ftpsclient.py diff --git a/octoprint_bambu_printer/gcode_executor.py b/octoprint_bambu_printer/printer/gcode_executor.py similarity index 100% rename from octoprint_bambu_printer/gcode_executor.py rename to octoprint_bambu_printer/printer/gcode_executor.py diff --git a/octoprint_bambu_printer/printer/print_job.py b/octoprint_bambu_printer/printer/print_job.py new file mode 100644 index 0000000..5e383d1 --- /dev/null +++ b/octoprint_bambu_printer/printer/print_job.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass +from octoprint_bambu_printer.printer.remote_sd_card_file_list import FileInfo + + +@dataclass +class PrintJob: + file_info: FileInfo + file_position: int + + @property + def progress(self): + if self.file_info.size is None: + return 100 + return 100 * self.file_position / self.file_info.size + + @progress.setter + def progress(self, value): + self.file_position = int(self.file_info.size * ((value + 1) / 100)) diff --git a/octoprint_bambu_printer/printer/printer_serial_io.py b/octoprint_bambu_printer/printer/printer_serial_io.py new file mode 100644 index 0000000..b1ba777 --- /dev/null +++ b/octoprint_bambu_printer/printer/printer_serial_io.py @@ -0,0 +1,265 @@ +import logging +import queue +import re +import threading +import time +from typing import Callable + +from octoprint.util import to_bytes, to_unicode +from serial import SerialTimeoutException + +from .char_counting_queue import CharCountingQueue + + +class PrinterSerialIO(threading.Thread): + command_regex = re.compile(r"^([GM])(\d+)") + + def __init__( + self, + handle_command_callback: Callable[[str, str, bytes], None], + settings, + serial_log_handler=None, + read_timeout=5.0, + write_timeout=10.0, + ) -> None: + super().__init__( + name="octoprint.plugins.bambu_printer.wait_thread", daemon=True + ) + self._handle_command_callback = handle_command_callback + self._settings = settings + self._serial_log = logging.getLogger( + "octoprint.plugins.bambu_printer.BambuPrinter.serial" + ) + self._serial_log.setLevel(logging.CRITICAL) + self._serial_log.propagate = False + + if serial_log_handler is not None: + self._serial_log.addHandler(serial_log_handler) + self._serial_log.setLevel(logging.INFO) + + self._serial_log.debug("-" * 78) + + self._read_timeout = read_timeout + self._write_timeout = write_timeout + + self._received_lines = 0 + self._wait_interval = 5.0 + self._running = True + + self._rx_buffer_size = 64 + self._incoming_lock = threading.RLock() + + self.incoming = CharCountingQueue(self._rx_buffer_size, name="RxBuffer") + self.outgoing = queue.Queue() + self.buffered = queue.Queue(maxsize=4) + self.command_queue = queue.Queue() + + @property + def incoming_lock(self): + return self._incoming_lock + + def run(self) -> None: + linenumber = 0 + next_wait_timeout = 0 + + def recalculate_next_wait_timeout(): + nonlocal next_wait_timeout + next_wait_timeout = time.monotonic() + self._wait_interval + + recalculate_next_wait_timeout() + + data = None + + buf = b"" + while self.incoming is not None and self._running: + try: + data = self.incoming.get(timeout=0.01) + data = to_bytes(data, encoding="ascii", errors="replace") + self.incoming.task_done() + except queue.Empty: + continue + except Exception: + if self.incoming is None: + # just got closed + break + + if data is not None: + buf += data + nl = buf.find(b"\n") + 1 + if nl > 0: + data = buf[:nl] + buf = buf[nl:] + else: + continue + + recalculate_next_wait_timeout() + + if data is None: + continue + + self._received_lines += 1 + + # strip checksum + if b"*" in data: + checksum = int(data[data.rfind(b"*") + 1 :]) + data = data[: data.rfind(b"*")] + if not checksum == self._calculate_checksum(data): + self._triggerResend(expected=self.current_line + 1) + continue + + self.current_line += 1 + elif self._settings.get_boolean(["forceChecksum"]): + self.send(self._format_error("checksum_missing")) + continue + + # track N = N + 1 + if data.startswith(b"N") and b"M110" in data: + linenumber = int(re.search(b"N([0-9]+)", data).group(1)) + self.lastN = linenumber + self.current_line = linenumber + self.sendOk() + continue + + elif data.startswith(b"N"): + linenumber = int(re.search(b"N([0-9]+)", data).group(1)) + expected = self.lastN + 1 + if linenumber != expected: + self._triggerResend(actual=linenumber) + continue + else: + self.lastN = linenumber + + data = data.split(None, 1)[1].strip() + + data += b"\n" + + command = to_unicode(data, encoding="ascii", errors="replace").strip() + + # actual command handling + command_match = self.command_regex.match(command) + if command_match is not None: + gcode = command_match.group(0) + gcode_letter = command_match.group(1) + + self._handle_command_callback(gcode_letter, gcode, data) + + self._serial_log.debug("Closing down read loop") + + def stop(self): + self._running = False + + def _showPrompt(self, text, choices): + self._hidePrompt() + self.send(f"//action:prompt_begin {text}") + for choice in choices: + self.send(f"//action:prompt_button {choice}") + self.send("//action:prompt_show") + + def _hidePrompt(self): + self.send("//action:prompt_end") + + def write(self, data: bytes) -> int: + data = to_bytes(data, errors="replace") + u_data = to_unicode(data, errors="replace") + + with self._incoming_lock: + if self.is_closed(): + return 0 + + try: + written = self.incoming.put( + data, timeout=self._write_timeout, partial=True + ) + self._serial_log.debug(f"<<< {u_data}") + return written + except queue.Full: + self._serial_log.error( + "Incoming queue is full, raising SerialTimeoutException" + ) + raise SerialTimeoutException() + + def readline(self) -> bytes: + assert self.outgoing is not None + timeout = self._read_timeout + + try: + # fetch a line from the queue, wait no longer than timeout + line = to_unicode(self.outgoing.get(timeout=timeout), errors="replace") + self._serial_log.debug(f">>> {line.strip()}") + self.outgoing.task_done() + return to_bytes(line) + except queue.Empty: + # queue empty? return empty line + return b"" + + def send(self, line: str) -> None: + if self.outgoing is not None: + self.outgoing.put(line) + + def sendOk(self): + if self.outgoing is None: + return + self.send("ok") + + def reset(self): + if self.incoming is not None: + self._clearQueue(self.incoming) + if self.outgoing is not None: + self._clearQueue(self.outgoing) + + def close(self): + self.stop() + self.incoming = None + self.outgoing = None + + def is_closed(self): + return self.incoming is None or self.outgoing is None + + def _triggerResend( + self, expected: int = None, actual: int = None, checksum: int = None + ) -> None: + with self._incoming_lock: + if expected is None: + expected = self.lastN + 1 + else: + self.lastN = expected - 1 + + if actual is None: + if checksum: + self.send(self._format_error("checksum_mismatch")) + else: + self.send(self._format_error("checksum_missing")) + else: + self.send(self._format_error("lineno_mismatch", expected, actual)) + + def request_resend(): + self.send("Resend:%d" % expected) + self.sendOk() + + request_resend() + + def _calculate_checksum(self, line: bytes) -> int: + checksum = 0 + for c in bytearray(line): + checksum ^= c + return checksum + + def _format_error(self, error: str, *args, **kwargs) -> str: + errors = { + "checksum_mismatch": "Checksum mismatch", + "checksum_missing": "Missing checksum", + "lineno_mismatch": "expected line {} got {}", + "lineno_missing": "No Line Number with checksum, Last Line: {}", + "maxtemp": "MAXTEMP triggered!", + "mintemp": "MINTEMP triggered!", + "command_unknown": "Unknown command {}", + } + return f"Error: {errors.get(error).format(*args, **kwargs)}" + + def _clearQueue(self, q: queue.Queue): + try: + while q.get(block=False): + q.task_done() + continue + except queue.Empty: + pass diff --git a/octoprint_bambu_printer/remote_sd_card_file_list.py b/octoprint_bambu_printer/printer/remote_sd_card_file_list.py similarity index 89% rename from octoprint_bambu_printer/remote_sd_card_file_list.py rename to octoprint_bambu_printer/printer/remote_sd_card_file_list.py index 202c1d4..5121ecb 100644 --- a/octoprint_bambu_printer/remote_sd_card_file_list.py +++ b/octoprint_bambu_printer/printer/remote_sd_card_file_list.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import asdict, dataclass import datetime import itertools @@ -8,11 +10,10 @@ import logging.handlers from octoprint.util import get_dos_filename from octoprint.util.files import unix_timestamp_to_m20_timestamp - from .ftpsclient import IoTFTPSClient -@dataclass +@dataclass(frozen=True) class FileInfo: dosname: str path: Path @@ -36,11 +37,17 @@ class RemoteSDCardFileList: self._settings = settings self._file_alias_cache = {} self._file_data_cache = {} - self._selectedFilePath = None - self._selectedSdFileSize = 0 - self._selectedSdFilePos = 0 + self._selected_file_info: FileInfo | None = None self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") + @property + def selected_file(self): + return self._selected_file_info + + @property + def has_selected_file(self): + return self._selected_file_info is not None + def _get_ftp_file_info( self, ftp: IoTFTPSClient, ftp_path, file_path: Path, existing_files: list[str] ): @@ -133,14 +140,18 @@ class RemoteSDCardFileList: self._logger.error(f"{file_name} open failed") return False - if self._selectedFilePath == file_info.path and check_already_open: + if ( + self._selected_file_info is not None + and self._selected_file_info.path == file_info.path + and check_already_open + ): return True - self._selectedFilePath = file_info.path - self._selectedSdFileSize = file_info.size + self._selected_file_info = file_info self._logger.info( - f"File opened: {file_info.file_name} Size: {self._selectedSdFileSize}" + f"File opened: {self._selected_file_info.file_name} Size: {self._selected_file_info.size}" ) + return True def delete_file(self, file_path: str) -> None: host = self._settings.get(["host"]) diff --git a/octoprint_bambu_printer/printer/states/__init__.py b/octoprint_bambu_printer/printer/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/octoprint_bambu_printer/printer/states/a_printer_state.py b/octoprint_bambu_printer/printer/states/a_printer_state.py new file mode 100644 index 0000000..6cf8e9b --- /dev/null +++ b/octoprint_bambu_printer/printer/states/a_printer_state.py @@ -0,0 +1,39 @@ +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from octoprint_bambu_printer.printer.bambu_virtual_printer import ( + BambuVirtualPrinter, + ) + + +class APrinterState: + def __init__(self, printer: BambuVirtualPrinter) -> None: + self._log = logging.getLogger( + "octoprint.plugins.bambu_printer.BambuPrinter.states" + ) + self._printer = printer + + def init(self): + pass + + def finalize(self): + pass + + def handle_gcode(self, gcode): + self._log.debug(f"{self.__class__.__name__} gcode execution disabled") + + def connect(self): + self._log_skip_state_transition("connect") + + def pause(self): + self._log_skip_state_transition("pause") + + def cancel(self): + self._log_skip_state_transition("cancel") + + def resume(self): + self._log_skip_state_transition("resume") + + def _log_skip_state_transition(self, method): + self._log.debug(f"skipping {self.__class__.__name__} state transition {method}") diff --git a/octoprint_bambu_printer/printer/states/idle_state.py b/octoprint_bambu_printer/printer/states/idle_state.py new file mode 100644 index 0000000..2a71558 --- /dev/null +++ b/octoprint_bambu_printer/printer/states/idle_state.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState + + +class IdleState(APrinterState): + pass diff --git a/octoprint_bambu_printer/printer/states/paused_state.py b/octoprint_bambu_printer/printer/states/paused_state.py new file mode 100644 index 0000000..0223aef --- /dev/null +++ b/octoprint_bambu_printer/printer/states/paused_state.py @@ -0,0 +1,37 @@ +import threading + +from octoprint.util import RepeatedTimer + +from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter +from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState + + +class PausedState(APrinterState): + + def __init__(self, printer: BambuVirtualPrinter) -> None: + super().__init__(printer) + self._pausedLock = threading.Event() + + def init(self): + if not self._pausedLock.is_set(): + self._pausedLock.set() + + self._printer.sendIO("// action:paused") + self._sendPaused() + + def finalize(self): + if self._pausedLock.is_set(): + self._pausedLock.clear() + + def _sendPaused(self): + if self._printer.current_print_job is None: + self._log.warn("job paused, but no print job available?") + return + paused_timer = RepeatedTimer( + interval=3.0, + function=self._printer.report_print_job_status, + daemon=True, + run_first=True, + condition=self._pausedLock.is_set, + ) + paused_timer.start() diff --git a/octoprint_bambu_printer/printer/states/print_finished_state.py b/octoprint_bambu_printer/printer/states/print_finished_state.py new file mode 100644 index 0000000..dce0684 --- /dev/null +++ b/octoprint_bambu_printer/printer/states/print_finished_state.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState + + +class PrintFinishedState(APrinterState): + def init(self): + if self._printer.current_print_job is not None: + self._printer.current_print_job.progress = 100 + self._finishSdPrint() + + def _finishSdPrint(self): + if self._printer.is_running: + self._printer.sendIO("Done printing file") + self._selectedSdFilePos = 0 + self._selectedSdFileSize = 0 + self._sdPrinting = False + self._sdPrintStarting = False + self._sdPrinter = None diff --git a/octoprint_bambu_printer/printer/states/printing_state.py b/octoprint_bambu_printer/printer/states/printing_state.py new file mode 100644 index 0000000..41c0bbe --- /dev/null +++ b/octoprint_bambu_printer/printer/states/printing_state.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import threading + +import pybambu +import pybambu.models +import pybambu.commands + +from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter +from octoprint_bambu_printer.printer.print_job import PrintJob +from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState + + +class PrintingState(APrinterState): + + def __init__(self, printer: BambuVirtualPrinter) -> None: + super().__init__(printer) + self._printingLock = threading.Event() + self._print_job: PrintJob | None = None + self._sd_printing_thread = None + + @property + def print_job(self): + return self._print_job + + def init(self): + if not self._printingLock.is_set(): + self._printingLock.set() + + def finalize(self): + if self._printingLock.is_set(): + self._printingLock.clear() + + def _start_worker_thread(self, from_printer: bool = False): + if self._sd_printing_thread is None: + self._sdPrinting = True + self._sdPrintStarting = True + self._sd_printing_thread = threading.Thread( + target=self._printing_worker, kwargs={"from_printer": from_printer} + ) + self._sd_printing_thread.start() + + def set_print_job_info(self, print_job_info): + filename: str = print_job_info.get("subtask_name") + project_file_info = self._printer.file_system.search_by_stem( + filename, [".3mf", ".gcode.3mf"] + ) + if project_file_info is None: + self._log.debug(f"No 3mf file found for {print_job_info}") + return + + if self._printer.file_system.select_file(filename): + self._printer.sendOk() + self.start_new_print(from_printer=True) + + # fuzzy math here to get print percentage to match BambuStudio + progress = print_job_info.get("print_percentage") + self._print_job = PrintJob(project_file_info, 0) + self._print_job.progress = + + def start_new_print(self, from_printer: bool = False): + if self._printer.file_system.selected_file is not None: + self._start_worker_thread(from_printer) + + if self._sd_printing_thread is not None: + if self._printer.bambu_client.connected: + if self._printer.bambu_client.publish(pybambu.commands.RESUME): + self._log.info("print resumed") + else: + self._log.info("print resume failed") + return True + + def _printing_worker(self, from_printer: bool = False): + try: + if not from_printer and self._printer.bambu_client.connected: + selected_file = self._printer.file_system.selected_file + print_command = { + "print": { + "sequence_id": 0, + "command": "project_file", + "param": "Metadata/plate_1.gcode", + "md5": "", + "profile_id": "0", + "project_id": "0", + "subtask_id": "0", + "task_id": "0", + "subtask_name": f"{selected_file}", + "file": f"{selected_file}", + "url": ( + f"file:///mnt/sdcard/{selected_file}" + if self._printer._settings.get_boolean(["device_type"]) + in ["X1", "X1C"] + else f"file:///sdcard/{selected_file}" + ), + "timelapse": self._printer._settings.get_boolean(["timelapse"]), + "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), + "flow_cali": self._printer._settings.get_boolean(["flow_cali"]), + "vibration_cali": self._printer._settings.get_boolean( + ["vibration_cali"] + ), + "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), + "use_ams": self._printer._settings.get_boolean(["use_ams"]), + } + } + self._printer.bambu_client.publish(print_command) + + while self._selectedSdFilePos < self._selectedSdFileSize: + if self._killed or not self._sdPrinting: + break + + # if we are paused, wait for resuming + self._sdPrintingSemaphore.wait() + self._reportSdStatus() + time.sleep(3) + self._log.debug(f"SD File Print: {self._selectedSdFile}") + except AttributeError: + if self.outgoing is not None: + raise + + self._printer.change_state(self._printer._state_finished) + + def cancel(self): + if self._printer.bambu_client.connected: + if self._printer.bambu_client.publish(pybambu.commands.STOP): + self._log.info("print cancelled") + self._printer.change_state(self._printer._state_finished) + return True + else: + self._log.info("print cancel failed") + return False + return False