Compare commits
	
		
			30 Commits
		
	
	
		
			feature/re
			...
			fd9ce76275
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fd9ce76275 | |||
| 8dafb9fa5a | |||
| 094959335a | |||
| f64fa7aea2 | |||
| fea0f0ed25 | |||
| c7c089ef68 | |||
| ba43df279d | |||
| f5e6b3d0dd | |||
| 9358533ce8 | |||
| 92e11cdbf3 | |||
| 61c9332f15 | |||
| ad08d3eb9a | |||
| 5661c11190 | |||
| 3690767ced | |||
| eb397ff7b7 | |||
| 3a615cfafe | |||
| e9c06bb4b5 | |||
| 3ccce10648 | |||
| c99eb38655 | |||
| 698f8f4151 | |||
| 7a0293bac7 | |||
|  | d0fd4a5434 | ||
|  | 3c218a548d | ||
|  | 03af51608d | ||
|  | c00285b1b2 | ||
|  | 7f1ae5a24b | ||
|  | 5754e81b72 | ||
|  | cd4103cc71 | ||
|  | 01c6cacf15 | ||
|  | fda4b86cbc | 
							
								
								
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| github: [jneilliii] | ||||
| patreon: jneilliii | ||||
| custom: ['https://www.paypal.me/jneilliii'] | ||||
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| --- | ||||
| name: Bug report | ||||
| about: Please make sure to check other issues, including closed ones, prior to submitting a bug report. Debug logs are required and any bug report submitted without them will be ignored and closed.  | ||||
| title: "[BUG]: " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Describe the Bug** | ||||
| <!-- A clear and concise description of what the bug is. --> | ||||
|  | ||||
| **Expected Behavior** | ||||
| <!-- A clear and concise description of what you expected to happen. --> | ||||
|  | ||||
| **Debug Logs** | ||||
| <!-- If logs are not included in your bug report it will be closed. Enable debug logging for octoprint.plugins.bambu_printer in OctoPrint's logging section of settings and recreate the issue then attach octoprint.log and plugin_bambu_printer_serial.log to this bug report. --> | ||||
|  | ||||
| **Screenshots** | ||||
| <!-- Please share any relevant screenshots related to the issue. --> | ||||
|  | ||||
| **Printer and Plugin Setting Details** | ||||
|  | ||||
| * Printer model? | ||||
| * Is your printer connected to Bambu Cloud?  | ||||
| * Is the  plugin configured for local access only? | ||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| --- | ||||
| name: Feature request | ||||
| about: Create a feature request for an improvement or change you'd like implemented. | ||||
| title: "[FR]: " | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Is your feature request related to a problem? Please describe.** | ||||
| <!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] --> | ||||
|  | ||||
| **Describe the solution you'd like** | ||||
| <!-- A clear and concise description of what you want to happen. --> | ||||
|  | ||||
| **Describe alternatives you've considered** | ||||
| <!-- A clear and concise description of any alternative solutions or features you've considered. --> | ||||
|  | ||||
| **Additional context** | ||||
| <!-- Add any other context or screenshots about the feature request here. --> | ||||
							
								
								
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # Number of days of inactivity before an issue becomes stale | ||||
| daysUntilStale: 14 | ||||
| # Number of days of inactivity before a stale issue is closed | ||||
| daysUntilClose: 7 | ||||
| # Issues with these labels will never be considered stale | ||||
| exemptLabels: | ||||
|   - enhancement | ||||
|   - bug | ||||
| # Label to use when marking an issue as stale | ||||
| staleLabel: stale | ||||
| # Comment to post when marking an issue as stale. Set to `false` to disable | ||||
| markComment: > | ||||
|   This issue has been automatically marked as stale because it has not had | ||||
|   activity in 14 days. It will be closed if no further activity occurs in 7 days. | ||||
| # Comment to post when closing a stale issue. Set to `false` to disable | ||||
| closeComment: false | ||||
							
								
								
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| name: Mark Stale Issues | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
|   - cron: "0 0 * * *" | ||||
| permissions: | ||||
|   actions: write | ||||
| jobs: | ||||
|   stale: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - uses: actions/stale@v9 | ||||
|       with: | ||||
|         repo-token: ${{ secrets.GITHUB_TOKEN }} | ||||
|         stale-issue-message: 'This issue has been automatically marked as stale because it has not had activity in 14 days. It will be closed if no further activity occurs in 7 days' | ||||
|         days-before-stale: 14 | ||||
|         days-before-close: 7 | ||||
|         stale-issue-label: 'stale' | ||||
|         days-before-issue-stale: 14 | ||||
|         days-before-pr-stale: -1 | ||||
|         days-before-issue-close: 7 | ||||
|         days-before-pr-close: -1 | ||||
|         exempt-issue-labels: 'bug,enhancement' | ||||
|     - uses: actions/checkout@v4 | ||||
|     - uses: gautamkrishnar/keepalive-workflow@v2 | ||||
|       with: | ||||
|         use_api: true | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -8,3 +8,5 @@ dist | ||||
| .DS_Store | ||||
| *.zip | ||||
| extras | ||||
|  | ||||
| test/test_output | ||||
| @@ -1,260 +1,10 @@ | ||||
| # coding=utf-8 | ||||
| from __future__ import absolute_import | ||||
|  | ||||
| import os | ||||
| import threading | ||||
| import time | ||||
| import flask | ||||
| import datetime | ||||
|  | ||||
| import octoprint.plugin | ||||
| from octoprint.events import Events | ||||
| from octoprint.util import get_formatted_size, get_formatted_datetime, is_hidden_path | ||||
| from octoprint.server.util.flask import no_firstrun_access | ||||
| from octoprint.server.util.tornado import LargeResponseHandler, UrlProxyHandler, path_validation_factory | ||||
| from octoprint.access.permissions import Permissions | ||||
| from urllib.parse import quote as urlquote | ||||
| from .ftpsclient import IoTFTPSClient | ||||
|  | ||||
|  | ||||
| class BambuPrintPlugin(octoprint.plugin.SettingsPlugin, | ||||
|                        octoprint.plugin.TemplatePlugin, | ||||
|                        octoprint.plugin.AssetPlugin, | ||||
|                        octoprint.plugin.EventHandlerPlugin, | ||||
|                        octoprint.plugin.SimpleApiPlugin, | ||||
|                        octoprint.plugin.BlueprintPlugin): | ||||
|  | ||||
|  | ||||
|     def get_assets(self): | ||||
|         return {'js': ["js/bambu_printer.js"]} | ||||
|     def get_template_configs(self): | ||||
|         return [{"type": "settings", "custom_bindings": True}, | ||||
|                 {"type": "generic", "custom_bindings": True, "template": "bambu_timelapse.jinja2"}] #, {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] | ||||
|  | ||||
|     def get_settings_defaults(self): | ||||
|         return {"device_type": "X1C", | ||||
|                 "serial": "", | ||||
|                 "host": "", | ||||
|                 "access_code": "", | ||||
|                 "username": "bblp", | ||||
|                 "timelapse": False, | ||||
|                 "bed_leveling": True, | ||||
|                 "flow_cali": False, | ||||
|                 "vibration_cali": True, | ||||
|                 "layer_inspect": True, | ||||
|                 "use_ams": False, | ||||
|                 "local_mqtt": True, | ||||
|                 "region": "", | ||||
|                 "email": "", | ||||
|                 "auth_token": "", | ||||
|                 "always_use_default_options": False | ||||
|                 } | ||||
|  | ||||
|     def is_api_adminonly(self): | ||||
|         return True | ||||
|  | ||||
|     def get_api_commands(self): | ||||
|         return {"register": ["email", "password", "region", "auth_token"]} | ||||
|     def on_api_command(self, command, data): | ||||
|         if command == "register": | ||||
|             if "email" in data and "password" in data and "region" in data and "auth_token" in data: | ||||
|                 self._logger.info(f"Registering user {data['email']}") | ||||
|                 from pybambu import BambuCloud | ||||
|                 bambu_cloud = BambuCloud(data["region"], data["email"], data["password"], data["auth_token"]) | ||||
|                 bambu_cloud.login(data["region"], data["email"], data["password"]) | ||||
|                 return flask.jsonify({"auth_token": bambu_cloud.auth_token, "username": bambu_cloud.username}) | ||||
|     def on_event(self, event, payload): | ||||
|         if event == Events.TRANSFER_DONE: | ||||
|             self._printer.commands("M20 L T", force=True) | ||||
|     def support_3mf_files(self): | ||||
|         return {'machinecode': {'3mf': ["3mf"]}} | ||||
|  | ||||
|     def upload_to_sd(self, printer, filename, path, sd_upload_started, sd_upload_succeeded, sd_upload_failed, *args, **kwargs): | ||||
|         self._logger.debug(f"Starting upload from {filename} to {filename}") | ||||
|         sd_upload_started(filename, filename) | ||||
|         def process(): | ||||
|             host = self._settings.get(["host"]) | ||||
|             access_code = self._settings.get(["access_code"]) | ||||
|             elapsed = time.monotonic() | ||||
|  | ||||
|             try: | ||||
|                 ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|                 if ftp.upload_file(path, f"{filename}"): | ||||
|                     elapsed = time.monotonic() - elapsed | ||||
|                     sd_upload_succeeded(filename, filename, elapsed) | ||||
|                     # remove local file after successful upload to Bambu | ||||
|                     # self._file_manager.remove_file("local", filename) | ||||
|                 else: | ||||
|                     raise Exception("upload failed") | ||||
|             except Exception as e: | ||||
|                 elapsed = time.monotonic() - elapsed | ||||
|                 sd_upload_failed(filename, filename, elapsed) | ||||
|                 self._logger.debug(f"Error uploading file {filename}") | ||||
|  | ||||
|         thread = threading.Thread(target=process) | ||||
|         thread.daemon = True | ||||
|         thread.start() | ||||
|  | ||||
|         return filename | ||||
|  | ||||
|     def get_template_vars(self): | ||||
|         return {"plugin_version": self._plugin_version} | ||||
|  | ||||
|     def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): | ||||
|         if not port == "BAMBU": | ||||
|             return None | ||||
|  | ||||
|         if self._settings.get(["serial"]) == "" or self._settings.get(["host"]) == "" or self._settings.get(["access_code"]) == "": | ||||
|             return None | ||||
|  | ||||
|         import logging.handlers | ||||
|  | ||||
|         from octoprint.logging.handlers import CleaningTimedRotatingFileHandler | ||||
|  | ||||
|         seriallog_handler = CleaningTimedRotatingFileHandler( | ||||
|             self._settings.get_plugin_logfile_path(postfix="serial"), | ||||
|             when="D", | ||||
|             backupCount=3, | ||||
|         ) | ||||
|         seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) | ||||
|         seriallog_handler.setLevel(logging.DEBUG) | ||||
|  | ||||
|         from . import virtual | ||||
|  | ||||
|         serial_obj = virtual.BambuPrinter( | ||||
|             self._settings, | ||||
|             self._printer_profile_manager, | ||||
|             data_folder=self.get_plugin_data_folder(), | ||||
|             seriallog_handler=seriallog_handler, | ||||
|             read_timeout=float(read_timeout), | ||||
|             faked_baudrate=baudrate, | ||||
|         ) | ||||
|         return serial_obj | ||||
|  | ||||
|     def get_additional_port_names(self, *args, **kwargs): | ||||
|         if self._settings.get(["serial"]) != "" and self._settings.get(["host"]) != "" and self._settings.get(["access_code"]) != "": | ||||
|             return ["BAMBU"] | ||||
|         else: | ||||
|             return [] | ||||
|  | ||||
|     def get_timelapse_file_list(self): | ||||
|         if flask.request.path.startswith('/api/timelapse'): | ||||
|             def process(): | ||||
|                 host = self._settings.get(["host"]) | ||||
|                 access_code = self._settings.get(["access_code"]) | ||||
|                 return_file_list = [] | ||||
|  | ||||
|                 try: | ||||
|                     ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|                     if self._settings.get(["device_type"]) in ["X1", "X1C"]: | ||||
|                         timelapse_file_list = ftp.list_files("timelapse/", ".mp4") or [] | ||||
|                     else: | ||||
|                         timelapse_file_list = ftp.list_files("timelapse/", ".avi") or [] | ||||
|  | ||||
|                     for entry in timelapse_file_list: | ||||
|                         if entry.startswith("/"): | ||||
|                             filename = entry[1:].replace("timelapse/", "") | ||||
|                         else: | ||||
|                             filename = entry.replace("timelapse/", "") | ||||
|  | ||||
|                         filesize = ftp.ftps_session.size(f"timelapse/{filename}") | ||||
|                         date_str = ftp.ftps_session.sendcmd(f"MDTM timelapse/{filename}").replace("213 ", "") | ||||
|                         filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp() | ||||
|  | ||||
|                         return_file_list.append( | ||||
|                             { | ||||
|                                 "bytes": filesize, | ||||
|                                 "date": get_formatted_datetime(datetime.datetime.fromtimestamp(filedate)), | ||||
|                                 "name": filename, | ||||
|                                 "size": get_formatted_size(filesize), | ||||
|                                 "thumbnail": "/plugin/bambu_printer/thumbnail/" + filename.replace(".mp4", ".jpg").replace(".avi", ".jpg"), | ||||
|                                 "timestamp": filedate, | ||||
|                                 "url": f"/plugin/bambu_printer/timelapse/{filename}" | ||||
|                             }) | ||||
|  | ||||
|                     self._plugin_manager.send_plugin_message(self._identifier, {'files': return_file_list}) | ||||
|  | ||||
|                 except Exception as e: | ||||
|                     self._logger.debug(f"Error getting timelapse files: {e}") | ||||
|  | ||||
|             thread = threading.Thread(target=process) | ||||
|             thread.daemon = True | ||||
|             thread.start() | ||||
|  | ||||
|  | ||||
|     def _hook_octoprint_server_api_before_request(self, *args, **kwargs): | ||||
|         return [self.get_timelapse_file_list] | ||||
|  | ||||
|     @octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"]) | ||||
|     @octoprint.server.util.flask.restricted_access | ||||
|     @no_firstrun_access | ||||
|     @Permissions.TIMELAPSE_DOWNLOAD.require(403) | ||||
|     def downloadTimelapse(self, filename): | ||||
|         dest_filename = os.path.join(self.get_plugin_data_folder(), filename) | ||||
|         host = self._settings.get(["host"]) | ||||
|         access_code = self._settings.get(["access_code"]) | ||||
|  | ||||
|         if not os.path.exists(dest_filename): | ||||
|             ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|             download_result = ftp.download_file( | ||||
|                 source=f"timelapse/{filename}", | ||||
|                 dest=dest_filename, | ||||
|             ) | ||||
|  | ||||
|         return flask.redirect("/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302) | ||||
|  | ||||
|     @octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"]) | ||||
|     @octoprint.server.util.flask.restricted_access | ||||
|     @no_firstrun_access | ||||
|     @Permissions.TIMELAPSE_DOWNLOAD.require(403) | ||||
|     def downloadThumbnail(self, filename): | ||||
|         dest_filename = os.path.join(self.get_plugin_data_folder(), filename) | ||||
|         host = self._settings.get(["host"]) | ||||
|         access_code = self._settings.get(["access_code"]) | ||||
|  | ||||
|         if not os.path.exists(dest_filename): | ||||
|             ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True) | ||||
|             download_result = ftp.download_file( | ||||
|                 source=f"timelapse/thumbnail/{filename}", | ||||
|                 dest=dest_filename, | ||||
|             ) | ||||
|  | ||||
|         return flask.redirect("/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302) | ||||
|  | ||||
|     def is_blueprint_csrf_protected(self): | ||||
|         return True | ||||
|  | ||||
|     def route_hook(self, server_routes, *args, **kwargs): | ||||
|         return [ | ||||
|             (r"/download/timelapse/(.*)", LargeResponseHandler, | ||||
|              {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( | ||||
|                  lambda path: not is_hidden_path(path), status_code=404)}), | ||||
|             (r"/download/thumbnail/(.*)", LargeResponseHandler, | ||||
|              {'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory( | ||||
|                  lambda path: not is_hidden_path(path), status_code=404)}) | ||||
|         ] | ||||
|  | ||||
|     def get_update_information(self): | ||||
|         return {'bambu_printer': {'displayName': "Bambu Printer", | ||||
|                                   'displayVersion': self._plugin_version, | ||||
|                                   'type': "github_release", | ||||
|                                   'user': "jneilliii", | ||||
|                                   'repo': "OctoPrint-BambuPrinter", | ||||
|                                   'current': self._plugin_version, | ||||
|                                   'stable_branch': {'name': "Stable", | ||||
|                                                     'branch': "master", | ||||
|                                                     'comittish': ["master"]}, | ||||
|                                   'prerelease_branches': [ | ||||
|                                       {'name': "Release Candidate", | ||||
|                                        'branch': "rc", | ||||
|                                        'comittish': ["rc", "master"]} | ||||
|                                   ], | ||||
|                                   'pip': "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip"}} | ||||
|  | ||||
|  | ||||
| __plugin_name__ = "Bambu Printer" | ||||
| __plugin_pythoncompat__ = ">=3.7,<4" | ||||
|  | ||||
| from .bambu_print_plugin import BambuPrintPlugin | ||||
|  | ||||
|  | ||||
| def __plugin_load__(): | ||||
|     plugin = BambuPrintPlugin() | ||||
| @@ -270,5 +20,5 @@ def __plugin_load__(): | ||||
|         "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, | ||||
|         "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, | ||||
|         "octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request, | ||||
|         "octoprint.server.http.routes": __plugin_implementation__.route_hook | ||||
|         "octoprint.server.http.routes": __plugin_implementation__.route_hook, | ||||
|     } | ||||
|   | ||||
							
								
								
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								octoprint_bambu_printer/bambu_print_plugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | ||||
| from __future__ import absolute_import, annotations | ||||
| from pathlib import Path | ||||
| import threading | ||||
| from time import perf_counter | ||||
| from contextlib import contextmanager | ||||
| import flask | ||||
| import logging.handlers | ||||
| from urllib.parse import quote as urlquote | ||||
|  | ||||
| import octoprint.printer | ||||
| import octoprint.server | ||||
| import octoprint.plugin | ||||
| from octoprint.events import Events | ||||
| import octoprint.settings | ||||
| from octoprint.util import is_hidden_path | ||||
| from octoprint.server.util.flask import no_firstrun_access | ||||
| from octoprint.server.util.tornado import ( | ||||
|     LargeResponseHandler, | ||||
|     path_validation_factory, | ||||
| ) | ||||
| from octoprint.access.permissions import Permissions | ||||
| from octoprint.logging.handlers import CleaningTimedRotatingFileHandler | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||
| from pybambu import BambuCloud | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||
|     RemoteSDCardFileList, | ||||
| ) | ||||
|  | ||||
| from .printer.file_system.bambu_timelapse_file_info import ( | ||||
|     BambuTimelapseFileInfo, | ||||
| ) | ||||
| from .printer.bambu_virtual_printer import BambuVirtualPrinter | ||||
|  | ||||
|  | ||||
| @contextmanager | ||||
| def measure_elapsed(): | ||||
|     start = perf_counter() | ||||
|  | ||||
|     def _get_elapsed(): | ||||
|         return perf_counter() - start | ||||
|  | ||||
|     yield _get_elapsed | ||||
|     print(f"Total elapsed: {_get_elapsed()}") | ||||
|  | ||||
|  | ||||
| class BambuPrintPlugin( | ||||
|     octoprint.plugin.SettingsPlugin, | ||||
|     octoprint.plugin.TemplatePlugin, | ||||
|     octoprint.plugin.AssetPlugin, | ||||
|     octoprint.plugin.EventHandlerPlugin, | ||||
|     octoprint.plugin.SimpleApiPlugin, | ||||
|     octoprint.plugin.BlueprintPlugin, | ||||
| ): | ||||
|     _logger: logging.Logger | ||||
|     _plugin_manager: octoprint.plugin.PluginManager | ||||
|     _bambu_file_system: RemoteSDCardFileList | ||||
|     _timelapse_files_view: CachedFileView | ||||
|  | ||||
|     def on_settings_initialized(self): | ||||
|         self._bambu_file_system = RemoteSDCardFileList(self._settings) | ||||
|         self._timelapse_files_view = CachedFileView(self._bambu_file_system) | ||||
|         if self._settings.get(["device_type"]) in ["X1", "X1C"]: | ||||
|             self._timelapse_files_view.with_filter("timelapse/", ".mp4") | ||||
|         else: | ||||
|             self._timelapse_files_view.with_filter("timelapse/", ".avi") | ||||
|  | ||||
|     def get_assets(self): | ||||
|         return {"js": ["js/bambu_printer.js"]} | ||||
|  | ||||
|     def get_template_configs(self): | ||||
|         return [ | ||||
|             {"type": "settings", "custom_bindings": True}, | ||||
|             { | ||||
|                 "type": "generic", | ||||
|                 "custom_bindings": True, | ||||
|                 "template": "bambu_timelapse.jinja2", | ||||
|             }, | ||||
|         ]  # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] | ||||
|  | ||||
|     def get_settings_defaults(self): | ||||
|         return { | ||||
|             "device_type": "X1C", | ||||
|             "serial": "", | ||||
|             "host": "", | ||||
|             "access_code": "", | ||||
|             "username": "octobambu", | ||||
|             "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": "Manus Bambu Printer", | ||||
|                 "displayVersion": self._plugin_version, | ||||
|                 "type": "github_release", | ||||
|                 "user": "ManuelW", | ||||
|                 "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://gitlab.fire-devils.org/3D-Druck/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" | ||||
							
								
								
									
										1403
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1403
									
								
								octoprint_bambu_printer/printer/bambu_virtual_printer.py
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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,70 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import TYPE_CHECKING, Callable, List, Optional | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| class CachedFileView: | ||||
|     def __init__( | ||||
|         self, file_system, on_update: Optional[Callable] = None, base_path: str = "" | ||||
|     ): | ||||
|         self._filters = [] | ||||
|         self._file_system = file_system | ||||
|         self._base_path = base_path | ||||
|         self._update_complete_callback = on_update | ||||
|         self._file_info_cache = [] | ||||
|  | ||||
|     def with_filter(self, path: str, extension: str): | ||||
|         self._filters.append({"path": path, "extension": extension}) | ||||
|         return self | ||||
|  | ||||
|     def update(self) -> None: | ||||
|         try: | ||||
|             file_info_list = self.list_all_views() | ||||
|             self._file_info_cache = file_info_list | ||||
|              | ||||
|             # Rufe Callback auf, wenn vorhanden | ||||
|             if self._update_complete_callback is not None: | ||||
|                 self._update_complete_callback() | ||||
|         except Exception as e: | ||||
|             import logging | ||||
|             logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter").error( | ||||
|                 f"Error updating file list: {e}", exc_info=True | ||||
|             ) | ||||
|  | ||||
|     def list_all_views(self) -> List[FileInfo]: | ||||
|         # Verwende die Mock-Implementation von get_file_list statt FTPS | ||||
|         try: | ||||
|             return self._file_system.get_file_list(self._base_path) | ||||
|         except Exception as e: | ||||
|             import logging | ||||
|             logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter").error( | ||||
|                 f"Error listing files: {e}", exc_info=True | ||||
|             ) | ||||
|             return [] | ||||
|  | ||||
|     def get_all_cached_info(self) -> List[FileInfo]: | ||||
|         return self._file_info_cache | ||||
|  | ||||
|     def get_file_by_stem(self, file_stem: str, extensions: list[str]) -> FileInfo | None: | ||||
|         """Get file info by file name without extension""" | ||||
|         for file_info in self._file_info_cache: | ||||
|             for extension in extensions: | ||||
|                 if file_info.file_name.lower().startswith(f"{file_stem.lower()}{extension}"): | ||||
|                     return file_info | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def get_file_data(self, file_path: str) -> FileInfo | None: | ||||
|         for file_info in self._file_info_cache: | ||||
|             if file_info.path.lower() == file_path.lower() or file_info.file_name.lower() == file_path.lower(): | ||||
|                 return file_info | ||||
|         return None | ||||
							
								
								
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								octoprint_bambu_printer/printer/file_system/file_info.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from dataclasses import asdict, dataclass | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
|  | ||||
| from octoprint.util.files import unix_timestamp_to_m20_timestamp | ||||
|  | ||||
|  | ||||
| @dataclass(frozen=True) | ||||
| class FileInfo: | ||||
|     dosname: str | ||||
|     path: Path | ||||
|     size: int | ||||
|     date: datetime | ||||
|  | ||||
|     @property | ||||
|     def file_name(self): | ||||
|         return self.path.name | ||||
|  | ||||
|     @property | ||||
|     def timestamp(self) -> float: | ||||
|         return self.date.timestamp() | ||||
|  | ||||
|     @property | ||||
|     def timestamp_m20(self) -> str: | ||||
|         return unix_timestamp_to_m20_timestamp(int(self.timestamp)) | ||||
|  | ||||
|     def get_gcode_info(self) -> str: | ||||
|         return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"' | ||||
|  | ||||
|     def to_dict(self): | ||||
|         return asdict(self) | ||||
| @@ -24,16 +24,21 @@ SOFTWARE. | ||||
| wrapper for FTPS server interactions | ||||
| """ | ||||
| 
 | ||||
| from __future__ import annotations | ||||
| from dataclasses import dataclass | ||||
| from datetime import datetime, timezone | ||||
| import ftplib | ||||
| import os | ||||
| from pathlib import Path | ||||
| import socket | ||||
| import ssl | ||||
| from typing import Optional, Union, List | ||||
| from typing import Generator, Union | ||||
| 
 | ||||
| from contextlib import redirect_stdout | ||||
| import io | ||||
| import re | ||||
| 
 | ||||
| 
 | ||||
| class ImplicitTLS(ftplib.FTP_TLS): | ||||
|     """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" | ||||
| 
 | ||||
