0.1.0 (#34)
* Add separate class for sftp file system * Add separate serial IO handling class * Replace function name mangling with gcode handler registration system * Add states to virtual Bambu printer that manage state specific interaction * Add synchronization utilities to work with virtual printer as if it is a binary stream * Add unittests with mocked Bambu printer to ensure core functionality works as expected * Fix formatting to be automatically processed by black formatter * Fix python 3.10 type annotations for readability
This commit is contained in:
		@@ -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/<filename>", 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/<filename>", 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,
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							@@ -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/<filename>", 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/<filename>", 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",
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
from .ftpsclient import IoTFTPSClient
 | 
			
		||||
							
								
								
									
										2
									
								
								octoprint_bambu_printer/printer/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								octoprint_bambu_printer/printer/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
__author__ = "Gina Häußge <osd@foosel.net>"
 | 
			
		||||
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
 | 
			
		||||
							
								
								
									
										619
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										619
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							@@ -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 (?:(?P<parameter>A1|E1|Pn[012])\s)?(?P<text>.*)", 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")
 | 
			
		||||
@@ -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}",
 | 
			
		||||
        )
 | 
			
		||||
@@ -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
 | 
			
		||||
							
								
								
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
			
		||||
@@ -24,16 +24,21 @@ SOFTWARE.
 | 
			
		||||
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 @@ class ImplicitTLS(ftplib.FTP_TLS):
 | 
			
		||||
        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
 | 
			
		||||
    ftps_session: ftplib.FTP | ImplicitTLS
 | 
			
		||||
 | 
			
		||||
    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()
 | 
			
		||||
 | 
			
		||||
        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 @@ class IoTFTPSClient:
 | 
			
		||||
            # 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 @@ class IoTFTPSClient:
 | 
			
		||||
                            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 @@ class IoTFTPSClient:
 | 
			
		||||
    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 @@ class IoTFTPSClient:
 | 
			
		||||
            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 @@ class IoTFTPSClient:
 | 
			
		||||
                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
 | 
			
		||||
@@ -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
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										319
									
								
								octoprint_bambu_printer/printer/gcode_executor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								octoprint_bambu_printer/printer/gcode_executor.py
									
									
									
									
									
										Normal file
									
								
							@@ -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')})"
 | 
			
		||||
							
								
								
									
										18
									
								
								octoprint_bambu_printer/printer/print_job.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								octoprint_bambu_printer/printer/print_job.py
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
			
		||||
							
								
								
									
										257
									
								
								octoprint_bambu_printer/printer/printer_serial_io.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								octoprint_bambu_printer/printer/printer_serial_io.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										0
									
								
								octoprint_bambu_printer/printer/states/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								octoprint_bambu_printer/printer/states/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										46
									
								
								octoprint_bambu_printer/printer/states/a_printer_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								octoprint_bambu_printer/printer/states/a_printer_state.py
									
									
									
									
									
										Normal file
									
								
							@@ -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}'"
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										58
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
			
		||||
							
								
								
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							@@ -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")
 | 
			
		||||
							
								
								
									
										92
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							@@ -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")
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user