Compare commits
	
		
			27 Commits
		
	
	
		
			698f8f4151
			...
			feature/re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 16dc138e9f | ||
|  | f42d3167c5 | ||
|  | 4ea98036e5 | ||
|  | 0d16732561 | ||
|  | ef305ee6ce | ||
|  | 1f7eed6b23 | ||
|  | 55b78cea05 | ||
|  | f35f456eb2 | ||
|  | 42ba306e4f | ||
|  | 19cac21db6 | ||
|  | 4faa240b06 | ||
|  | 38a6f58306 | ||
|  | ed33fd8fb1 | ||
|  | 53e1f88e1a | ||
|  | 8178dea15a | ||
|  | 73f77ed659 | ||
|  | a13a5a1e2a | ||
|  | 06c9d68390 | ||
|  | 07f601694d | ||
|  | 98a1f59169 | ||
|  | ba2eadb064 | ||
|  | f5017b5631 | ||
|  | 956a261a45 | ||
|  | 155f3d2bd3 | ||
|  | 75b0a11fef | ||
|  | 4da769da49 | ||
|  | 527ec9ef3c | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,3 +8,5 @@ dist | |||||||
| .DS_Store | .DS_Store | ||||||
| *.zip | *.zip | ||||||
| extras | extras | ||||||
|  |  | ||||||
|  | test/test_output | ||||||
| @@ -1,260 +1,10 @@ | |||||||
| # coding=utf-8 | # 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_name__ = "Bambu Printer" | ||||||
| __plugin_pythoncompat__ = ">=3.7,<4" | __plugin_pythoncompat__ = ">=3.7,<4" | ||||||
|  |  | ||||||
|  | from .bambu_print_plugin import BambuPrintPlugin | ||||||
|  |  | ||||||
|  |  | ||||||
| def __plugin_load__(): | def __plugin_load__(): | ||||||
|     plugin = BambuPrintPlugin() |     plugin = BambuPrintPlugin() | ||||||
| @@ -270,5 +20,5 @@ def __plugin_load__(): | |||||||
|         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, |         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, | ||||||
|         "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, |         "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.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, | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										304
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										304
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,304 @@ | |||||||
|  | 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() | ||||||
|  |     yield lambda: perf_counter() - start | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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" | ||||||
							
								
								
									
										580
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										580
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,580 @@ | |||||||
|  | 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._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 | ||||||
|  |  | ||||||
|  |         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._sdstatus_reporter is not None: | ||||||
|  |                 self._sdstatus_reporter.cancel() | ||||||
|  |                 self._sdstatus_reporter = None | ||||||
|  |  | ||||||
|  |             if self._settings.get_boolean(["simulateReset"]): | ||||||
|  |                 for item in self._settings.get(["resetLines"]): | ||||||
|  |                     self.sendIO(item + "\n") | ||||||
|  |  | ||||||
|  |             self._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_cached_file_data(file_path) | ||||||
|  |         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() | ||||||
|  |         self._list_project_files() | ||||||
|  |         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 self._sdstatus_reporter is not None: | ||||||
|  |                 self._sdstatus_reporter.cancel() | ||||||
|  |  | ||||||
|  |             if interval > 0: | ||||||
|  |                 self._sdstatus_reporter = RepeatedTimer( | ||||||
|  |                     interval, self.report_print_job_status | ||||||
|  |                 ) | ||||||
|  |                 self._sdstatus_reporter.start() | ||||||
|  |             else: | ||||||
|  |                 self._sdstatus_reporter = None | ||||||
|  |  | ||||||
|  |         self.report_print_job_status() | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M30") | ||||||
|  |     def _delete_sd_file(self, data: str) -> bool: | ||||||
|  |         file_path = data.split(None, 1)[1].strip() | ||||||
|  |         self._list_project_files() | ||||||
|  |         self.file_system.delete_file(Path(file_path)) | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @gcode_executor.register("M105") | ||||||
|  |     def _report_temperatures(self, data: str) -> bool: | ||||||
|  |         return self._processTemperatureQuery() | ||||||
|  |  | ||||||
|  |     # 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") | ||||||
|  |         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 _list_project_files(self, data: str = ""): | ||||||
|  |         self._project_files_view.update() | ||||||
|  |         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") | ||||||
|  |  | ||||||
|  |     @gcode_executor.register_no_data("M24") | ||||||
|  |     def _start_print(self): | ||||||
|  |         self._current_state.start_resume_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): | ||||||
|  |         print_job = self.current_print_job | ||||||
|  |         if print_job is not None: | ||||||
|  |             self.sendIO( | ||||||
|  |                 f"SD printing byte {print_job.file_position}/{print_job.file_info.size}" | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             self.sendIO("Not SD printing") | ||||||
|  |  | ||||||
|  |     def _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,79 @@ | |||||||
|  | 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: set[tuple[str, str | list[str] | None]] = field(default_factory=set) | ||||||
|  |     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.add((folder, extensions)) | ||||||
|  |         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: | ||||||
|  |                 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.file_name for info in files} | ||||||
|  |         self._file_data_cache = {info.file_name: 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_by_suffix(self, file_stem: str, allowed_suffixes: list[str]): | ||||||
|  |         if file_stem == "": | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes) | ||||||
|  |         if file_data is None: | ||||||
|  |             self.update() | ||||||
|  |             file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes) | ||||||
|  |         return file_data | ||||||
|  |  | ||||||
|  |     def get_cached_file_data(self, file_name: str) -> FileInfo | None: | ||||||
|  |         file_name = Path(file_name).name | ||||||
|  |         file_name = self._file_alias_cache.get(file_name, file_name) | ||||||
|  |         return self._file_data_cache.get(file_name, None) | ||||||
|  |  | ||||||
|  |     def _get_file_by_suffix_cached(self, file_stem: str, allowed_suffixes: list[str]): | ||||||
|  |         for suffix in allowed_suffixes: | ||||||
|  |             file_data = self.get_cached_file_data( | ||||||
|  |                 Path(file_stem).with_suffix(suffix).as_posix() | ||||||
|  |             ) | ||||||
|  |             if file_data is not None: | ||||||
|  |                 return file_data | ||||||
|  |         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 | wrapper for FTPS server interactions | ||||||
| """ | """ | ||||||
| 
 | 
 | ||||||