| @@ -57,67 +62,20 @@ class ImplicitTLS(ftplib.FTP_TLS): | ||||
|         conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) | ||||
| 
 | ||||
|         if self._prot_p: | ||||
|             conn = self.context.wrap_socket(conn, | ||||
|                                             server_hostname=self.host, | ||||
|                                             session=self.sock.session)  # this is the fix | ||||
|             conn = self.context.wrap_socket( | ||||
|                 conn, server_hostname=self.host, session=self.sock.session | ||||
|             )  # this is the fix | ||||
|         return conn, size | ||||
| 
 | ||||
| 
 | ||||
| class IoTFTPSClient: | ||||
| @dataclass | ||||
| class IoTFTPSConnection: | ||||
|     """iot ftps ftpsclient""" | ||||
| 
 | ||||
|     ftps_host: str | ||||
|     ftps_port: int | ||||
|     ftps_user: str | ||||
|     ftps_pass: str | ||||
|     ssl_implicit: bool | ||||
|     ftps_session: Union[ftplib.FTP, ImplicitTLS] | ||||
|     last_error: Optional[str] = None | ||||
|     welcome: str | ||||
|     ftps_session: ftplib.FTP | ImplicitTLS | ||||
| 
 | ||||
|     def __init__( | ||||
|             self, | ||||
|             ftps_host: str, | ||||
|             ftps_port: Optional[int] = 21, | ||||
|             ftps_user: Optional[str] = "", | ||||
|             ftps_pass: Optional[str] = "", | ||||
|             ssl_implicit: Optional[bool] = False, | ||||
|     ) -> None: | ||||
|         self.ftps_host = ftps_host | ||||
|         self.ftps_port = ftps_port | ||||
|         self.ftps_user = ftps_user | ||||
|         self.ftps_pass = ftps_pass | ||||
|         self.ssl_implicit = ssl_implicit | ||||
|         self.instantiate_ftps_session() | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
|         return ( | ||||
|             "IoT FTPS Client\n" | ||||
|             "--------------------\n" | ||||
|             f"host: {self.ftps_host}\n" | ||||
|             f"port: {self.ftps_port}\n" | ||||
|             f"user: {self.ftps_user}\n" | ||||
|             f"ssl: {self.ssl_implicit}" | ||||
|         ) | ||||
| 
 | ||||
