diff --git a/.gitignore b/.gitignore index 56b0eec..10fca6b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist .DS_Store *.zip extras + +test/test_output \ No newline at end of file diff --git a/octoprint_bambu_printer/__init__.py b/octoprint_bambu_printer/__init__.py index 5feabf2..f9b69d2 100644 --- a/octoprint_bambu_printer/__init__.py +++ b/octoprint_bambu_printer/__init__.py @@ -1,260 +1,10 @@ # coding=utf-8 -from __future__ import absolute_import - -import os -import threading -import time -import flask -import datetime - -import octoprint.plugin -from octoprint.events import Events -from octoprint.util import get_formatted_size, get_formatted_datetime, is_hidden_path -from octoprint.server.util.flask import no_firstrun_access -from octoprint.server.util.tornado import LargeResponseHandler, UrlProxyHandler, path_validation_factory -from octoprint.access.permissions import Permissions -from urllib.parse import quote as urlquote -from .ftpsclient import IoTFTPSClient - - -class BambuPrintPlugin(octoprint.plugin.SettingsPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.AssetPlugin, - octoprint.plugin.EventHandlerPlugin, - octoprint.plugin.SimpleApiPlugin, - octoprint.plugin.BlueprintPlugin): - - - def get_assets(self): - return {'js': ["js/bambu_printer.js"]} - def get_template_configs(self): - return [{"type": "settings", "custom_bindings": True}, - {"type": "generic", "custom_bindings": True, "template": "bambu_timelapse.jinja2"}] #, {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] - - def get_settings_defaults(self): - return {"device_type": "X1C", - "serial": "", - "host": "", - "access_code": "", - "username": "bblp", - "timelapse": False, - "bed_leveling": True, - "flow_cali": False, - "vibration_cali": True, - "layer_inspect": True, - "use_ams": False, - "local_mqtt": True, - "region": "", - "email": "", - "auth_token": "", - "always_use_default_options": False - } - - def is_api_adminonly(self): - return True - - def get_api_commands(self): - return {"register": ["email", "password", "region", "auth_token"]} - def on_api_command(self, command, data): - if command == "register": - if "email" in data and "password" in data and "region" in data and "auth_token" in data: - self._logger.info(f"Registering user {data['email']}") - from pybambu import BambuCloud - bambu_cloud = BambuCloud(data["region"], data["email"], data["password"], data["auth_token"]) - bambu_cloud.login(data["region"], data["email"], data["password"]) - return flask.jsonify({"auth_token": bambu_cloud.auth_token, "username": bambu_cloud.username}) - def on_event(self, event, payload): - if event == Events.TRANSFER_DONE: - self._printer.commands("M20 L T", force=True) - def support_3mf_files(self): - return {'machinecode': {'3mf': ["3mf"]}} - - def upload_to_sd(self, printer, filename, path, sd_upload_started, sd_upload_succeeded, sd_upload_failed, *args, **kwargs): - self._logger.debug(f"Starting upload from {filename} to {filename}") - sd_upload_started(filename, filename) - def process(): - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - elapsed = time.monotonic() - - try: - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - if ftp.upload_file(path, f"{filename}"): - elapsed = time.monotonic() - elapsed - sd_upload_succeeded(filename, filename, elapsed) - # remove local file after successful upload to Bambu - # self._file_manager.remove_file("local", filename) - else: - raise Exception("upload failed") - except Exception as e: - elapsed = time.monotonic() - elapsed - sd_upload_failed(filename, filename, elapsed) - self._logger.debug(f"Error uploading file {filename}") - - thread = threading.Thread(target=process) - thread.daemon = True - thread.start() - - return filename - - def get_template_vars(self): - return {"plugin_version": self._plugin_version} - - def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): - if not port == "BAMBU": - return None - - if self._settings.get(["serial"]) == "" or self._settings.get(["host"]) == "" or self._settings.get(["access_code"]) == "": - return None - - import logging.handlers - - from octoprint.logging.handlers import CleaningTimedRotatingFileHandler - - seriallog_handler = CleaningTimedRotatingFileHandler( - self._settings.get_plugin_logfile_path(postfix="serial"), - when="D", - backupCount=3, - ) - seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) - seriallog_handler.setLevel(logging.DEBUG) - - from . import virtual - - serial_obj = virtual.BambuPrinter( - self._settings, - self._printer_profile_manager, - data_folder=self.get_plugin_data_folder(), - seriallog_handler=seriallog_handler, - read_timeout=float(read_timeout), - faked_baudrate=baudrate, - ) - return serial_obj - - def get_additional_port_names(self, *args, **kwargs): - if self._settings.get(["serial"]) != "" and self._settings.get(["host"]) != "" and self._settings.get(["access_code"]) != "": - return ["BAMBU"] - else: - return [] - - def get_timelapse_file_list(self): - if flask.request.path.startswith('/api/timelapse'): - def process(): - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - return_file_list = [] - - try: - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - if self._settings.get(["device_type"]) in ["X1", "X1C"]: - timelapse_file_list = ftp.list_files("timelapse/", ".mp4") or [] - else: - timelapse_file_list = ftp.list_files("timelapse/", ".avi") or [] - - for entry in timelapse_file_list: - if entry.startswith("/"): - filename = entry[1:].replace("timelapse/", "") - else: - filename = entry.replace("timelapse/", "") - - filesize = ftp.ftps_session.size(f"timelapse/{filename}") - date_str = ftp.ftps_session.sendcmd(f"MDTM timelapse/{filename}").replace("213 ", "") - filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp() - - return_file_list.append( - { - "bytes": filesize, - "date": get_formatted_datetime(datetime.datetime.fromtimestamp(filedate)), - "name": filename, - "size": get_formatted_size(filesize), - "thumbnail": "/plugin/bambu_printer/thumbnail/" + filename.replace(".mp4", ".jpg").replace(".avi", ".jpg"), - "timestamp": filedate, - "url": f"/plugin/bambu_printer/timelapse/{filename}" - }) - - self._plugin_manager.send_plugin_message(self._identifier, {'files': return_file_list}) - - except Exception as e: - self._logger.debug(f"Error getting timelapse files: {e}") - - thread = threading.Thread(target=process) - thread.daemon = True - thread.start() - - - def _hook_octoprint_server_api_before_request(self, *args, **kwargs): - return [self.get_timelapse_file_list] - - @octoprint.plugin.BlueprintPlugin.route("/timelapse/", methods=["GET"]) - @octoprint.server.util.flask.restricted_access - @no_firstrun_access - @Permissions.TIMELAPSE_DOWNLOAD.require(403) - def downloadTimelapse(self, filename): - dest_filename = os.path.join(self.get_plugin_data_folder(), filename) - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - - if not os.path.exists(dest_filename): - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - download_result = ftp.download_file( - source=f"timelapse/{filename}", - dest=dest_filename, - ) - - return flask.redirect("/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302) - - @octoprint.plugin.BlueprintPlugin.route("/thumbnail/", methods=["GET"]) - @octoprint.server.util.flask.restricted_access - @no_firstrun_access - @Permissions.TIMELAPSE_DOWNLOAD.require(403) - def downloadThumbnail(self, filename): - dest_filename = os.path.join(self.get_plugin_data_folder(), filename) - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - - if not os.path.exists(dest_filename): - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - download_result = ftp.download_file( - source=f"timelapse/thumbnail/{filename}", - dest=dest_filename, - ) - - return flask.redirect("/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302) - - def is_blueprint_csrf_protected(self): - return True - - def route_hook(self, server_routes, *args, **kwargs): - return [ - (r"/download/timelapse/(.*)", LargeResponseHandler, - {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( - lambda path: not is_hidden_path(path), status_code=404)}), - (r"/download/thumbnail/(.*)", LargeResponseHandler, - {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( - lambda path: not is_hidden_path(path), status_code=404)}) - ] - - def get_update_information(self): - return {'bambu_printer': {'displayName': "Bambu Printer", - 'displayVersion': self._plugin_version, - 'type': "github_release", - 'user': "jneilliii", - 'repo': "OctoPrint-BambuPrinter", - 'current': self._plugin_version, - 'stable_branch': {'name': "Stable", - 'branch': "master", - 'comittish': ["master"]}, - 'prerelease_branches': [ - {'name': "Release Candidate", - 'branch': "rc", - 'comittish': ["rc", "master"]} - ], - 'pip': "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip"}} - __plugin_name__ = "Bambu Printer" __plugin_pythoncompat__ = ">=3.7,<4" +from .bambu_print_plugin import BambuPrintPlugin + def __plugin_load__(): plugin = BambuPrintPlugin() @@ -270,5 +20,5 @@ def __plugin_load__(): "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, "octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request, - "octoprint.server.http.routes": __plugin_implementation__.route_hook + "octoprint.server.http.routes": __plugin_implementation__.route_hook, } diff --git a/octoprint_bambu_printer/bambu_print_plugin.py b/octoprint_bambu_printer/bambu_print_plugin.py new file mode 100644 index 0000000..dd255f0 --- /dev/null +++ b/octoprint_bambu_printer/bambu_print_plugin.py @@ -0,0 +1,309 @@ +from __future__ import absolute_import, annotations +from pathlib import Path +import threading +from time import perf_counter +from contextlib import contextmanager +import flask +import logging.handlers +from urllib.parse import quote as urlquote + +import octoprint.printer +import octoprint.server +import octoprint.plugin +from octoprint.events import Events +import octoprint.settings +from octoprint.util import is_hidden_path +from octoprint.server.util.flask import no_firstrun_access +from octoprint.server.util.tornado import ( + LargeResponseHandler, + path_validation_factory, +) +from octoprint.access.permissions import Permissions +from octoprint.logging.handlers import CleaningTimedRotatingFileHandler + +from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView +from pybambu import BambuCloud + +from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( + RemoteSDCardFileList, +) + +from .printer.file_system.bambu_timelapse_file_info import ( + BambuTimelapseFileInfo, +) +from .printer.bambu_virtual_printer import BambuVirtualPrinter + + +@contextmanager +def measure_elapsed(): + start = perf_counter() + + def _get_elapsed(): + return perf_counter() - start + + yield _get_elapsed + print(f"Total elapsed: {_get_elapsed()}") + + +class BambuPrintPlugin( + octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.EventHandlerPlugin, + octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.BlueprintPlugin, +): + _logger: logging.Logger + _plugin_manager: octoprint.plugin.PluginManager + _bambu_file_system: RemoteSDCardFileList + _timelapse_files_view: CachedFileView + + def on_settings_initialized(self): + self._bambu_file_system = RemoteSDCardFileList(self._settings) + self._timelapse_files_view = CachedFileView(self._bambu_file_system) + if self._settings.get(["device_type"]) in ["X1", "X1C"]: + self._timelapse_files_view.with_filter("timelapse/", ".mp4") + else: + self._timelapse_files_view.with_filter("timelapse/", ".avi") + + def get_assets(self): + return {"js": ["js/bambu_printer.js"]} + + def get_template_configs(self): + return [ + {"type": "settings", "custom_bindings": True}, + { + "type": "generic", + "custom_bindings": True, + "template": "bambu_timelapse.jinja2", + }, + ] # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] + + def get_settings_defaults(self): + return { + "device_type": "X1C", + "serial": "", + "host": "", + "access_code": "", + "username": "bblp", + "timelapse": False, + "bed_leveling": True, + "flow_cali": False, + "vibration_cali": True, + "layer_inspect": False, + "use_ams": False, + "local_mqtt": True, + "region": "", + "email": "", + "auth_token": "", + "always_use_default_options": False, + } + + def is_api_adminonly(self): + return True + + def get_api_commands(self): + return {"register": ["email", "password", "region", "auth_token"]} + + def on_api_command(self, command, data): + if command == "register": + if ( + "email" in data + and "password" in data + and "region" in data + and "auth_token" in data + ): + self._logger.info(f"Registering user {data['email']}") + bambu_cloud = BambuCloud( + data["region"], data["email"], data["password"], data["auth_token"] + ) + bambu_cloud.login(data["region"], data["email"], data["password"]) + return flask.jsonify( + { + "auth_token": bambu_cloud.auth_token, + "username": bambu_cloud.username, + } + ) + + def on_event(self, event, payload): + if event == Events.TRANSFER_DONE: + self._printer.commands("M20 L T", force=True) + + def support_3mf_files(self): + return {"machinecode": {"3mf": ["3mf"]}} + + def upload_to_sd( + self, + printer, + filename, + path, + sd_upload_started, + sd_upload_succeeded, + sd_upload_failed, + *args, + **kwargs, + ): + self._logger.debug(f"Starting upload from {filename} to {filename}") + sd_upload_started(filename, filename) + + def process(): + with measure_elapsed() as get_elapsed: + try: + with self._bambu_file_system.get_ftps_client() as ftp: + if ftp.upload_file(path, f"{filename}"): + sd_upload_succeeded(filename, filename, get_elapsed()) + else: + raise Exception("upload failed") + except Exception as e: + sd_upload_failed(filename, filename, get_elapsed()) + self._logger.exception(e) + + thread = threading.Thread(target=process) + thread.daemon = True + thread.start() + return filename + + def get_template_vars(self): + return {"plugin_version": self._plugin_version} + + def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): + if not port == "BAMBU": + return None + if ( + self._settings.get(["serial"]) == "" + or self._settings.get(["host"]) == "" + or self._settings.get(["access_code"]) == "" + ): + return None + seriallog_handler = CleaningTimedRotatingFileHandler( + self._settings.get_plugin_logfile_path(postfix="serial"), + when="D", + backupCount=3, + ) + seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + seriallog_handler.setLevel(logging.DEBUG) + + serial_obj = BambuVirtualPrinter( + self._settings, + self._printer_profile_manager, + data_folder=self.get_plugin_data_folder(), + serial_log_handler=seriallog_handler, + read_timeout=float(read_timeout), + faked_baudrate=baudrate, + ) + return serial_obj + + def get_additional_port_names(self, *args, **kwargs): + if ( + self._settings.get(["serial"]) != "" + and self._settings.get(["host"]) != "" + and self._settings.get(["access_code"]) != "" + ): + return ["BAMBU"] + else: + return [] + + def get_timelapse_file_list(self): + if flask.request.path.startswith("/api/timelapse"): + + def process(): + return_file_list = [] + for file_info in self._timelapse_files_view.get_all_info(): + timelapse_info = BambuTimelapseFileInfo.from_file_info(file_info) + return_file_list.append(timelapse_info.to_dict()) + self._plugin_manager.send_plugin_message( + self._identifier, {"files": return_file_list} + ) + + thread = threading.Thread(target=process) + thread.daemon = True + thread.start() + + def _hook_octoprint_server_api_before_request(self, *args, **kwargs): + return [self.get_timelapse_file_list] + + def _download_file(self, file_name: str, source_path: str): + destination = Path(self.get_plugin_data_folder()) / file_name + if destination.exists(): + return destination + + with self._bambu_file_system.get_ftps_client() as ftp: + ftp.download_file( + source=(Path(source_path) / file_name).as_posix(), + dest=destination.as_posix(), + ) + return destination + + @octoprint.plugin.BlueprintPlugin.route("/timelapse/", methods=["GET"]) + @octoprint.server.util.flask.restricted_access + @no_firstrun_access + @Permissions.TIMELAPSE_DOWNLOAD.require(403) + def downloadTimelapse(self, filename): + self._download_file(filename, "timelapse/") + return flask.redirect( + "/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302 + ) + + @octoprint.plugin.BlueprintPlugin.route("/thumbnail/", methods=["GET"]) + @octoprint.server.util.flask.restricted_access + @no_firstrun_access + @Permissions.TIMELAPSE_DOWNLOAD.require(403) + def downloadThumbnail(self, filename): + self._download_file(filename, "timelapse/thumbnail/") + return flask.redirect( + "/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302 + ) + + def is_blueprint_csrf_protected(self): + return True + + def route_hook(self, server_routes, *args, **kwargs): + return [ + ( + r"/download/timelapse/(.*)", + LargeResponseHandler, + { + "path": self.get_plugin_data_folder(), + "as_attachment": True, + "path_validation": path_validation_factory( + lambda path: not is_hidden_path(path), status_code=404 + ), + }, + ), + ( + r"/download/thumbnail/(.*)", + LargeResponseHandler, + { + "path": self.get_plugin_data_folder(), + "as_attachment": True, + "path_validation": path_validation_factory( + lambda path: not is_hidden_path(path), status_code=404 + ), + }, + ), + ] + + def get_update_information(self): + return { + "bambu_printer": { + "displayName": "Bambu Printer", + "displayVersion": self._plugin_version, + "type": "github_release", + "user": "jneilliii", + "repo": "OctoPrint-BambuPrinter", + "current": self._plugin_version, + "stable_branch": { + "name": "Stable", + "branch": "master", + "comittish": ["master"], + }, + "prerelease_branches": [ + { + "name": "Release Candidate", + "branch": "rc", + "comittish": ["rc", "master"], + } + ], + "pip": "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip", + } + } diff --git a/octoprint_bambu_printer/ftpsclient/__init__.py b/octoprint_bambu_printer/ftpsclient/__init__.py deleted file mode 100644 index 239b9c9..0000000 --- a/octoprint_bambu_printer/ftpsclient/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .ftpsclient import IoTFTPSClient diff --git a/octoprint_bambu_printer/printer/__init__.py b/octoprint_bambu_printer/printer/__init__.py new file mode 100644 index 0000000..23aaa95 --- /dev/null +++ b/octoprint_bambu_printer/printer/__init__.py @@ -0,0 +1,2 @@ +__author__ = "Gina Häußge " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" 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..0c71fb0 --- /dev/null +++ b/octoprint_bambu_printer/printer/bambu_virtual_printer.py @@ -0,0 +1,619 @@ +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 + +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 + + +# 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._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": + 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"]) == "" + or self._settings.get(["username"]) == "" + or self._settings.get(["access_code"]) == "" + ): + msg = "invalid settings to start connection with Bambu Printer" + self._log.debug(msg) + raise ValueError(msg) + + 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) + else: + self.stop_continuous_status_report() + + 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 + + # noinspection PyUnusedLocal + @gcode_executor.register_no_data("M115") + def _report_firmware_info(self) -> bool: + self.sendIO("Bambu Printer Integration") + 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[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: 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: + self.sendIO( + f"SD printing byte {self.current_print_job.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") diff --git a/octoprint_bambu_printer/printer/file_system/__init__.py b/octoprint_bambu_printer/printer/file_system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/octoprint_bambu_printer/printer/file_system/bambu_timelapse_file_info.py b/octoprint_bambu_printer/printer/file_system/bambu_timelapse_file_info.py new file mode 100644 index 0000000..a23a396 --- /dev/null +++ b/octoprint_bambu_printer/printer/file_system/bambu_timelapse_file_info.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path + +from .file_info import FileInfo + +from octoprint.util import get_formatted_size, get_formatted_datetime + + +@dataclass(frozen=True) +class BambuTimelapseFileInfo: + bytes: int + date: str | None + name: str + size: str + thumbnail: str + timestamp: float + url: str + + def to_dict(self): + return asdict(self) + + @staticmethod + def from_file_info(file_info: FileInfo): + return BambuTimelapseFileInfo( + bytes=file_info.size, + date=get_formatted_datetime(file_info.date), + name=file_info.file_name, + size=get_formatted_size(file_info.size), + thumbnail=f"/plugin/bambu_printer/thumbnail/{file_info.path.stem}.jpg", + timestamp=file_info.timestamp, + url=f"/plugin/bambu_printer/timelapse/{file_info.file_name}", + ) diff --git a/octoprint_bambu_printer/printer/file_system/cached_file_view.py b/octoprint_bambu_printer/printer/file_system/cached_file_view.py new file mode 100644 index 0000000..ff94c13 --- /dev/null +++ b/octoprint_bambu_printer/printer/file_system/cached_file_view.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( + RemoteSDCardFileList, + ) + +from dataclasses import dataclass, field +from pathlib import Path +from octoprint_bambu_printer.printer.file_system.file_info import FileInfo + + +@dataclass +class CachedFileView: + file_system: RemoteSDCardFileList + folder_view: dict[tuple[str, str | list[str] | None], None] = field( + default_factory=dict + ) # dict preserves order, but set does not. We use only dict keys as storage + on_update: Callable[[], None] | None = None + + def __post_init__(self): + self._file_alias_cache: dict[str, str] = {} + self._file_data_cache: dict[str, FileInfo] = {} + + def with_filter( + self, folder: str, extensions: str | list[str] | None = None + ) -> "CachedFileView": + self.folder_view[(folder, extensions)] = None + return self + + def list_all_views(self): + existing_files: list[str] = [] + result: list[FileInfo] = [] + + with self.file_system.get_ftps_client() as ftp: + for filter in self.folder_view.keys(): + result.extend(self.file_system.list_files(*filter, ftp, existing_files)) + return result + + def update(self): + file_info_list = self.list_all_views() + self._update_file_list_cache(file_info_list) + if self.on_update: + self.on_update() + + def _update_file_list_cache(self, files: list[FileInfo]): + self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files} + self._file_data_cache = {info.path.as_posix(): info for info in files} + + def get_all_info(self): + self.update() + return self.get_all_cached_info() + + def get_all_cached_info(self): + return list(self._file_data_cache.values()) + + def get_file_data(self, file_path: str | Path) -> FileInfo | None: + file_data = self.get_file_data_cached(file_path) + if file_data is None: + self.update() + file_data = self.get_file_data_cached(file_path) + return file_data + + def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None: + if isinstance(file_path, str): + file_path = Path(file_path).as_posix().strip("/") + else: + file_path = file_path.as_posix().strip("/") + + if file_path not in self._file_data_cache: + file_path = self._file_alias_cache.get(file_path, file_path) + return self._file_data_cache.get(file_path, None) + + def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]): + if file_stem == "": + return None + + file_stem = Path(file_stem).with_suffix("").stem + file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) + if file_data is None: + self.update() + file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) + return file_data + + def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]): + for file_path_str in self._file_data_cache.keys(): + file_path = Path(file_path_str) + if file_stem == file_path.with_suffix("").stem and all( + suffix in allowed_suffixes for suffix in file_path.suffixes + ): + return self.get_file_data_cached(file_path) + return None diff --git a/octoprint_bambu_printer/printer/file_system/file_info.py b/octoprint_bambu_printer/printer/file_system/file_info.py new file mode 100644 index 0000000..95b0256 --- /dev/null +++ b/octoprint_bambu_printer/printer/file_system/file_info.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path + +from octoprint.util.files import unix_timestamp_to_m20_timestamp + + +@dataclass(frozen=True) +class FileInfo: + dosname: str + path: Path + size: int + date: datetime + + @property + def file_name(self): + return self.path.name + + @property + def timestamp(self) -> float: + return self.date.timestamp() + + @property + def timestamp_m20(self) -> str: + return unix_timestamp_to_m20_timestamp(int(self.timestamp)) + + def get_gcode_info(self) -> str: + return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"' + + def to_dict(self): + return asdict(self) diff --git a/octoprint_bambu_printer/ftpsclient/ftpsclient.py b/octoprint_bambu_printer/printer/file_system/ftps_client.py similarity index 64% rename from octoprint_bambu_printer/ftpsclient/ftpsclient.py rename to octoprint_bambu_printer/printer/file_system/ftps_client.py index 638e3e8..1f5feea 100644 --- a/octoprint_bambu_printer/ftpsclient/ftpsclient.py +++ b/octoprint_bambu_printer/printer/file_system/ftps_client.py @@ -24,16 +24,21 @@ wrapper for FTPS server interactions """ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone import ftplib import os +from pathlib import Path import socket import ssl -from typing import Optional, Union, List +from typing import Generator, Union from contextlib import redirect_stdout import io import re + class ImplicitTLS(ftplib.FTP_TLS): """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" @@ -57,67 +62,20 @@ 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) # this is the fix + conn = self.context.wrap_socket( + conn, server_hostname=self.host, session=self.sock.session + ) # this is the fix return conn, size -class IoTFTPSClient: +@dataclass +class IoTFTPSConnection: """iot ftps ftpsclient""" - ftps_host: str - ftps_port: int - ftps_user: str - ftps_pass: str - ssl_implicit: bool - ftps_session: Union[ftplib.FTP, ImplicitTLS] - last_error: Optional[str] = None - welcome: str - - def __init__( - self, - ftps_host: str, - ftps_port: Optional[int] = 21, - ftps_user: Optional[str] = "", - ftps_pass: Optional[str] = "", - ssl_implicit: Optional[bool] = False, - ) -> None: - self.ftps_host = ftps_host - self.ftps_port = ftps_port - self.ftps_user = ftps_user - self.ftps_pass = ftps_pass - self.ssl_implicit = ssl_implicit - self.instantiate_ftps_session() - - def __repr__(self) -> str: - return ( - "IoT FTPS Client\n" - "--------------------\n" - f"host: {self.ftps_host}\n" - f"port: {self.ftps_port}\n" - f"user: {self.ftps_user}\n" - f"ssl: {self.ssl_implicit}" - ) - - def instantiate_ftps_session(self) -> None: - """init ftps_session based on input params""" - self.ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() - self.ftps_session.set_debuglevel(0) - - self.welcome = self.ftps_session.connect( - host=self.ftps_host, port=self.ftps_port) - - if self.ftps_user and self.ftps_pass: - self.ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) - else: - self.ftps_session.login() + ftps_session: ftplib.FTP | ImplicitTLS - if self.ssl_implicit: - self.ftps_session.prot_p() - - def disconnect(self) -> None: - """disconnect the current session from the ftps server""" + def close(self) -> None: + """close the current session from the ftps server""" self.ftps_session.close() def download_file(self, source: str, dest: str): @@ -137,7 +95,7 @@ def upload_file(self, source: str, dest: str, callback=None) -> bool: # Taken from ftplib.storbinary but with custom ssl handling # due to the shitty bambu p1p ftps server TODO fix properly. with open(source, "rb") as fp: - self.ftps_session.voidcmd('TYPE I') + self.ftps_session.voidcmd("TYPE I") with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: while 1: @@ -152,7 +110,9 @@ def upload_file(self, source: str, dest: str, callback=None) -> bool: callback(buf) # shutdown ssl layer - if ftplib._SSLSocket is not None and isinstance(conn, ftplib._SSLSocket): + if ftplib._SSLSocket is not None and isinstance( + conn, ftplib._SSLSocket + ): # Yeah this is suposed to be conn.unwrap # But since we operate in prot p mode # we can close the connection always. @@ -185,19 +145,26 @@ def move_file(self, source: str, dest: str): def mkdir(self, path: str) -> str: return self.ftps_session.mkd(path) - def list_files(self, path: str, file_pattern: Optional[str] = None) -> Union[List[str], None]: + def list_files( + self, list_path: str, extensions: str | list[str] | None = None + ) -> Generator[Path]: """list files under a path inside the FTPS server""" + + if extensions is None: + _extension_acceptable = lambda p: True + else: + if isinstance(extensions, str): + extensions = [extensions] + _extension_acceptable = lambda p: any(s in p.suffixes for s in extensions) + try: - files = self.ftps_session.nlst(path) - if not files: - return - if file_pattern: - return [f for f in files if file_pattern in f] - return files + list_result = self.ftps_session.nlst(list_path) or [] + for file_list_entry in list_result: + path = Path(list_path) / Path(file_list_entry).name + if _extension_acceptable(path): + yield path except Exception as ex: print(f"unexpected exception occurred: {ex}") - pass - return def list_files_ex(self, path: str) -> Union[list[str], None]: """list files under a path inside the FTPS server""" @@ -208,7 +175,8 @@ def list_files_ex(self, path: str) -> Union[list[str], None]: s = f.getvalue() files = [] for row in s.split("\n"): - if len(row) <= 0: continue + if len(row) <= 0: + continue attribs = row.split(" ") @@ -219,10 +187,70 @@ def list_files_ex(self, path: str) -> Union[list[str], None]: else: name = attribs[len(attribs) - 1] - file = ( attribs[0], name ) + file = (attribs[0], name) files.append(file) return files except Exception as ex: print(f"unexpected exception occurred: [{ex}]") pass return + + def get_file_size(self, file_path: str): + try: + return self.ftps_session.size(file_path) + except Exception as e: + raise RuntimeError( + f'Cannot get file size for "{file_path}" due to error: {str(e)}' + ) + + def get_file_date(self, file_path: str) -> datetime: + try: + date_response = self.ftps_session.sendcmd(f"MDTM {file_path}").replace( + "213 ", "" + ) + date = datetime.strptime(date_response, "%Y%m%d%H%M%S").replace( + tzinfo=timezone.utc + ) + return date + except Exception as e: + raise RuntimeError( + f'Cannot get file date for "{file_path}" due to error: {str(e)}' + ) + + +@dataclass +class IoTFTPSClient: + ftps_host: str + ftps_port: int = 21 + ftps_user: str = "" + ftps_pass: str = "" + ssl_implicit: bool = False + welcome: str = "" + _connection: IoTFTPSConnection | None = None + + def __enter__(self): + session = self.open_ftps_session() + self._connection = IoTFTPSConnection(session) + return self._connection + + def __exit__(self, type, value, traceback): + if self._connection is not None: + self._connection.close() + self._connection = None + + def open_ftps_session(self) -> ftplib.FTP | ImplicitTLS: + """init ftps_session based on input params""" + ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() + ftps_session.set_debuglevel(0) + + self.welcome = ftps_session.connect(host=self.ftps_host, port=self.ftps_port) + + if self.ftps_user and self.ftps_pass: + ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) + else: + ftps_session.login() + + if self.ssl_implicit: + ftps_session.prot_p() + + return ftps_session diff --git a/octoprint_bambu_printer/printer/file_system/remote_sd_card_file_list.py b/octoprint_bambu_printer/printer/file_system/remote_sd_card_file_list.py new file mode 100644 index 0000000..b6bd14d --- /dev/null +++ b/octoprint_bambu_printer/printer/file_system/remote_sd_card_file_list.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import datetime +from pathlib import Path +from typing import Iterable, Iterator +import logging.handlers + +from octoprint.util import get_dos_filename + +from .ftps_client import IoTFTPSClient, IoTFTPSConnection +from .file_info import FileInfo + + +class RemoteSDCardFileList: + + def __init__(self, settings) -> None: + self._settings = settings + self._selected_project_file: FileInfo | None = None + self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") + + def delete_file(self, file_path: Path) -> None: + try: + with self.get_ftps_client() as ftp: + if ftp.delete_file(file_path.as_posix()): + self._logger.debug(f"{file_path} deleted") + else: + raise RuntimeError(f"Deleting file {file_path} failed") + except Exception as e: + self._logger.exception(e) + + def list_files( + self, + folder: str, + extensions: str | list[str] | None, + ftp: IoTFTPSConnection, + existing_files=None, + ): + if existing_files is None: + existing_files = [] + + return list( + self.get_file_info_for_names( + ftp, ftp.list_files(folder, extensions), existing_files + ) + ) + + def _get_ftp_file_info( + self, + ftp: IoTFTPSConnection, + file_path: Path, + existing_files: list[str] | None = None, + ): + file_size = ftp.get_file_size(file_path.as_posix()) + date = ftp.get_file_date(file_path.as_posix()) + file_name = file_path.name.lower() + dosname = get_dos_filename(file_name, existing_filenames=existing_files).lower() + return FileInfo( + dosname, + file_path, + file_size if file_size is not None else 0, + date, + ) + + def get_file_info_for_names( + self, + ftp: IoTFTPSConnection, + files: Iterable[Path], + existing_files: list[str] | None = None, + ) -> Iterator[FileInfo]: + if existing_files is None: + existing_files = [] + + for entry in files: + try: + file_info = self._get_ftp_file_info(ftp, entry, existing_files) + yield file_info + existing_files.append(file_info.file_name) + existing_files.append(file_info.dosname) + except Exception as e: + self._logger.exception(e, exc_info=False) + + def get_ftps_client(self): + host = self._settings.get(["host"]) + access_code = self._settings.get(["access_code"]) + return IoTFTPSClient( + f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True + ) diff --git a/octoprint_bambu_printer/printer/gcode_executor.py b/octoprint_bambu_printer/printer/gcode_executor.py new file mode 100644 index 0000000..0d6b4f3 --- /dev/null +++ b/octoprint_bambu_printer/printer/gcode_executor.py @@ -0,0 +1,319 @@ +import itertools +import logging +from inspect import signature +import traceback + + +GCODE_DOCUMENTATION = { + "G0": "Linear Move", + "G1": "Linear Move", + "G2": "Arc or Circle Move", + "G3": "Arc or Circle Move", + "G4": "Dwell", + "G5": "Bézier cubic spline", + "G6": "Direct Stepper Move", + "G10": "Retract", + "G11": "Recover", + "G12": "Clean the Nozzle", + "G17": "CNC Workspace Planes", + "G18": "CNC Workspace Planes", + "G19": "CNC Workspace Planes", + "G20": "Inch Units", + "G21": "Millimeter Units", + "G26": "Mesh Validation Pattern", + "G27": "Park toolhead", + "G28": "Auto Home", + "G29": "Bed Leveling", + "G29": "Bed Leveling (3-Point)", + "G29": "Bed Leveling (Linear)", + "G29": "Bed Leveling (Manual)", + "G29": "Bed Leveling (Bilinear)", + "G29": "Bed Leveling (Unified)", + "G30": "Single Z-Probe", + "G31": "Dock Sled", + "G32": "Undock Sled", + "G33": "Delta Auto Calibration", + "G34": "Z Steppers Auto-Alignment", + "G34": "Mechanical Gantry Calibration", + "G35": "Tramming Assistant", + "G38.2": "Probe target", + "G38.3": "Probe target", + "G38.4": "Probe target", + "G38.5": "Probe target", + "G42": "Move to mesh coordinate", + "G53": "Move in Machine Coordinates", + "G60": "Save Current Position", + "G61": "Return to Saved Position", + "G76": "Probe temperature calibration", + "G80": "Cancel Current Motion Mode", + "G90": "Absolute Positioning", + "G91": "Relative Positioning", + "G92": "Set Position", + "G425": "Backlash Calibration", + "M0": "Unconditional stop", + "M1": "Unconditional stop", + "M3": "Spindle CW / Laser On", + "M4": "Spindle CCW / Laser On", + "M5": "Spindle / Laser Off", + "M7": "Coolant Controls", + "M8": "Coolant Controls", + "M9": "Coolant Controls", + "M10": "Vacuum / Blower Control", + "M11": "Vacuum / Blower Control", + "M16": "Expected Printer Check", + "M17": "Enable Steppers", + "M18": "Disable steppers", + "M84": "Disable steppers", + "M20": "List SD Card", + "M21": "Init SD card", + "M22": "Release SD card", + "M23": "Select SD file", + "M24": "Start or Resume SD print", + "M25": "Pause SD print", + "M26": "Set SD position", + "M27": "Report SD print status", + "M28": "Start SD write", + "M29": "Stop SD write", + "M30": "Delete SD file", + "M31": "Print time", + "M32": "Select and Start", + "M33": "Get Long Path", + "M34": "SDCard Sorting", + "M42": "Set Pin State", + "M43": "Debug Pins", + "M48": "Probe Repeatability Test", + "M73": "Set Print Progress", + "M75": "Start Print Job Timer", + "M76": "Pause Print Job Timer", + "M77": "Stop Print Job Timer", + "M78": "Print Job Stats", + "M80": "Power On", + "M81": "Power Off", + "M82": "E Absolute", + "M83": "E Relative", + "M85": "Inactivity Shutdown", + "M86": "Hotend Idle Timeout", + "M87": "Disable Hotend Idle Timeout", + "M92": "Set Axis Steps-per-unit", + "M100": "Free Memory", + "M102": "Configure Bed Distance Sensor", + "M104": "Set Hotend Temperature", + "M105": "Report Temperatures", + "M106": "Set Fan Speed", + "M107": "Fan Off", + "M108": "Break and Continue", + "M109": "Wait for Hotend Temperature", + "M110": "Set / Get Line Number", + "M111": "Debug Level", + "M112": "Full Shutdown", + "M113": "Host Keepalive", + "M114": "Get Current Position", + "M115": "Firmware Info", + "M117": "Set LCD Message", + "M118": "Serial print", + "M119": "Endstop States", + "M120": "Enable Endstops", + "M121": "Disable Endstops", + "M122": "TMC Debugging", + "M123": "Fan Tachometers", + "M125": "Park Head", + "M126": "Baricuda 1 Open", + "M127": "Baricuda 1 Close", + "M128": "Baricuda 2 Open", + "M129": "Baricuda 2 Close", + "M140": "Set Bed Temperature", + "M141": "Set Chamber Temperature", + "M143": "Set Laser Cooler Temperature", + "M145": "Set Material Preset", + "M149": "Set Temperature Units", + "M150": "Set RGB(W) Color", + "M154": "Position Auto-Report", + "M155": "Temperature Auto-Report", + "M163": "Set Mix Factor", + "M164": "Save Mix", + "M165": "Set Mix", + "M166": "Gradient Mix", + "M190": "Wait for Bed Temperature", + "M191": "Wait for Chamber Temperature", + "M192": "Wait for Probe temperature", + "M193": "Set Laser Cooler Temperature", + "M200": "Set Filament Diameter", + "M201": "Print / Travel Move Limits", + "M203": "Set Max Feedrate", + "M204": "Set Starting Acceleration", + "M205": "Set Advanced Settings", + "M206": "Set Home Offsets", + "M207": "Set Firmware Retraction", + "M208": "Firmware Recover", + "M209": "Set Auto Retract", + "M211": "Software Endstops", + "M217": "Filament swap parameters", + "M218": "Set Hotend Offset", + "M220": "Set Feedrate Percentage", + "M221": "Set Flow Percentage", + "M226": "Wait for Pin State", + "M240": "Trigger Camera", + "M250": "LCD Contrast", + "M255": "LCD Sleep/Backlight Timeout", + "M256": "LCD Brightness", + "M260": "I2C Send", + "M261": "I2C Request", + "M280": "Servo Position", + "M281": "Edit Servo Angles", + "M282": "Detach Servo", + "M290": "Babystep", + "M300": "Play Tone", + "M301": "Set Hotend PID", + "M302": "Cold Extrude", + "M303": "PID autotune", + "M304": "Set Bed PID", + "M305": "User Thermistor Parameters", + "M306": "Model Predictive Temp. Control", + "M350": "Set micro-stepping", + "M351": "Set Microstep Pins", + "M355": "Case Light Control", + "M360": "SCARA Theta A", + "M361": "SCARA Theta-B", + "M362": "SCARA Psi-A", + "M363": "SCARA Psi-B", + "M364": "SCARA Psi-C", + "M380": "Activate Solenoid", + "M381": "Deactivate Solenoids", + "M400": "Finish Moves", + "M401": "Deploy Probe", + "M402": "Stow Probe", + "M403": "MMU2 Filament Type", + "M404": "Set Filament Diameter", + "M405": "Filament Width Sensor On", + "M406": "Filament Width Sensor Off", + "M407": "Filament Width", + "M410": "Quickstop", + "M412": "Filament Runout", + "M413": "Power-loss Recovery", + "M420": "Bed Leveling State", + "M421": "Set Mesh Value", + "M422": "Set Z Motor XY", + "M423": "X Twist Compensation", + "M425": "Backlash compensation", + "M428": "Home Offsets Here", + "M430": "Power Monitor", + "M486": "Cancel Objects", + "M493": "Fixed-Time Motion", + "M500": "Save Settings", + "M501": "Restore Settings", + "M502": "Factory Reset", + "M503": "Report Settings", + "M504": "Validate EEPROM contents", + "M510": "Lock Machine", + "M511": "Unlock Machine", + "M512": "Set Passcode", + "M524": "Abort SD print", + "M540": "Endstops Abort SD", + "M569": "Set TMC stepping mode", + "M575": "Serial baud rate", + "M592": "Nonlinear Extrusion Control", + "M593": "ZV Input Shaping", + "M600": "Filament Change", + "M603": "Configure Filament Change", + "M605": "Multi Nozzle Mode", + "M665": "Delta Configuration", + "M665": "SCARA Configuration", + "M666": "Set Delta endstop adjustments", + "M666": "Set dual endstop offsets", + "M672": "Duet Smart Effector sensitivity", + "M701": "Load filament", + "M702": "Unload filament", + "M710": "Controller Fan settings", + "M808": "Repeat Marker", + "M851": "XYZ Probe Offset", + "M852": "Bed Skew Compensation", + "M871": "Probe temperature config", + "M876": "Handle Prompt Response", + "M900": "Linear Advance Factor", + "M906": "Stepper Motor Current", + "M907": "Set Motor Current", + "M908": "Set Trimpot Pins", + "M909": "DAC Print Values", + "M910": "Commit DAC to EEPROM", + "M911": "TMC OT Pre-Warn Condition", + "M912": "Clear TMC OT Pre-Warn", + "M913": "Set Hybrid Threshold Speed", + "M914": "TMC Bump Sensitivity", + "M915": "TMC Z axis calibration", + "M916": "L6474 Thermal Warning Test", + "M917": "L6474 Overcurrent Warning Test", + "M918": "L6474 Speed Warning Test", + "M919": "TMC Chopper Timing", + "M928": "Start SD Logging", + "M951": "Magnetic Parking Extruder", + "M993": "Back up flash settings to SD", + "M994": "Restore flash from SD", + "M995": "Touch Screen Calibration", + "M997": "Firmware update", + "M999": "STOP Restart", + "M7219": "MAX7219 Control", +} + + +class GCodeExecutor: + def __init__(self): + self._log = logging.getLogger( + "octoprint.plugins.bambu_printer.BambuPrinter.gcode_executor" + ) + self.handler_names = set() + self.gcode_handlers = {} + self.gcode_handlers_no_data = {} + + def __contains__(self, item): + return item in self.gcode_handlers or item in self.gcode_handlers_no_data + + def _get_required_args_count(self, func): + sig = signature(func) + required_count = sum( + 1 + for p in sig.parameters.values() + if (p.kind == p.POSITIONAL_OR_KEYWORD or p.kind == p.POSITIONAL_ONLY) + and p.default == p.empty + ) + return required_count + + def register(self, gcode): + def decorator(func): + required_count = self._get_required_args_count(func) + if required_count == 1: + self.gcode_handlers_no_data[gcode] = func + elif required_count == 2: + self.gcode_handlers[gcode] = func + else: + raise ValueError( + f"Cannot register function with {required_count} required parameters" + ) + return func + + return decorator + + def register_no_data(self, gcode): + def decorator(func): + self.gcode_handlers_no_data[gcode] = func + return func + + return decorator + + def execute(self, printer, gcode, data): + gcode_info = self._gcode_with_info(gcode) + try: + if gcode in self.gcode_handlers: + self._log.debug(f"Executing {gcode_info}") + return self.gcode_handlers[gcode](printer, data) + elif gcode in self.gcode_handlers_no_data: + self._log.debug(f"Executing {gcode_info}") + return self.gcode_handlers_no_data[gcode](printer) + else: + self._log.debug(f"ignoring {gcode_info} command.") + return False + except Exception as e: + self._log.error(f"Error during gcode {gcode_info}") + raise + + def _gcode_with_info(self, gcode): + return f"{gcode} ({GCODE_DOCUMENTATION.get(gcode, 'Info not specified')})" diff --git a/octoprint_bambu_printer/printer/print_job.py b/octoprint_bambu_printer/printer/print_job.py new file mode 100644 index 0000000..6998f46 --- /dev/null +++ b/octoprint_bambu_printer/printer/print_job.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( + FileInfo, +) + + +@dataclass +class PrintJob: + file_info: FileInfo + progress: int + + @property + def file_position(self): + if self.file_info.size is None: + return 0 + return int(self.file_info.size * self.progress / 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..eddff2b --- /dev/null +++ b/octoprint_bambu_printer/printer/printer_serial_io.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +from io import BufferedIOBase +import logging +import queue +import re +import threading +import traceback +from types import TracebackType +from typing import Callable + +from octoprint.util import to_bytes, to_unicode +from serial import SerialTimeoutException + + +class PrinterSerialIO(threading.Thread, BufferedIOBase): + command_regex = re.compile(r"^([GM])(\d+)") + + def __init__( + self, + handle_command_callback: Callable[[str, str], None], + settings, + serial_log_handler=None, + read_timeout=5.0, + write_timeout=10.0, + ) -> None: + super().__init__( + name="octoprint.plugins.bambu_printer.printer_worker", daemon=True + ) + self._handle_command_callback = handle_command_callback + self._settings = settings + self._log = self._init_logger(serial_log_handler) + + self._read_timeout = read_timeout + self._write_timeout = write_timeout + + self.current_line = 0 + self._received_lines = 0 + self._wait_interval = 5.0 + self._running = True + + self._rx_buffer_size = 64 + self._incoming_lock = threading.RLock() + + self.input_bytes = queue.Queue(self._rx_buffer_size) + self.output_bytes = queue.Queue() + self._error_detected: Exception | None = None + + def _init_logger(self, log_handler): + log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter.serial") + if log_handler is not None: + log.addHandler(log_handler) + log.debug("-" * 78) + return log + + @property + def incoming_lock(self): + return self._incoming_lock + + def run(self) -> None: + buffer = b"" + + while self._running: + try: + data = self.input_bytes.get(block=True, timeout=0.01) + data = to_bytes(data, encoding="ascii", errors="replace") + + buffer += data + line, buffer = self._read_next_line(buffer) + while line is not None: + self._received_lines += 1 + self._process_input_gcode_line(line) + line, buffer = self._read_next_line(buffer) + self.input_bytes.task_done() + except queue.Empty: + continue + except Exception as e: + self._error_detected = e + self.input_bytes.task_done() + self._clearQueue(self.input_bytes) + self._log.info( + "\n".join(traceback.format_exception_only(type(e), e)[-50:]) + ) + self._running = False + + self._log.debug("Closing IO read loop") + + def _read_next_line(self, buffer: bytes): + new_line_pos = buffer.find(b"\n") + 1 + if new_line_pos > 0: + line = buffer[:new_line_pos] + buffer = buffer[new_line_pos:] + return line, buffer + else: + return None, buffer + + def close(self): + self.flush() + self._running = False + self.join() + + def flush(self): + self.input_bytes.join() + self.raise_if_error() + + def raise_if_error(self): + if self._error_detected is not None: + raise self._error_detected + + 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: + self._log.debug(f"<<< {u_data}") + self.input_bytes.put(data, timeout=self._write_timeout) + return len(data) + except queue.Full: + self._log.error( + "Incoming queue is full, raising SerialTimeoutException" + ) + raise SerialTimeoutException() + + def readline(self) -> bytes: + try: + # fetch a line from the queue, wait no longer than timeout + line = to_unicode( + self.output_bytes.get(timeout=self._read_timeout), errors="replace" + ) + self._log.debug(f">>> {line.strip()}") + self.output_bytes.task_done() + return to_bytes(line) + except queue.Empty: + # queue empty? return empty line + return b"" + + def readlines(self): + result = [] + next_line = self.readline() + while next_line != b"": + result.append(next_line) + next_line = self.readline() + return result + + def send(self, line: str) -> None: + if self.output_bytes is not None: + self.output_bytes.put(line) + + def sendOk(self): + self.send("ok") + + def reset(self): + self._clearQueue(self.input_bytes) + self._clearQueue(self.output_bytes) + + def is_closed(self): + return not self._running + + def _process_input_gcode_line(self, data: bytes): + 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) + return + + self.current_line += 1 + elif self._settings.get_boolean(["forceChecksum"]): + self.send(self._format_error("checksum_missing")) + return + + line = self._process_linenumber_marker(data) + if line is None: + return + + command = to_unicode(line, encoding="ascii", errors="replace").strip() + command_match = self.command_regex.match(command) + if command_match is not None: + gcode = command_match.group(0) + self._handle_command_callback(gcode, command) + else: + self._log.warn(f'Not a valid gcode command "{command}"') + + def _process_linenumber_marker(self, data: bytes): + linenumber = 0 + 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() + return None + 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) + return None + else: + self.lastN = linenumber + data = data.split(None, 1)[1].strip() + return data + + def _triggerResend( + self, + expected: int | None = None, + actual: int | None = None, + checksum: int | None = 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/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..e8b7f2d --- /dev/null +++ b/octoprint_bambu_printer/printer/states/a_printer_state.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +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 update_print_job_info(self): + self._log_skip_state_transition("start_new_print") + + def start_new_print(self): + self._log_skip_state_transition("start_new_print") + + def pause_print(self): + self._log_skip_state_transition("pause_print") + + def cancel_print(self): + self._log_skip_state_transition("cancel_print") + + def resume_print(self): + self._log_skip_state_transition("resume_print") + + def _log_skip_state_transition(self, method): + self._log.debug( + f"skipping {self.__class__.__name__} state transition for '{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..deb8e75 --- /dev/null +++ b/octoprint_bambu_printer/printer/states/idle_state.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from pathlib import Path + +from octoprint_bambu_printer.printer.file_system.file_info import FileInfo +from octoprint_bambu_printer.printer.print_job import PrintJob +from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState + + +class IdleState(APrinterState): + + def start_new_print(self): + selected_file = self._printer.selected_file + if selected_file is None: + self._log.warn("Cannot start print job if file was not selected") + return + + print_command = self._get_print_command_for_file(selected_file) + self._log.debug(f"Sending print command: {print_command}") + if self._printer.bambu_client.publish(print_command): + self._log.info(f"Started print for {selected_file.file_name}") + else: + self._log.warn(f"Failed to start print for {selected_file.file_name}") + + def _get_print_command_for_file(self, selected_file: FileInfo): + + # URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf" + filesystem_root = ( + "file:///mnt/sdcard/" + if self._printer._settings.get_boolean(["device_type"]) in ["X1", "X1C"] + else "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": selected_file.file_name, + "url": f"{filesystem_root}{selected_file.path.as_posix()}", + "bed_type": "auto", + "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"]), + "ams_mapping": "", + } + } + + return print_command 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..79d5d54 --- /dev/null +++ b/octoprint_bambu_printer/printer/states/paused_state.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from octoprint_bambu_printer.printer.bambu_virtual_printer import ( + BambuVirtualPrinter, + ) + +import threading + +import pybambu.commands +from octoprint.util import RepeatedTimer + +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() + self._paused_repeated_report = None + + def init(self): + if not self._pausedLock.is_set(): + self._pausedLock.set() + + self._printer.sendIO("// action:paused") + self._printer.start_continuous_status_report(3) + + def finalize(self): + if self._pausedLock.is_set(): + self._pausedLock.clear() + if self._paused_repeated_report is not None: + self._paused_repeated_report.join() + self._paused_repeated_report = None + + def start_new_print(self): + 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") + + def cancel_print(self): + if self._printer.bambu_client.connected: + if self._printer.bambu_client.publish(pybambu.commands.STOP): + self._log.info("print cancelled") + self._printer.finalize_print_job() + else: + self._log.info("print cancel failed") 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..b0ff060 --- /dev/null +++ b/octoprint_bambu_printer/printer/states/printing_state.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from octoprint_bambu_printer.printer.bambu_virtual_printer import ( + BambuVirtualPrinter, + ) + +import threading + +import pybambu +import pybambu.models +import pybambu.commands + +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._is_printing = False + self._sd_printing_thread = None + + def init(self): + self._is_printing = True + self._printer.remove_project_selection() + self.update_print_job_info() + self._start_worker_thread() + + def finalize(self): + if self._sd_printing_thread is not None and self._sd_printing_thread.is_alive(): + self._is_printing = False + self._sd_printing_thread.join() + self._sd_printing_thread = None + + def _start_worker_thread(self): + if self._sd_printing_thread is None: + self._is_printing = True + self._sd_printing_thread = threading.Thread(target=self._printing_worker) + self._sd_printing_thread.start() + + def _printing_worker(self): + while ( + self._is_printing + and self._printer.current_print_job is not None + and self._printer.current_print_job.progress < 100 + ): + self.update_print_job_info() + self._printer.report_print_job_status() + time.sleep(3) + + self.update_print_job_info() + if ( + self._printer.current_print_job is not None + and self._printer.current_print_job.progress >= 100 + ): + self._printer.finalize_print_job() + + def update_print_job_info(self): + print_job_info = self._printer.bambu_client.get_device().print_job + task_name: str = print_job_info.subtask_name + project_file_info = self._printer.project_files.get_file_by_stem( + task_name, [".gcode", ".3mf"] + ) + if project_file_info is None: + self._log.debug(f"No 3mf file found for {print_job_info}") + self._current_print_job = None + self._printer.change_state(self._printer._state_idle) + return + + progress = print_job_info.print_percentage + self._printer.current_print_job = PrintJob(project_file_info, progress) + self._printer.select_project_file(project_file_info.path.as_posix()) + + def pause_print(self): + if self._printer.bambu_client.connected: + if self._printer.bambu_client.publish(pybambu.commands.PAUSE): + self._log.info("print paused") + else: + self._log.info("print pause failed") + + def cancel_print(self): + if self._printer.bambu_client.connected: + if self._printer.bambu_client.publish(pybambu.commands.STOP): + self._log.info("print cancelled") + self._printer.finalize_print_job() + else: + self._log.info("print cancel failed") diff --git a/octoprint_bambu_printer/virtual.py b/octoprint_bambu_printer/virtual.py deleted file mode 100644 index 556f45b..0000000 --- a/octoprint_bambu_printer/virtual.py +++ /dev/null @@ -1,1098 +0,0 @@ -__author__ = "Gina Häußge " -__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" - - -import collections -import datetime -import math -import os -import queue -import re -import threading -import time -from typing import Any, Dict, List, Optional -import asyncio -from pybambu import BambuClient, commands - -from serial import SerialTimeoutException -from octoprint.util import RepeatedTimer, to_bytes, to_unicode, get_dos_filename -from octoprint.util.files import unix_timestamp_to_m20_timestamp - -from .ftpsclient import IoTFTPSClient - - -# noinspection PyBroadException -class BambuPrinter: - 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._sdCardReady = True - self._sdPrinter = None - self._sdPrinting = False - self._sdPrintStarting = False - self._sdPrintingSemaphore = threading.Event() - self._sdPrintingPausedSemaphore = threading.Event() - self._sdFileListCache = {} - self._selectedSdFile = None - self._selectedSdFileSize = 0 - self._selectedSdFilePos = 0 - - self._busy = None - self._busy_loop = None - - - - import logging - - 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._seriallog = logging.getLogger( - "octoprint.plugins.bambu_printer.BambuPrinter.serial" - ) - self._seriallog.setLevel(logging.CRITICAL) - self._seriallog.propagate = False - - if seriallog_handler is not None: - import logging.handlers - - self._seriallog.addHandler(seriallog_handler) - self._seriallog.setLevel(logging.INFO) - - self._seriallog.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 = None - - readThread = threading.Thread( - target=self._processIncoming, - name="octoprint.plugins.bambu_printer.wait_thread", - daemon=True - ) - readThread.start() - - # bufferThread = threading.Thread( - # target=self._processBuffer, - # name="octoprint.plugins.bambu_printer.buffer_thread", - # daemon=True - # ) - # bufferThread.start() - - # Move this into M110 command response? - connectionThread = threading.Thread( - target=self._create_connection, - name="octoprint.plugins.bambu_printer.connection_thread", - daemon=True - ) - connectionThread.start() - - 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 = print_job.get("subtask_name") - if not self._sdFileListCache.get(filename.lower()): - if self._sdFileListCache.get(f"{filename.lower()}.3mf"): - filename = f"{filename.lower()}.3mf" - elif self._sdFileListCache.get(f"{filename.lower()}.gcode.3mf"): - filename = f"{filename.lower()}.gcode.3mf" - elif filename.startswith("cache/"): - filename = filename[6:] - else: - self._logger.debug(f"No 3mf file found for {print_job}") - - self._selectSdFile(filename) - 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._sdCardReady = True - 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._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" - - data = to_unicode(data, encoding="ascii", errors="replace").strip() - - # actual command handling - command_match = BambuPrinter.command_regex.match(data) - if command_match is not None: - command = command_match.group(0) - letter = command_match.group(1) - - try: - # if we have a method _gcode_G, _gcode_M or _gcode_T, execute that first - letter_handler = f"_gcode_{letter}" - if hasattr(self, letter_handler): - code = command_match.group(2) - handled = getattr(self, letter_handler)(code, data) - if handled: - self._sendOk() - continue - - # then look for a method _gcode_ and execute that if it exists - command_handler = f"_gcode_{command}" - if hasattr(self, command_handler): - handled = getattr(self, command_handler)(data) - if handled: - self._sendOk() - continue - else: - self._sendOk() - - 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 - - finally: - self._logger.debug(f"{data}") - - self._logger.debug("Closing down read loop") - - ##~~ command implementations - - # noinspection PyUnusedLocal - def _gcode_M20(self, data: str) -> bool: - if self._sdCardReady: - self._listSd(incl_long="L" in data, incl_timestamp="T" in data) - return True - - # noinspection PyUnusedLocal - def _gcode_M21(self, data: str) -> bool: - self._sdCardReady = True - self._send("SD card ok") - return True - - # noinspection PyUnusedLocal - def _gcode_M22(self, data: str) -> bool: - self._logger.debug("ignoring M22 command.") - self._send("M22 disabled for Bambu") - return True - - def _gcode_M23(self, data: str) -> bool: - if self._sdCardReady: - filename = data.split(None, 1)[1].strip() - self._selectSdFile(filename) - return True - - # noinspection PyUnusedLocal - def _gcode_M24(self, data: str) -> bool: - if self._sdCardReady: - self._startSdPrint() - return True - - # noinspection PyUnusedLocal - def _gcode_M25(self, data: str) -> bool: - if self._sdCardReady: - self._pauseSdPrint() - return True - - def _gcode_M524(self, data: str) -> bool: - if self._sdCardReady: - return self._cancelSdPrint() - return False - - def _gcode_M26(self, data: str) -> bool: - if data == "M26 S0": - if self._sdCardReady: - return self._cancelSdPrint() - return False - else: - self._logger.debug("ignoring M26 command.") - self._send("M26 disabled for Bambu") - return True - - def _gcode_M27(self, data: str) -> bool: - def report(): - if self._sdCardReady: - self._reportSdStatus() - - 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, report) - self._sdstatus_reporter.start() - else: - self._sdstatus_reporter = None - - report() - return True - - def _gcode_M28(self, data: str) -> bool: - self._logger.debug("ignoring M28 command.") - self._send("M28 disabled for Bambu") - return True - - # noinspection PyUnusedLocal - def _gcode_M29(self, data: str) -> bool: - self._logger.debug("ignoring M29 command.") - self._send("M29 disabled for Bambu") - return True - - def _gcode_M30(self, data: str) -> bool: - if self._sdCardReady: - filename = data.split(None, 1)[1].strip() - self._deleteSdFile(filename) - return True - - def _gcode_M33(self, data: str) -> bool: - self._logger.debug("ignoring M33 command.") - self._send("M33 disabled for Bambu") - return True - - # noinspection PyUnusedLocal - def _gcode_M105(self, data: str) -> bool: - return self._processTemperatureQuery() - - # noinspection PyUnusedLocal - 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 - - 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 - - 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 - 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 - - # noinspection PyUnusedLocal - def _gcode_M400(self, data: str) -> bool: - 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._error("checksum_mismatch")) - else: - self._send(self._error("checksum_missing")) - else: - self._send(self._error("lineno_mismatch", expected, actual)) - - def request_resend(): - self._send("Resend:%d" % expected) - # if not self._brokenResend: - self._sendOk() - - request_resend() - - def _listSd(self, incl_long=False, incl_timestamp=False): - line = "{dosname} {size} {timestamp} \"{name}\"" - - self._send("Begin file list") - for item in map(lambda x: line.format(**x), self._getSdFiles()): - self._send(item) - self._send("End file list") - - def _mappedSdList(self) -> Dict[str, Dict[str, Any]]: - result = {} - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - filelist = ftp.list_files("", ".3mf") or [] - - for entry in filelist: - if entry.startswith("/"): - filename = entry[1:] - else: - filename = entry - filesize = ftp.ftps_session.size(entry) - date_str = ftp.ftps_session.sendcmd(f"MDTM {entry}").replace("213 ", "") - filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp() - dosname = get_dos_filename(filename, existing_filenames=list(result.keys())).lower() - data = { - "dosname": dosname, - "name": filename, - "path": filename, - "size": filesize, - "timestamp": unix_timestamp_to_m20_timestamp(int(filedate)) - } - result[dosname.lower()] = filename.lower() - result[filename.lower()] = data - - filelistcache = ftp.list_files("cache/", ".3mf") or [] - - for entry in filelistcache: - if entry.startswith("/"): - filename = entry[1:].replace("cache/", "") - else: - filename = entry.replace("cache/", "") - filesize = ftp.ftps_session.size(f"cache/{filename}") - date_str = ftp.ftps_session.sendcmd(f"MDTM cache/{filename}").replace("213 ", "") - filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp() - dosname = get_dos_filename(filename, existing_filenames=list(result.keys())).lower() - data = { - "dosname": dosname, - "name": filename, - "path": "cache/"+filename, - "size": filesize, - "timestamp": unix_timestamp_to_m20_timestamp(int(filedate)) - } - result[dosname.lower()] = filename.lower() - result[filename.lower()] = data - - return result - - def _getSdFileData(self, filename: str) -> Optional[Dict[str, Any]]: - self._logger.debug(f"_getSdFileData: {filename}") - data = self._sdFileListCache.get(filename.lower()) - if isinstance(data, str): - data = self._sdFileListCache.get(data.lower()) - self._logger.debug(f"_getSdFileData: {data}") - return data - - def _getSdFiles(self) -> List[Dict[str, Any]]: - self._sdFileListCache = self._mappedSdList() - self._logger.debug(f"_getSdFiles return: {self._sdFileListCache}") - return [x for x in self._sdFileListCache.values() if isinstance(x, dict)] - - def _selectSdFile(self, filename: str, check_already_open: bool = False) -> None: - self._logger.debug(f"_selectSdFile: {filename}, check_already_open={check_already_open}") - if filename.startswith("/"): - filename = filename[1:] - - file = self._getSdFileData(filename) - if file is None: - self._listSd(incl_long=True, incl_timestamp=True) - self._sendOk() - file = self._getSdFileData(filename) - if file is None: - self._send(f"{filename} open failed") - return - - if self._selectedSdFile == file["path"] and check_already_open: - return - - self._selectedSdFile = file["path"] - self._selectedSdFileSize = file["size"] - self._send(f"File opened: {file['name']} Size: {self._selectedSdFileSize}") - self._send("File selected") - - def _startSdPrint(self, from_printer: bool = False) -> None: - 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() - # self._sdPrintingSemaphore.set() - if self._sdPrinter is not None: - if self.bambu.connected: - if self.bambu.publish(commands.RESUME): - self._logger.info("print resumed") - # if not self._sdPrintingSemaphore.is_set(): - # self._sdPrintingSemaphore.set() - else: - self._logger.info("print resume failed") - - 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") - - 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 - - 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 _deleteSdFile(self, filename: str) -> None: - host = self._settings.get(["host"]) - access_code = self._settings.get(["access_code"]) - - if filename.startswith("/"): - filename = filename[1:] - file = self._getSdFileData(filename) - if file is not None: - ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) - try: - if ftp.delete_file(file["path"]): - self._logger.debug(f"{filename} deleted") - else: - raise Exception("delete failed") - except Exception as e: - self._logger.debug(f"Error deleting file {filename}") - - 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 _processBuffer(self): - # while self.buffered is not None: - # try: - # line = self.buffered.get(timeout=0.5) - # except queue.Empty: - # continue - # - # if line is None: - # continue - # - # self.buffered.task_done() - # - # self._logger.debug("Closing down buffer loop") - - 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._seriallog.debug(f"<<< {u_data}") - self._kill() - return len(data) - - try: - written = self.incoming.put(data, timeout=self._write_timeout, partial=True) - self._seriallog.debug(f"<<< {u_data}") - return written - except queue.Full: - self._logger.info( - "Incoming queue is full, raising SerialTimeoutException" - ) - raise SerialTimeoutException() - - def readline(self) -> bytes: - 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._seriallog.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 - ok = self._ok() - if ok: - 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 _ok(self): - return "ok" - - def _error(self, error: str, *args, **kwargs) -> str: - return f"Error: {self._errors.get(error).format(*args, **kwargs)}" - -# noinspection PyUnresolvedReferences -class CharCountingQueue(queue.Queue): - def __init__(self, maxsize, name=None): - queue.Queue.__init__(self, maxsize=maxsize) - self._size = 0 - self._name = name - - def clear(self): - with self.mutex: - self.queue.clear() - - def put(self, item, block=True, timeout=None, partial=False) -> int: - self.not_full.acquire() - - try: - if not self._will_it_fit(item) and partial: - space_left = self.maxsize - self._qsize() - if space_left: - item = item[:space_left] - - if not block: - if not self._will_it_fit(item): - raise queue.Full - elif timeout is None: - while not self._will_it_fit(item): - self.not_full.wait() - elif timeout < 0: - raise ValueError("'timeout' must be a positive number") - else: - endtime = time.monotonic() + timeout - while not self._will_it_fit(item): - remaining = endtime - time.monotonic() - if remaining <= 0: - raise queue.Full - self.not_full.wait(remaining) - - self._put(item) - self.unfinished_tasks += 1 - self.not_empty.notify() - - return self._len(item) - finally: - self.not_full.release() - - # noinspection PyMethodMayBeStatic - def _len(self, item): - return len(item) - - def _qsize(self, l=len): # noqa: E741 - return self._size - - # Put a new item in the queue - def _put(self, item): - self.queue.append(item) - self._size += self._len(item) - - # Get an item from the queue - def _get(self): - item = self.queue.popleft() - self._size -= self._len(item) - return item - - def _will_it_fit(self, item): - return self.maxsize - self._qsize() >= self._len(item) diff --git a/setup.py b/setup.py index cd5bed3..3abd25c 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "OctoPrint-BambuPrinter" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "0.0.23" +plugin_version = "0.1.0" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..25561f1 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,9 @@ +from pathlib import Path +from pytest import fixture + + +@fixture +def output_folder(): + folder = Path(__file__).parent / "test_output" + folder.mkdir(parents=True, exist_ok=True) + return folder diff --git a/test/test_data_conversions.py b/test/test_data_conversions.py new file mode 100644 index 0000000..66eec57 --- /dev/null +++ b/test/test_data_conversions.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from datetime import datetime +from pathlib import Path + +from octoprint.util import get_formatted_size, get_formatted_datetime +from octoprint_bambu_printer.printer.file_system.bambu_timelapse_file_info import ( + BambuTimelapseFileInfo, +) +from octoprint_bambu_printer.printer.file_system.file_info import FileInfo + + +def test_timelapse_info_valid(): + file_name = "part.mp4" + file_size = 1000 + file_date = datetime(2020, 1, 1) + file_timestamp = file_date.timestamp() + + file_info = FileInfo(file_name, Path(file_name), file_size, file_date) + timelapse = BambuTimelapseFileInfo.from_file_info(file_info) + + assert timelapse.to_dict() == { + "bytes": file_size, + "date": get_formatted_datetime(datetime.fromtimestamp(file_timestamp)), + "name": file_name, + "size": get_formatted_size(file_size), + "thumbnail": "/plugin/bambu_printer/thumbnail/" + + file_name.replace(".mp4", ".jpg").replace(".avi", ".jpg"), + "timestamp": file_timestamp, + "url": f"/plugin/bambu_printer/timelapse/{file_name}", + } diff --git a/test/test_gcode_execution.py b/test/test_gcode_execution.py new file mode 100644 index 0000000..3639d4d --- /dev/null +++ b/test/test_gcode_execution.py @@ -0,0 +1,562 @@ +from __future__ import annotations +from datetime import datetime, timezone +import logging +from pathlib import Path +import sys +from typing import Any +from unittest.mock import MagicMock, patch + +from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView +import pybambu +import pybambu.commands +from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter +from octoprint_bambu_printer.printer.file_system.file_info import FileInfo +from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient +from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( + RemoteSDCardFileList, +) +from octoprint_bambu_printer.printer.states.idle_state import IdleState +from octoprint_bambu_printer.printer.states.paused_state import PausedState +from octoprint_bambu_printer.printer.states.printing_state import PrintingState +from pytest import fixture + + +@fixture +def output_test_folder(output_folder: Path): + folder = output_folder / "test_gcode" + folder.mkdir(parents=True, exist_ok=True) + return folder + + +@fixture +def log_test(): + log = logging.getLogger("gcode_unittest") + log.setLevel(logging.DEBUG) + return log + + +class DictGetter: + def __init__(self, options: dict, default_value=None) -> None: + self.options: dict[str | tuple[str, ...], Any] = options + self._default_value = default_value + + def __call__(self, key: str | list[str] | tuple[str, ...]): + if isinstance(key, list): + key = tuple(key) + return self.options.get(key, self._default_value) + + +@fixture +def settings(output_test_folder): + _settings = MagicMock() + _settings.get.side_effect = DictGetter( + { + "serial": "BAMBU", + "host": "localhost", + "access_code": "12345", + } + ) + _settings.get_boolean.side_effect = DictGetter({"forceChecksum": False}) + + log_file_path = output_test_folder / "log.txt" + log_file_path.touch() + _settings.get_plugin_logfile_path.return_value = log_file_path.as_posix() + return _settings + + +@fixture +def profile_manager(): + _profile_manager = MagicMock() + _profile_manager.get_current.side_effect = MagicMock() + _profile_manager.get_current().get.side_effect = DictGetter( + { + "heatedChamber": False, + } + ) + return _profile_manager + + +def _ftp_date_format(dt: datetime): + return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S") + + +@fixture +def project_files_info_ftp(): + return { + "print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))), + "print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), + } + + +@fixture +def cache_files_info_ftp(): + return { + "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), + "cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), + "cache/long file path with spaces.gcode.3mf": ( + 1200, + _ftp_date_format(datetime(2024, 5, 7)), + ), + } + + +@fixture +def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp): + all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp) + ftps_session = MagicMock() + ftps_session.size.side_effect = DictGetter( + {file: info[0] for file, info in all_file_info.items()} + ) + + ftps_session.sendcmd.side_effect = DictGetter( + {f"MDTM {file}": info[1] for file, info in all_file_info.items()} + ) + + ftps_session.nlst.side_effect = DictGetter( + { + "": list(map(lambda p: Path(p).name, project_files_info_ftp)) + + ["Mock folder"], + "cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp)) + + ["Mock folder"], + "timelapse/": ["video.mp4", "video.avi"], + } + ) + IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session) + yield ftps_session + + +@fixture(scope="function") +def print_job_mock(): + print_job = MagicMock() + print_job.subtask_name = "" + print_job.print_percentage = 0 + return print_job + + +@fixture(scope="function") +def temperatures_mock(): + temperatures = MagicMock() + temperatures.nozzle_temp = 0 + temperatures.target_nozzle_temp = 0 + temperatures.bed_temp = 0 + temperatures.target_bed_temp = 0 + temperatures.chamber_temp = 0 + return temperatures + + +@fixture(scope="function") +def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient: + bambu_client = MagicMock() + bambu_client.connected = True + device_mock = MagicMock() + device_mock.print_job = print_job_mock + device_mock.temperatures = temperatures_mock + bambu_client.get_device.return_value = device_mock + return bambu_client + + +@fixture(scope="function") +def printer( + output_test_folder, + settings, + profile_manager, + log_test, + ftps_session_mock, + bambu_client_mock, +): + async def _mock_connection(self): + pass + + BambuVirtualPrinter._create_client_connection_async = _mock_connection + printer_test = BambuVirtualPrinter( + settings, + profile_manager, + data_folder=output_test_folder, + serial_log_handler=log_test, + read_timeout=0.01, + faked_baudrate=115200, + ) + printer_test._bambu_client = bambu_client_mock + printer_test.flush() + printer_test.readlines() + yield printer_test + printer_test.close() + + +def test_initial_state(printer: BambuVirtualPrinter): + assert isinstance(printer.current_state, IdleState) + + +def test_list_sd_card(printer: BambuVirtualPrinter): + printer.write(b"M20\n") # GCode for listing SD card + printer.flush() + result = printer.readlines() + assert result[0] == b"Begin file list" + assert result[1].endswith(b'"print.3mf"') + assert result[2].endswith(b'"print2.3mf"') + assert result[3].endswith(b'"print.3mf"') + assert result[4].endswith(b'"print3.gcode.3mf"') + assert result[-3] == b"End file list" + assert result[-2] == b"ok" + assert result[-1] == b"ok" + + +def test_list_ftp_paths_p1s(settings, ftps_session_mock): + file_system = RemoteSDCardFileList(settings) + file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi") + + timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"] + ftps_session_mock.size.side_effect = DictGetter( + {file: 100 for file in timelapse_files} + ) + ftps_session_mock.sendcmd.side_effect = DictGetter( + { + f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) + for file in timelapse_files + } + ) + ftps_session_mock.nlst.side_effect = DictGetter( + {"timelapse/": [Path(f).name for f in timelapse_files]} + ) + + timelapse_paths = list(map(Path, timelapse_files)) + result_files = file_view.get_all_info() + assert len(timelapse_files) == len(result_files) and all( + file_info.path in timelapse_paths for file_info in result_files + ) + + +def test_list_ftp_paths_x1(settings, ftps_session_mock): + file_system = RemoteSDCardFileList(settings) + file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4") + + timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"] + ftps_session_mock.size.side_effect = DictGetter( + {file: 100 for file in timelapse_files} + ) + ftps_session_mock.sendcmd.side_effect = DictGetter( + { + f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) + for file in timelapse_files + } + ) + ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files}) + + timelapse_paths = list(map(Path, timelapse_files)) + result_files = file_view.get_all_info() + assert len(timelapse_files) == len(result_files) and all( + file_info.path in timelapse_paths for file_info in result_files + ) + + +def test_delete_sd_file_gcode(printer: BambuVirtualPrinter): + with patch( + "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" + ) as delete_function: + printer.write(b"M30 print.3mf\n") + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + delete_function.assert_called_with("print.3mf") + + printer.write(b"M30 cache/print.3mf\n") + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + delete_function.assert_called_with("cache/print.3mf") + + +def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter): + with patch( + "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" + ) as delete_function: + file_info = printer.project_files.get_file_data("cache/print.3mf") + assert file_info is not None + + printer.write(b"M30 " + file_info.dosname.encode() + b"\n") + printer.flush() + assert printer.readlines()[-1] == b"ok" + assert delete_function.call_count == 1 + delete_function.assert_called_with("cache/print.3mf") + + printer.write(b"M30 cache/print.3mf\n") + printer.flush() + assert printer.readlines()[-1] == b"ok" + assert delete_function.call_count == 2 + delete_function.assert_called_with("cache/print.3mf") + + +def test_select_project_file_by_stem(printer: BambuVirtualPrinter): + printer.write(b"M23 print3\n") + printer.flush() + result = printer.readlines() + assert printer.selected_file is not None + assert printer.selected_file.path == Path("cache/print3.gcode.3mf") + assert result[-2] == b"File selected" + assert result[-1] == b"ok" + + +def test_select_project_long_name_file_with_multiple_extensions( + printer: BambuVirtualPrinter, +): + printer.write(b"M23 long file path with spaces.gcode.3mf\n") + printer.flush() + result = printer.readlines() + assert printer.selected_file is not None + assert printer.selected_file.path == Path( + "cache/long file path with spaces.gcode.3mf" + ) + assert result[-2] == b"File selected" + assert result[-1] == b"ok" + + +def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): + printer.write(b"M24\n") + printer.flush() + result = printer.readlines() + assert result[0] == b"ok" + assert isinstance(printer.current_state, IdleState) + + +def test_non_existing_file_not_selected(printer: BambuVirtualPrinter): + assert printer.selected_file is None + + printer.write(b"M23 non_existing.3mf\n") + printer.flush() + result = printer.readlines() + assert result[-2] != b"File selected" + assert result[-1] == b"ok" + assert printer.selected_file is None + + +def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock): + assert printer.selected_file is None + + printer.write(b"M20\n") + printer.flush() + printer.readlines() + + printer.write(b"M23 print.3mf\n") + printer.flush() + result = printer.readlines() + assert result[-2] == b"File selected" + assert result[-1] == b"ok" + + assert printer.selected_file is not None + assert printer.selected_file.file_name == "print.3mf" + + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M24\n") + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + + # emulate printer reporting it's status + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + + +def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock): + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M20\n") + printer.write(b"M23 print.3mf\n") + printer.write(b"M24\n") + printer.flush() + + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + + printer.write(b"M25\n") # pausing the print + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + + print_job_mock.gcode_state = "PAUSE" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PausedState) + bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE) + + +def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock): + print_job_mock.subtask_name = "print.3mf" + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + + print_job_mock.gcode_state = "PAUSE" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PausedState) + + print_job_mock.gcode_state = "IDLE" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, IdleState) + + print_job_mock.gcode_state = "FINISH" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, IdleState) + + print_job_mock.gcode_state = "FAILED" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, IdleState) + + +def test_printer_info_check(printer: BambuVirtualPrinter): + printer.write(b"M27\n") # printer get info + printer.flush() + + result = printer.readlines() + assert result[-1] == b"ok" + assert isinstance(printer.current_state, IdleState) + + +def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock): + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M20\nM23 print.3mf\nM24\n") + printer.flush() + print_job_mock.gcode_state = "RUNNING" + print_job_mock.print_percentage = 50 + printer.new_update("event_printer_data_update") + printer.flush() + printer.readlines() + assert isinstance(printer.current_state, PrintingState) + + printer.write(b"M26 S0\n") + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + assert isinstance(printer.current_state, IdleState) + + +def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock): + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M20\nM23 print.3mf\nM24\n") + printer.flush() + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + + printer.write(b"M25\n") + printer.flush() + print_job_mock.gcode_state = "PAUSE" + printer.new_update("event_printer_data_update") + printer.flush() + + printer.readlines() + assert isinstance(printer.current_state, PausedState) + + printer.write(b"M26 S0\n") + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + assert isinstance(printer.current_state, IdleState) + + +def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock): + gcode = b"G28\nG1 X10 Y10\n" + printer.write(gcode) + printer.flush() + result = printer.readlines() + assert result[-1] == b"ok" + + gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE + gcode_command["print"]["param"] = "G28\n" + bambu_client_mock.publish.assert_called_with(gcode_command) + + gcode_command["print"]["param"] = "G1 X10 Y10\n" + bambu_client_mock.publish.assert_called_with(gcode_command) + + +def test_file_selection_does_not_affect_current_print( + printer: BambuVirtualPrinter, print_job_mock +): + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M23 print.3mf\nM24\n") + printer.flush() + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + assert printer.current_print_job is not None + assert printer.current_print_job.file_info.file_name == "print.3mf" + assert printer.current_print_job.progress == 0 + + printer.write(b"M23 print2.3mf\n") + printer.flush() + assert printer.current_print_job is not None + assert printer.current_print_job.file_info.file_name == "print.3mf" + assert printer.current_print_job.progress == 0 + + +def test_finished_print_job_reset_after_new_file_selected( + printer: BambuVirtualPrinter, print_job_mock +): + print_job_mock.subtask_name = "print.3mf" + + printer.write(b"M23 print.3mf\nM24\n") + printer.flush() + print_job_mock.gcode_state = "RUNNING" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + assert printer.current_print_job is not None + assert printer.current_print_job.file_info.file_name == "print.3mf" + assert printer.current_print_job.progress == 0 + + print_job_mock.print_percentage = 100 + printer.current_state.update_print_job_info() + assert isinstance(printer.current_state, PrintingState) + assert printer.current_print_job.progress == 100 + + print_job_mock.gcode_state = "FINISH" + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, IdleState) + assert printer.current_print_job is None + assert printer.selected_file is not None + assert printer.selected_file.file_name == "print.3mf" + + printer.write(b"M23 print2.3mf\n") + printer.flush() + assert printer.current_print_job is None + assert printer.selected_file is not None + assert printer.selected_file.file_name == "print2.3mf" + + +def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock): + print_job_mock.subtask_name = "print.3mf" + print_job_mock.gcode_state = "RUNNING" + print_job_mock.print_percentage = 99 + printer.new_update("event_printer_data_update") + printer.flush() + assert isinstance(printer.current_state, PrintingState) + assert printer.current_print_job is not None + assert printer.current_print_job.file_info.file_name == "print.3mf" + assert printer.current_print_job.progress == 99 + + print_job_mock.print_percentage = 100 + print_job_mock.gcode_state = "FINISH" + printer.new_update("event_printer_data_update") + printer.flush() + result = printer.readlines() + assert result[-3].endswith(b"1000/1000") + assert result[-2] == b"Done printing file" + assert result[-1] == b"Not SD printing" + assert isinstance(printer.current_state, IdleState) + assert printer.current_print_job is None + assert printer.selected_file is not None + assert printer.selected_file.file_name == "print.3mf"