|  | from __future__ import annotations | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from datetime import datetime, timezone | ||||||
| import ftplib | import ftplib | ||||||
| import os | import os | ||||||
|  | from pathlib import Path | ||||||
| import socket | import socket | ||||||
| import ssl | import ssl | ||||||
| from typing import Optional, Union, List | from typing import Generator, Union | ||||||
| 
 | 
 | ||||||
| from contextlib import redirect_stdout | from contextlib import redirect_stdout | ||||||
| import io | import io | ||||||
| import re | import re | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| class ImplicitTLS(ftplib.FTP_TLS): | class ImplicitTLS(ftplib.FTP_TLS): | ||||||
|     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" |     """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) |         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) | ||||||
| 
 | 
 | ||||||
|         if self._prot_p: |         if self._prot_p: | ||||||
|             conn = self.context.wrap_socket(conn, |             conn = self.context.wrap_socket( | ||||||
|                                             server_hostname=self.host, |                 conn, server_hostname=self.host, session=self.sock.session | ||||||
|                                             session=self.sock.session)  # this is the fix |             )  # this is the fix | ||||||
|         return conn, size |         return conn, size | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class IoTFTPSClient: | @dataclass | ||||||
|  | class IoTFTPSConnection: | ||||||
|     """iot ftps ftpsclient""" |     """iot ftps ftpsclient""" | ||||||
| 
 | 
 | ||||||
|     ftps_host: str |     ftps_session: ftplib.FTP | ImplicitTLS | ||||||
|     ftps_port: int |  | ||||||
|     ftps_user: str |  | ||||||
|     ftps_pass: str |  | ||||||
|     ssl_implicit: bool |  | ||||||
|     ftps_session: Union[ftplib.FTP, ImplicitTLS] |  | ||||||
|     last_error: Optional[str] = None |  | ||||||
|     welcome: str |  | ||||||
| 
 | 
 | ||||||
|     def __init__( |     def close(self) -> None: | ||||||
|             self, |         """close the current session from the ftps server""" | ||||||
|             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""" |  | ||||||
|         self.ftps_session.close() |         self.ftps_session.close() | ||||||
| 
 | 
 | ||||||
|     def download_file(self, source: str, dest: str): |     def download_file(self, source: str, dest: str): | ||||||
| @@ -137,7 +95,7 @@ class IoTFTPSClient: | |||||||
|             # Taken from ftplib.storbinary but with custom ssl handling |             # Taken from ftplib.storbinary but with custom ssl handling | ||||||
|             # due to the shitty bambu p1p ftps server TODO fix properly. |             # due to the shitty bambu p1p ftps server TODO fix properly. | ||||||
|             with open(source, "rb") as fp: |             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: |                 with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: | ||||||
|                     while 1: |                     while 1: | ||||||
| @@ -152,7 +110,9 @@ class IoTFTPSClient: | |||||||
|                             callback(buf) |                             callback(buf) | ||||||
| 
 | 
 | ||||||
|                     # shutdown ssl layer |                     # 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 |                         # Yeah this is suposed to be conn.unwrap | ||||||
|                         # But since we operate in prot p mode |                         # But since we operate in prot p mode | ||||||
|                         # we can close the connection always. |                         # we can close the connection always. | ||||||
| @@ -185,19 +145,26 @@ class IoTFTPSClient: | |||||||
|     def mkdir(self, path: str) -> str: |     def mkdir(self, path: str) -> str: | ||||||
|         return self.ftps_session.mkd(path) |         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""" |         """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: |         try: | ||||||
|             files = self.ftps_session.nlst(path) |             list_result = self.ftps_session.nlst(list_path) or [] | ||||||
|             if not files: |             for file_list_entry in list_result: | ||||||
|                 return |                 path = Path(list_path) / Path(file_list_entry).name | ||||||
|             if file_pattern: |                 if _extension_acceptable(path): | ||||||
|                 return [f for f in files if file_pattern in f] |                     yield path | ||||||
|             return files |  | ||||||
|         except Exception as ex: |         except Exception as ex: | ||||||
|             print(f"unexpected exception occurred: {ex}") |             print(f"unexpected exception occurred: {ex}") | ||||||
|             pass |  | ||||||
|         return |  | ||||||
| 
 | 
 | ||||||
|     def list_files_ex(self, path: str) -> Union[list[str], None]: |     def list_files_ex(self, path: str) -> Union[list[str], None]: | ||||||
|         """list files under a path inside the FTPS server""" |         """list files under a path inside the FTPS server""" | ||||||
| @@ -208,7 +175,8 @@ class IoTFTPSClient: | |||||||
|             s = f.getvalue() |             s = f.getvalue() | ||||||
|             files = [] |             files = [] | ||||||
|             for row in s.split("\n"): |             for row in s.split("\n"): | ||||||
|                 if len(row) <= 0: continue |                 if len(row) <= 0: | ||||||
|  |                     continue | ||||||
| 
 | 
 | ||||||
|                 attribs = row.split(" ") |                 attribs = row.split(" ") | ||||||
| 
 | 
 | ||||||
| @@ -219,10 +187,70 @@ class IoTFTPSClient: | |||||||
|                 else: |                 else: | ||||||
|                     name = attribs[len(attribs) - 1] |                     name = attribs[len(attribs) - 1] | ||||||
| 
 | 
 | ||||||
|                 file = ( attribs[0], name ) |                 file = (attribs[0], name) | ||||||
|                 files.append(file) |                 files.append(file) | ||||||
|             return files |             return files | ||||||
|         except Exception as ex: |         except Exception as ex: | ||||||
|             print(f"unexpected exception occurred: [{ex}]") |             print(f"unexpected exception occurred: [{ex}]") | ||||||
|             pass |             pass | ||||||
|         return |         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,89 @@ | |||||||
|  | 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 octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||||
|  |  | ||||||
|  | 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(str(file_path)): | ||||||
|  |                     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}'" | ||||||
|  |         ) | ||||||
							
								
								
									
										53
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | 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_resume_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.file_name) | ||||||
|  |         if self._printer.bambu_client.publish(print_command): | ||||||
|  |             self._log.info(f"Started print for {selected_file.file_name}") | ||||||
|  |             self._printer.change_state(self._printer._state_printing) | ||||||
|  |         else: | ||||||
|  |             self._log.warn(f"Failed to start print for {selected_file.file_name}") | ||||||
|  |             self._printer.change_state(self._printer._state_idle) | ||||||
|  |  | ||||||
|  |     def _get_print_command_for_file(self, selected_file): | ||||||
|  |         print_command = { | ||||||
|  |             "print": { | ||||||
|  |                 "sequence_id": 0, | ||||||
|  |                 "command": "project_file", | ||||||
|  |                 "param": "Metadata/plate_1.gcode", | ||||||
|  |                 "md5": "", | ||||||
|  |                 "profile_id": "0", | ||||||
|  |                 "project_id": "0", | ||||||
|  |                 "subtask_id": "0", | ||||||
|  |                 "task_id": "0", | ||||||
|  |                 "subtask_name": f"{selected_file}", | ||||||
|  |                 "file": f"{selected_file}", | ||||||
|  |                 "url": ( | ||||||
|  |                     f"file:///mnt/sdcard/{selected_file}" | ||||||
|  |                     if self._printer._settings.get_boolean(["device_type"]) | ||||||
|  |                     in ["X1", "X1C"] | ||||||
|  |                     else f"file:///sdcard/{selected_file}" | ||||||
|  |                 ), | ||||||
|  |                 "timelapse": self._printer._settings.get_boolean(["timelapse"]), | ||||||
|  |                 "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), | ||||||
|  |                 "flow_cali": self._printer._settings.get_boolean(["flow_cali"]), | ||||||
|  |                 "vibration_cali": self._printer._settings.get_boolean( | ||||||
|  |                     ["vibration_cali"] | ||||||
|  |                 ), | ||||||
|  |                 "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), | ||||||
|  |                 "use_ams": self._printer._settings.get_boolean(["use_ams"]), | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return print_command | ||||||
							
								
								
									
										53
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | 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() | ||||||
|  |  | ||||||
|  |     def init(self): | ||||||
|  |         if not self._pausedLock.is_set(): | ||||||
|  |             self._pausedLock.set() | ||||||
|  |  | ||||||
|  |         self._printer.sendIO("// action:paused") | ||||||
|  |         self._sendPaused() | ||||||
|  |  | ||||||
|  |     def finalize(self): | ||||||
|  |         if self._pausedLock.is_set(): | ||||||
|  |             self._pausedLock.clear() | ||||||
|  |  | ||||||
|  |     def _sendPaused(self): | ||||||
|  |         if self._printer.current_print_job is None: | ||||||
|  |             self._log.warn("job paused, but no print job available?") | ||||||
|  |             return | ||||||
|  |         paused_timer = RepeatedTimer( | ||||||
|  |             interval=3.0, | ||||||
|  |             function=self._printer.report_print_job_status, | ||||||
|  |             daemon=True, | ||||||
|  |             run_first=True, | ||||||
|  |             condition=self._pausedLock.is_set, | ||||||
|  |         ) | ||||||
|  |         paused_timer.start() | ||||||
|  |  | ||||||
|  |     def start_resume_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.RESUME): | ||||||
|  |                 self._log.info("print resumed") | ||||||
|  |                 self._printer.change_state(self._printer._state_printing) | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print resume failed") | ||||||
							
								
								
									
										104
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | |||||||
|  | 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) | ||||||
|  |  | ||||||
|  |         if self._printer.current_print_job is None: | ||||||
|  |  | ||||||
|  |             self._log.warn("Printing state was triggered with empty print job") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if self._printer.current_print_job.progress >= 100: | ||||||
|  |             self._finish_print() | ||||||
|  |  | ||||||
|  |     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_suffix( | ||||||
|  |             task_name, [".3mf", ".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 | ||||||
|  |             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.file_name) | ||||||
|  |  | ||||||
|  |     def pause_print(self): | ||||||
|  |         if self._printer.bambu_client.connected: | ||||||
|  |             if self._printer.bambu_client.publish(pybambu.commands.PAUSE): | ||||||
|  |                 self._log.info("print paused") | ||||||
|  |                 self._printer.change_state(self._printer._state_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._finish_print() | ||||||
|  |                 self._printer.change_state(self._printer._state_idle) | ||||||
|  |             else: | ||||||
|  |                 self._log.info("print cancel failed") | ||||||
|  |  | ||||||
|  |     def _finish_print(self): | ||||||
|  |         if self._printer.current_print_job is not None: | ||||||
|  |             self._log.debug( | ||||||
|  |                 f"SD File Print finishing: {self._printer.current_print_job.file_info.file_name}" | ||||||
|  |             ) | ||||||
|  |             self._printer.sendIO("Done printing file") | ||||||
|  |             self._printer.current_print_job = None | ||||||
|  |  | ||||||
|  |         self._printer.change_state(self._printer._state_idle) | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										0
									
								
								test/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								test/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										9
									
								
								test/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								test/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | from pathlib import Path | ||||||
|  | from pytest import fixture | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def output_folder(): | ||||||
|  |     folder = Path(__file__).parent / "test_output" | ||||||
|  |     folder.mkdir(parents=True, exist_ok=True) | ||||||
|  |     return folder | ||||||
							
								
								
									
										30
									
								
								test/test_data_conversions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								test/test_data_conversions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from octoprint.util import get_formatted_size, get_formatted_datetime | ||||||
|  | from octoprint_bambu_printer.printer.file_system.bambu_timelapse_file_info import ( | ||||||
|  |     BambuTimelapseFileInfo, | ||||||
|  | ) | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_timelapse_info_valid(): | ||||||
|  |     file_name = "part.mp4" | ||||||
|  |     file_size = 1000 | ||||||
|  |     file_date = datetime(2020, 1, 1) | ||||||
|  |     file_timestamp = file_date.timestamp() | ||||||
|  |  | ||||||
|  |     file_info = FileInfo(file_name, Path(file_name), file_size, file_date) | ||||||
|  |     timelapse = BambuTimelapseFileInfo.from_file_info(file_info) | ||||||
|  |  | ||||||
|  |     assert timelapse.to_dict() == { | ||||||
|  |         "bytes": file_size, | ||||||
|  |         "date": get_formatted_datetime(datetime.fromtimestamp(file_timestamp)), | ||||||
|  |         "name": file_name, | ||||||
|  |         "size": get_formatted_size(file_size), | ||||||
|  |         "thumbnail": "/plugin/bambu_printer/thumbnail/" | ||||||
|  |         + file_name.replace(".mp4", ".jpg").replace(".avi", ".jpg"), | ||||||
|  |         "timestamp": file_timestamp, | ||||||
|  |         "url": f"/plugin/bambu_printer/timelapse/{file_name}", | ||||||
|  |     } | ||||||
							
								
								
									
										415
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										415
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,415 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  | from datetime import datetime, timezone | ||||||
|  | import logging | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Any | ||||||
|  | from unittest.mock import MagicMock | ||||||
|  |  | ||||||
|  | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||||
|  | import pybambu | ||||||
|  | import pybambu.commands | ||||||
|  | from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter | ||||||
|  | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||||
|  | from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient | ||||||
|  | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||||
|  |     RemoteSDCardFileList, | ||||||
|  | ) | ||||||
|  | from octoprint_bambu_printer.printer.states.idle_state import IdleState | ||||||
|  | from octoprint_bambu_printer.printer.states.paused_state import PausedState | ||||||
|  | from octoprint_bambu_printer.printer.states.printing_state import PrintingState | ||||||
|  | from pytest import fixture | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def output_test_folder(output_folder: Path): | ||||||
|  |     folder = output_folder / "test_gcode" | ||||||
|  |     folder.mkdir(parents=True, exist_ok=True) | ||||||
|  |     return folder | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def log_test(): | ||||||
|  |     return logging.getLogger("gcode_unittest") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DictGetter: | ||||||
|  |     def __init__(self, options: dict, default_value=None) -> None: | ||||||
|  |         self.options: dict[str | tuple[str, ...], Any] = options | ||||||
|  |         self._default_value = default_value | ||||||
|  |  | ||||||
|  |     def __call__(self, key: str | list[str] | tuple[str, ...]): | ||||||
|  |         if isinstance(key, list): | ||||||
|  |             key = tuple(key) | ||||||
|  |         return self.options.get(key, self._default_value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def settings(output_test_folder): | ||||||
|  |     _settings = MagicMock() | ||||||
|  |     _settings.get.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "serial": "BAMBU", | ||||||
|  |             "host": "localhost", | ||||||
|  |             "access_code": "12345", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     _settings.get_boolean.side_effect = DictGetter({"forceChecksum": False}) | ||||||
|  |  | ||||||
|  |     log_file_path = output_test_folder / "log.txt" | ||||||
|  |     log_file_path.touch() | ||||||
|  |     _settings.get_plugin_logfile_path.return_value = log_file_path.as_posix() | ||||||
|  |     return _settings | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def profile_manager(): | ||||||
|  |     _profile_manager = MagicMock() | ||||||
|  |     _profile_manager.get_current.side_effect = MagicMock() | ||||||
|  |     _profile_manager.get_current().get.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "heatedChamber": False, | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     return _profile_manager | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _ftp_date_format(dt: datetime): | ||||||
|  |     return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def project_files_info_ftp(): | ||||||
|  |     return { | ||||||
|  |         "print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))), | ||||||
|  |         "print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def cache_files_info_ftp(): | ||||||
|  |     return { | ||||||
|  |         "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |         "cache/print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture | ||||||
|  | def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp): | ||||||
|  |     all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp) | ||||||
|  |     ftps_session = MagicMock() | ||||||
|  |     ftps_session.size.side_effect = DictGetter( | ||||||
|  |         {file: info[0] for file, info in all_file_info.items()} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     ftps_session.sendcmd.side_effect = DictGetter( | ||||||
|  |         {f"MDTM {file}": info[1] for file, info in all_file_info.items()} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     ftps_session.nlst.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             "": list(map(lambda p: Path(p).name, project_files_info_ftp)) | ||||||
|  |             + ["Mock folder"], | ||||||
|  |             "cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp)) | ||||||
|  |             + ["Mock folder"], | ||||||
|  |             "timelapse/": ["video.mp4", "video.avi"], | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session) | ||||||
|  |     yield ftps_session | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def print_job_mock(): | ||||||
|  |     print_job = MagicMock() | ||||||
|  |     print_job.subtask_name = "" | ||||||
|  |     print_job.print_percentage = 0 | ||||||
|  |     return print_job | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def temperatures_mock(): | ||||||
|  |     temperatures = MagicMock() | ||||||
|  |     temperatures.nozzle_temp = 0 | ||||||
|  |     temperatures.target_nozzle_temp = 0 | ||||||
|  |     temperatures.bed_temp = 0 | ||||||
|  |     temperatures.target_bed_temp = 0 | ||||||
|  |     temperatures.chamber_temp = 0 | ||||||
|  |     return temperatures | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient: | ||||||
|  |     bambu_client = MagicMock() | ||||||
|  |     bambu_client.connected = True | ||||||
|  |     device_mock = MagicMock() | ||||||
|  |     device_mock.print_job = print_job_mock | ||||||
|  |     device_mock.temperatures = temperatures_mock | ||||||
|  |     bambu_client.get_device.return_value = device_mock | ||||||
|  |     return bambu_client | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @fixture(scope="function") | ||||||
|  | def printer( | ||||||
|  |     output_test_folder, | ||||||
|  |     settings, | ||||||
|  |     profile_manager, | ||||||
|  |     log_test, | ||||||
|  |     ftps_session_mock, | ||||||
|  |     bambu_client_mock, | ||||||
|  | ): | ||||||
|  |     async def _mock_connection(self): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     BambuVirtualPrinter._create_client_connection_async = _mock_connection | ||||||
|  |     printer_test = BambuVirtualPrinter( | ||||||
|  |         settings, | ||||||
|  |         profile_manager, | ||||||
|  |         data_folder=output_test_folder, | ||||||
|  |         serial_log_handler=log_test, | ||||||
|  |         read_timeout=0.01, | ||||||
|  |         faked_baudrate=115200, | ||||||
|  |     ) | ||||||
|  |     printer_test._bambu_client = bambu_client_mock | ||||||
|  |     printer_test.flush() | ||||||
|  |     printer_test.readlines() | ||||||
|  |     yield printer_test | ||||||
|  |     printer_test.close() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_initial_state(printer: BambuVirtualPrinter): | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_sd_card(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M20\n")  # GCode for listing SD card | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"Begin file list" | ||||||
|  |     assert result[1].endswith(b'"print.3mf"') | ||||||
|  |     assert result[2].endswith(b'"print2.3mf"') | ||||||
|  |     assert result[3] == b"End file list" | ||||||
|  |     assert result[4] == b"ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_ftp_paths_p1s(settings, ftps_session_mock): | ||||||
|  |     file_system = RemoteSDCardFileList(settings) | ||||||
|  |     file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi") | ||||||
|  |  | ||||||
|  |     timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"] | ||||||
|  |     ftps_session_mock.size.side_effect = DictGetter( | ||||||
|  |         {file: 100 for file in timelapse_files} | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||||
|  |             for file in timelapse_files | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.nlst.side_effect = DictGetter( | ||||||
|  |         {"timelapse/": [Path(f).name for f in timelapse_files]} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     timelapse_paths = list(map(Path, timelapse_files)) | ||||||
|  |     result_files = file_view.get_all_info() | ||||||
|  |     assert len(timelapse_files) == len(result_files) and all( | ||||||
|  |         file_info.path in timelapse_paths for file_info in result_files | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_list_ftp_paths_x1(settings, ftps_session_mock): | ||||||
|  |     file_system = RemoteSDCardFileList(settings) | ||||||
|  |     file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4") | ||||||
|  |  | ||||||
|  |     timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"] | ||||||
|  |     ftps_session_mock.size.side_effect = DictGetter( | ||||||
|  |         {file: 100 for file in timelapse_files} | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||||
|  |         { | ||||||
|  |             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||||
|  |             for file in timelapse_files | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files}) | ||||||
|  |  | ||||||
|  |     timelapse_paths = list(map(Path, timelapse_files)) | ||||||
|  |     result_files = file_view.get_all_info() | ||||||
|  |     assert len(timelapse_files) == len(result_files) and all( | ||||||
|  |         file_info.path in timelapse_paths for file_info in result_files | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_non_existing_file_not_selected(printer: BambuVirtualPrinter): | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 non_existing.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-2] != b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     assert printer.selected_file is None | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\n") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-2] == b"File selected" | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print.3mf" | ||||||
|  |  | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M20\n") | ||||||
|  |     printer.write(b"M23 print.3mf\n") | ||||||
|  |     printer.write(b"M24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |     bambu_client_mock.publish.return_value = True | ||||||
|  |     printer.write(b"M25\n")  # GCode for pausing the print | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[0] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, PausedState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock): | ||||||
|  |     print_job_mock.gcode_state = "RUNNING" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "PAUSE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, PausedState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "IDLE" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FINISH" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FAILED" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_printer_info_check(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M27\n")  # printer get info | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_abort_print(printer: BambuVirtualPrinter): | ||||||
|  |     printer.write(b"M26\n")  # GCode for aborting the print | ||||||
|  |     printer.flush() | ||||||
|  |  | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock): | ||||||
|  |     gcode = b"G28\nG1 X10 Y10\n" | ||||||
|  |     printer.write(gcode) | ||||||
|  |     printer.flush() | ||||||
|  |     result = printer.readlines() | ||||||
|  |     assert result[-1] == b"ok" | ||||||
|  |  | ||||||
|  |     gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE | ||||||
|  |     gcode_command["print"]["param"] = "G28\n" | ||||||
|  |     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||||
|  |  | ||||||
|  |     gcode_command["print"]["param"] = "G1 X10 Y10\n" | ||||||
|  |     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_file_selection_does_not_affect_current_print( | ||||||
|  |     printer: BambuVirtualPrinter, print_job_mock | ||||||
|  | ): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print2.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_finished_print_job_reset_after_new_file_selected( | ||||||
|  |     printer: BambuVirtualPrinter, print_job_mock | ||||||
|  | ): | ||||||
|  |     print_job_mock.subtask_name = "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print.3mf\nM24\n") | ||||||
|  |     printer.flush() | ||||||
|  |     printer.readlines() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job is not None | ||||||
|  |     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||||
|  |     assert printer.current_print_job.progress == 0 | ||||||
|  |  | ||||||
|  |     print_job_mock.print_percentage = 100 | ||||||
|  |     printer.current_state.update_print_job_info() | ||||||
|  |     assert isinstance(printer.current_state, PrintingState) | ||||||
|  |     assert printer.current_print_job.progress == 100 | ||||||
|  |  | ||||||
|  |     print_job_mock.gcode_state = "FINISH" | ||||||
|  |     printer.new_update("event_printer_data_update") | ||||||
|  |     printer.flush() | ||||||
|  |     assert isinstance(printer.current_state, IdleState) | ||||||
|  |     assert printer.current_print_job is None | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print.3mf" | ||||||
|  |  | ||||||
|  |     printer.write(b"M23 print2.3mf\n") | ||||||
|  |     printer.flush() | ||||||
|  |     assert printer.current_print_job is None | ||||||
|  |     assert printer.selected_file is not None | ||||||
|  |     assert printer.selected_file.file_name == "print2.3mf" | ||||||
		Reference in New Issue
	
	Block a user