|     def instantiate_ftps_session(self) -> None: | ||||
|         """init ftps_session based on input params""" | ||||
|         self.ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() | ||||
|         self.ftps_session.set_debuglevel(0) | ||||
| 
 | ||||
|         self.welcome = self.ftps_session.connect( | ||||
|             host=self.ftps_host, port=self.ftps_port) | ||||
| 
 | ||||
|         if self.ftps_user and self.ftps_pass: | ||||
|             self.ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) | ||||
|         else: | ||||
|             self.ftps_session.login() | ||||
| 
 | ||||
|         if self.ssl_implicit: | ||||
|             self.ftps_session.prot_p() | ||||
| 
 | ||||
|     def disconnect(self) -> None: | ||||
|         """disconnect the current session from the ftps server""" | ||||
|     def close(self) -> None: | ||||
|         """close the current session from the ftps server""" | ||||
|         self.ftps_session.close() | ||||
| 
 | ||||
|     def download_file(self, source: str, dest: str): | ||||
| @@ -137,7 +95,7 @@ class IoTFTPSClient: | ||||
|             # Taken from ftplib.storbinary but with custom ssl handling | ||||
|             # due to the shitty bambu p1p ftps server TODO fix properly. | ||||
|             with open(source, "rb") as fp: | ||||
|                 self.ftps_session.voidcmd('TYPE I') | ||||
|                 self.ftps_session.voidcmd("TYPE I") | ||||
| 
 | ||||
|                 with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: | ||||
|                     while 1: | ||||
| @@ -152,12 +110,14 @@ class IoTFTPSClient: | ||||
|                             callback(buf) | ||||
| 
 | ||||
|                     # shutdown ssl layer | ||||
|                     if ftplib._SSLSocket is not None and isinstance(conn, ftplib._SSLSocket): | ||||
|                     if ftplib._SSLSocket is not None and isinstance( | ||||
|                         conn, ftplib._SSLSocket | ||||
|                     ): | ||||
|                         # Yeah this is suposed to be conn.unwrap | ||||
|                         # But since we operate in prot p mode | ||||
|                         # we can close the connection always. | ||||
|                         # This is cursed but it works. | ||||
|                         if "vsFTPd" in self.welcome: | ||||
|                         if "vsFTPd" in self.ftps_session.welcome: | ||||
|                             conn.unwrap() | ||||
|                         else: | ||||
|                             conn.shutdown(socket.SHUT_RDWR) | ||||
| @@ -185,19 +145,26 @@ class IoTFTPSClient: | ||||
|     def mkdir(self, path: str) -> str: | ||||
|         return self.ftps_session.mkd(path) | ||||
| 
 | ||||
|     def list_files(self, path: str, file_pattern: Optional[str] = None) -> Union[List[str], None]: | ||||
|     def list_files( | ||||
|         self, list_path: str, extensions: str | list[str] | None = None | ||||
|     ) -> Generator[Path]: | ||||
|         """list files under a path inside the FTPS server""" | ||||
| 
 | ||||
|         if extensions is None: | ||||
|             _extension_acceptable = lambda p: True | ||||
|         else: | ||||
|             if isinstance(extensions, str): | ||||
|                 extensions = [extensions] | ||||
|             _extension_acceptable = lambda p: any(s in p.suffixes for s in extensions) | ||||
| 
 | ||||
|         try: | ||||
|             files = self.ftps_session.nlst(path) | ||||
|             if not files: | ||||
|                 return | ||||
|             if file_pattern: | ||||
|                 return [f for f in files if file_pattern in f] | ||||
|             return files | ||||
|             list_result = self.ftps_session.nlst(list_path) or [] | ||||
|             for file_list_entry in list_result: | ||||
|                 path = Path(list_path) / Path(file_list_entry).name | ||||
|                 if _extension_acceptable(path): | ||||
|                     yield path | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: {ex}") | ||||
|             pass | ||||
|         return | ||||
| 
 | ||||
|     def list_files_ex(self, path: str) -> Union[list[str], None]: | ||||
|         """list files under a path inside the FTPS server""" | ||||
| @@ -208,7 +175,8 @@ class IoTFTPSClient: | ||||
|             s = f.getvalue() | ||||
|             files = [] | ||||
|             for row in s.split("\n"): | ||||
|                 if len(row) <= 0: continue | ||||
|                 if len(row) <= 0: | ||||
|                     continue | ||||
| 
 | ||||
|                 attribs = row.split(" ") | ||||
| 
 | ||||
| @@ -219,10 +187,70 @@ class IoTFTPSClient: | ||||
|                 else: | ||||
|                     name = attribs[len(attribs) - 1] | ||||
| 
 | ||||
|                 file = ( attribs[0], name ) | ||||
|                 file = (attribs[0], name) | ||||
|                 files.append(file) | ||||
|             return files | ||||
|         except Exception as ex: | ||||
|             print(f"unexpected exception occurred: [{ex}]") | ||||
|             pass | ||||
|         return | ||||
| 
 | ||||
|     def get_file_size(self, file_path: str): | ||||
|         try: | ||||
|             return self.ftps_session.size(file_path) | ||||
|         except Exception as e: | ||||
|             raise RuntimeError( | ||||
|                 f'Cannot get file size for "{file_path}" due to error: {str(e)}' | ||||
|             ) | ||||
| 
 | ||||
|     def get_file_date(self, file_path: str) -> datetime: | ||||
|         try: | ||||
|             date_response = self.ftps_session.sendcmd(f"MDTM {file_path}").replace( | ||||
|                 "213 ", "" | ||||
|             ) | ||||
|             date = datetime.strptime(date_response, "%Y%m%d%H%M%S").replace( | ||||
|                 tzinfo=timezone.utc | ||||
|             ) | ||||
|             return date | ||||
|         except Exception as e: | ||||
|             raise RuntimeError( | ||||
|                 f'Cannot get file date for "{file_path}" due to error: {str(e)}' | ||||
|             ) | ||||
| 
 | ||||
| 
 | ||||
| @dataclass | ||||
| class IoTFTPSClient: | ||||
|     ftps_host: str | ||||
|     ftps_port: int = 21 | ||||
|     ftps_user: str = "" | ||||
|     ftps_pass: str = "" | ||||
|     ssl_implicit: bool = False | ||||
|     welcome: str = "" | ||||
|     _connection: IoTFTPSConnection | None = None | ||||
| 
 | ||||
|     def __enter__(self): | ||||
|         session = self.open_ftps_session() | ||||
|         self._connection = IoTFTPSConnection(session) | ||||
|         return self._connection | ||||
| 
 | ||||
|     def __exit__(self, type, value, traceback): | ||||
|         if self._connection is not None: | ||||
|             self._connection.close() | ||||
|             self._connection = None | ||||
| 
 | ||||
|     def open_ftps_session(self) -> ftplib.FTP | ImplicitTLS: | ||||
|         """init ftps_session based on input params""" | ||||
|         ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() | ||||
|         ftps_session.set_debuglevel(0) | ||||
| 
 | ||||
|         self.welcome = ftps_session.connect(host=self.ftps_host, port=self.ftps_port) | ||||
| 
 | ||||
|         if self.ftps_user and self.ftps_pass: | ||||
|             ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) | ||||
|         else: | ||||
|             ftps_session.login() | ||||
| 
 | ||||
|         if self.ssl_implicit: | ||||
|             ftps_session.prot_p() | ||||
| 
 | ||||
|         return ftps_session | ||||
| @@ -0,0 +1,136 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import datetime | ||||
| from pathlib import Path | ||||
| from typing import Iterable, Iterator, List | ||||
| import logging.handlers | ||||
|  | ||||
| from octoprint.util import get_dos_filename | ||||
|  | ||||
| from .ftps_client import IoTFTPSClient, IoTFTPSConnection | ||||
| from .file_info import FileInfo | ||||
|  | ||||
|  | ||||
| class RemoteSDCardFileList: | ||||
|  | ||||
|     def __init__(self, settings) -> None: | ||||
|         self._settings = settings | ||||
|         self._selected_project_file: FileInfo | None = None | ||||
|         self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") | ||||
|         self._mock_files = []  # Lokales Cache für Mock-Dateien | ||||
|  | ||||
|     def delete_file(self, file_path: Path) -> None: | ||||
|         try: | ||||
|             with self.get_ftps_client() as ftp: | ||||
|                 if ftp.delete_file(file_path.as_posix()): | ||||
|                     self._logger.debug(f"{file_path} deleted") | ||||
|                 else: | ||||
|                     raise RuntimeError(f"Deleting file {file_path} failed") | ||||
|         except Exception as e: | ||||
|             self._logger.exception(e) | ||||
|  | ||||
|     def list_files( | ||||
|         self, | ||||
|         folder: str, | ||||
|         extensions: str | list[str] | None, | ||||
|         ftp: IoTFTPSConnection, | ||||
|         existing_files=None, | ||||
|     ): | ||||
|         if existing_files is None: | ||||
|             existing_files = [] | ||||
|  | ||||
|         return list( | ||||
|             self.get_file_info_for_names( | ||||
|                 ftp, ftp.list_files(folder, extensions), existing_files | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def _get_ftp_file_info( | ||||
|         self, | ||||
|         ftp: IoTFTPSConnection, | ||||
|         file_path: Path, | ||||
|         existing_files: list[str] | None = None, | ||||
|     ): | ||||
|         file_size = ftp.get_file_size(file_path.as_posix()) | ||||
|         date = ftp.get_file_date(file_path.as_posix()) | ||||
|         file_name = file_path.name.lower() | ||||
|         dosname = get_dos_filename(file_name, existing_filenames=existing_files).lower() | ||||
|         return FileInfo( | ||||
|             dosname, | ||||
|             file_path, | ||||
|             file_size if file_size is not None else 0, | ||||
|             date, | ||||
|         ) | ||||
|  | ||||
|     def get_file_info_for_names( | ||||
|         self, | ||||
|         ftp: IoTFTPSConnection, | ||||
|         files: Iterable[Path], | ||||
|         existing_files: list[str] | None = None, | ||||
|     ) -> Iterator[FileInfo]: | ||||
|         if existing_files is None: | ||||
|             existing_files = [] | ||||
|  | ||||
|         for entry in files: | ||||
|             try: | ||||
|                 file_info = self._get_ftp_file_info(ftp, entry, existing_files) | ||||
|                 yield file_info | ||||
|                 existing_files.append(file_info.file_name) | ||||
|                 existing_files.append(file_info.dosname) | ||||
|             except Exception as e: | ||||
|                 self._logger.exception(e, exc_info=False) | ||||
|  | ||||
|     def get_ftps_client(self): | ||||
|         """ | ||||
|         Implementieren wir eine Mock-Version des FTPS-Clients, die keinen echten FTP-Zugriff erfordert. | ||||
|         """ | ||||
|         class MockFTPSClient: | ||||
|             def __enter__(self): | ||||
|                 return self | ||||
|                  | ||||
|             def __exit__(self, exc_type, exc_val, exc_tb): | ||||
|                 pass | ||||
|                  | ||||
|             def get_file_list(self, path=""): | ||||
|                 """Gibt die Mock-Dateiliste zurück""" | ||||
|                 return self._mock_files | ||||
|                  | ||||
|         mock_client = MockFTPSClient() | ||||
|         mock_client._mock_files = self._mock_files | ||||
|         return mock_client | ||||
|          | ||||
|     @property | ||||
|     def is_available(self) -> bool: | ||||
|         """ | ||||
|         Da wir kein FTP verwenden, ist dieser Service immer verfügbar | ||||
|         """ | ||||
|         return True | ||||
|  | ||||
|     def get_file_list(self, path: str) -> List[FileInfo]: | ||||
|         """ | ||||
|         Gibt eine Liste von Dateien im angegebenen Pfad zurück. | ||||
|         Da wir kein FTP verwenden, geben wir eine leere Liste oder gespeicherte Mock-Dateien zurück. | ||||
|         """ | ||||
|         self._logger.debug(f"Listing files in path: {path}") | ||||
|         return self._mock_files | ||||
|  | ||||
|     def add_mock_file(self, file_info: FileInfo): | ||||
|         """ | ||||
|         Fügt eine Mock-Datei zur Liste hinzu (für Tests oder wenn keine FTP-Verbindung möglich ist) | ||||
|         """ | ||||
|         self._mock_files.append(file_info) | ||||
|         self._logger.debug(f"Added mock file: {file_info.file_name}") | ||||
|      | ||||
|     def clear_mock_files(self): | ||||
|         """Löscht alle gespeicherten Mock-Dateien""" | ||||
|         self._mock_files = [] | ||||
|         self._logger.debug("Mock file list cleared") | ||||
|  | ||||
|     def delete_file(self, path: str) -> bool: | ||||
|         """ | ||||
|         Simuliert das Löschen einer Datei, entfernt sie aus der Mock-Liste | ||||
|         """ | ||||
|         self._logger.debug(f"Deleting file: {path}") | ||||
|         before_count = len(self._mock_files) | ||||
|         self._mock_files = [f for f in self._mock_files if f.path != path] | ||||
|         return before_count > len(self._mock_files) | ||||
							
								
								
									
										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}'" | ||||
|         ) | ||||
							
								
								
									
										56
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								octoprint_bambu_printer/printer/states/idle_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||
| from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||
|  | ||||
|  | ||||
| class IdleState(APrinterState): | ||||
|  | ||||
|     def start_new_print(self): | ||||
|         selected_file = self._printer.selected_file | ||||
|         if selected_file is None: | ||||
|             self._log.warn("Cannot start print job if file was not selected") | ||||
|             return | ||||
|  | ||||
|         print_command = self._get_print_command_for_file(selected_file) | ||||
|         self._log.debug(f"Sending print command: {print_command}") | ||||
|         if self._printer.bambu_client.publish(print_command): | ||||
|             self._log.info(f"Started print for {selected_file.file_name}") | ||||
|         else: | ||||
|             self._log.warn(f"Failed to start print for {selected_file.file_name}") | ||||
|  | ||||
|     def _get_print_command_for_file(self, selected_file: FileInfo): | ||||
|  | ||||
|         # URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf" | ||||
|         filesystem_root = ( | ||||
|             "file:///mnt/sdcard/" | ||||
|             if self._printer._settings.get(["device_type"]) in ["X1", "X1C"] | ||||
|             else "file:///" | ||||
|         ) | ||||
|  | ||||
|         print_command = { | ||||
|             "print": { | ||||
|                 "sequence_id": 0, | ||||
|                 "command": "project_file", | ||||
|                 "param": "Metadata/plate_1.gcode", | ||||
|                 "md5": "", | ||||
|                 "profile_id": "0", | ||||
|                 "project_id": "0", | ||||
|                 "subtask_id": "0", | ||||
|                 "task_id": "0", | ||||
|                 "subtask_name": selected_file.file_name, | ||||
|                 "url": f"{filesystem_root}{selected_file.path.as_posix()}", | ||||
|                 "bed_type": "auto", | ||||
|                 "timelapse": self._printer._settings.get_boolean(["timelapse"]), | ||||
|                 "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), | ||||
|                 "flow_cali": self._printer._settings.get_boolean(["flow_cali"]), | ||||
|                 "vibration_cali": self._printer._settings.get_boolean( | ||||
|                     ["vibration_cali"] | ||||
|                 ), | ||||
|                 "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), | ||||
|                 "use_ams": self._printer._settings.get_boolean(["use_ams"]), | ||||
|                 "ams_mapping": "", | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return print_command | ||||
							
								
								
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								octoprint_bambu_printer/printer/states/paused_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| from __future__ import annotations | ||||
| from typing import TYPE_CHECKING | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from octoprint_bambu_printer.printer.bambu_virtual_printer import ( | ||||
|         BambuVirtualPrinter, | ||||
|     ) | ||||
|  | ||||
| import threading | ||||
|  | ||||
| import pybambu.commands | ||||
| from octoprint.util import RepeatedTimer | ||||
|  | ||||
| from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||
|  | ||||
|  | ||||
| class PausedState(APrinterState): | ||||
|  | ||||
|     def __init__(self, printer: BambuVirtualPrinter) -> None: | ||||
|         super().__init__(printer) | ||||
|         self._pausedLock = threading.Event() | ||||
|         self._paused_repeated_report = None | ||||
|  | ||||
|     def init(self): | ||||
|         if not self._pausedLock.is_set(): | ||||
|             self._pausedLock.set() | ||||
|  | ||||
|         self._printer.sendIO("// action:paused") | ||||
|         self._printer.start_continuous_status_report(3) | ||||
|  | ||||
|     def finalize(self): | ||||
|         if self._pausedLock.is_set(): | ||||
|             self._pausedLock.clear() | ||||
|             if self._paused_repeated_report is not None: | ||||
|                 self._paused_repeated_report.join() | ||||
|                 self._paused_repeated_report = None | ||||
|  | ||||
|     def start_new_print(self): | ||||
|         if self._printer.bambu_client.connected: | ||||
|             if self._printer.bambu_client.publish(pybambu.commands.RESUME): | ||||
|                 self._log.info("print resumed") | ||||
|             else: | ||||
|                 self._log.info("print resume failed") | ||||
|  | ||||
|     def cancel_print(self): | ||||
|         if self._printer.bambu_client.connected: | ||||
|             if self._printer.bambu_client.publish(pybambu.commands.STOP): | ||||
|                 self._log.info("print cancelled") | ||||
|                 self._printer.finalize_print_job() | ||||
|             else: | ||||
|                 self._log.info("print cancel failed") | ||||
							
								
								
									
										94
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								octoprint_bambu_printer/printer/states/printing_state.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| 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._current_print_job = None | ||||
|         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 | ||||
|         self._printer.current_print_job = None | ||||
|  | ||||
|     def _start_worker_thread(self): | ||||
|         if self._sd_printing_thread is None: | ||||
|             self._is_printing = True | ||||
|             self._sd_printing_thread = threading.Thread(target=self._printing_worker) | ||||
|             self._sd_printing_thread.start() | ||||
|  | ||||
|     def _printing_worker(self): | ||||
|         while ( | ||||
|             self._is_printing | ||||
|             and self._printer.current_print_job is not None | ||||
|             and self._printer.current_print_job.progress < 100 | ||||
|         ): | ||||
|             self.update_print_job_info() | ||||
|             self._printer.report_print_job_status() | ||||
|             time.sleep(3) | ||||
|  | ||||
|         self.update_print_job_info() | ||||
|         if ( | ||||
|             self._printer.current_print_job is not None | ||||
|             and self._printer.current_print_job.progress >= 100 | ||||
|         ): | ||||
|             self._printer.finalize_print_job() | ||||
|  | ||||
|     def update_print_job_info(self): | ||||
|         print_job_info = self._printer.bambu_client.get_device().print_job | ||||
|         task_name: str = print_job_info.subtask_name | ||||
|         project_file_info = self._printer.project_files.get_file_by_stem( | ||||
|             task_name, [".gcode", ".3mf"] | ||||
|         ) | ||||
|         if project_file_info is None: | ||||
|             self._log.debug(f"No 3mf file found for {print_job_info}") | ||||
|             self._current_print_job = None | ||||
|             self._printer.change_state(self._printer._state_idle) | ||||
|             return | ||||
|  | ||||
|         progress = print_job_info.print_percentage | ||||
|         self._printer.current_print_job = PrintJob(project_file_info, progress) | ||||
|         self._printer.select_project_file(project_file_info.path.as_posix()) | ||||
|  | ||||
|     def pause_print(self): | ||||
|         if self._printer.bambu_client.connected: | ||||
|             if self._printer.bambu_client.publish(pybambu.commands.PAUSE): | ||||
|                 self._log.info("print paused") | ||||
|             else: | ||||
|                 self._log.info("print pause failed") | ||||
|  | ||||
|     def cancel_print(self): | ||||
|         if self._printer.bambu_client.connected: | ||||
|             if self._printer.bambu_client.publish(pybambu.commands.STOP): | ||||
|                 self._log.info("print cancelled") | ||||
|                 self._printer.finalize_print_job() | ||||
|             else: | ||||
|                 self._log.info("print cancel failed") | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -7,3 +7,11 @@ | ||||
| ### | ||||
|  | ||||
| . | ||||
|  | ||||
| pytest~=7.4.4 | ||||
| pybambu~=1.0.1 | ||||
| OctoPrint~=1.10.2 | ||||
| setuptools~=70.0.0 | ||||
| pyserial~=3.5 | ||||
| Flask~=2.2.5 | ||||
| paho-mqtt~=2.1.0 | ||||
|   | ||||
							
								
								
									
										8
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								setup.py
									
									
									
									
									
								
							| @@ -14,20 +14,20 @@ plugin_package = "octoprint_bambu_printer" | ||||
| plugin_name = "OctoPrint-BambuPrinter" | ||||
|  | ||||
| # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module | ||||
| plugin_version = "0.0.23" | ||||
| plugin_version = "1.0.0" | ||||
|  | ||||
| # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin | ||||
| # module | ||||
| plugin_description = """Connects OctoPrint to BambuLabs printers.""" | ||||
|  | ||||
| # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module | ||||
| plugin_author = "jneilliii" | ||||
| plugin_author = "ManuelW" | ||||
|  | ||||
| # The plugin's author's mail address. | ||||
| plugin_author_email = "jneilliii+github@gmail.com" | ||||
| plugin_author_email = "manuelw@example.com" | ||||
|  | ||||
| # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module | ||||
| plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter" | ||||
| plugin_url = "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter" | ||||
|  | ||||
| # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module | ||||
| plugin_license = "AGPLv3" | ||||
|   | ||||
							
								
								
									
										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}", | ||||
|     } | ||||
							
								
								
									
										562
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										562
									
								
								test/test_gcode_execution.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,562 @@ | ||||
| from __future__ import annotations | ||||
| from datetime import datetime, timezone | ||||
| import logging | ||||
| from pathlib import Path | ||||
| import sys | ||||
| from typing import Any | ||||
| from unittest.mock import MagicMock, patch | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||
| import pybambu | ||||
| import pybambu.commands | ||||
| from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter | ||||
| from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||
| from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient | ||||
| from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||
|     RemoteSDCardFileList, | ||||
| ) | ||||
| from octoprint_bambu_printer.printer.states.idle_state import IdleState | ||||
| from octoprint_bambu_printer.printer.states.paused_state import PausedState | ||||
| from octoprint_bambu_printer.printer.states.printing_state import PrintingState | ||||
| from pytest import fixture | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def output_test_folder(output_folder: Path): | ||||
|     folder = output_folder / "test_gcode" | ||||
|     folder.mkdir(parents=True, exist_ok=True) | ||||
|     return folder | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def log_test(): | ||||
|     log = logging.getLogger("gcode_unittest") | ||||
|     log.setLevel(logging.DEBUG) | ||||
|     return log | ||||
|  | ||||
|  | ||||
| class DictGetter: | ||||
|     def __init__(self, options: dict, default_value=None) -> None: | ||||
|         self.options: dict[str | tuple[str, ...], Any] = options | ||||
|         self._default_value = default_value | ||||
|  | ||||
|     def __call__(self, key: str | list[str] | tuple[str, ...]): | ||||
|         if isinstance(key, list): | ||||
|             key = tuple(key) | ||||
|         return self.options.get(key, self._default_value) | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def settings(output_test_folder): | ||||
|     _settings = MagicMock() | ||||
|     _settings.get.side_effect = DictGetter( | ||||
|         { | ||||
|             "serial": "BAMBU", | ||||
|             "host": "localhost", | ||||
|             "access_code": "12345", | ||||
|         } | ||||
|     ) | ||||
|     _settings.get_boolean.side_effect = DictGetter({"forceChecksum": False}) | ||||
|  | ||||
|     log_file_path = output_test_folder / "log.txt" | ||||
|     log_file_path.touch() | ||||
|     _settings.get_plugin_logfile_path.return_value = log_file_path.as_posix() | ||||
|     return _settings | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def profile_manager(): | ||||
|     _profile_manager = MagicMock() | ||||
|     _profile_manager.get_current.side_effect = MagicMock() | ||||
|     _profile_manager.get_current().get.side_effect = DictGetter( | ||||
|         { | ||||
|             "heatedChamber": False, | ||||
|         } | ||||
|     ) | ||||
|     return _profile_manager | ||||
|  | ||||
|  | ||||
| def _ftp_date_format(dt: datetime): | ||||
|     return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S") | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def project_files_info_ftp(): | ||||
|     return { | ||||
|         "print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))), | ||||
|         "print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def cache_files_info_ftp(): | ||||
|     return { | ||||
|         "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||
|         "cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), | ||||
|         "cache/long file path with spaces.gcode.3mf": ( | ||||
|             1200, | ||||
|             _ftp_date_format(datetime(2024, 5, 7)), | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @fixture | ||||
| def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp): | ||||
|     all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp) | ||||
|     ftps_session = MagicMock() | ||||
|     ftps_session.size.side_effect = DictGetter( | ||||
|         {file: info[0] for file, info in all_file_info.items()} | ||||
|     ) | ||||
|  | ||||
|     ftps_session.sendcmd.side_effect = DictGetter( | ||||
|         {f"MDTM {file}": info[1] for file, info in all_file_info.items()} | ||||
|     ) | ||||
|  | ||||
|     ftps_session.nlst.side_effect = DictGetter( | ||||
|         { | ||||
|             "": list(map(lambda p: Path(p).name, project_files_info_ftp)) | ||||
|             + ["Mock folder"], | ||||
|             "cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp)) | ||||
|             + ["Mock folder"], | ||||
|             "timelapse/": ["video.mp4", "video.avi"], | ||||
|         } | ||||
|     ) | ||||
|     IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session) | ||||
|     yield ftps_session | ||||
|  | ||||
|  | ||||
| @fixture(scope="function") | ||||
| def print_job_mock(): | ||||
|     print_job = MagicMock() | ||||
|     print_job.subtask_name = "" | ||||
|     print_job.print_percentage = 0 | ||||
|     return print_job | ||||
|  | ||||
|  | ||||
| @fixture(scope="function") | ||||
| def temperatures_mock(): | ||||
|     temperatures = MagicMock() | ||||
|     temperatures.nozzle_temp = 0 | ||||
|     temperatures.target_nozzle_temp = 0 | ||||
|     temperatures.bed_temp = 0 | ||||
|     temperatures.target_bed_temp = 0 | ||||
|     temperatures.chamber_temp = 0 | ||||
|     return temperatures | ||||
|  | ||||
|  | ||||
| @fixture(scope="function") | ||||
| def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient: | ||||
|     bambu_client = MagicMock() | ||||
|     bambu_client.connected = True | ||||
|     device_mock = MagicMock() | ||||
|     device_mock.print_job = print_job_mock | ||||
|     device_mock.temperatures = temperatures_mock | ||||
|     bambu_client.get_device.return_value = device_mock | ||||
|     return bambu_client | ||||
|  | ||||
|  | ||||
| @fixture(scope="function") | ||||
| def printer( | ||||
|     output_test_folder, | ||||
|     settings, | ||||
|     profile_manager, | ||||
|     log_test, | ||||
|     ftps_session_mock, | ||||
|     bambu_client_mock, | ||||
| ): | ||||
|     async def _mock_connection(self): | ||||
|         pass | ||||
|  | ||||
|     BambuVirtualPrinter._create_client_connection_async = _mock_connection | ||||
|     printer_test = BambuVirtualPrinter( | ||||
|         settings, | ||||
|         profile_manager, | ||||
|         data_folder=output_test_folder, | ||||
|         serial_log_handler=log_test, | ||||
|         read_timeout=0.01, | ||||
|         faked_baudrate=115200, | ||||
|     ) | ||||
|     printer_test._bambu_client = bambu_client_mock | ||||
|     printer_test.flush() | ||||
|     printer_test.readlines() | ||||
|     yield printer_test | ||||
|     printer_test.close() | ||||
|  | ||||
|  | ||||
| def test_initial_state(printer: BambuVirtualPrinter): | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_list_sd_card(printer: BambuVirtualPrinter): | ||||
|     printer.write(b"M20\n")  # GCode for listing SD card | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[0] == b"Begin file list" | ||||
|     assert result[1].endswith(b'"print.3mf"') | ||||
|     assert result[2].endswith(b'"print2.3mf"') | ||||
|     assert result[3].endswith(b'"print.3mf"') | ||||
|     assert result[4].endswith(b'"print3.gcode.3mf"') | ||||
|     assert result[-3] == b"End file list" | ||||
|     assert result[-2] == b"ok" | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|  | ||||
| def test_list_ftp_paths_p1s(settings, ftps_session_mock): | ||||
|     file_system = RemoteSDCardFileList(settings) | ||||
|     file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi") | ||||
|  | ||||
|     timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"] | ||||
|     ftps_session_mock.size.side_effect = DictGetter( | ||||
|         {file: 100 for file in timelapse_files} | ||||
|     ) | ||||
|     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||
|         { | ||||
|             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||
|             for file in timelapse_files | ||||
|         } | ||||
|     ) | ||||
|     ftps_session_mock.nlst.side_effect = DictGetter( | ||||
|         {"timelapse/": [Path(f).name for f in timelapse_files]} | ||||
|     ) | ||||
|  | ||||
|     timelapse_paths = list(map(Path, timelapse_files)) | ||||
|     result_files = file_view.get_all_info() | ||||
|     assert len(timelapse_files) == len(result_files) and all( | ||||
|         file_info.path in timelapse_paths for file_info in result_files | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_list_ftp_paths_x1(settings, ftps_session_mock): | ||||
|     file_system = RemoteSDCardFileList(settings) | ||||
|     file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4") | ||||
|  | ||||
|     timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"] | ||||
|     ftps_session_mock.size.side_effect = DictGetter( | ||||
|         {file: 100 for file in timelapse_files} | ||||
|     ) | ||||
|     ftps_session_mock.sendcmd.side_effect = DictGetter( | ||||
|         { | ||||
|             f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) | ||||
|             for file in timelapse_files | ||||
|         } | ||||
|     ) | ||||
|     ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files}) | ||||
|  | ||||
|     timelapse_paths = list(map(Path, timelapse_files)) | ||||
|     result_files = file_view.get_all_info() | ||||
|     assert len(timelapse_files) == len(result_files) and all( | ||||
|         file_info.path in timelapse_paths for file_info in result_files | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_delete_sd_file_gcode(printer: BambuVirtualPrinter): | ||||
|     with patch( | ||||
|         "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" | ||||
|     ) as delete_function: | ||||
|         printer.write(b"M30 print.3mf\n") | ||||
|         printer.flush() | ||||
|         result = printer.readlines() | ||||
|         assert result[-1] == b"ok" | ||||
|         delete_function.assert_called_with("print.3mf") | ||||
|  | ||||
|         printer.write(b"M30 cache/print.3mf\n") | ||||
|         printer.flush() | ||||
|         result = printer.readlines() | ||||
|         assert result[-1] == b"ok" | ||||
|         delete_function.assert_called_with("cache/print.3mf") | ||||
|  | ||||
|  | ||||
| def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter): | ||||
|     with patch( | ||||
|         "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" | ||||
|     ) as delete_function: | ||||
|         file_info = printer.project_files.get_file_data("cache/print.3mf") | ||||
|         assert file_info is not None | ||||
|  | ||||
|         printer.write(b"M30 " + file_info.dosname.encode() + b"\n") | ||||
|         printer.flush() | ||||
|         assert printer.readlines()[-1] == b"ok" | ||||
|         assert delete_function.call_count == 1 | ||||
|         delete_function.assert_called_with("cache/print.3mf") | ||||
|  | ||||
|         printer.write(b"M30 cache/print.3mf\n") | ||||
|         printer.flush() | ||||
|         assert printer.readlines()[-1] == b"ok" | ||||
|         assert delete_function.call_count == 2 | ||||
|         delete_function.assert_called_with("cache/print.3mf") | ||||
|  | ||||
|  | ||||
| def test_select_project_file_by_stem(printer: BambuVirtualPrinter): | ||||
|     printer.write(b"M23 print3\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.path == Path("cache/print3.gcode.3mf") | ||||
|     assert result[-2] == b"File selected" | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|  | ||||
| def test_select_project_long_name_file_with_multiple_extensions( | ||||
|     printer: BambuVirtualPrinter, | ||||
| ): | ||||
|     printer.write(b"M23 long file path with spaces.gcode.3mf\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.path == Path( | ||||
|         "cache/long file path with spaces.gcode.3mf" | ||||
|     ) | ||||
|     assert result[-2] == b"File selected" | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|  | ||||
| def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): | ||||
|     printer.write(b"M24\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[0] == b"ok" | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_non_existing_file_not_selected(printer: BambuVirtualPrinter): | ||||
|     assert printer.selected_file is None | ||||
|  | ||||
|     printer.write(b"M23 non_existing.3mf\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-2] != b"File selected" | ||||
|     assert result[-1] == b"ok" | ||||
|     assert printer.selected_file is None | ||||
|  | ||||
|  | ||||
| def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock): | ||||
|     assert printer.selected_file is None | ||||
|  | ||||
|     printer.write(b"M20\n") | ||||
|     printer.flush() | ||||
|     printer.readlines() | ||||
|  | ||||
|     printer.write(b"M23 print.3mf\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-2] == b"File selected" | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.file_name == "print.3mf" | ||||
|  | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M24\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|     # emulate printer reporting it's status | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|  | ||||
|  | ||||
| def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M20\n") | ||||
|     printer.write(b"M23 print.3mf\n") | ||||
|     printer.write(b"M24\n") | ||||
|     printer.flush() | ||||
|  | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|  | ||||
|     printer.write(b"M25\n")  # pausing the print | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|     print_job_mock.gcode_state = "PAUSE" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PausedState) | ||||
|     bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE) | ||||
|  | ||||
|  | ||||
| def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|  | ||||
|     print_job_mock.gcode_state = "PAUSE" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PausedState) | ||||
|  | ||||
|     print_job_mock.gcode_state = "IDLE" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|     print_job_mock.gcode_state = "FINISH" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|     print_job_mock.gcode_state = "FAILED" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_printer_info_check(printer: BambuVirtualPrinter): | ||||
|     printer.write(b"M27\n")  # printer get info | ||||
|     printer.flush() | ||||
|  | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M20\nM23 print.3mf\nM24\n") | ||||
|     printer.flush() | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     print_job_mock.print_percentage = 50 | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     printer.readlines() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|  | ||||
|     printer.write(b"M26 S0\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M20\nM23 print.3mf\nM24\n") | ||||
|     printer.flush() | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|  | ||||
|     printer.write(b"M25\n") | ||||
|     printer.flush() | ||||
|     print_job_mock.gcode_state = "PAUSE" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|  | ||||
|     printer.readlines() | ||||
|     assert isinstance(printer.current_state, PausedState) | ||||
|  | ||||
|     printer.write(b"M26 S0\n") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|  | ||||
|  | ||||
| def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock): | ||||
|     gcode = b"G28\nG1 X10 Y10\n" | ||||
|     printer.write(gcode) | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-1] == b"ok" | ||||
|  | ||||
|     gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE | ||||
|     gcode_command["print"]["param"] = "G28\n" | ||||
|     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||
|  | ||||
|     gcode_command["print"]["param"] = "G1 X10 Y10\n" | ||||
|     bambu_client_mock.publish.assert_called_with(gcode_command) | ||||
|  | ||||
|  | ||||
| def test_file_selection_does_not_affect_current_print( | ||||
|     printer: BambuVirtualPrinter, print_job_mock | ||||
| ): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M23 print.3mf\nM24\n") | ||||
|     printer.flush() | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|     assert printer.current_print_job is not None | ||||
|     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||
|     assert printer.current_print_job.progress == 0 | ||||
|  | ||||
|     printer.write(b"M23 print2.3mf\n") | ||||
|     printer.flush() | ||||
|     assert printer.current_print_job is not None | ||||
|     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||
|     assert printer.current_print_job.progress == 0 | ||||
|  | ||||
|  | ||||
| def test_finished_print_job_reset_after_new_file_selected( | ||||
|     printer: BambuVirtualPrinter, print_job_mock | ||||
| ): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|  | ||||
|     printer.write(b"M23 print.3mf\nM24\n") | ||||
|     printer.flush() | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|     assert printer.current_print_job is not None | ||||
|     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||
|     assert printer.current_print_job.progress == 0 | ||||
|  | ||||
|     print_job_mock.print_percentage = 100 | ||||
|     printer.current_state.update_print_job_info() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|     assert printer.current_print_job.progress == 100 | ||||
|  | ||||
|     print_job_mock.gcode_state = "FINISH" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|     assert printer.current_print_job is None | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.file_name == "print.3mf" | ||||
|  | ||||
|     printer.write(b"M23 print2.3mf\n") | ||||
|     printer.flush() | ||||
|     assert printer.current_print_job is None | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.file_name == "print2.3mf" | ||||
|  | ||||
|  | ||||
| def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock): | ||||
|     print_job_mock.subtask_name = "print.3mf" | ||||
|     print_job_mock.gcode_state = "RUNNING" | ||||
|     print_job_mock.print_percentage = 99 | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     assert isinstance(printer.current_state, PrintingState) | ||||
|     assert printer.current_print_job is not None | ||||
|     assert printer.current_print_job.file_info.file_name == "print.3mf" | ||||
|     assert printer.current_print_job.progress == 99 | ||||
|  | ||||
|     print_job_mock.print_percentage = 100 | ||||
|     print_job_mock.gcode_state = "FINISH" | ||||
|     printer.new_update("event_printer_data_update") | ||||
|     printer.flush() | ||||
|     result = printer.readlines() | ||||
|     assert result[-3].endswith(b"1000/1000") | ||||
|     assert result[-2] == b"Done printing file" | ||||
|     assert result[-1] == b"Not SD printing" | ||||
|     assert isinstance(printer.current_state, IdleState) | ||||
|     assert printer.current_print_job is None | ||||
|     assert printer.selected_file is not None | ||||
|     assert printer.selected_file.file_name == "print.3mf" | ||||
		Reference in New Issue
	
	Block a user