Compare commits
	
		
			21 Commits
		
	
	
		
			0.1.8rc1
			...
			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 | 
							
								
								
									
										21
									
								
								.github/workflows/issue-validator.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/issue-validator.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,21 +0,0 @@ | ||||
| name: issue validator | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   issues: | ||||
|     types: [opened, edited] | ||||
|  | ||||
| permissions: | ||||
|   issues: write | ||||
|  | ||||
| jobs: | ||||
|   validate: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: Okabe-Junya/issue-validator@v0.4.1 | ||||
|         with: | ||||
|           body: '/\[(octoprint\.log)\]|\[(plugin_bambu_printer_serial\.log)\]/g' | ||||
|           body-regex-flags: 'true' | ||||
|           is-auto-close: 'true' | ||||
|           issue-type: 'both' | ||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||
| @@ -22,7 +22,7 @@ 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 octoprint_bambu_printer.printer.pybambu import BambuCloud | ||||
| from pybambu import BambuCloud | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||
|     RemoteSDCardFileList, | ||||
| @@ -67,9 +67,7 @@ class BambuPrintPlugin( | ||||
|             self._timelapse_files_view.with_filter("timelapse/", ".avi") | ||||
|  | ||||
|     def get_assets(self): | ||||
|         return {"js": ["js/jquery-ui.min.js", "js/knockout-sortable.1.2.0.js", "js/bambu_printer.js"], | ||||
|                 "css": ["css/bambu_printer.css"] | ||||
|                 } | ||||
|         return {"js": ["js/bambu_printer.js"]} | ||||
|  | ||||
|     def get_template_configs(self): | ||||
|         return [ | ||||
| @@ -79,7 +77,7 @@ class BambuPrintPlugin( | ||||
|                 "custom_bindings": True, | ||||
|                 "template": "bambu_timelapse.jinja2", | ||||
|             }, | ||||
|             {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] | ||||
|         ]  # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] | ||||
|  | ||||
|     def get_settings_defaults(self): | ||||
|         return { | ||||
| @@ -87,7 +85,7 @@ class BambuPrintPlugin( | ||||
|             "serial": "", | ||||
|             "host": "", | ||||
|             "access_code": "", | ||||
|             "username": "bblp", | ||||
|             "username": "octobambu", | ||||
|             "timelapse": False, | ||||
|             "bed_leveling": True, | ||||
|             "flow_cali": False, | ||||
| @@ -99,9 +97,6 @@ class BambuPrintPlugin( | ||||
|             "email": "", | ||||
|             "auth_token": "", | ||||
|             "always_use_default_options": False, | ||||
|             "ams_data": [], | ||||
|             "ams_mapping": [], | ||||
|             "ams_current_tray": None, | ||||
|         } | ||||
|  | ||||
|     def is_api_adminonly(self): | ||||
| @@ -291,10 +286,10 @@ class BambuPrintPlugin( | ||||
|     def get_update_information(self): | ||||
|         return { | ||||
|             "bambu_printer": { | ||||
|                 "displayName": "Bambu Printer", | ||||
|                 "displayName": "Manus Bambu Printer", | ||||
|                 "displayVersion": self._plugin_version, | ||||
|                 "type": "github_release", | ||||
|                 "user": "jneilliii", | ||||
|                 "user": "ManuelW", | ||||
|                 "repo": "OctoPrint-BambuPrinter", | ||||
|                 "current": self._plugin_version, | ||||
|                 "stable_branch": { | ||||
| @@ -309,6 +304,6 @@ class BambuPrintPlugin( | ||||
|                         "comittish": ["rc", "master"], | ||||
|                     } | ||||
|                 ], | ||||
|                 "pip": "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip", | ||||
|                 "pip": "https://gitlab.fire-devils.org/3D-Druck/OctoPrint-BambuPrinter/archive/{target_version}.zip", | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import collections | ||||
| from dataclasses import dataclass, field, asdict | ||||
| from dataclasses import dataclass, field | ||||
| import math | ||||
| from pathlib import Path | ||||
| import queue | ||||
| @@ -11,9 +11,11 @@ import time | ||||
| from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||
| from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||
| from octoprint_bambu_printer.printer.print_job import PrintJob | ||||
| from octoprint_bambu_printer.printer.pybambu import BambuClient, commands | ||||
| from pybambu import BambuClient, commands | ||||
| import logging | ||||
| import logging.handlers | ||||
| import json | ||||
| import paho.mqtt.client as mqtt | ||||
|  | ||||
| from octoprint.util import RepeatedTimer | ||||
|  | ||||
| @@ -43,10 +45,234 @@ class BambuPrinterTelemetry: | ||||
|     lastTempAt: float = time.monotonic() | ||||
|     firmwareName: str = "Bambu" | ||||
|     extruderCount: int = 1 | ||||
|     ams_current_tray: int = -1 | ||||
|  | ||||
|  | ||||
| # noinspection PyBroadException | ||||
| class BambuMqttBridgeClient: | ||||
|     """ | ||||
|     Implements compatible interface with BambuClient but uses Paho MQTT | ||||
|     to connect to a MQTT broker that bridges Bambu topics | ||||
|     """ | ||||
|     def __init__(self,  | ||||
|                  device_type, | ||||
|                  serial, | ||||
|                  host, | ||||
|                  mqtt_port=1883, | ||||
|                  **kwargs): | ||||
|         self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuMqttBridge") | ||||
|         self._device_type = device_type | ||||
|         self._serial = serial | ||||
|         self._host = host | ||||
|         self._mqtt_port = mqtt_port | ||||
|         self.connected = False | ||||
|         self._mqtt_client = mqtt.Client(f"octoprint_bambu_bridge_{serial}") | ||||
|         self._device_data = self._create_empty_device_data() | ||||
|         self._callbacks = {} | ||||
|          | ||||
|         # Setup callbacks | ||||
|         self._mqtt_client.on_connect = self._on_connect | ||||
|         self._mqtt_client.on_message = self._on_message | ||||
|         self._mqtt_client.on_disconnect = self._on_disconnect | ||||
|          | ||||
|     def _create_empty_device_data(self): | ||||
|         """Creates empty device data structure compatible with BambuClient""" | ||||
|         from types import SimpleNamespace | ||||
|          | ||||
|         # Create basic structure matching BambuClient | ||||
|         device = SimpleNamespace() | ||||
|         device.print_job = SimpleNamespace() | ||||
|         device.print_job.gcode_state = "IDLE" | ||||
|         device.temperature = SimpleNamespace() | ||||
|         device.temperature.nozzle_temp = AMBIENT_TEMPERATURE | ||||
|         device.temperature.target_nozzle_temp = 0.0 | ||||
|         device.temperature.bed_temp = AMBIENT_TEMPERATURE | ||||
|         device.temperature.target_bed_temp = 0.0 | ||||
|         device.temperature.chamber_temp = AMBIENT_TEMPERATURE | ||||
|         device.hms = SimpleNamespace() | ||||
|         device.hms.errors = {"Count": 0} | ||||
|         return device | ||||
|          | ||||
|     def _on_connect(self, client, userdata, flags, rc): | ||||
|         if rc == 0: | ||||
|             self._log.info(f"Connected to MQTT broker at {self._host}:{self._mqtt_port}") | ||||
|             self.connected = True | ||||
|              | ||||
|             # Subscribe to Bambu topics | ||||
|             topic_base = f"device/{self._device_type}/{self._serial}" | ||||
|             self._mqtt_client.subscribe(f"{topic_base}/report") | ||||
|             self._mqtt_client.subscribe(f"{topic_base}/report_hms") | ||||
|             self._log.info(f"Subscribed to topic: {topic_base}/#") | ||||
|              | ||||
|             if 'callback' in self._callbacks: | ||||
|                 self._callbacks['callback']("event_printer_data_update") | ||||
|                  | ||||
|             if hasattr(self, 'on_connect') and callable(self.on_connect): | ||||
|                 self.on_connect(client, userdata, flags, rc) | ||||
|         else: | ||||
|             self._log.error(f"Failed to connect to MQTT broker, return code: {rc}") | ||||
|              | ||||
|     def _on_disconnect(self, client, userdata, rc): | ||||
|         self._log.warning(f"Disconnected from MQTT broker with code: {rc}") | ||||
|         self.connected = False | ||||
|          | ||||
|         # Add reconnection attempt | ||||
|         if rc != 0:  # Non-zero means unexpected disconnect | ||||
|             self._log.info("Attempting to reconnect to MQTT broker...") | ||||
|             try: | ||||
|                 self._mqtt_client.reconnect() | ||||
|             except Exception as e: | ||||
|                 self._log.error(f"Failed to reconnect to MQTT broker: {str(e)}", exc_info=True) | ||||
|          | ||||
|         if hasattr(self, 'on_disconnect') and callable(self.on_disconnect): | ||||
|             self.on_disconnect(client, userdata, rc) | ||||
|              | ||||
|     def _on_message(self, client, userdata, msg): | ||||
|         try: | ||||
|             payload = json.loads(msg.payload.decode('utf-8')) | ||||
|             self._log.debug(f"Received message on topic {msg.topic}: {payload}") | ||||
|              | ||||
|             if msg.topic.endswith('/report'): | ||||
|                 self._process_report_message(payload) | ||||
|                 if 'callback' in self._callbacks: | ||||
|                     self._callbacks['callback']("event_printer_data_update") | ||||
|             elif msg.topic.endswith('/report_hms'): | ||||
|                 self._process_hms_message(payload) | ||||
|                 if 'callback' in self._callbacks: | ||||
|                     self._callbacks['callback']("event_hms_errors") | ||||
|         except json.JSONDecodeError: | ||||
|             self._log.error(f"Failed to decode JSON from message: {msg.payload}") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing message: {str(e)}") | ||||
|              | ||||
|     def _process_report_message(self, data): | ||||
|         """Process printer status report messages""" | ||||
|         try: | ||||
|             # Update print state if available | ||||
|             if 'print' in data: | ||||
|                 print_data = data['print'] | ||||
|                  | ||||
|                 # Update printer state | ||||
|                 if 'gcode_state' in print_data: | ||||
|                     self._device_data.print_job.gcode_state = print_data['gcode_state'] | ||||
|                     self._log.debug(f"Updated printer state: {print_data['gcode_state']}") | ||||
|                  | ||||
|                 # Process direct temperature values in print data | ||||
|                 if 'nozzle_temper' in print_data: | ||||
|                     self._device_data.temperature.nozzle_temp = float(print_data['nozzle_temper']) | ||||
|                 if 'nozzle_target_temper' in print_data: | ||||
|                     self._device_data.temperature.target_nozzle_temp = float(print_data['nozzle_target_temper']) | ||||
|                 if 'bed_temper' in print_data: | ||||
|                     self._device_data.temperature.bed_temp = float(print_data['bed_temper']) | ||||
|                 if 'bed_target_temper' in print_data: | ||||
|                     self._device_data.temperature.target_bed_temp = float(print_data['bed_target_temper']) | ||||
|                 if 'chamber_temper' in print_data: | ||||
|                     self._device_data.temperature.chamber_temp = float(print_data['chamber_temper']) | ||||
|              | ||||
|             # Process temperature section if available | ||||
|             if 'temperature' in data: | ||||
|                 temp = self._device_data.temperature | ||||
|                 temp_data = data['temperature'] | ||||
|                  | ||||
|                 if 'nozzle_temp' in temp_data: | ||||
|                     temp.nozzle_temp = float(temp_data['nozzle_temp']) | ||||
|                 if 'target_nozzle_temp' in temp_data: | ||||
|                     temp.target_nozzle_temp = float(temp_data['target_nozzle_temp']) | ||||
|                 if 'bed_temp' in temp_data: | ||||
|                     temp.bed_temp = float(temp_data['bed_temp']) | ||||
|                 if 'target_bed_temp' in temp_data: | ||||
|                     temp.target_bed_temp = float(temp_data['target_bed_temp']) | ||||
|                 if 'chamber_temp' in temp_data: | ||||
|                     temp.chamber_temp = float(temp_data['chamber_temp']) | ||||
|                      | ||||
|             self._log.debug(f"Updated temperatures - Nozzle: {self._device_data.temperature.nozzle_temp}/" + | ||||
|                           f"{self._device_data.temperature.target_nozzle_temp}, " + | ||||
|                           f"Bed: {self._device_data.temperature.bed_temp}/" + | ||||
|                           f"{self._device_data.temperature.target_bed_temp}, " + | ||||
|                           f"Chamber: {self._device_data.temperature.chamber_temp}") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing report message: {str(e)}", exc_info=True) | ||||
|              | ||||
|     def _process_hms_message(self, data): | ||||
|         """Process HMS error messages""" | ||||
|         try: | ||||
|             if 'hms' in data: | ||||
|                 error_count = 0 | ||||
|                 hms_errors = {"Count": 0} | ||||
|                  | ||||
|                 for error in data['hms']: | ||||
|                     error_count += 1 | ||||
|                     if isinstance(error, dict) and 'msg' in error: | ||||
|                         hms_errors[f"{error_count}-Error"] = error['msg'] | ||||
|                     else: | ||||
|                         hms_errors[f"{error_count}-Error"] = str(error) | ||||
|                  | ||||
|                 hms_errors["Count"] = error_count | ||||
|                 self._device_data.hms.errors = hms_errors | ||||
|                  | ||||
|                 if error_count > 0: | ||||
|                     self._log.info(f"Found {error_count} HMS errors") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing HMS message: {str(e)}", exc_info=True) | ||||
|      | ||||
|     def connect(self, callback=None): | ||||
|         """Connect to MQTT broker""" | ||||
|         if callback: | ||||
|             self._callbacks['callback'] = callback | ||||
|              | ||||
|         try: | ||||
|             self._log.info(f"Connecting to MQTT broker {self._host}:{self._mqtt_port}") | ||||
|             self._mqtt_client.connect(self._host, self._mqtt_port) | ||||
|             self._mqtt_client.loop_start() | ||||
|              | ||||
|             # Wait a bit for the connection to establish | ||||
|             time.sleep(1) | ||||
|              | ||||
|             # If not connected after waiting, try again | ||||
|             if not self.connected: | ||||
|                 self._log.warning("Initial connection attempt failed, retrying...") | ||||
|                 try: | ||||
|                     self._mqtt_client.reconnect() | ||||
|                     time.sleep(2)  # Wait a bit longer for retry | ||||
|                 except Exception as e: | ||||
|                     self._log.error(f"Reconnection failed: {str(e)}", exc_info=True) | ||||
|                      | ||||
|             return self.connected | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Failed to connect to MQTT broker: {str(e)}", exc_info=True) | ||||
|             return False | ||||
|              | ||||
|     def disconnect(self): | ||||
|         """Disconnect from MQTT broker""" | ||||
|         if self.connected: | ||||
|             self._mqtt_client.loop_stop() | ||||
|             self._mqtt_client.disconnect() | ||||
|             self.connected = False | ||||
|              | ||||
|     def get_device(self): | ||||
|         """Returns device data structure""" | ||||
|         return self._device_data | ||||
|          | ||||
|     def publish(self, command): | ||||
|         """Publishes command to device""" | ||||
|         if not self.connected: | ||||
|             self._log.error("Cannot publish: Not connected to MQTT broker") | ||||
|             return False | ||||
|              | ||||
|         try: | ||||
|             topic_base = f"device/{self._device_type}/{self._serial}" | ||||
|             if 'print' in command and 'param' in command['print']: | ||||
|                 # Commands go to command topic | ||||
|                 message = json.dumps(command) | ||||
|                 result = self._mqtt_client.publish(f"{topic_base}/cmd", message) | ||||
|                 self._log.debug(f"Published command to {topic_base}/cmd: {message}") | ||||
|                 return result.rc == mqtt.MQTT_ERR_SUCCESS | ||||
|             else: | ||||
|                 self._log.warning(f"Invalid command format: {command}") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Failed to publish command: {str(e)}", exc_info=True) | ||||
|              | ||||
|         return False | ||||
|  | ||||
|  | ||||
| class BambuVirtualPrinter: | ||||
|     gcode_executor = GCodeExecutor() | ||||
|  | ||||
| @@ -65,7 +291,6 @@ class BambuVirtualPrinter: | ||||
|         self._data_folder = data_folder | ||||
|         self._last_hms_errors = None | ||||
|         self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") | ||||
|         self.ams_data = self._settings.get(["ams_data"]) | ||||
|  | ||||
|         self._state_idle = IdleState(self) | ||||
|         self._state_printing = PrintingState(self) | ||||
| @@ -106,7 +331,10 @@ class BambuVirtualPrinter: | ||||
|  | ||||
|         self._serial_io.start() | ||||
|         self._printer_thread.start() | ||||
|  | ||||
|         self._mqtt_client = None | ||||
|         self._mqtt_connected = False | ||||
|         self._bambu_client = None | ||||
|         self._custom_connected = False | ||||
|         self._bambu_client: BambuClient = self._create_client_connection_async() | ||||
|  | ||||
|     @property | ||||
| @@ -129,10 +357,6 @@ class BambuVirtualPrinter: | ||||
|     def current_print_job(self, value): | ||||
|         self._current_print_job = value | ||||
|  | ||||
|     @property | ||||
|     def selected_file(self): | ||||
|         return self._selected_project_file | ||||
|  | ||||
|     @property | ||||
|     def has_selected_file(self): | ||||
|         return self._selected_project_file is not None | ||||
| @@ -167,68 +391,41 @@ class BambuVirtualPrinter: | ||||
|     def project_files(self): | ||||
|         return self._project_files_view | ||||
|  | ||||
|     def change_state(self, new_state: APrinterState): | ||||
|         self._state_change_queue.put(new_state) | ||||
|  | ||||
|     def _convert2serialize(self, obj): | ||||
|         if isinstance(obj, dict): | ||||
|             return {k: self._convert2serialize(v) for k, v in obj.items()} | ||||
|         elif hasattr(obj, "_ast"): | ||||
|             return self._convert2serialize(obj._ast()) | ||||
|         elif not isinstance(obj, str) and hasattr(obj, "__iter__"): | ||||
|             return [self._convert2serialize(v) for v in obj] | ||||
|         elif hasattr(obj, "__dict__"): | ||||
|             return { | ||||
|                 k: self._convert2serialize(v) | ||||
|                 for k, v in obj.__dict__.items() | ||||
|                 if not callable(v) and not k.startswith('_') | ||||
|             } | ||||
|         else: | ||||
|             return obj | ||||
|     @property | ||||
|     def is_connected(self): | ||||
|         """Custom property to track connection status without modifying BambuClient directly""" | ||||
|         connection_status = self._custom_connected and self._mqtt_connected | ||||
|         self._log.debug(f"Connection status check: custom_connected={self._custom_connected}, mqtt_connected={self._mqtt_connected}, result={connection_status}") | ||||
|         return connection_status | ||||
|  | ||||
|     def new_update(self, event_type): | ||||
|         if event_type == "event_hms_errors": | ||||
|         """Custom property to track connection status without modifying BambuClient directly""" | ||||
|         if event_type == "event_printer_data_update": | ||||
|             self._log.debug("Received event_printer_data_update") | ||||
|             self._update_hms_errors() | ||||
|         elif event_type == "event_printer_data_update": | ||||
|             self._update_printer_info() | ||||
|  | ||||
|     def _update_printer_info(self): | ||||
|             # Gib den aktuellen Status detaillierter aus | ||||
|             device_data = self.bambu_client.get_device() | ||||
|             print_job_state = device_data.print_job.gcode_state | ||||
|         temperatures = device_data.temperature | ||||
|         ams_data = self._convert2serialize(device_data.ams.data) | ||||
|             self._log.debug(f"BambuClient printer state: {print_job_state}") | ||||
|             self._log.debug(f"Current temperatures - Nozzle: {self._telemetry.temp[0]}/{self._telemetry.targetTemp[0]}, " + | ||||
|                           f"Bed: {self._telemetry.bedTemp}/{self._telemetry.bedTargetTemp}, " + | ||||
|                           f"Chamber: {self._telemetry.chamberTemp}") | ||||
|              | ||||
|         if self.ams_data != ams_data: | ||||
|             self._log.debug(f"Recieveid AMS Update: {ams_data}") | ||||
|             self.ams_data = ams_data | ||||
|             self._settings.set(["ams_data"], ams_data) | ||||
|             self._settings.save(trigger_event=True) | ||||
|  | ||||
|         self.lastTempAt = time.monotonic() | ||||
|         self._telemetry.temp[0] = temperatures.nozzle_temp | ||||
|         self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp | ||||
|         self._telemetry.bedTemp = temperatures.bed_temp | ||||
|         self._telemetry.bedTargetTemp = temperatures.target_bed_temp | ||||
|         self._telemetry.chamberTemp = temperatures.chamber_temp | ||||
|         self._telemetry.ams_current_tray = device_data.push_all_data["ams"]["tray_now"] or -1 | ||||
|  | ||||
|         if self._telemetry.ams_current_tray != self._settings.get_int(["ams_current_tray"]): | ||||
|             self._settings.set_int(["ams_current_tray"], self._telemetry.ams_current_tray) | ||||
|             self._settings.save(trigger_event=True) | ||||
|  | ||||
|         self._log.debug(f"Received printer state update: {print_job_state}") | ||||
|         if ( | ||||
|             print_job_state == "IDLE" | ||||
|             or print_job_state == "FINISH" | ||||
|             or print_job_state == "FAILED" | ||||
|         ): | ||||
|             self.change_state(self._state_idle) | ||||
|         elif print_job_state == "RUNNING" or print_job_state == "PREPARE": | ||||
|             self.change_state(self._state_printing) | ||||
|         elif print_job_state == "PAUSE": | ||||
|             self.change_state(self._state_paused) | ||||
|             # Process the printer state even if it's "unknown" | ||||
|             if print_job_state: | ||||
|                 self._process_print_state(print_job_state) | ||||
|             else: | ||||
|             self._log.warn(f"Unknown print job state: {print_job_state}") | ||||
|                 self._log.debug("No printer state received, skipping state processing") | ||||
|                  | ||||
|         elif event_type == "event_hms_errors": | ||||
|             self._log.debug("Received event_hms_errors") | ||||
|             bambu_printer = self.bambu_client.get_device() | ||||
|             if bambu_printer.hms.errors != self._last_hms_errors: | ||||
|                 self._log.debug(f"HMS Error: {bambu_printer.hms.errors}") | ||||
|                 for n in range(1, bambu_printer.hms.errors["Count"] + 1): | ||||
|                     error = bambu_printer.hms.errors[f"{n}-Error"].strip() | ||||
|                     self.sendIO(f"// action:notification {error}") | ||||
|                 self._last_hms_errors = bambu_printer.hms.errors | ||||
|  | ||||
|     def _update_hms_errors(self): | ||||
|         bambu_printer = self.bambu_client.get_device() | ||||
| @@ -244,14 +441,396 @@ class BambuVirtualPrinter: | ||||
|  | ||||
|     def on_disconnect(self, on_disconnect): | ||||
|         self._log.debug(f"on disconnect called") | ||||
|         self.stop_continuous_status_report() | ||||
|         self.stop_continuous_temp_report() | ||||
|         return on_disconnect | ||||
|  | ||||
|     def on_connect(self, on_connect): | ||||
|         self._log.debug(f"on connect called") | ||||
|         return on_connect | ||||
|  | ||||
|     def _on_mqtt_connect(self, client, userdata, flags, rc): | ||||
|         self._log.debug(f"MQTT connected with result code: {rc}") | ||||
|         if rc == 0: | ||||
|             # Notify that we're connected | ||||
|             self._mqtt_connected = True | ||||
|             self._custom_connected = True | ||||
|             # Subscribe to the relevant topics for the Bambu printer | ||||
|             device_topic = f"device/{self._settings.get(['serial'])}/report" | ||||
|             client.subscribe(device_topic) | ||||
|             self._log.debug(f"Subscribed to topic: {device_topic}") | ||||
|             self._log.info(f"MQTT connection successful. Connected: {self.is_connected}") | ||||
|         else: | ||||
|             self._mqtt_connected = False | ||||
|             self._custom_connected = False | ||||
|             self._log.error(f"Failed to connect to MQTT broker with result code: {rc}") | ||||
|  | ||||
|     def _on_mqtt_disconnect(self, client, userdata, rc): | ||||
|         self._mqtt_connected = False | ||||
|         self._custom_connected = False | ||||
|         self._log.debug(f"MQTT disconnected with result code: {rc}") | ||||
|  | ||||
|     def _on_mqtt_message(self, client, userdata, msg): | ||||
|         try: | ||||
|             # Decode message and update client data | ||||
|             payload = json.loads(msg.payload.decode('utf-8')) | ||||
|             self._log.debug(f"MQTT message received on topic {msg.topic}: {list(payload.keys())}") | ||||
|              | ||||
|             # Direkte Verarbeitung der Daten | ||||
|             self._process_mqtt_payload(payload) | ||||
|              | ||||
|             # Auch an Bambu Client weiterleiten | ||||
|             try: | ||||
|                 # Wenn der BambuClient eine eigene Verarbeitungsmethode hat, nutzen wir diese | ||||
|                 if hasattr(self._bambu_client, '_process_message') and callable(self._bambu_client._process_message): | ||||
|                     self._bambu_client._process_message(msg.topic, payload) | ||||
|                     self._log.debug("Message forwarded to pybambu via _process_message") | ||||
|                 elif hasattr(self._bambu_client, '_handle_mqtt_message') and callable(self._bambu_client._handle_mqtt_message): | ||||
|                     self._bambu_client._handle_mqtt_message(client, userdata, msg) | ||||
|                     self._log.debug("Message forwarded to pybambu via _handle_mqtt_message") | ||||
|                 else: | ||||
|                     # Wenn keine Methode zur Verarbeitung verfügbar ist, aktualisieren wir die Datenstruktur manuell | ||||
|                     self._log.debug("No message handler found in BambuClient, updating state manually") | ||||
|                     self._update_bambu_client_state(payload) | ||||
|             except Exception as e: | ||||
|                 self._log.error(f"Error forwarding to pybambu: {e}", exc_info=True) | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing MQTT message: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_mqtt_payload(self, payload): | ||||
|         """Zentrale Methode zur Verarbeitung von MQTT-Payloads""" | ||||
|         try: | ||||
|             # Verarbeite print-Daten | ||||
|             if 'print' in payload: | ||||
|                 print_data = payload['print'] | ||||
|                 self._log.info(f"Processing print data with keys: {list(print_data.keys())}") | ||||
|                  | ||||
|                 # Temperaturdaten direkt verarbeiten | ||||
|                 self._process_direct_temperature_data(print_data) | ||||
|                  | ||||
|                 # Status verarbeiten | ||||
|                 self._process_print_state(print_data['gcode_state']) | ||||
|                  | ||||
|                 # Fortschritt verarbeiten | ||||
|                 self._process_progress_data(print_data) | ||||
|                  | ||||
|                 # Schicht-Informationen verarbeiten | ||||
|                 self._process_layer_data(print_data) | ||||
|                  | ||||
|                 # Lüfter-Informationen verarbeiten | ||||
|                 self._process_fan_data(print_data) | ||||
|                  | ||||
|                 # Datei-Informationen verarbeiten | ||||
|                 self._process_file_data(print_data) | ||||
|                  | ||||
|                 # Trigger update | ||||
|                 self.new_update("event_printer_data_update") | ||||
|                  | ||||
|             # Verarbeite info-Daten | ||||
|             if 'info' in payload: | ||||
|                 info_data = payload['info'] | ||||
|                 self._log.info(f"Processing info data with keys: {list(info_data.keys())}") | ||||
|                  | ||||
|                 # HMS-Fehler verarbeiten | ||||
|                 self._process_hms_errors(info_data['hms']) | ||||
|                 self.new_update("event_hms_errors") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing MQTT payload: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_layer_data(self, print_data): | ||||
|         """Verarbeitet Schicht-Informationen aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             current_layer = None | ||||
|             total_layers = None | ||||
|              | ||||
|             if 'layer_num' in print_data: | ||||
|                 current_layer = int(print_data['layer_num']) | ||||
|                 self._log.debug(f"Current layer: {current_layer}") | ||||
|                  | ||||
|             if 'total_layer_num' in print_data: | ||||
|                 total_layers = int(print_data['total_layer_num']) | ||||
|                 self._log.debug(f"Total layers: {total_layers}") | ||||
|                  | ||||
|             # Aktualisiere den PrintJob, wenn einer existiert | ||||
|             if self.current_print_job is not None: | ||||
|                 if current_layer is not None: | ||||
|                     self.current_print_job.current_layer = current_layer | ||||
|                 if total_layers is not None: | ||||
|                     self.current_print_job.total_layers = total_layers | ||||
|                  | ||||
|             # Aktualisiere auch die pybambu-Datenstruktur, wenn vorhanden | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                 if current_layer is not None: | ||||
|                     self._bambu_client.device.print_job.current_layer = current_layer | ||||
|                 if total_layers is not None: | ||||
|                     self._bambu_client.device.print_job.total_layers = total_layers | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing layer data: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_fan_data(self, print_data): | ||||
|         """Verarbeitet Lüfterdaten aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             # Verschiedene Lüfter-Typen | ||||
|             fan_data = {} | ||||
|              | ||||
|             if 'heatbreak_fan_speed' in print_data: | ||||
|                 fan_data['heatbreak'] = int(print_data['heatbreak_fan_speed']) | ||||
|                  | ||||
|             if 'cooling_fan_speed' in print_data: | ||||
|                 fan_data['cooling'] = int(print_data['cooling_fan_speed']) | ||||
|                  | ||||
|             if 'big_fan1_speed' in print_data: | ||||
|                 fan_data['chamber1'] = int(print_data['big_fan1_speed']) | ||||
|                  | ||||
|             if 'big_fan2_speed' in print_data: | ||||
|                 fan_data['chamber2'] = int(print_data['big_fan2_speed']) | ||||
|                  | ||||
|             if fan_data: | ||||
|                 self._log.debug(f"Fan speeds: {fan_data}") | ||||
|                  | ||||
|             # Aktualisiere die pybambu-Struktur, wenn vorhanden | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'fan_speeds'): | ||||
|                 try: | ||||
|                     for fan_type, speed in fan_data.items(): | ||||
|                         setattr(self._bambu_client.device.fan_speeds, fan_type, speed) | ||||
|                 except: | ||||
|                     # Wenn fan_speeds nicht die erwarteten Attribute hat, erstellen wir sie | ||||
|                     self._bambu_client.device.fan_speeds = type('', (), fan_data)() | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing fan data: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_speed_data(self, print_data): | ||||
|         """Verarbeitet Geschwindigkeitsdaten aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             if 'spd_mag' in print_data: | ||||
|                 speed_magnitude = int(print_data['spd_mag']) | ||||
|                 self._log.debug(f"Speed magnitude: {speed_magnitude}%") | ||||
|                  | ||||
|             if 'spd_lvl' in print_data: | ||||
|                 speed_level = int(print_data['spd_lvl']) | ||||
|                 self._log.debug(f"Speed level: {speed_level}") | ||||
|                  | ||||
|             # Aktualisiere die pybambu-Struktur, wenn vorhanden | ||||
|             if hasattr(self._bambu_client, 'device') and not hasattr(self._bambu_client.device, 'speed'): | ||||
|                 self._bambu_client.device.speed = type('', (), {})() | ||||
|                  | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'speed'): | ||||
|                 if 'spd_mag' in print_data: | ||||
|                     self._bambu_client.device.speed.magnitude = int(print_data['spd_mag']) | ||||
|                 if 'spd_lvl' in print_data: | ||||
|                     self._bambu_client.device.speed.level = int(print_data['spd_lvl']) | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing speed data: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_file_data(self, print_data): | ||||
|         """Verarbeitet Dateiinformationen aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             # Dateiname | ||||
|             if 'gcode_file' in print_data and print_data['gcode_file']: | ||||
|                 filename = print_data['gcode_file'] | ||||
|                 self._log.debug(f"Print file: {filename}") | ||||
|                  | ||||
|                 # Aktualisiere den PrintJob, wenn einer existiert | ||||
|                 if self.current_print_job is not None: | ||||
|                     self.current_print_job.gcode_file = filename | ||||
|                      | ||||
|                 # Aktualisiere auch die pybambu-Datenstruktur | ||||
|                 if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                     self._bambu_client.device.print_job.gcode_file = filename | ||||
|  | ||||
|             # Subtask Name (oft der Projektname) | ||||
|             if 'subtask_name' in print_data and print_data['subtask_name']: | ||||
|                 subtask_name = print_data['subtask_name'] | ||||
|                 self._log.debug(f"Subtask name: {subtask_name}") | ||||
|                  | ||||
|                 # Aktualisiere den PrintJob, wenn einer existiert | ||||
|                 if self.current_print_job is not None: | ||||
|                     self.current_print_job.subtask_name = subtask_name | ||||
|                      | ||||
|                 # Aktualisiere auch die pybambu-Datenstruktur | ||||
|                 if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                     self._bambu_client.device.print_job.subtask_name = subtask_name | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing file data: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_print_state(self, print_job_state): | ||||
|         """Verarbeitet den Druckerstatus aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             self._log.debug(f"Received printer state update: {print_job_state}") | ||||
|              | ||||
|             # Erweitern der Statuserkennung - in Bambu können die Status auch kleingeschrieben sein | ||||
|             # oder andere Werte haben als die, die wir erwarten | ||||
|             print_job_state = print_job_state.upper() if print_job_state else "UNKNOWN" | ||||
|              | ||||
|             # Explicitly handle "unknown" state (both upper and lowercase) | ||||
|             if print_job_state in ["UNKNOWN", ""] or print_job_state.upper() == "UNKNOWN": | ||||
|                 self._log.debug("Detected 'unknown' printer state, trying to determine actual state") | ||||
|                 # Wenn wir keinen erkannten Status haben, versuchen wir ihn aus anderen Daten abzuleiten | ||||
|                 # Prüfe ob Druckfortschritt vorhanden ist | ||||
|                 if self.current_print_job and self.current_print_job.print_percentage > 0: | ||||
|                     print_job_state = "RUNNING" | ||||
|                     self._log.debug(f"Changed unknown state to RUNNING based on print progress from current_print_job") | ||||
|                 # Prüfe auf Temperaturen, die auf einen laufenden Druck hinweisen könnten | ||||
|                 elif self._telemetry.targetTemp[0] > 150 or self._telemetry.bedTargetTemp > 40: | ||||
|                     print_job_state = "PREPARE" | ||||
|                     self._log.debug(f"Changed unknown state to PREPARE based on target temperatures") | ||||
|                 else: | ||||
|                     self._log.debug("Keeping state as IDLE since no indicators for print activity were found") | ||||
|                     print_job_state = "IDLE"  # Default to IDLE if we can't determine state | ||||
|              | ||||
|             # Status im PrintJob aktualisieren | ||||
|             if self.current_print_job is None and print_job_state in ["RUNNING", "PREPARE", "PAUSE"]: | ||||
|                 # Wenn wir keinen PrintJob haben, aber ein Druck läuft, erstellen wir einen | ||||
|                 self._log.info(f"Creating new PrintJob for running print with state: {print_job_state}") | ||||
|                 self.current_print_job = PrintJob() | ||||
|                 self.current_print_job.gcode_state = print_job_state | ||||
|             elif self.current_print_job is not None: | ||||
|                 self.current_print_job.gcode_state = print_job_state | ||||
|                  | ||||
|             # Prüfe auf zusätzliche Indikatoren für einen aktiven Druck | ||||
|             is_printing = False | ||||
|              | ||||
|             # Check 1: Standard-Statuserkennung | ||||
|             if print_job_state in ["RUNNING", "PREPARE"]: | ||||
|                 is_printing = True | ||||
|                  | ||||
|             # Check 2: Druckfortschritt > 0 und < 100 | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                 if hasattr(self._bambu_client.device.print_job, 'mc_percent'): | ||||
|                     progress = getattr(self._bambu_client.device.print_job, 'mc_percent', 0) | ||||
|                     if progress > 0 and progress < 100: | ||||
|                         is_printing = True | ||||
|                         self._log.debug(f"Detected active printing based on progress: {progress}%") | ||||
|                  | ||||
|             # Check 3: Temperaturen deuten auf aktiven Druck hin | ||||
|             if self._telemetry.temp[0] > 170 and self._telemetry.bedTemp > 40: | ||||
|                 # Hohe aktuelle Temperaturen deuten auf einen laufenden Druck hin | ||||
|                 is_printing = True | ||||
|                 self._log.debug(f"Detected potential printing based on actual temperatures: " + | ||||
|                              f"Nozzle={self._telemetry.temp[0]}, Bed={self._telemetry.bedTemp}") | ||||
|                  | ||||
|             # Statusänderung in den Zustandsautomaten übertragen basierend auf allen Checks | ||||
|             if print_job_state in ["IDLE", "FINISH", "FAILED"] and not is_printing: | ||||
|                 self._log.debug(f"Changing to IDLE state based on status: {print_job_state} and is_printing={is_printing}") | ||||
|                 self.change_state(self._state_idle) | ||||
|             elif print_job_state in ["RUNNING", "PREPARE"] or is_printing: | ||||
|                 self._log.debug(f"Changing to PRINTING state based on status: {print_job_state} and is_printing={is_printing}") | ||||
|                 self.change_state(self._state_printing) | ||||
|             elif print_job_state == "PAUSE": | ||||
|                 self._log.debug(f"Changing to PAUSED state based on status: {print_job_state}") | ||||
|                 self.change_state(self._state_paused) | ||||
|             else: | ||||
|                 self._log.warn(f"Unknown print job state: {print_job_state}") | ||||
|              | ||||
|             # Aktualisiere auch die pybambu-Datenstruktur | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                 self._bambu_client.device.print_job.gcode_state = print_job_state | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing print state: {e}", exc_info=True) | ||||
|             # Default to a safe state in case of errors | ||||
|             self.change_state(self._state_idle) | ||||
|  | ||||
|     def _process_direct_temperature_data(self, print_data): | ||||
|         """Verarbeitet Temperaturdaten direkt aus dem print-Objekt""" | ||||
|         try: | ||||
|             # Extruder Temperatur - direkt aus den Feldern | ||||
|             if 'nozzle_temper' in print_data: | ||||
|                 self._telemetry.temp[0] = float(print_data['nozzle_temper']) | ||||
|                 self._log.debug(f"Updated nozzle temperature: {self._telemetry.temp[0]}") | ||||
|             if 'nozzle_target_temper' in print_data: | ||||
|                 self._telemetry.targetTemp[0] = float(print_data['nozzle_target_temper']) | ||||
|                 self._log.debug(f"Updated nozzle target: {self._telemetry.targetTemp[0]}") | ||||
|                  | ||||
|             # Bett Temperatur | ||||
|             if 'bed_temper' in print_data: | ||||
|                 self._telemetry.bedTemp = float(print_data['bed_temper']) | ||||
|                 self._log.debug(f"Updated bed temperature: {self._telemetry.bedTemp}") | ||||
|             if 'bed_target_temper' in print_data: | ||||
|                 self._telemetry.bedTargetTemp = float(print_data['bed_target_temper']) | ||||
|                 self._log.debug(f"Updated bed target: {self._telemetry.bedTargetTemp}") | ||||
|                  | ||||
|             # Kammer Temperatur | ||||
|             if 'chamber_temper' in print_data: | ||||
|                 self._telemetry.chamberTemp = float(print_data['chamber_temper']) | ||||
|                 self._log.debug(f"Updated chamber temperature: {self._telemetry.chamberTemp}") | ||||
|                  | ||||
|             # Log der aktualisierten Temperaturen | ||||
|             self._log.debug(f"Current temperatures - Nozzle: {self._telemetry.temp[0]}/{self._telemetry.targetTemp[0]}, " + | ||||
|                           f"Bed: {self._telemetry.bedTemp}/{self._telemetry.bedTargetTemp}, " + | ||||
|                           f"Chamber: {self._telemetry.chamberTemp}") | ||||
|                            | ||||
|             # Auch im BambuClient aktualisieren | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'temperature'): | ||||
|                 try: | ||||
|                     temp_obj = self._bambu_client.device.temperature | ||||
|                     if 'nozzle_temper' in print_data: | ||||
|                         temp_obj.nozzle_temp = float(print_data['nozzle_temper']) | ||||
|                     if 'nozzle_target_temper' in print_data: | ||||
|                         temp_obj.target_nozzle_temp = float(print_data['nozzle_target_temper']) | ||||
|                     if 'bed_temper' in print_data: | ||||
|                         temp_obj.bed_temp = float(print_data['bed_temper']) | ||||
|                     if 'bed_target_temper' in print_data: | ||||
|                         temp_obj.target_bed_temp = float(print_data['bed_target_temper']) | ||||
|                     if 'chamber_temper' in print_data: | ||||
|                         temp_obj.chamber_temp = float(print_data['chamber_temper']) | ||||
|                 except Exception as e: | ||||
|                     self._log.error(f"Error updating BambuClient temperature: {e}", exc_info=True) | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing temperature data: {e}", exc_info=True) | ||||
|  | ||||
|     def _process_progress_data(self, print_data): | ||||
|         """Verarbeitet Fortschrittsdaten aus MQTT-Nachrichten""" | ||||
|         try: | ||||
|             progress = -1 | ||||
|              | ||||
|             if 'mc_percent' in print_data: | ||||
|                 progress = int(print_data['mc_percent']) | ||||
|                  | ||||
|             remaining_time = 0 | ||||
|             if 'mc_remaining_time' in print_data: | ||||
|                 remaining_time = int(print_data['mc_remaining_time']) | ||||
|                  | ||||
|             # Aktualisiere den PrintJob, wenn einer existiert | ||||
|             if self.current_print_job is not None: | ||||
|                 self.current_print_job.print_percentage = progress | ||||
|                 self.current_print_job.remaining_time = remaining_time | ||||
|                 self._log.debug(f"Updated print progress: {progress}%, remaining: {remaining_time}s") | ||||
|                  | ||||
|             # Aktualisiere auch die pybambu-Datenstruktur | ||||
|             if hasattr(self._bambu_client, 'device') and hasattr(self._bambu_client.device, 'print_job'): | ||||
|                 self._bambu_client.device.print_job.mc_percent = progress | ||||
|                 self._bambu_client.device.print_job.mc_remaining_time = remaining_time | ||||
|                  | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error processing progress data: {e}", exc_info=True) | ||||
|  | ||||
|     def _update_bambu_client_state(self, payload): | ||||
|         """Aktualisiert die internen Zustände des BambuClient""" | ||||
|         try: | ||||
|             if not hasattr(self._bambu_client, 'device'): | ||||
|                 self._log.debug("BambuClient has no device attribute, initializing") | ||||
|                 return | ||||
|              | ||||
|             if 'print' in payload: | ||||
|                 print_data = payload['print'] | ||||
|                  | ||||
|                 # Temperatur aktualisieren | ||||
|                 if 'temperature' in print_data and hasattr(self._bambu_client.device, 'temperature'): | ||||
|                     temp_obj = self._bambu_client.device.temperature | ||||
|                     temp_data = print_data['temperature'] | ||||
|                      | ||||
|                     # Direkte Zuweisung der Temperaturen | ||||
|                     if 'nozzle_temp' in temp_data: | ||||
|                         temp_obj.nozzle_temp = float(temp_data['nozzle_temp']) | ||||
|                     if 'target_nozzle_temp' in temp_data: | ||||
|                         temp_obj.target_nozzle_temp = float(temp_data['target_nozzle_temp']) | ||||
|                     if 'bed_temp' in temp_data: | ||||
|                         temp_obj.bed_temp = float(temp_data['bed_temp']) | ||||
|                     if 'target_bed_temp' in temp_data: | ||||
|                         temp_obj.target_bed_temp = float(temp_data['target_bed_temp']) | ||||
|                     if 'chamber_temp' in temp_data: | ||||
|                         temp_obj.chamber_temp = float(temp_data['chamber_temp']) | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error updating BambuClient state: {e}", exc_info=True) | ||||
|  | ||||
|     def _create_client_connection_async(self): | ||||
|         self._create_client_connection() | ||||
|         if self._bambu_client is None: | ||||
| @@ -262,16 +841,36 @@ class BambuVirtualPrinter: | ||||
|         if ( | ||||
|             self._settings.get(["device_type"]) == "" | ||||
|             or self._settings.get(["serial"]) == "" | ||||
|             or self._settings.get(["username"]) == "" | ||||
|             or self._settings.get(["access_code"]) == "" | ||||
|         ): | ||||
|             msg = "invalid settings to start connection with Bambu Printer" | ||||
|             self._log.debug(msg) | ||||
|             raise ValueError(msg) | ||||
|     | ||||
|         # Check if we should use MQTT bridge mode | ||||
|         use_mqtt_bridge = self._settings.get_boolean(["use_mqtt_bridge"]) | ||||
|          | ||||
|         if use_mqtt_bridge: | ||||
|             self._log.debug( | ||||
|                 f"connecting via mqtt bridge: {self._settings.get(['mqtt_host'])}:{self._settings.get(['mqtt_port'])}" | ||||
|             ) | ||||
|             self._log.debug( | ||||
|                 f"Creating MQTT bridge client: {self._settings.get(['mqtt_host'])}:{self._settings.get(['mqtt_port'])}" | ||||
|             ) | ||||
|             # Create MQTT bridge client | ||||
|             bambu_client = BambuMqttBridgeClient( | ||||
|                 device_type=self._settings.get(["device_type"]), | ||||
|                 serial=self._settings.get(["serial"]), | ||||
|                 host=self._settings.get(["mqtt_host"]), | ||||
|                 mqtt_port=int(self._settings.get(["mqtt_port"]) or 1883) | ||||
|             ) | ||||
|         else: | ||||
|             # Use standard BambuClient | ||||
|             self._log.debug( | ||||
|                 f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" | ||||
|             ) | ||||
|             self._log.debug( | ||||
|                 f"Creating standard BambuClient with local_mqtt: {self._settings.get_boolean(['local_mqtt'])}" | ||||
|             ) | ||||
|             bambu_client = BambuClient( | ||||
|                 device_type=self._settings.get(["device_type"]), | ||||
|                 serial=self._settings.get(["serial"]), | ||||
| @@ -287,13 +886,59 @@ class BambuVirtualPrinter: | ||||
|                 email=self._settings.get(["email"]), | ||||
|                 auth_token=self._settings.get(["auth_token"]), | ||||
|             ) | ||||
|          | ||||
|         bambu_client.on_disconnect = self.on_disconnect(bambu_client.on_disconnect) | ||||
|         bambu_client.on_connect = self.on_connect(bambu_client.on_connect) | ||||
|          | ||||
|         # Add more robust connection retry logic | ||||
|         connection_attempts = 0 | ||||
|         max_attempts = 3 | ||||
|         retry_delay = 2 | ||||
|          | ||||
|         while connection_attempts < max_attempts: | ||||
|             try: | ||||
|                 self._log.info(f"Connection attempt {connection_attempts + 1}/{max_attempts}...") | ||||
|                 bambu_client.connect(callback=self.new_update) | ||||
|         self._log.info(f"bambu connection status: {bambu_client.connected}") | ||||
|                  | ||||
|                 # Wait a moment to verify connection | ||||
|                 time.sleep(retry_delay) | ||||
|                  | ||||
|                 if bambu_client.connected: | ||||
|                     self._log.info(f"Bambu connection successful: {bambu_client.connected}") | ||||
|                     break | ||||
|                  | ||||
|                 self._log.warning("Connection attempt failed, retrying...") | ||||
|                 connection_attempts += 1 | ||||
|                 time.sleep(retry_delay) | ||||
|             except Exception as e: | ||||
|                 self._log.error(f"Error during connection attempt {connection_attempts + 1}: {str(e)}", exc_info=True) | ||||
|                 connection_attempts += 1 | ||||
|                 if connection_attempts < max_attempts: | ||||
|                     time.sleep(retry_delay) | ||||
|          | ||||
|         self.sendOk() | ||||
|         self._bambu_client = bambu_client | ||||
|  | ||||
|         self._log.info(f"Custom connection status: {self.is_connected}") | ||||
|         self.sendOk() | ||||
|  | ||||
|     def publish_mqtt(self, topic, payload): | ||||
|         """Publish a message to the MQTT broker""" | ||||
|         if self._mqtt_client and self._mqtt_connected: | ||||
|             return self._mqtt_client.publish(topic, json.dumps(payload)) | ||||
|         return False | ||||
|  | ||||
|     # Override BambuClient's publish method to use our MQTT client | ||||
|     def publish(self, command): | ||||
|         """Publish a command using our MQTT client""" | ||||
|         if not self.is_connected: | ||||
|             self._log.error("Cannot publish command: MQTT not connected") | ||||
|             return False | ||||
|  | ||||
|         serial = self._settings.get(["serial"]) | ||||
|         topic = f"device/{serial}/request" | ||||
|         return self.publish_mqtt(topic, command) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( | ||||
|             read_timeout=self.timeout, | ||||
| @@ -301,6 +946,10 @@ class BambuVirtualPrinter: | ||||
|             options={ | ||||
|                 "device_type": self._settings.get(["device_type"]), | ||||
|                 "host": self._settings.get(["host"]), | ||||
|                 "local_mqtt": self._settings.get_boolean(["local_mqtt"]), | ||||
|                 "region": self._settings.get(["region"]), | ||||
|                 "email": self._settings.get(["email"]), | ||||
|                 "auth_token": self._settings.get(["auth_token"]), | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @@ -316,7 +965,6 @@ class BambuVirtualPrinter: | ||||
|             if self._settings.get_boolean(["simulateReset"]): | ||||
|                 for item in self._settings.get(["resetLines"]): | ||||
|                     self.sendIO(item + "\n") | ||||
|  | ||||
|             self._serial_io.reset() | ||||
|  | ||||
|     def write(self, data: bytes) -> int: | ||||
| @@ -348,6 +996,7 @@ class BambuVirtualPrinter: | ||||
|         file_info = self._project_files_view.get_file_by_stem( | ||||
|             file_path, [".gcode", ".3mf"] | ||||
|         ) | ||||
|  | ||||
|         if ( | ||||
|             self._selected_project_file is not None | ||||
|             and file_info is not None | ||||
| @@ -447,7 +1096,6 @@ class BambuVirtualPrinter: | ||||
|                 self.start_continuous_temp_report(interval) | ||||
|             else: | ||||
|                 self.stop_continuous_temp_report() | ||||
|  | ||||
|         self.report_print_job_status() | ||||
|         return True | ||||
|  | ||||
| @@ -548,7 +1196,7 @@ class BambuVirtualPrinter: | ||||
|                 acc_mag = acc_mag_scaled(speed_percentage) | ||||
|                 feed = feed_rate_scaled(speed_percentage) | ||||
|                 # speed_level = 1.539 * (acc_mag**2) - 0.7032 * acc_mag + 4.0834 | ||||
|                 return f"M204.2 K{acc_mag:.2f}\nM220 K{feed:.2f}\nM73.2 R{speed_frac:.2f}\n" # M1002 set_gcode_claim_speed_level ${speed_level:.0f}\n | ||||
|                 return f"M204.2 K{acc_mag:.2f}\nM220 K{feed:.2f}\nM73.2 R{speed_frac:.2f}\n" # M1002 set_gcode_claim_speed_level ${speed_level:.0f}\n" | ||||
|  | ||||
|             speed_command = speed_adjust(percent) | ||||
|  | ||||
| @@ -565,10 +1213,10 @@ class BambuVirtualPrinter: | ||||
|             return | ||||
|  | ||||
|         # post gcode to printer otherwise | ||||
|         if self.bambu_client.connected: | ||||
|         if self.is_connected: | ||||
|             GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE | ||||
|             GCODE_COMMAND["print"]["param"] = full_command + "\n" | ||||
|             if self.bambu_client.publish(GCODE_COMMAND): | ||||
|             if self.publish(GCODE_COMMAND): | ||||
|                 self._log.info("command sent successfully") | ||||
|                 self.sendOk() | ||||
|  | ||||
| @@ -657,20 +1305,59 @@ class BambuVirtualPrinter: | ||||
|         return output | ||||
|  | ||||
|     def _processTemperatureQuery(self) -> bool: | ||||
|         # includeOk = not self._okBeforeCommandOutput | ||||
|         if self.bambu_client.connected: | ||||
|         # Debug-Log hinzufügen, um zu prüfen, ob die Methode aufgerufen wird | ||||
|         self._log.debug(f"Processing temperature query - connected: {self.is_connected}") | ||||
|         # Aktuelle Temperaturdaten ausgeben | ||||
|         self._log.debug(f"Current temperature data: Nozzle={self._telemetry.temp[0]}/{self._telemetry.targetTemp[0]}, " + | ||||
|                       f"Bed={self._telemetry.bedTemp}/{self._telemetry.bedTargetTemp}") | ||||
|          | ||||
|         # Temperaturmeldung erzeugen und senden, unabhängig von Connected-Status | ||||
|         output = self._create_temperature_message() | ||||
|         self._log.debug(f"Sending temperature message: {output.strip()}") | ||||
|         self.sendIO(output) | ||||
|         return True | ||||
|         else: | ||||
|             return False | ||||
|  | ||||
|     def close(self): | ||||
|         if self.bambu_client.connected: | ||||
|             self.bambu_client.disconnect() | ||||
|         """Safely close all connections.""" | ||||
|         try: | ||||
|             # Log that we're starting to close connections | ||||
|             self._log.debug("Starting to close all connections...") | ||||
|              | ||||
|             if self._mqtt_client and self._mqtt_connected: | ||||
|                 self._log.debug("Stopping MQTT client loop and disconnecting...") | ||||
|                 self._mqtt_client.loop_stop() | ||||
|                 self._mqtt_client.disconnect() | ||||
|                 self._mqtt_connected = False | ||||
|                 self._custom_connected = False | ||||
|                 self._log.debug("MQTT client disconnected") | ||||
|                  | ||||
|             # Sicherstellen, dass wir keinen AttributError bekommen, wenn wir den BambuClient trennen | ||||
|             if self._bambu_client: | ||||
|                 self._log.debug("Disconnecting BambuClient...") | ||||
|                 try: | ||||
|                     self._bambu_client.disconnect() | ||||
|                     self._log.debug("BambuClient disconnected successfully") | ||||
|                 except AttributeError: | ||||
|                     # BambuClient hat keinen client-Attribut oder die disconnect-Methode funktioniert nicht wie erwartet | ||||
|                     self._log.warning("BambuClient disconnect failed, cleaning up manually") | ||||
|                     # Manuell aufräumen | ||||
|                     if hasattr(self._bambu_client, '_mqtt_client') and self._bambu_client._mqtt_client: | ||||
|                         try: | ||||
|                             self._log.debug("Manually stopping BambuClient's MQTT client...") | ||||
|                             self._bambu_client._mqtt_client.loop_stop() | ||||
|                             self._bambu_client._mqtt_client.disconnect() | ||||
|                             self._log.debug("BambuClient's MQTT client manually disconnected") | ||||
|                         except Exception as ex: | ||||
|                             self._log.error(f"Error during manual MQTT client cleanup: {str(ex)}") | ||||
|         except Exception as e: | ||||
|             self._log.error(f"Error during close: {e}", exc_info=True) | ||||
|         finally: | ||||
|             # Immer in einen sicheren Zustand zurückkehren | ||||
|             self._log.debug("Final cleanup in close() method") | ||||
|             self.change_state(self._state_idle) | ||||
|             self._serial_io.close() | ||||
|             self.stop() | ||||
|             self._log.debug("Connection cleanup completed") | ||||
|  | ||||
|     def stop(self): | ||||
|         self._running = False | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import TYPE_CHECKING, Callable | ||||
| from typing import TYPE_CHECKING, Callable, List, Optional | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( | ||||
| @@ -12,83 +12,59 @@ from pathlib import Path | ||||
| from octoprint_bambu_printer.printer.file_system.file_info import FileInfo | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class CachedFileView: | ||||
|     file_system: RemoteSDCardFileList | ||||
|     folder_view: dict[tuple[str, str | list[str] | None], None] = field( | ||||
|         default_factory=dict | ||||
|     )  # dict preserves order, but set does not. We use only dict keys as storage | ||||
|     on_update: Callable[[], None] | None = None | ||||
|     def __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 __post_init__(self): | ||||
|         self._file_alias_cache: dict[str, str] = {} | ||||
|         self._file_data_cache: dict[str, FileInfo] = {} | ||||
|  | ||||
|     def with_filter( | ||||
|         self, folder: str, extensions: str | list[str] | None = None | ||||
|     ) -> "CachedFileView": | ||||
|         self.folder_view[(folder, extensions)] = None | ||||
|     def with_filter(self, path: str, extension: str): | ||||
|         self._filters.append({"path": path, "extension": extension}) | ||||
|         return self | ||||
|  | ||||
|     def list_all_views(self): | ||||
|         existing_files: list[str] = [] | ||||
|         result: list[FileInfo] = [] | ||||
|  | ||||
|         with self.file_system.get_ftps_client() as ftp: | ||||
|             for filter in self.folder_view.keys(): | ||||
|                 result.extend(self.file_system.list_files(*filter, ftp, existing_files)) | ||||
|         return result | ||||
|  | ||||
|     def update(self): | ||||
|     def update(self) -> None: | ||||
|         try: | ||||
|             file_info_list = self.list_all_views() | ||||
|         self._update_file_list_cache(file_info_list) | ||||
|         if self.on_update: | ||||
|             self.on_update() | ||||
|             self._file_info_cache = file_info_list | ||||
|              | ||||
|     def _update_file_list_cache(self, files: list[FileInfo]): | ||||
|         self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files} | ||||
|         self._file_data_cache = {info.path.as_posix(): info for info in files} | ||||
|             # 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 get_all_info(self): | ||||
|         self.update() | ||||
|         return self.get_all_cached_info() | ||||
|     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): | ||||
|         return list(self._file_data_cache.values()) | ||||
|     def get_all_cached_info(self) -> List[FileInfo]: | ||||
|         return self._file_info_cache | ||||
|  | ||||
|     def get_file_data(self, file_path: str | Path) -> FileInfo | None: | ||||
|         file_data = self.get_file_data_cached(file_path) | ||||
|         if file_data is None: | ||||
|             self.update() | ||||
|             file_data = self.get_file_data_cached(file_path) | ||||
|         return file_data | ||||
|     def get_file_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 | ||||
|  | ||||
|     def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None: | ||||
|         if isinstance(file_path, str): | ||||
|             file_path = Path(file_path).as_posix().strip("/") | ||||
|         else: | ||||
|             file_path = file_path.as_posix().strip("/") | ||||
|  | ||||
|         if file_path not in self._file_data_cache: | ||||
|             file_path = self._file_alias_cache.get(file_path, file_path) | ||||
|         return self._file_data_cache.get(file_path, None) | ||||
|  | ||||
|     def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]): | ||||
|         if file_stem == "": | ||||
|         return None | ||||
|  | ||||
|         file_stem = Path(file_stem).with_suffix("").stem | ||||
|         file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) | ||||
|         if file_data is None: | ||||
|             self.update() | ||||
|             file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) | ||||
|         return file_data | ||||
|  | ||||
|     def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]): | ||||
|         for file_path_str in list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys()): | ||||
|             file_path = Path(file_path_str) | ||||
|             if file_stem == file_path.with_suffix("").stem and all( | ||||
|                 suffix in allowed_suffixes for suffix in file_path.suffixes | ||||
|             ): | ||||
|                 return self.get_file_data_cached(file_path) | ||||
|     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 | ||||
|   | ||||
| @@ -2,7 +2,7 @@ from __future__ import annotations | ||||
|  | ||||
| import datetime | ||||
| from pathlib import Path | ||||
| from typing import Iterable, Iterator | ||||
| from typing import Iterable, Iterator, List | ||||
| import logging.handlers | ||||
|  | ||||
| from octoprint.util import get_dos_filename | ||||
| @@ -17,6 +17,7 @@ class RemoteSDCardFileList: | ||||
|         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: | ||||
| @@ -80,8 +81,56 @@ class RemoteSDCardFileList: | ||||
|                 self._logger.exception(e, exc_info=False) | ||||
|  | ||||
|     def get_ftps_client(self): | ||||
|         host = self._settings.get(["host"]) | ||||
|         access_code = self._settings.get(["access_code"]) | ||||
|         return IoTFTPSClient( | ||||
|             f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True | ||||
|         ) | ||||
|         """ | ||||
|         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) | ||||
|   | ||||
| @@ -1,4 +0,0 @@ | ||||
| """Initialise the Bambu Client""" | ||||
| # TODO: Once complete, move pybambu to PyPi | ||||
| from .bambu_client import BambuClient | ||||
| from .bambu_cloud  import BambuCloud | ||||
| @@ -1,552 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import queue | ||||
| import json | ||||
| import math | ||||
| import re | ||||
| import socket | ||||
| import ssl | ||||
| import struct | ||||
| import threading | ||||
| import time | ||||
|  | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| import paho.mqtt.client as mqtt | ||||
|  | ||||
| from .bambu_cloud import BambuCloud | ||||
| from .const import ( | ||||
|     LOGGER, | ||||
|     Features, | ||||
| ) | ||||
| from .models import Device, SlicerSettings | ||||
| from .commands import ( | ||||
|     GET_VERSION, | ||||
|     PUSH_ALL, | ||||
|     START_PUSH, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class WatchdogThread(threading.Thread): | ||||
|  | ||||
|     def __init__(self, client): | ||||
|         self._client = client | ||||
|         self._watchdog_fired = False | ||||
|         self._stop_event = threading.Event() | ||||
|         self._last_received_data = time.time() | ||||
|         super().__init__() | ||||
|         self.setName(f"{self._client._device.info.device_type}-Watchdog-{threading.get_native_id()}") | ||||
|  | ||||
|     def stop(self): | ||||
|         self._stop_event.set() | ||||
|  | ||||
|     def received_data(self): | ||||
|         self._last_received_data = time.time() | ||||
|  | ||||
|     def run(self): | ||||
|         LOGGER.info("Watchdog thread started.") | ||||
|         WATCHDOG_TIMER = 30 | ||||
|         while True: | ||||
|             # Wait out the remainder of the watchdog delay or 1s, whichever is higher. | ||||
|             interval = time.time() - self._last_received_data | ||||
|             wait_time = max(1, WATCHDOG_TIMER - interval) | ||||
|             if self._stop_event.wait(wait_time): | ||||
|                 # Stop event has been set. Exit thread. | ||||
|                 break | ||||
|             interval = time.time() - self._last_received_data | ||||
|             if not self._watchdog_fired and (interval > WATCHDOG_TIMER): | ||||
|                 LOGGER.debug(f"Watchdog fired. No data received for {math.floor(interval)} seconds for {self._client._serial}.") | ||||
|                 self._watchdog_fired = True | ||||
|                 self._client._on_watchdog_fired() | ||||
|             elif interval < WATCHDOG_TIMER: | ||||
|                 self._watchdog_fired = False | ||||
|  | ||||
|         LOGGER.info("Watchdog thread exited.") | ||||
|  | ||||
|  | ||||
| class ChamberImageThread(threading.Thread): | ||||
|     def __init__(self, client): | ||||
|         self._client = client | ||||
|         self._stop_event = threading.Event() | ||||
|         super().__init__() | ||||
|         self.setName(f"{self._client._device.info.device_type}-Chamber-{threading.get_native_id()}") | ||||
|  | ||||
|     def stop(self): | ||||
|         self._stop_event.set() | ||||
|  | ||||
|     def run(self): | ||||
|         LOGGER.debug("Chamber image thread started.") | ||||
|  | ||||
|         auth_data = bytearray() | ||||
|  | ||||
|         username = 'bblp' | ||||
|         access_code = self._client._access_code | ||||
|         hostname = self._client.host | ||||
|         port = 6000 | ||||
|         MAX_CONNECT_ATTEMPTS = 12 | ||||
|         connect_attempts = 0 | ||||
|  | ||||
|         auth_data += struct.pack("<I", 0x40)   # '@'\0\0\0 | ||||
|         auth_data += struct.pack("<I", 0x3000) # \0'0'\0\0 | ||||
|         auth_data += struct.pack("<I", 0)      # \0\0\0\0 | ||||
|         auth_data += struct.pack("<I", 0)      # \0\0\0\0 | ||||
|         for i in range(0, len(username)): | ||||
|             auth_data += struct.pack("<c", username[i].encode('ascii')) | ||||
|         for i in range(0, 32 - len(username)): | ||||
|             auth_data += struct.pack("<x") | ||||
|         for i in range(0, len(access_code)): | ||||
|             auth_data += struct.pack("<c", access_code[i].encode('ascii')) | ||||
|         for i in range(0, 32 - len(access_code)): | ||||
|             auth_data += struct.pack("<x") | ||||
|  | ||||
|         ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | ||||
|         ctx.check_hostname = False | ||||
|         ctx.verify_mode = ssl.CERT_NONE | ||||
|  | ||||
|         jpeg_start = bytearray([0xff, 0xd8, 0xff, 0xe0]) | ||||
|         jpeg_end = bytearray([0xff, 0xd9]) | ||||
|  | ||||
|         read_chunk_size = 4096 # 4096 is the max we'll get even if we increase this. | ||||
|  | ||||
|         # Payload format for each image is: | ||||
|         # 16 byte header: | ||||
|         #   Bytes 0:3   = little endian payload size for the jpeg image (does not include this header). | ||||
|         #   Bytes 4:7   = 0x00000000 | ||||
|         #   Bytes 8:11  = 0x00000001 | ||||
|         #   Bytes 12:15 = 0x00000000 | ||||
|         # These first 16 bytes are always delivered by themselves. | ||||
|         # | ||||
|         # Bytes 16:19                       = jpeg_start magic bytes | ||||
|         # Bytes 20:payload_size-2           = jpeg image bytes | ||||
|         # Bytes payload_size-2:payload_size = jpeg_end magic bytes | ||||
|         # | ||||
|         # Further attempts to receive data will get SSLWantReadError until a new image is ready (1-2 seconds later) | ||||
|         while connect_attempts < MAX_CONNECT_ATTEMPTS and not self._stop_event.is_set(): | ||||
|             connect_attempts += 1 | ||||
|             try: | ||||
|                 with socket.create_connection((hostname, port)) as sock: | ||||
|                     try: | ||||
|                         sslSock = ctx.wrap_socket(sock, server_hostname=hostname) | ||||
|                         sslSock.write(auth_data) | ||||
|                         img = None | ||||
|                         payload_size = 0 | ||||
|  | ||||
|                         status = sslSock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) | ||||
|                         LOGGER.debug(f"SOCKET STATUS: {status}") | ||||
|                         if status != 0: | ||||
|                             LOGGER.error(f"Socket error: {status}") | ||||
|                     except socket.error as e: | ||||
|                         LOGGER.error(f"Socket error: {e}") | ||||
|                         # Sleep to allow printer to stabilize during boot when it may fail these connection attempts repeatedly. | ||||
|                         time.sleep(1) | ||||
|                         continue | ||||
|  | ||||
|                     sslSock.setblocking(False) | ||||
|                     while not self._stop_event.is_set(): | ||||
|                         try: | ||||
|                             dr = sslSock.recv(read_chunk_size) | ||||
|                             #LOGGER.debug(f"Received {len(dr)} bytes.") | ||||
|  | ||||
|                         except ssl.SSLWantReadError: | ||||
|                             #LOGGER.debug("SSLWantReadError") | ||||
|                             time.sleep(1) | ||||
|                             continue | ||||
|  | ||||
|                         except Exception as e: | ||||
|                             LOGGER.error("A Chamber Image thread inner exception occurred:") | ||||
|                             LOGGER.error(f"Exception. Type: {type(e)} Args: {e}") | ||||
|                             time.sleep(1) | ||||
|                             continue | ||||
|  | ||||
|                         if img is not None and len(dr) > 0: | ||||
|                             img += dr | ||||
|                             if len(img) > payload_size: | ||||
|                                 # We got more data than we expected. | ||||
|                                 LOGGER.error(f"Unexpected image payload received: {len(img)} > {payload_size}") | ||||
|                                 # Reset buffer | ||||
|                                 img = None | ||||
|                             elif len(img) == payload_size: | ||||
|                                 # We should have the full image now. | ||||
|                                 if img[:4] != jpeg_start: | ||||
|                                     LOGGER.error("JPEG start magic bytes missing.") | ||||
|                                 elif img[-2:] != jpeg_end: | ||||
|                                     LOGGER.error("JPEG end magic bytes missing.") | ||||
|                                 else: | ||||
|                                     # Content is as expected. Send it. | ||||
|                                     self._client.on_jpeg_received(img) | ||||
|  | ||||
|                                 # Reset buffer | ||||
|                                 img = None | ||||
|                             # else:      | ||||
|                             # Otherwise we need to continue looping without reseting the buffer to receive the remaining data | ||||
|                             # and without delaying. | ||||
|  | ||||
|                         elif len(dr) == 16: | ||||
|                             # We got the header bytes. Get the expected payload size from it and create the image buffer bytearray. | ||||
|                             # Reset connect_attempts now we know the connect was successful. | ||||
|                             connect_attempts = 0 | ||||
|                             img = bytearray() | ||||
|                             payload_size = int.from_bytes(dr[0:3], byteorder='little') | ||||
|  | ||||
|                         elif len(dr) == 0: | ||||
|                             # This occurs if the wrong access code was provided. | ||||
|                             LOGGER.error("Chamber image connection rejected by the printer. Check provided access code and IP address.") | ||||
|                             # Sleep for a short while and then re-attempt the connection. | ||||
|                             time.sleep(5) | ||||
|                             break | ||||
|  | ||||
|                         else: | ||||
|                             LOGGER.error(f"UNEXPECTED DATA RECEIVED: {len(dr)}") | ||||
|                             time.sleep(1) | ||||
|  | ||||
|             except OSError as e: | ||||
|                 if e.errno == 113: | ||||
|                     LOGGER.debug("Host is unreachable") | ||||
|                 else: | ||||
|                     LOGGER.error("A Chamber Image thread outer exception occurred:") | ||||
|                     LOGGER.error(f"Exception. Type: {type(e)} Args: {e}") | ||||
|                 if not self._stop_event.is_set(): | ||||
|                     time.sleep(1)  # Avoid a tight loop if this is a persistent error. | ||||
|  | ||||
|             except Exception as e: | ||||
|                 LOGGER.error(f"A Chamber Image thread outer exception occurred:") | ||||
|                 LOGGER.error(f"Exception. Type: {type(e)} Args: {e}") | ||||
|                 if not self._stop_event.is_set(): | ||||
|                     time.sleep(1)  # Avoid a tight loop if this is a persistent error. | ||||
|  | ||||
|         LOGGER.debug("Chamber image thread exited.") | ||||
|  | ||||
|  | ||||
| class MqttThread(threading.Thread): | ||||
|     def __init__(self, client): | ||||
|         self._client = client | ||||
|         self._stop_event = threading.Event() | ||||
|         super().__init__() | ||||
|         self.setName(f"{self._client._device.info.device_type}-Mqtt-{threading.get_native_id()}") | ||||
|  | ||||
|     def stop(self): | ||||
|         self._stop_event.set() | ||||
|  | ||||
|     def run(self): | ||||
|         LOGGER.info("MQTT listener thread started.") | ||||
|         exceptionSeen = "" | ||||
|         while True: | ||||
|             try: | ||||
|                 host = self._client.host if self._client._local_mqtt else self._client.bambu_cloud.cloud_mqtt_host | ||||
|                 LOGGER.debug(f"Connect: Attempting Connection to {host}") | ||||
|                 self._client.client.connect(host, self._client._port, keepalive=5) | ||||
|  | ||||
|                 LOGGER.debug("Starting listen loop") | ||||
|                 self._client.client.loop_forever() | ||||
|                 LOGGER.debug("Ended listen loop.") | ||||
|                 break | ||||
|             except TimeoutError as e: | ||||
|                 if exceptionSeen != "TimeoutError": | ||||
|                     LOGGER.debug(f"TimeoutError: {e}.") | ||||
|                 exceptionSeen = "TimeoutError" | ||||
|                 time.sleep(5) | ||||
|             except ConnectionError as e: | ||||
|                 if exceptionSeen != "ConnectionError": | ||||
|                     LOGGER.debug(f"ConnectionError: {e}.") | ||||
|                 exceptionSeen = "ConnectionError" | ||||
|                 time.sleep(5) | ||||
|             except OSError as e: | ||||
|                 if e.errno == 113: | ||||
|                     if exceptionSeen != "OSError113": | ||||
|                         LOGGER.debug(f"OSError: {e}.") | ||||
|                     exceptionSeen = "OSError113" | ||||
|                     time.sleep(5) | ||||
|                 else: | ||||
|                     LOGGER.error("A listener loop thread exception occurred:") | ||||
|                     LOGGER.error(f"Exception. Type: {type(e)} Args: {e}") | ||||
|                     time.sleep(1)  # Avoid a tight loop if this is a persistent error. | ||||
|             except Exception as e: | ||||
|                 LOGGER.error("A listener loop thread exception occurred:") | ||||
|                 LOGGER.error(f"Exception. Type: {type(e)} Args: {e}") | ||||
|                 time.sleep(1)  # Avoid a tight loop if this is a persistent error. | ||||
|  | ||||
|             if self._client.client is None: | ||||
|                 break | ||||
|  | ||||
|             self._client.client.disconnect() | ||||
|  | ||||
|         LOGGER.info("MQTT listener thread exited.") | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class BambuClient: | ||||
|     """Initialize Bambu Client to connect to MQTT Broker""" | ||||
|     _watchdog = None | ||||
|     _camera = None | ||||
|     _usage_hours: float | ||||
|  | ||||
|     def __init__(self, device_type: str, serial: str, host: str, local_mqtt: bool, region: str, email: str, | ||||
|                  username: str, auth_token: str, access_code: str, usage_hours: float = 0, manual_refresh_mode: bool = False): | ||||
|         self.callback = None | ||||
|         self.host = host | ||||
|         self._local_mqtt = local_mqtt | ||||
|         self._serial = serial | ||||
|         self._auth_token = auth_token | ||||
|         self._access_code = access_code | ||||
|         self._username = username | ||||
|         self._connected = False | ||||
|         self._device_type = device_type | ||||
|         self._usage_hours = usage_hours | ||||
|         self._port = 1883 | ||||
|         self._refreshed = False | ||||
|         self._manual_refresh_mode = manual_refresh_mode | ||||
|         self._device = Device(self) | ||||
|         self.bambu_cloud = BambuCloud(region, email, username, auth_token) | ||||
|         self.slicer_settings = SlicerSettings(self) | ||||
|  | ||||
|     @property | ||||
|     def connected(self): | ||||
|         """Return if connected to server""" | ||||
|         return self._connected | ||||
|  | ||||
|     @property | ||||
|     def manual_refresh_mode(self): | ||||
|         """Return if the integration is running in poll mode""" | ||||
|         return self._manual_refresh_mode | ||||
|  | ||||
|     async def set_manual_refresh_mode(self, on): | ||||
|         self._manual_refresh_mode = on | ||||
|         if self._manual_refresh_mode: | ||||
|             # Disconnect from the server. User must manually hit the refresh button to connect to refresh and then it will immediately disconnect. | ||||
|             self.disconnect() | ||||
|         else: | ||||
|             # Reconnect normally | ||||
|             self.connect(self.callback) | ||||
|  | ||||
|     def connect(self, callback): | ||||
|         """Connect to the MQTT Broker""" | ||||
|         self.client = mqtt.Client() | ||||
|         self.callback = callback | ||||
|         self.client.on_connect = self.on_connect | ||||
|         self.client.on_disconnect = self.on_disconnect | ||||
|         self.client.on_message = self.on_message | ||||
|         # Set aggressive reconnect polling. | ||||
|         self.client.reconnect_delay_set(min_delay=1, max_delay=1) | ||||
|  | ||||
|         self.client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE) | ||||
|         self.client.tls_insecure_set(True) | ||||
|         self._port = 8883 | ||||
|         if self._local_mqtt: | ||||
|             self.client.username_pw_set("bblp", password=self._access_code) | ||||
|         else: | ||||
|             self.client.username_pw_set(self._username, password=self._auth_token) | ||||
|  | ||||
|         LOGGER.debug("Starting MQTT listener thread") | ||||
|         self._mqtt = MqttThread(self) | ||||
|         self._mqtt.start() | ||||
|  | ||||
|     def subscribe_and_request_info(self): | ||||
|         LOGGER.debug("Loading slicer settings...") | ||||
|         self.slicer_settings.update() | ||||
|         LOGGER.debug("Now subscribing...") | ||||
|         self.subscribe() | ||||
|         LOGGER.debug("On Connect: Getting version info") | ||||
|         self.publish(GET_VERSION) | ||||
|         LOGGER.debug("On Connect: Request push all") | ||||
|         self.publish(PUSH_ALL) | ||||
|  | ||||
|     def on_connect(self, | ||||
|                    client_: mqtt.Client, | ||||
|                    userdata: None, | ||||
|                    flags: dict[str, Any], | ||||
|                    result_code: int, | ||||
|                    properties: mqtt.Properties | None = None, ): | ||||
|         """Handle connection""" | ||||
|         LOGGER.info("On Connect: Connected to printer") | ||||
|         self._on_connect() | ||||
|  | ||||
|     def _on_connect(self): | ||||
|         self._connected = True | ||||
|         self.subscribe_and_request_info() | ||||
|  | ||||
|         LOGGER.debug("Starting watchdog thread") | ||||
|         self._watchdog = WatchdogThread(self) | ||||
|         self._watchdog.start() | ||||
|  | ||||
|         if self._device.supports_feature(Features.CAMERA_IMAGE): | ||||
|             LOGGER.debug("Starting Chamber Image thread") | ||||
|             self._camera = ChamberImageThread(self) | ||||
|             self._camera.start() | ||||
|  | ||||
|     def try_on_connect(self, | ||||
|                        client_: mqtt.Client, | ||||
|                        userdata: None, | ||||
|                        flags: dict[str, Any], | ||||
|                        result_code: int, | ||||
|                        properties: mqtt.Properties | None = None, ): | ||||
|         """Handle connection""" | ||||
|         LOGGER.info("On Connect: Connected to printer") | ||||
|         self._connected = True | ||||
|         LOGGER.debug("Now test subscribing...") | ||||
|         self.subscribe() | ||||
|         # For the initial configuration connection attempt, we just need version info. | ||||
|         LOGGER.debug("On Connect: Getting version info") | ||||
|         self.publish(GET_VERSION) | ||||
|  | ||||
|     def on_disconnect(self, | ||||
|                       client_: mqtt.Client, | ||||
|                       userdata: None, | ||||
|                       result_code: int): | ||||
|         """Called when MQTT Disconnects""" | ||||
|         LOGGER.warn(f"On Disconnect: Printer disconnected with error code: {result_code}") | ||||
|         self._on_disconnect() | ||||
|      | ||||
|     def _on_disconnect(self): | ||||
|         LOGGER.debug("_on_disconnect: Lost connection to the printer") | ||||
|         self._connected = False | ||||
|         self._device.info.set_online(False) | ||||
|         if self._watchdog is not None: | ||||
|             LOGGER.debug("Stopping watchdog thread") | ||||
|             self._watchdog.stop() | ||||
|             self._watchdog.join() | ||||
|         if self._camera is not None: | ||||
|             LOGGER.debug("Stopping camera thread") | ||||
|             self._camera.stop() | ||||
|             self._camera.join() | ||||
|  | ||||
|     def _on_watchdog_fired(self): | ||||
|         LOGGER.info("Watch dog fired") | ||||
|         self._device.info.set_online(False) | ||||
|         self.publish(START_PUSH) | ||||
|  | ||||
|     def on_jpeg_received(self, bytes): | ||||
|         self._device.chamber_image.set_jpeg(bytes) | ||||
|  | ||||
|     def on_message(self, client, userdata, message): | ||||
|         """Return the payload when received""" | ||||
|         try: | ||||
|             # X1 mqtt payload is inconsistent. Adjust it for consistent logging. | ||||
|             clean_msg = re.sub(r"\\n *", "", str(message.payload)) | ||||
|             if self._refreshed: | ||||
|                 LOGGER.debug(f"Received data: {clean_msg}") | ||||
|  | ||||
|             json_data = json.loads(message.payload) | ||||
|             if json_data.get("event"): | ||||
|                 # These are events from the bambu cloud mqtt feed and allow us to detect when a local | ||||
|                 # device has connected/disconnected (e.g. turned on/off) | ||||
|                 if json_data.get("event").get("event") == "client.connected": | ||||
|                     LOGGER.debug("Client connected event received.") | ||||
|                     self._on_disconnect() # We aren't guaranteed to recieve a client.disconnected event. | ||||
|                     self._on_connect() | ||||
|                 elif json_data.get("event").get("event") == "client.disconnected": | ||||
|                     LOGGER.debug("Client disconnected event received.") | ||||
|                     self._on_disconnect() | ||||
|             else: | ||||
|                 self._device.info.set_online(True) | ||||
|                 self._watchdog.received_data() | ||||
|                 if json_data.get("print"): | ||||
|                     self._device.print_update(data=json_data.get("print")) | ||||
|                     # Once we receive data, if in manual refresh mode, we disconnect again. | ||||
|                     if self._manual_refresh_mode: | ||||
|                         self.disconnect() | ||||
|                     if json_data.get("print").get("msg", 0) == 0: | ||||
|                         self._refreshed= False | ||||
|                 elif json_data.get("info") and json_data.get("info").get("command") == "get_version": | ||||
|                     LOGGER.debug("Got Version Data") | ||||
|                     self._device.info_update(data=json_data.get("info")) | ||||
|         except Exception as e: | ||||
|             LOGGER.error("An exception occurred processing a message:") | ||||
|             LOGGER.error(f"Exception type: {type(e)}") | ||||
|             LOGGER.error(f"Exception data: {e}") | ||||
|  | ||||
|     def subscribe(self): | ||||
|         """Subscribe to report topic""" | ||||
|         LOGGER.debug(f"Subscribing: device/{self._serial}/report") | ||||
|         self.client.subscribe(f"device/{self._serial}/report") | ||||
|  | ||||
|     def publish(self, msg): | ||||
|         """Publish a custom message""" | ||||
|         result = self.client.publish(f"device/{self._serial}/request", json.dumps(msg)) | ||||
|         status = result[0] | ||||
|         if status == 0: | ||||
|             LOGGER.debug(f"Sent {msg} to topic device/{self._serial}/request") | ||||
|             return True | ||||
|  | ||||
|         LOGGER.error(f"Failed to send message to topic device/{self._serial}/request") | ||||
|         return False | ||||
|  | ||||
|     async def refresh(self): | ||||
|         """Force refresh data""" | ||||
|  | ||||
|         if self._manual_refresh_mode: | ||||
|             self.connect(self.callback) | ||||
|         else: | ||||
|             LOGGER.debug("Force Refresh: Getting Version Info") | ||||
|             self._refreshed = True | ||||
|             self.publish(GET_VERSION) | ||||
|             LOGGER.debug("Force Refresh: Request Push All") | ||||
|             self._refreshed = True | ||||
|             self.publish(PUSH_ALL) | ||||
|  | ||||
|         self.slicer_settings.update() | ||||
|  | ||||
|     def get_device(self): | ||||
|         """Return device""" | ||||
|         return self._device | ||||
|  | ||||
|     def disconnect(self): | ||||
|         """Disconnect the Bambu Client from server""" | ||||
|         LOGGER.debug(" Disconnect: Client Disconnecting") | ||||
|         if self.client is not None: | ||||
|             self.client.disconnect() | ||||
|             self.client = None | ||||
|  | ||||
|     async def try_connection(self): | ||||
|         """Test if we can connect to an MQTT broker.""" | ||||
|         LOGGER.debug("Try Connection") | ||||
|  | ||||
|         result: queue.Queue[bool] = queue.Queue(maxsize=1) | ||||
|  | ||||
|         def on_message(client, userdata, message): | ||||
|             json_data = json.loads(message.payload) | ||||
|             LOGGER.debug(f"Try Connection: Got '{json_data}'") | ||||
|             if json_data.get("info") and json_data.get("info").get("command") == "get_version": | ||||
|                 LOGGER.debug("Got Version Command Data") | ||||
|                 self._device.info_update(data=json_data.get("info")) | ||||
|                 result.put(True) | ||||
|  | ||||
|         self.client = mqtt.Client() | ||||
|         self.client.on_connect = self.try_on_connect | ||||
|         self.client.on_disconnect = self.on_disconnect | ||||
|         self.client.on_message = on_message | ||||
|  | ||||
|         self.client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE) | ||||
|         self.client.tls_insecure_set(True) | ||||
|         if self._local_mqtt: | ||||
|             self.client.username_pw_set("bblp", password=self._access_code) | ||||
|         else: | ||||
|             self.client.username_pw_set(self._username, password=self._auth_token) | ||||
|         self._port = 8883 | ||||
|  | ||||
|         LOGGER.debug("Test connection: Connecting to %s", self.host) | ||||
|         try: | ||||
|             self.client.connect(self.host, self._port) | ||||
|             self.client.loop_start() | ||||
|             if result.get(timeout=10): | ||||
|                 return True | ||||
|         except OSError as e: | ||||
|             return False | ||||
|         except queue.Empty: | ||||
|             return False | ||||
|         finally: | ||||
|             self.disconnect() | ||||
|  | ||||
|     async def __aenter__(self): | ||||
|         """Async enter. | ||||
|         Returns: | ||||
|             The BambuLab object. | ||||
|         """ | ||||
|         return self | ||||
|  | ||||
|     async def __aexit__(self, *_exc_info): | ||||
|         """Async exit. | ||||
|         Args: | ||||
|             _exc_info: Exec type. | ||||
|         """ | ||||
|         self.disconnect() | ||||
| @@ -1,293 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import base64 | ||||
| import json | ||||
| import httpx | ||||
|  | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from .const import LOGGER | ||||
|  | ||||
| @dataclass | ||||
| class BambuCloud: | ||||
|    | ||||
|     def __init__(self, region: str, email: str, username: str, auth_token: str): | ||||
|         self._region = region | ||||
|         self._email = email | ||||
|         self._username = username | ||||
|         self._auth_token = auth_token | ||||
|  | ||||
|     def _get_authentication_token(self) -> dict: | ||||
|         LOGGER.debug("Getting accessToken from Bambu Cloud") | ||||
|         if self._region == "China": | ||||
|             url = 'https://api.bambulab.cn/v1/user-service/user/login' | ||||
|         else: | ||||
|             url = 'https://api.bambulab.com/v1/user-service/user/login' | ||||
|         headers = {'User-Agent' : "HA Bambulab"} | ||||
|         data = {'account': self._email, 'password': self._password} | ||||
|         with httpx.Client(http2=True) as client: | ||||
|             response = client.post(url, headers=headers, json=data, timeout=10) | ||||
|         if response.status_code >= 400: | ||||
|             LOGGER.debug(f"Received error: {response.status_code}") | ||||
|             raise ValueError(response.status_code) | ||||
|         return response.json()['accessToken'] | ||||
|  | ||||
|     def _get_username_from_authentication_token(self) -> str: | ||||
|         # User name is in 2nd portion of the auth token (delimited with periods) | ||||
|         b64_string = self._auth_token.split(".")[1] | ||||
|         # String must be multiples of 4 chars in length. For decode pad with = character | ||||
|         b64_string += "=" * ((4 - len(b64_string) % 4) % 4) | ||||
|         jsonAuthToken = json.loads(base64.b64decode(b64_string)) | ||||
|         # Gives json payload with "username":"u_<digits>" within it | ||||
|         return jsonAuthToken['username'] | ||||
|      | ||||
|     # Retrieves json description of devices in the form: | ||||
|     # { | ||||
|     #     'message': 'success', | ||||
|     #     'code': None, | ||||
|     #     'error': None, | ||||
|     #     'devices': [ | ||||
|     #         { | ||||
|     #             'dev_id': 'REDACTED', | ||||
|     #             'name': 'Bambu P1S', | ||||
|     #             'online': True, | ||||
|     #             'print_status': 'SUCCESS', | ||||
|     #             'dev_model_name': 'C12', | ||||
|     #             'dev_product_name': 'P1S', | ||||
|     #             'dev_access_code': 'REDACTED', | ||||
|     #             'nozzle_diameter': 0.4 | ||||
|     #             }, | ||||
|     #         { | ||||
|     #             'dev_id': 'REDACTED', | ||||
|     #             'name': 'Bambu P1P', | ||||
|     #             'online': True, | ||||
|     #             'print_status': 'RUNNING', | ||||
|     #             'dev_model_name': 'C11', | ||||
|     #             'dev_product_name': 'P1P', | ||||
|     #             'dev_access_code': 'REDACTED', | ||||
|     #             'nozzle_diameter': 0.4 | ||||
|     #             }, | ||||
|     #         { | ||||
|     #             'dev_id': 'REDACTED', | ||||
|     #             'name': 'Bambu X1C', | ||||
|     #             'online': True, | ||||
|     #             'print_status': 'RUNNING', | ||||
|     #             'dev_model_name': 'BL-P001', | ||||
|     #             'dev_product_name': 'X1 Carbon', | ||||
|     #             'dev_access_code': 'REDACTED', | ||||
|     #             'nozzle_diameter': 0.4 | ||||
|     #             } | ||||
|     #     ] | ||||
|     # } | ||||
|      | ||||
|     def test_authentication(self, region: str, email: str, username: str, auth_token: str) -> bool: | ||||
|         self._region = region | ||||
|         self._email = email | ||||
|         self._username = username | ||||
|         self._auth_token = auth_token | ||||
|         try: | ||||
|             self.get_device_list() | ||||
|         except: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|     def login(self, region: str, email: str, password: str): | ||||
|         self._region = region | ||||
|         self._email = email | ||||
|         self._password = password | ||||
|  | ||||
|         self._auth_token = self._get_authentication_token() | ||||
|         self._username = self._get_username_from_authentication_token() | ||||
|  | ||||
|     def get_device_list(self) -> dict: | ||||
|         LOGGER.debug("Getting device list from Bambu Cloud") | ||||
|         if self._region == "China": | ||||
|             url = 'https://api.bambulab.cn/v1/iot-service/api/user/bind' | ||||
|         else: | ||||
|             url = 'https://api.bambulab.com/v1/iot-service/api/user/bind' | ||||
|         headers = {'Authorization': 'Bearer ' + self._auth_token, 'User-Agent' : "HA Bambulab"} | ||||
|         with httpx.Client(http2=True) as client: | ||||
|             response = client.get(url, headers=headers, timeout=10) | ||||
|         if response.status_code >= 400: | ||||
|             LOGGER.debug(f"Received error: {response.status_code}") | ||||
|             raise ValueError(response.status_code) | ||||
|         return response.json()['devices'] | ||||
|  | ||||
|     # The slicer settings are of the following form: | ||||
|     # | ||||
|     # { | ||||
|     #     "message": "success", | ||||
|     #     "code": null, | ||||
|     #     "error": null, | ||||
|     #     "print": { | ||||
|     #         "public": [ | ||||
|     #             { | ||||
|     #                 "setting_id": "GP004", | ||||
|     #                 "version": "01.09.00.15", | ||||
|     #                 "name": "0.20mm Standard @BBL X1C", | ||||
|     #                 "update_time": "2024-07-04 11:27:08", | ||||
|     #                 "nickname": null | ||||
|     #             }, | ||||
|     #             ... | ||||
|     #         } | ||||
|     #         "private": [] | ||||
|     #     }, | ||||
|     #     "printer": { | ||||
|     #         "public": [ | ||||
|     #             { | ||||
|     #                 "setting_id": "GM001", | ||||
|     #                 "version": "01.09.00.15", | ||||
|     #                 "name": "Bambu Lab X1 Carbon 0.4 nozzle", | ||||
|     #                 "update_time": "2024-07-04 11:25:07", | ||||
|     #                 "nickname": null | ||||
|     #             }, | ||||
|     #             ... | ||||
|     #         ], | ||||
|     #         "private": [] | ||||
|     #     }, | ||||
|     #     "filament": { | ||||
|     #         "public": [ | ||||
|     #             { | ||||
|     #                 "setting_id": "GFSA01", | ||||
|     #                 "version": "01.09.00.15", | ||||
|     #                 "name": "Bambu PLA Matte @BBL X1C", | ||||
|     #                 "update_time": "2024-07-04 11:29:21", | ||||
|     #                 "nickname": null, | ||||
|     #                 "filament_id": "GFA01" | ||||
|     #             }, | ||||
|     #             ... | ||||
|     #         ], | ||||
|     #         "private": [ | ||||
|     #             { | ||||
|     #                 "setting_id": "PFUS46ea5c221cabe5", | ||||
|     #                 "version": "1.9.0.14", | ||||
|     #                 "name": "Fillamentum PLA Extrafill @Bambu Lab X1 Carbon 0.4 nozzle", | ||||
|     #                 "update_time": "2024-07-10 06:48:17", | ||||
|     #                 "base_id": null, | ||||
|     #                 "filament_id": "Pc628b24", | ||||
|     #                 "filament_type": "PLA", | ||||
|     #                 "filament_is_support": "0", | ||||
|     #                 "nozzle_temperature": [ | ||||
|     #                     190, | ||||
|     #                     240 | ||||
|     #                 ], | ||||
|     #                 "nozzle_hrc": "3", | ||||
|     #                 "filament_vendor": "Fillamentum" | ||||
|     #             }, | ||||
|     #             ... | ||||
|     #         ] | ||||
|     #     }, | ||||
|     #     "settings": {} | ||||
|     # } | ||||
|  | ||||
|     def get_slicer_settings(self) -> dict: | ||||
|         LOGGER.debug("Getting slicer settings from Bambu Cloud") | ||||
|         if self._region == "China": | ||||
|             url = 'https://api.bambulab.cn/v1/iot-service/api/slicer/setting?version=undefined' | ||||
|         else: | ||||
|             url = 'https://api.bambulab.com/v1/iot-service/api/slicer/setting?version=undefined' | ||||
|         headers = {'Authorization': 'Bearer ' + self._auth_token, 'User-Agent' : "HA Bambulab"} | ||||
|         with httpx.Client(http2=True) as client: | ||||
|             response = client.get(url, headers=headers, timeout=10) | ||||
|         if response.status_code >= 400: | ||||
|             LOGGER.error(f"Slicer settings load failed: {response.status_code}") | ||||
|             return None | ||||
|         return response.json() | ||||
|          | ||||
|     # The task list is of the following form with a 'hits' array with typical 20 entries. | ||||
|     # | ||||
|     # "total": 531, | ||||
|     # "hits": [ | ||||
|     #     { | ||||
|     #     "id": 35237965, | ||||
|     #     "designId": 0, | ||||
|     #     "designTitle": "", | ||||
|     #     "instanceId": 0, | ||||
|     #     "modelId": "REDACTED", | ||||
|     #     "title": "REDACTED", | ||||
|     #     "cover": "REDACTED", | ||||
|     #     "status": 4, | ||||
|     #     "feedbackStatus": 0, | ||||
|     #     "startTime": "2023-12-21T19:02:16Z", | ||||
|     #     "endTime": "2023-12-21T19:02:35Z", | ||||
|     #     "weight": 34.62, | ||||
|     #     "length": 1161, | ||||
|     #     "costTime": 10346, | ||||
|     #     "profileId": 35276233, | ||||
|     #     "plateIndex": 1, | ||||
|     #     "plateName": "", | ||||
|     #     "deviceId": "REDACTED", | ||||
|     #     "amsDetailMapping": [ | ||||
|     #         { | ||||
|     #         "ams": 4, | ||||
|     #         "sourceColor": "F4D976FF", | ||||
|     #         "targetColor": "F4D976FF", | ||||
|     #         "filamentId": "GFL99", | ||||
|     #         "filamentType": "PLA", | ||||
|     #         "targetFilamentType": "", | ||||
|     #         "weight": 34.62 | ||||
|     #         } | ||||
|     #     ], | ||||
|     #     "mode": "cloud_file", | ||||
|     #     "isPublicProfile": false, | ||||
|     #     "isPrintable": true, | ||||
|     #     "deviceModel": "P1P", | ||||
|     #     "deviceName": "Bambu P1P", | ||||
|     #     "bedType": "textured_plate" | ||||
|     #     }, | ||||
|  | ||||
|     def get_tasklist(self) -> dict: | ||||
|         if self._region == "China": | ||||
|             url = 'https://api.bambulab.cn/v1/user-service/my/tasks' | ||||
|         else: | ||||
|             url = 'https://api.bambulab.com/v1/user-service/my/tasks' | ||||
|         headers = {'Authorization': 'Bearer ' + self._auth_token, 'User-Agent' : "HA Bambulab"} | ||||
|         with httpx.Client(http2=True) as client: | ||||
|             response = client.get(url, headers=headers, timeout=10) | ||||
|         if response.status_code >= 400: | ||||
|             LOGGER.debug(f"Received error: {response.status_code}") | ||||
|             raise ValueError(response.status_code) | ||||
|         return response.json() | ||||
|      | ||||
|     def get_latest_task_for_printer(self, deviceId: str) -> dict: | ||||
|         LOGGER.debug(f"Getting latest task from Bambu Cloud for Printer: {deviceId}") | ||||
|         data = self.get_tasklist_for_printer(deviceId) | ||||
|         if len(data) != 0: | ||||
|             return data[0] | ||||
|         LOGGER.debug("No tasks found for printer") | ||||
|         return None | ||||
|  | ||||
|     def get_tasklist_for_printer(self, deviceId: str) -> dict: | ||||
|         LOGGER.debug(f"Getting task list from Bambu Cloud for Printer: {deviceId}") | ||||
|         tasks = [] | ||||
|         data = self.get_tasklist() | ||||
|         for task in data['hits']: | ||||
|             if task['deviceId'] == deviceId: | ||||
|                 tasks.append(task) | ||||
|         return tasks | ||||
|  | ||||
|     def get_device_type_from_device_product_name(self, device_product_name: str): | ||||
|         if device_product_name == "X1 Carbon": | ||||
|             return "X1C" | ||||
|         return device_product_name.replace(" ", "") | ||||
|  | ||||
|     def download(self, url: str) -> bytearray: | ||||
|         LOGGER.debug(f"Downloading cover image: {url}") | ||||
|         with httpx.Client(http2=True) as client: | ||||
|             response = client.get(url, timeout=10) | ||||
|         if response.status_code >= 400: | ||||
|             LOGGER.debug(f"Received error: {response.status_code}") | ||||
|             raise ValueError(response.status_code) | ||||
|         return response.content | ||||
|  | ||||
|     @property | ||||
|     def username(self): | ||||
|         return self._username | ||||
|      | ||||
|     @property | ||||
|     def auth_token(self): | ||||
|         return self._auth_token | ||||
|      | ||||
|     @property | ||||
|     def cloud_mqtt_host(self): | ||||
|         return "cn.mqtt.bambulab.com" if self._region == "China" else "us.mqtt.bambulab.com" | ||||
| @@ -1,24 +0,0 @@ | ||||
| """MQTT Commands""" | ||||
| CHAMBER_LIGHT_ON = { | ||||
|     "system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "on", | ||||
|                "led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}} | ||||
| CHAMBER_LIGHT_OFF = { | ||||
|     "system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "off", | ||||
|                "led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}} | ||||
|  | ||||
| SPEED_PROFILE_TEMPLATE = {"print": {"sequence_id": "0", "command": "print_speed", "param": ""}} | ||||
|  | ||||
| GET_VERSION = {"info": {"sequence_id": "0", "command": "get_version"}} | ||||
|  | ||||
| PAUSE = {"print": {"sequence_id": "0", "command": "pause"}} | ||||
| RESUME = {"print": {"sequence_id": "0", "command": "resume"}} | ||||
| STOP = {"print": {"sequence_id": "0", "command": "stop"}} | ||||
|  | ||||
| PUSH_ALL = {"pushing": {"sequence_id": "0", "command": "pushall"}} | ||||
|  | ||||
| START_PUSH = { "pushing": {"sequence_id": "0", "command": "start"}} | ||||
|  | ||||
| SEND_GCODE_TEMPLATE = {"print": {"sequence_id": "0", "command": "gcode_line", "param": ""}} # param = GCODE_EACH_LINE_SEPARATED_BY_\n | ||||
|  | ||||
| # X1 only currently | ||||
| GET_ACCESSORIES = {"system": {"sequence_id": "0", "command": "get_accessories", "accessory_type": "none"}} | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,72 +0,0 @@ | ||||
| { | ||||
|     "GFA00": "Bambu PLA Basic", | ||||
|     "GFA01": "Bambu PLA Matte", | ||||
|     "GFA02": "Bambu PLA Metal", | ||||
|     "GFA05": "Bambu PLA Silk", | ||||
|     "GFA07": "Bambu PLA Marble", | ||||
|     "GFA08": "Bambu PLA Sparkle", | ||||
|     "GFA09": "Bambu PLA Tough", | ||||
|     "GFA11": "Bambu PLA Aero", | ||||
|     "GFA12": "Bambu PLA Glow", | ||||
|     "GFA13": "Bambu PLA Dynamic", | ||||
|     "GFA15": "Bambu PLA Galaxy", | ||||
|     "GFA50": "Bambu PLA-CF", | ||||
|     "GFB00": "Bambu ABS", | ||||
|     "GFB01": "Bambu ASA", | ||||
|     "GFB02": "Bambu ASA-Aero", | ||||
|     "GFB50": "Bambu ABS-GF", | ||||
|     "GFB60": "PolyLite ABS", | ||||
|     "GFB61": "PolyLite ASA", | ||||
|     "GFB98": "Generic ASA", | ||||
|     "GFB99": "Generic ABS", | ||||
|     "GFC00": "Bambu PC", | ||||
|     "GFC99": "Generic PC", | ||||
|     "GFG00": "Bambu PETG Basic", | ||||
|     "GFG01": "Bambu PETG Translucent", | ||||
|     "GFG02": "Bambu PETG HF", | ||||
|     "GFG50": "Bambu PETG-CF", | ||||
|     "GFG60": "PolyLite PETG", | ||||
|     "GFG97": "Generic PCTG", | ||||
|     "GFG98": "Generic PETG-CF", | ||||
|     "GFG99": "Generic PETG", | ||||
|     "GFL00": "PolyLite PLA", | ||||
|     "GFL01": "PolyTerra PLA", | ||||
|     "GFL03": "eSUN PLA+", | ||||
|     "GFL04": "Overture PLA", | ||||
|     "GFL05": "Overture Matte PLA", | ||||
|     "GFL95": "Generic PLA High Speed", | ||||
|     "GFL96": "Generic PLA Silk", | ||||
|     "GFL98": "Generic PLA-CF", | ||||
|     "GFL99": "Generic PLA", | ||||
|     "GFN03": "Bambu PA-CF", | ||||
|     "GFN04": "Bambu PAHT-CF", | ||||
|     "GFN05": "Bambu PA6-CF", | ||||
|     "GFN08": "Bambu PA6-GF", | ||||
|     "GFN96": "Generic PPA-GF", | ||||
|     "GFN97": "Generic PPA-CF", | ||||
|     "GFN98": "Generic PA-CF", | ||||
|     "GFN99": "Generic PA", | ||||
|     "GFP95": "Generic PP-GF", | ||||
|     "GFP96": "Generic PP-CF", | ||||
|     "GFP97": "Generic PP", | ||||
|     "GFP98": "Generic PE-CF", | ||||
|     "GFP99": "Generic PE", | ||||
|     "GFR98": "Generic PHA", | ||||
|     "GFR99": "Generic EVA", | ||||
|     "GFS00": "Bambu Support W", | ||||
|     "GFS01": "Bambu Support G", | ||||
|     "GFS02": "Bambu Support For PLA", | ||||
|     "GFS03": "Bambu Support For PA/PET", | ||||
|     "GFS04": "Bambu PVA", | ||||
|     "GFS05": "Bambu Support For PLA/PETG", | ||||
|     "GFS06": "Bambu Support for ABS", | ||||
|     "GFS97": "Generic BVOH", | ||||
|     "GFS98": "Generic HIPS", | ||||
|     "GFS99": "Generic PVA", | ||||
|     "GFT01": "Bambu PET-CF", | ||||
|     "GFT97": "Generic PPS", | ||||
|     "GFT98": "Generic PPS-CF", | ||||
|     "GFU00": "Bambu TPU 95A HF", | ||||
|     "GFU01": "Bambu TPU 95A", | ||||
|     "GFU99": "Generic TPU" | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,227 +0,0 @@ | ||||
| import math | ||||
| from datetime import datetime, timedelta | ||||
|  | ||||
| from .const import ( | ||||
|     CURRENT_STAGE_IDS, | ||||
|     SPEED_PROFILE, | ||||
|     FILAMENT_NAMES, | ||||
|     HMS_ERRORS, | ||||
|     HMS_AMS_ERRORS, | ||||
|     PRINT_ERROR_ERRORS, | ||||
|     HMS_SEVERITY_LEVELS, | ||||
|     HMS_MODULES, | ||||
|     LOGGER, | ||||
|     FansEnum, | ||||
| ) | ||||
| from .commands import SEND_GCODE_TEMPLATE | ||||
|  | ||||
|  | ||||
| def search(lst, predicate, default={}): | ||||
|     """Search an array for a string""" | ||||
|     for item in lst: | ||||
|         if predicate(item): | ||||
|             return item | ||||
|     return default | ||||
|  | ||||
|  | ||||
| def fan_percentage(speed): | ||||
|     """Converts a fan speed to percentage""" | ||||
|     if not speed: | ||||
|         return 0 | ||||
|     percentage = (int(speed) / 15) * 100 | ||||
|     return round(percentage / 10) * 10 | ||||
|  | ||||
|  | ||||
| def fan_percentage_to_gcode(fan: FansEnum, percentage: int): | ||||
|     """Converts a fan speed percentage to the gcode command to set that""" | ||||
|     if fan == FansEnum.PART_COOLING: | ||||
|         fanString = "P1" | ||||
|     elif fan == FansEnum.AUXILIARY: | ||||
|         fanString = "P2" | ||||
|     elif fan == FansEnum.CHAMBER: | ||||
|         fanString = "P3" | ||||
|  | ||||
|     percentage = round(percentage / 10) * 10 | ||||
|     speed = math.ceil(255 * percentage / 100) | ||||
|     command = SEND_GCODE_TEMPLATE | ||||
|     command['print']['param'] = f"M106 {fanString} S{speed}\n" | ||||
|     return command | ||||
|  | ||||
|  | ||||
| def to_whole(number): | ||||
|     if not number: | ||||
|         return 0 | ||||
|     return round(number) | ||||
|  | ||||
|  | ||||
| def get_filament_name(idx, custom_filaments: dict): | ||||
|     """Converts a filament idx to a human-readable name""" | ||||
|     result = FILAMENT_NAMES.get(idx, "unknown") | ||||
|     if result == "unknown" and idx != "": | ||||
|         result = custom_filaments.get(idx, "unknown") | ||||
|     if result == "unknown" and idx != "": | ||||
|         LOGGER.debug(f"UNKNOWN FILAMENT IDX: '{idx}'") | ||||
|     return result | ||||
|  | ||||
|  | ||||
| def get_speed_name(id): | ||||
|     """Return the human-readable name for a speed id""" | ||||
|     return SPEED_PROFILE.get(int(id), "standard") | ||||
|  | ||||
|  | ||||
| def get_current_stage(id) -> str: | ||||
|     """Return the human-readable description for a stage action""" | ||||
|     return CURRENT_STAGE_IDS.get(int(id), "unknown") | ||||
|  | ||||
|  | ||||
| def get_HMS_error_text(hms_code: str): | ||||
|     """Return the human-readable description for an HMS error""" | ||||
|  | ||||
|     ams_code = get_generic_AMS_HMS_error_code(hms_code) | ||||
|     ams_error = HMS_AMS_ERRORS.get(ams_code, "") | ||||
|     if ams_error != "": | ||||
|         # 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error | ||||
|         ams_index = int(hms_code[3:4], 16) + 1 | ||||
|         ams_slot = int(hms_code[6:7], 16) + 1 | ||||
|         ams_error = ams_error.replace('AMS1', f"AMS{ams_index}") | ||||
|         ams_error = ams_error.replace('slot 1', f"slot {ams_slot}") | ||||
|         return ams_error | ||||
|  | ||||
|     return HMS_ERRORS.get(hms_code, "unknown") | ||||
|  | ||||
|  | ||||
| def get_print_error_text(print_error_code: str): | ||||
|     """Return the human-readable description for a print error""" | ||||
|  | ||||
|     hex_conversion = f'0{int(print_error_code):x}' | ||||
|     print_error_code = hex_conversion[slice(0,4,1)] + "_" + hex_conversion[slice(4,8,1)] | ||||
|     print_error = PRINT_ERROR_ERRORS.get(print_error_code.upper(), "") | ||||
|     if print_error != "": | ||||
|         return print_error | ||||
|  | ||||
|     return PRINT_ERROR_ERRORS.get(print_error_code, "unknown") | ||||
|  | ||||
|  | ||||
| def get_HMS_severity(code: int) -> str: | ||||
|     uint_code = code >> 16 | ||||
|     if code > 0 and uint_code in HMS_SEVERITY_LEVELS: | ||||
|         return HMS_SEVERITY_LEVELS[uint_code] | ||||
|     return HMS_SEVERITY_LEVELS["default"] | ||||
|  | ||||
|  | ||||
| def get_HMS_module(attr: int) -> str: | ||||
|     uint_attr = (attr >> 24) & 0xFF | ||||
|     if attr > 0 and uint_attr in HMS_MODULES: | ||||
|         return HMS_MODULES[uint_attr] | ||||
|     return HMS_MODULES["default"] | ||||
|  | ||||
|  | ||||
| def get_generic_AMS_HMS_error_code(hms_code: str): | ||||
|     code1 = int(hms_code[0:4], 16) | ||||
|     code2 = int(hms_code[5:9], 16) | ||||
|     code3 = int(hms_code[10:14], 16) | ||||
|     code4 = int(hms_code[15:19], 16) | ||||
|  | ||||
|     # 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error | ||||
|     ams_code = f"{code1 & 0xFFF8:0>4X}_{code2 & 0xF8FF:0>4X}_{code3:0>4X}_{code4:0>4X}" | ||||
|     ams_error = HMS_AMS_ERRORS.get(ams_code, "") | ||||
|     if ams_error != "": | ||||
|         return ams_code | ||||
|  | ||||
|     return f"{code1:0>4X}_{code2:0>4X}_{code3:0>4X}_{code4:0>4X}" | ||||
|  | ||||
|  | ||||
| def get_printer_type(modules, default): | ||||
|     # Known possible values: | ||||
|     #  | ||||
|     # A1/P1 printers are of the form: | ||||
|     # { | ||||
|     #     "name": "esp32", | ||||
|     #     "project_name": "C11", | ||||
|     #     "sw_ver": "01.07.23.47", | ||||
|     #     "hw_ver": "AP04", | ||||
|     #     "sn": "**REDACTED**", | ||||
|     #     "flag": 0 | ||||
|     # }, | ||||
|     # P1P    = AP04 / C11 | ||||
|     # P1S    = AP04 / C12 | ||||
|     # A1Mini = AP05 / N1 or AP04 / N1 or AP07 / N1 | ||||
|     # A1     = AP05 / N2S | ||||
|     # | ||||
|     # X1C printers are of the form: | ||||
|     # { | ||||
|     #     "hw_ver": "AP05", | ||||
|     #     "name": "rv1126", | ||||
|     #     "sn": "**REDACTED**", | ||||
|     #     "sw_ver": "00.00.28.55" | ||||
|     # }, | ||||
|     # X1C = AP05 | ||||
|     # | ||||
|     # X1E printers are of the form: | ||||
|     # { | ||||
|     #     "flag": 0, | ||||
|     #     "hw_ver": "AP02", | ||||
|     #     "name": "ap", | ||||
|     #     "sn": "**REDACTED**", | ||||
|     #     "sw_ver": "00.00.32.14" | ||||
|     # } | ||||
|     # X1E = AP02 | ||||
|  | ||||
|     apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0) | ||||
|     if len(apNode.keys()) > 1: | ||||
|         hw_ver = apNode['hw_ver'] | ||||
|         project_name = apNode.get('project_name', '') | ||||
|         if hw_ver == 'AP02': | ||||
|             return 'X1E' | ||||
|         elif project_name == 'N1': | ||||
|             return 'A1MINI' | ||||
|         elif hw_ver == 'AP04': | ||||
|             if project_name == 'C11': | ||||
|                 return 'P1P' | ||||
|             if project_name == 'C12': | ||||
|                 return 'P1S' | ||||
|         elif hw_ver == 'AP05': | ||||
|             if project_name == 'N2S': | ||||
|                 return 'A1' | ||||
|             if project_name == '': | ||||
|                 return 'X1C' | ||||
|         LOGGER.debug(f"UNKNOWN DEVICE: hw_ver='{hw_ver}' / project_name='{project_name}'") | ||||
|     return default | ||||
|  | ||||
|  | ||||
| def get_hw_version(modules, default): | ||||
|     """Retrieve hardware version of printer""" | ||||
|     apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0) | ||||
|     if len(apNode.keys()) > 1: | ||||
|         return apNode.get("hw_ver") | ||||
|     return default | ||||
|  | ||||
|  | ||||
| def get_sw_version(modules, default): | ||||
|     """Retrieve software version of printer""" | ||||
|     ota = search(modules, lambda x: x.get('name', "") == "ota") | ||||
|     if len(ota.keys()) > 1: | ||||
|         return ota.get("sw_ver") | ||||
|     return default | ||||
|  | ||||
|  | ||||
| def get_start_time(timestamp): | ||||
|     """Return start time of a print""" | ||||
|     if timestamp == 0: | ||||
|         return None | ||||
|     return datetime.fromtimestamp(timestamp) | ||||
|  | ||||
|  | ||||
| def get_end_time(remaining_time): | ||||
|     """Calculate the end time of a print""" | ||||
|     end_time = round_minute(datetime.now() + timedelta(minutes=remaining_time)) | ||||
|     return end_time | ||||
|  | ||||
|  | ||||
| def round_minute(date: datetime = None, round_to: int = 1): | ||||
|     """ Round datetime object to minutes""" | ||||
|     if not date: | ||||
|         date = datetime.now() | ||||
|     date = date.replace(second=0, microsecond=0) | ||||
|     delta = date.minute % round_to | ||||
|     return date.replace(minute=date.minute - delta) | ||||
| @@ -49,7 +49,7 @@ class IdleState(APrinterState): | ||||
|                 ), | ||||
|                 "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), | ||||
|                 "use_ams": self._printer._settings.get_boolean(["use_ams"]), | ||||
|                 "ams_mapping": self._printer._settings.get(["ams_mapping"]), | ||||
|                 "ams_mapping": "", | ||||
|             } | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -8,7 +8,7 @@ if TYPE_CHECKING: | ||||
|  | ||||
| import threading | ||||
|  | ||||
| import octoprint_bambu_printer.printer.pybambu.commands | ||||
| import pybambu.commands | ||||
| from octoprint.util import RepeatedTimer | ||||
|  | ||||
| from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState | ||||
|   | ||||
| @@ -10,9 +10,9 @@ if TYPE_CHECKING: | ||||
|  | ||||
| import threading | ||||
|  | ||||
| import octoprint_bambu_printer.printer.pybambu | ||||
| import octoprint_bambu_printer.printer.pybambu.models | ||||
| import octoprint_bambu_printer.printer.pybambu.commands | ||||
| 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 | ||||
|   | ||||
| @@ -1,24 +0,0 @@ | ||||
| #sidebar_plugin_bambu_printer div.well { | ||||
|     min-height: 70px; | ||||
| } | ||||
|  | ||||
| #sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid { | ||||
|     padding-top: 10px; | ||||
| } | ||||
|  | ||||
| #sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid.active { | ||||
|     border: 2px solid; | ||||
|     -webkit-border-radius: 4px; | ||||
|     -moz-border-radius: 4px; | ||||
|     border-radius: 4px; | ||||
| } | ||||
|  | ||||
| #bambu_printer_print_options div.well { | ||||
|     min-height: 60px; | ||||
| } | ||||
|  | ||||
| #bambu_printer_print_options div.modal-body { | ||||
|     overflow: inherit !important; | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -15,27 +15,6 @@ $(function () { | ||||
|         self.accessViewModel = parameters[3]; | ||||
|         self.timelapseViewModel = parameters[4]; | ||||
|  | ||||
|         self.use_ams = true; | ||||
|         self.ams_mapping = ko.observableArray([]); | ||||
|  | ||||
|         self.ams_mapping_computed = function(){ | ||||
|             var output_list = []; | ||||
|             var index = 0; | ||||
|  | ||||
|             ko.utils.arrayForEach(self.settingsViewModel.settings.plugins.bambu_printer.ams_data(), function(item){ | ||||
|                 if(item){ | ||||
|                     output_list = output_list.concat(item.tray()); | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             ko.utils.arrayForEach(output_list, function(item){ | ||||
|                 item["index"] = ko.observable(index); | ||||
|                 index++; | ||||
|             }); | ||||
|  | ||||
|             return output_list; | ||||
|         }; | ||||
|  | ||||
|         self.getAuthToken = function (data) { | ||||
|             self.settingsViewModel.settings.plugins.bambu_printer.auth_token(""); | ||||
|             OctoPrint.simpleApiCommand("bambu_printer", "register", { | ||||
| @@ -89,6 +68,7 @@ $(function () { | ||||
|             } | ||||
|  | ||||
|             if (data.files !== undefined) { | ||||
|                 console.log(data.files); | ||||
|                 self.listHelper.updateItems(data.files); | ||||
|                 self.listHelper.resetPage(); | ||||
|             } | ||||
| @@ -98,59 +78,71 @@ $(function () { | ||||
|             $('#bambu_timelapse').appendTo("#timelapse"); | ||||
|         }; | ||||
|  | ||||
|         self.onAfterBinding = function () { | ||||
|             console.log(self.ams_mapping_computed()); | ||||
|         }; | ||||
|  | ||||
|         self.showTimelapseThumbnail = function(data) { | ||||
|             $("#bambu_printer_timelapse_thumbnail").attr("src", data.thumbnail); | ||||
|             $("#bambu_printer_timelapse_preview").modal('show'); | ||||
|         }; | ||||
|  | ||||
|         self.onBeforePrintStart = function(start_print_command, data) { | ||||
|             self.ams_mapping(self.ams_mapping_computed()); | ||||
|             self.start_print_command = start_print_command; | ||||
|             self.use_ams = self.settingsViewModel.settings.plugins.bambu_printer.use_ams(); | ||||
|             // prevent starting locally stored files, once data is added to core OctoPrint this | ||||
|             // could be adjusted to include additional processing like get sliced file's | ||||
|             // spool assignments and colors from plate_#.json inside 3mf file. | ||||
|             if(data && data.origin !== "sdcard") { | ||||
|                 return false; | ||||
|         /*$('#files div.upload-buttons > span.fileinput-button:first, #files div.folder-button').remove(); | ||||
|         $('#files div.upload-buttons > span.fileinput-button:first').removeClass('span6').addClass('input-block-level'); | ||||
|  | ||||
|         self.onBeforePrintStart = function(start_print_command) { | ||||
|             let confirmation_html = '' + | ||||
|                 '            <div class="row-fluid form-vertical">\n' + | ||||
|                 '                <div class="control-group">\n' + | ||||
|                 '                    <label class="control-label">' + gettext("Plate Number") + '</label>\n' + | ||||
|                 '                    <div class="controls">\n' + | ||||
|                 '                        <input type="number" min="1" value="1" id="bambu_printer_plate_number" class="input-mini">\n' + | ||||
|                 '                    </div>\n' + | ||||
|                 '                </div>\n' + | ||||
|                 '            </div>'; | ||||
|  | ||||
|             if(!self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options()){ | ||||
|                 confirmation_html += '\n' + | ||||
|                     '            <div class="row-fluid">\n' + | ||||
|                     '                <div class="span6">\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_timelapse" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.timelapse()) ? ' checked' : '') + '> ' + gettext("Enable timelapse") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_bed_leveling" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling()) ? ' checked' : '') + '> ' + gettext("Enable bed leveling") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_flow_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.flow_cali()) ? ' checked' : '') + '> ' + gettext("Enable flow calibration") + '</label>\n' + | ||||
|                     '                </div>\n' + | ||||
|                     '                <div class="span6">\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_vibration_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali()) ? ' checked' : '') + '> ' + gettext("Enable vibration calibration") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_layer_inspect" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect()) ? ' checked' : '') + '> ' + gettext("Enable first layer inspection") + '</label>\n' + | ||||
|                     '                    <label class="checkbox"><input id="bambu_printer_use_ams" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.use_ams()) ? ' checked' : '') + '> ' + gettext("Use AMS") + '</label>\n' + | ||||
|                     '                </div>\n' + | ||||
|                     '            </div>\n'; | ||||
|             } | ||||
|             $("#bambu_printer_print_options").modal('show'); | ||||
|             return false; | ||||
|         }; | ||||
|  | ||||
|         self.toggle_spool_active = function(data) { | ||||
|             if(data.index() >= 0){ | ||||
|                 data.original_index = ko.observable(data.index()); | ||||
|                 data.index(-1); | ||||
|             } else { | ||||
|                 data.index(data.original_index()); | ||||
|             showConfirmationDialog({ | ||||
|                 title: "Bambu Print Options", | ||||
|                 html: confirmation_html, | ||||
|                 cancel: gettext("Cancel"), | ||||
|                 proceed: [gettext("Print"), gettext("Always")], | ||||
|                 onproceed: function (idx) { | ||||
|                     if(idx === 1){ | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.timelapse($('#bambu_printer_timelapse').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling($('#bambu_printer_bed_leveling').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.flow_cali($('#bambu_printer_flow_cali').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali($('#bambu_printer_vibration_cali').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect($('#bambu_printer_layer_inspect').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.use_ams($('#bambu_printer_use_ams').is(':checked')); | ||||
|                         self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options(true); | ||||
|                         self.settingsViewModel.saveData(); | ||||
|                     } | ||||
|         }; | ||||
|  | ||||
|         self.cancel_print_options = function() { | ||||
|             self.settingsViewModel.settings.plugins.bambu_printer.use_ams(self.use_ams); | ||||
|             $("#bambu_printer_print_options").modal('hide'); | ||||
|         }; | ||||
|  | ||||
|         self.accept_print_options = function() { | ||||
|             console.log("starting print!!!!"); | ||||
|             console.log(self.ams_mapping()); | ||||
|             $("#bambu_printer_print_options").modal('hide'); | ||||
|             var flattened_ams_mapping = ko.utils.arrayMap(self.ams_mapping(), function(item) { | ||||
|                 return item.index(); | ||||
|                     // replace this with our own print command API call? | ||||
|                     start_print_command(); | ||||
|                 }, | ||||
|                 nofade: true | ||||
|             }); | ||||
|             self.settingsViewModel.settings.plugins.bambu_printer.ams_mapping(flattened_ams_mapping); | ||||
|             self.settingsViewModel.saveData(undefined, self.start_print_command); | ||||
|             // self.settingsViewModel.saveData(); | ||||
|         }; | ||||
|             return false; | ||||
|         };*/ | ||||
|     } | ||||
|  | ||||
|     OCTOPRINT_VIEWMODELS.push({ | ||||
|         construct: Bambu_printerViewModel, | ||||
|         // ViewModels your plugin depends on, e.g. loginStateViewModel, settingsViewModel, ... | ||||
|         dependencies: ["settingsViewModel", "filesViewModel", "loginStateViewModel", "accessViewModel", "timelapseViewModel"], | ||||
|         elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse", "#sidebar_plugin_bambu_printer"] | ||||
|         // Elements to bind to, e.g. #settings_plugin_bambu_printer, #tab_plugin_bambu_printer, ... | ||||
|         elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse"] | ||||
|     }); | ||||
| }); | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,490 +0,0 @@ | ||||
| // knockout-sortable 1.2.0 | (c) 2019 Ryan Niemeyer |  http://www.opensource.org/licenses/mit-license | ||||
| ;(function(factory) { | ||||
|     if (typeof define === "function" && define.amd) { | ||||
|         // AMD anonymous module | ||||
|         define(["knockout", "jquery", "jquery-ui/ui/widgets/sortable", "jquery-ui/ui/widgets/draggable", "jquery-ui/ui/widgets/droppable"], factory); | ||||
|     } else if (typeof require === "function" && typeof exports === "object" && typeof module === "object") { | ||||
|         // CommonJS module | ||||
|         var ko = require("knockout"), | ||||
|             jQuery = require("jquery"); | ||||
|         require("jquery-ui/ui/widgets/sortable"); | ||||
|         require("jquery-ui/ui/widgets/draggable"); | ||||
|         require("jquery-ui/ui/widgets/droppable"); | ||||
|         factory(ko, jQuery); | ||||
|     } else { | ||||
|         // No module loader (plain <script> tag) - put directly in global namespace | ||||
|         factory(window.ko, window.jQuery); | ||||
|     } | ||||
| })(function(ko, $) { | ||||
|     var ITEMKEY = "ko_sortItem", | ||||
|         INDEXKEY = "ko_sourceIndex", | ||||
|         LISTKEY = "ko_sortList", | ||||
|         PARENTKEY = "ko_parentList", | ||||
|         DRAGKEY = "ko_dragItem", | ||||
|         unwrap = ko.utils.unwrapObservable, | ||||
|         dataGet = ko.utils.domData.get, | ||||
|         dataSet = ko.utils.domData.set, | ||||
|         version = $.ui && $.ui.version, | ||||
|         //1.8.24 included a fix for how events were triggered in nested sortables. indexOf checks will fail if version starts with that value (0 vs. -1) | ||||
|         hasNestedSortableFix = version && version.indexOf("1.6.") && version.indexOf("1.7.") && (version.indexOf("1.8.") || version === "1.8.24"); | ||||
|  | ||||
|     //internal afterRender that adds meta-data to children | ||||
|     var addMetaDataAfterRender = function(elements, data) { | ||||
|         ko.utils.arrayForEach(elements, function(element) { | ||||
|             if (element.nodeType === 1) { | ||||
|                 dataSet(element, ITEMKEY, data); | ||||
|                 dataSet(element, PARENTKEY, dataGet(element.parentNode, LISTKEY)); | ||||
|             } | ||||
|         }); | ||||
|     }; | ||||
|  | ||||
|     //prepare the proper options for the template binding | ||||
|     var prepareTemplateOptions = function(valueAccessor, dataName) { | ||||
|         var result = {}, | ||||
|             options = {}, | ||||
|             actualAfterRender; | ||||
|  | ||||
|         //build our options to pass to the template engine | ||||
|         if (ko.utils.peekObservable(valueAccessor()).data) { | ||||
|             options = unwrap(valueAccessor() || {}); | ||||
|             result[dataName] = options.data; | ||||
|             if (options.hasOwnProperty("template")) { | ||||
|                 result.name = options.template; | ||||
|             } | ||||
|         } else { | ||||
|             result[dataName] = valueAccessor(); | ||||
|         } | ||||
|  | ||||
|         ko.utils.arrayForEach(["afterAdd", "afterRender", "as", "beforeRemove", "includeDestroyed", "templateEngine", "templateOptions", "nodes"], function (option) { | ||||
|             if (options.hasOwnProperty(option)) { | ||||
|                 result[option] = options[option]; | ||||
|             } else if (ko.bindingHandlers.sortable.hasOwnProperty(option)) { | ||||
|                 result[option] = ko.bindingHandlers.sortable[option]; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         //use an afterRender function to add meta-data | ||||
|         if (dataName === "foreach") { | ||||
|             if (result.afterRender) { | ||||
|                 //wrap the existing function, if it was passed | ||||
|                 actualAfterRender = result.afterRender; | ||||
|                 result.afterRender = function(element, data) { | ||||
|                     addMetaDataAfterRender.call(data, element, data); | ||||
|                     actualAfterRender.call(data, element, data); | ||||
|                 }; | ||||
|             } else { | ||||
|                 result.afterRender = addMetaDataAfterRender; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         //return options to pass to the template binding | ||||
|         return result; | ||||
|     }; | ||||
|  | ||||
|     var updateIndexFromDestroyedItems = function(index, items) { | ||||
|         var unwrapped = unwrap(items); | ||||
|  | ||||
|         if (unwrapped) { | ||||
|             for (var i = 0; i <= index; i++) { | ||||
|                 //add one for every destroyed item we find before the targetIndex in the target array | ||||
|                 if (unwrapped[i] && unwrap(unwrapped[i]._destroy)) { | ||||
|                     index++; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return index; | ||||
|     }; | ||||
|  | ||||
|     //remove problematic leading/trailing whitespace from templates | ||||
|     var stripTemplateWhitespace = function(element, name) { | ||||
|         var templateSource, | ||||
|             templateElement; | ||||
|  | ||||
|         //process named templates | ||||
|         if (name) { | ||||
|             templateElement = document.getElementById(name); | ||||
|             if (templateElement) { | ||||
|                 templateSource = new ko.templateSources.domElement(templateElement); | ||||
|                 templateSource.text($.trim(templateSource.text())); | ||||
|             } | ||||
|         } | ||||
|         else { | ||||
|             //remove leading/trailing non-elements from anonymous templates | ||||
|             $(element).contents().each(function() { | ||||
|                 if (this && this.nodeType !== 1) { | ||||
|                     element.removeChild(this); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     //connect items with observableArrays | ||||
|     ko.bindingHandlers.sortable = { | ||||
|         init: function(element, valueAccessor, allBindingsAccessor, data, context) { | ||||
|             var $element = $(element), | ||||
|                 value = unwrap(valueAccessor()) || {}, | ||||
|                 templateOptions = prepareTemplateOptions(valueAccessor, "foreach"), | ||||
|                 sortable = {}, | ||||
|                 startActual, updateActual; | ||||
|  | ||||
|             stripTemplateWhitespace(element, templateOptions.name); | ||||
|  | ||||
|             //build a new object that has the global options with overrides from the binding | ||||
|             $.extend(true, sortable, ko.bindingHandlers.sortable); | ||||
|             if (value.options && sortable.options) { | ||||
|                 ko.utils.extend(sortable.options, value.options); | ||||
|                 delete value.options; | ||||
|             } | ||||
|             ko.utils.extend(sortable, value); | ||||
|  | ||||
|             //if allowDrop is an observable or a function, then execute it in a computed observable | ||||
|             if (sortable.connectClass && (ko.isObservable(sortable.allowDrop) || typeof sortable.allowDrop == "function")) { | ||||
|                 ko.computed({ | ||||
|                     read: function() { | ||||
|                         var value = unwrap(sortable.allowDrop), | ||||
|                             shouldAdd = typeof value == "function" ? value.call(this, templateOptions.foreach) : value; | ||||
|                         ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, shouldAdd); | ||||
|                     }, | ||||
|                     disposeWhenNodeIsRemoved: element | ||||
|                 }, this); | ||||
|             } else { | ||||
|                 ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, sortable.allowDrop); | ||||
|             } | ||||
|  | ||||
|             //wrap the template binding | ||||
|             ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context); | ||||
|  | ||||
|             //keep a reference to start/update functions that might have been passed in | ||||
|             startActual = sortable.options.start; | ||||
|             updateActual = sortable.options.update; | ||||
|  | ||||
|             //ensure draggable table row cells maintain their width while dragging (unless a helper is provided) | ||||
|             if ( !sortable.options.helper ) { | ||||
|                 sortable.options.helper = function(e, ui) { | ||||
|                     if (ui.is("tr")) { | ||||
|                         ui.children().each(function() { | ||||
|                             $(this).width($(this).width()); | ||||
|                         }); | ||||
|                     } | ||||
|                     return ui; | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             //initialize sortable binding after template binding has rendered in update function | ||||
|             var createTimeout = setTimeout(function() { | ||||
|                 var dragItem; | ||||
|                 var originalReceive = sortable.options.receive; | ||||
|  | ||||
|                 $element.sortable(ko.utils.extend(sortable.options, { | ||||
|                     start: function(event, ui) { | ||||
|                         //track original index | ||||
|                         var el = ui.item[0]; | ||||
|                         dataSet(el, INDEXKEY, ko.utils.arrayIndexOf(ui.item.parent().children(), el)); | ||||
|  | ||||
|                         //make sure that fields have a chance to update model | ||||
|                         ui.item.find("input:focus").change(); | ||||
|                         if (startActual) { | ||||
|                             startActual.apply(this, arguments); | ||||
|                         } | ||||
|                     }, | ||||
|                     receive: function(event, ui) { | ||||
|                         //optionally apply an existing receive handler | ||||
|                         if (typeof originalReceive === "function") { | ||||
|                             originalReceive.call(this, event, ui); | ||||
|                         } | ||||
|  | ||||
|                         dragItem = dataGet(ui.item[0], DRAGKEY); | ||||
|                         if (dragItem) { | ||||
|                             //copy the model item, if a clone option is provided | ||||
|                             if (dragItem.clone) { | ||||
|                                 dragItem = dragItem.clone(); | ||||
|                             } | ||||
|  | ||||
|                             //configure a handler to potentially manipulate item before drop | ||||
|                             if (sortable.dragged) { | ||||
|                                 dragItem = sortable.dragged.call(this, dragItem, event, ui) || dragItem; | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     update: function(event, ui) { | ||||
|                         var sourceParent, targetParent, sourceIndex, targetIndex, arg, | ||||
|                             el = ui.item[0], | ||||
|                             parentEl = ui.item.parent()[0], | ||||
|                             item = dataGet(el, ITEMKEY) || dragItem; | ||||
|  | ||||
|                         if (!item) { | ||||
|                             $(el).remove(); | ||||
|                         } | ||||
|                         dragItem = null; | ||||
|  | ||||
|                         //make sure that moves only run once, as update fires on multiple containers | ||||
|                         if (item && (this === parentEl) || (!hasNestedSortableFix && $.contains(this, parentEl))) { | ||||
|                             //identify parents | ||||
|                             sourceParent = dataGet(el, PARENTKEY); | ||||
|                             sourceIndex = dataGet(el, INDEXKEY); | ||||
|                             targetParent = dataGet(el.parentNode, LISTKEY); | ||||
|                             targetIndex = ko.utils.arrayIndexOf(ui.item.parent().children(), el); | ||||
|  | ||||
|                             //take destroyed items into consideration | ||||
|                             if (!templateOptions.includeDestroyed) { | ||||
|                                 sourceIndex = updateIndexFromDestroyedItems(sourceIndex, sourceParent); | ||||
|                                 targetIndex = updateIndexFromDestroyedItems(targetIndex, targetParent); | ||||
|                             } | ||||
|  | ||||
|                             //build up args for the callbacks | ||||
|                             if (sortable.beforeMove || sortable.afterMove) { | ||||
|                                 arg = { | ||||
|                                     item: item, | ||||
|                                     sourceParent: sourceParent, | ||||
|                                     sourceParentNode: sourceParent && ui.sender || el.parentNode, | ||||
|                                     sourceIndex: sourceIndex, | ||||
|                                     targetParent: targetParent, | ||||
|                                     targetIndex: targetIndex, | ||||
|                                     cancelDrop: false | ||||
|                                 }; | ||||
|  | ||||
|                                 //execute the configured callback prior to actually moving items | ||||
|                                 if (sortable.beforeMove) { | ||||
|                                     sortable.beforeMove.call(this, arg, event, ui); | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             //call cancel on the correct list, so KO can take care of DOM manipulation | ||||
|                             if (sourceParent) { | ||||
|                                 $(sourceParent === targetParent ? this : ui.sender || this).sortable("cancel"); | ||||
|                             } | ||||
|                             //for a draggable item just remove the element | ||||
|                             else { | ||||
|                                 $(el).remove(); | ||||
|                             } | ||||
|  | ||||
|                             //if beforeMove told us to cancel, then we are done | ||||
|                             if (arg && arg.cancelDrop) { | ||||
|                                 return; | ||||
|                             } | ||||
|  | ||||
|                             //if the strategy option is unset or false, employ the order strategy involving removal and insertion of items | ||||
|                             if (!sortable.hasOwnProperty("strategyMove") || sortable.strategyMove === false) { | ||||
|                                 //do the actual move | ||||
|                                 if (targetIndex >= 0) { | ||||
|                                     if (sourceParent) { | ||||
|                                         sourceParent.splice(sourceIndex, 1); | ||||
|  | ||||
|                                         //if using deferred updates plugin, force updates | ||||
|                                         if (ko.processAllDeferredBindingUpdates) { | ||||
|                                             ko.processAllDeferredBindingUpdates(); | ||||
|                                         } | ||||
|  | ||||
|                                         //if using deferred updates on knockout 3.4, force updates | ||||
|                                         if (ko.options && ko.options.deferUpdates) { | ||||
|                                             ko.tasks.runEarly(); | ||||
|                                         } | ||||
|                                     } | ||||
|  | ||||
|                                     targetParent.splice(targetIndex, 0, item); | ||||
|                                 } | ||||
|  | ||||
|                                 //rendering is handled by manipulating the observableArray; ignore dropped element | ||||
|                                 dataSet(el, ITEMKEY, null); | ||||
|                             } | ||||
|                             else { //employ the strategy of moving items | ||||
|                                 if (targetIndex >= 0) { | ||||
|                                     if (sourceParent) { | ||||
|                                         if (sourceParent !== targetParent) { | ||||
|                                             // moving from one list to another | ||||
|  | ||||
|                                             sourceParent.splice(sourceIndex, 1); | ||||
|                                             targetParent.splice(targetIndex, 0, item); | ||||
|  | ||||
|                                             //rendering is handled by manipulating the observableArray; ignore dropped element | ||||
|                                             dataSet(el, ITEMKEY, null); | ||||
|                                             ui.item.remove(); | ||||
|                                         } | ||||
|                                         else { | ||||
|                                             // moving within same list | ||||
|                                             var underlyingList = unwrap(sourceParent); | ||||
|  | ||||
|                                             // notify 'beforeChange' subscribers | ||||
|                                             if (sourceParent.valueWillMutate) { | ||||
|                                                 sourceParent.valueWillMutate(); | ||||
|                                             } | ||||
|  | ||||
|                                             // move from source index ... | ||||
|                                             underlyingList.splice(sourceIndex, 1); | ||||
|                                             // ... to target index | ||||
|                                             underlyingList.splice(targetIndex, 0, item); | ||||
|  | ||||
|                                             // notify subscribers | ||||
|                                             if (sourceParent.valueHasMutated) { | ||||
|                                                 sourceParent.valueHasMutated(); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                     else { | ||||
|                                         // drop new element from outside | ||||
|                                         targetParent.splice(targetIndex, 0, item); | ||||
|  | ||||
|                                         //rendering is handled by manipulating the observableArray; ignore dropped element | ||||
|                                         dataSet(el, ITEMKEY, null); | ||||
|                                         ui.item.remove(); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             //if using deferred updates plugin, force updates | ||||
|                             if (ko.processAllDeferredBindingUpdates) { | ||||
|                                 ko.processAllDeferredBindingUpdates(); | ||||
|                             } | ||||
|  | ||||
|                             //allow binding to accept a function to execute after moving the item | ||||
|                             if (sortable.afterMove) { | ||||
|                                 sortable.afterMove.call(this, arg, event, ui); | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         if (updateActual) { | ||||
|                             updateActual.apply(this, arguments); | ||||
|                         } | ||||
|                     }, | ||||
|                     connectWith: sortable.connectClass ? "." + sortable.connectClass : false | ||||
|                 })); | ||||
|  | ||||
|                 //handle enabling/disabling sorting | ||||
|                 if (sortable.isEnabled !== undefined) { | ||||
|                     ko.computed({ | ||||
|                         read: function() { | ||||
|                             $element.sortable(unwrap(sortable.isEnabled) ? "enable" : "disable"); | ||||
|                         }, | ||||
|                         disposeWhenNodeIsRemoved: element | ||||
|                     }); | ||||
|                 } | ||||
|             }, 0); | ||||
|  | ||||
|             //handle disposal | ||||
|             ko.utils.domNodeDisposal.addDisposeCallback(element, function() { | ||||
|                 //only call destroy if sortable has been created | ||||
|                 if ($element.data("ui-sortable") || $element.data("sortable")) { | ||||
|                     $element.sortable("destroy"); | ||||
|                 } | ||||
|  | ||||
|                 ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, false); | ||||
|  | ||||
|                 //do not create the sortable if the element has been removed from DOM | ||||
|                 clearTimeout(createTimeout); | ||||
|             }); | ||||
|  | ||||
|             return { 'controlsDescendantBindings': true }; | ||||
|         }, | ||||
|         update: function(element, valueAccessor, allBindingsAccessor, data, context) { | ||||
|             var templateOptions = prepareTemplateOptions(valueAccessor, "foreach"); | ||||
|  | ||||
|             //attach meta-data | ||||
|             dataSet(element, LISTKEY, templateOptions.foreach); | ||||
|  | ||||
|             //call template binding's update with correct options | ||||
|             ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context); | ||||
|         }, | ||||
|         connectClass: 'ko_container', | ||||
|         allowDrop: true, | ||||
|         afterMove: null, | ||||
|         beforeMove: null, | ||||
|         options: {} | ||||
|     }; | ||||
|  | ||||
|     //create a draggable that is appropriate for dropping into a sortable | ||||
|     ko.bindingHandlers.draggable = { | ||||
|         init: function(element, valueAccessor, allBindingsAccessor, data, context) { | ||||
|             var value = unwrap(valueAccessor()) || {}, | ||||
|                 options = value.options || {}, | ||||
|                 draggableOptions = ko.utils.extend({}, ko.bindingHandlers.draggable.options), | ||||
|                 templateOptions = prepareTemplateOptions(valueAccessor, "data"), | ||||
|                 connectClass = value.connectClass || ko.bindingHandlers.draggable.connectClass, | ||||
|                 isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.draggable.isEnabled; | ||||
|  | ||||
|             value = "data" in value ? value.data : value; | ||||
|  | ||||
|             //set meta-data | ||||
|             dataSet(element, DRAGKEY, value); | ||||
|  | ||||
|             //override global options with override options passed in | ||||
|             ko.utils.extend(draggableOptions, options); | ||||
|  | ||||
|             //setup connection to a sortable | ||||
|             draggableOptions.connectToSortable = connectClass ? "." + connectClass : false; | ||||
|  | ||||
|             //initialize draggable | ||||
|             $(element).draggable(draggableOptions); | ||||
|  | ||||
|             //handle enabling/disabling sorting | ||||
|             if (isEnabled !== undefined) { | ||||
|                 ko.computed({ | ||||
|                     read: function() { | ||||
|                         $(element).draggable(unwrap(isEnabled) ? "enable" : "disable"); | ||||
|                     }, | ||||
|                     disposeWhenNodeIsRemoved: element | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             //handle disposal | ||||
|             ko.utils.domNodeDisposal.addDisposeCallback(element, function() { | ||||
|                 $(element).draggable("destroy"); | ||||
|             }); | ||||
|  | ||||
|             return ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context); | ||||
|         }, | ||||
|         update: function(element, valueAccessor, allBindingsAccessor, data, context) { | ||||
|             var templateOptions = prepareTemplateOptions(valueAccessor, "data"); | ||||
|  | ||||
|             return ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context); | ||||
|         }, | ||||
|         connectClass: ko.bindingHandlers.sortable.connectClass, | ||||
|         options: { | ||||
|             helper: "clone" | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Simple Droppable Implementation | ||||
|     // binding that updates (function or observable) | ||||
|     ko.bindingHandlers.droppable = { | ||||
|         init: function(element, valueAccessor, allBindingsAccessor, data, context) { | ||||
|             var value = unwrap(valueAccessor()) || {}, | ||||
|                 options = value.options || {}, | ||||
|                 droppableOptions = ko.utils.extend({}, ko.bindingHandlers.droppable.options), | ||||
|                 isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.droppable.isEnabled; | ||||
|  | ||||
|             //override global options with override options passed in | ||||
|             ko.utils.extend(droppableOptions, options); | ||||
|  | ||||
|             //get reference to drop method | ||||
|             value = "data" in value ? value.data : valueAccessor(); | ||||
|  | ||||
|             //set drop method | ||||
|             droppableOptions.drop = function(event, ui) { | ||||
|                 var droppedItem = dataGet(ui.draggable[0], DRAGKEY) || dataGet(ui.draggable[0], ITEMKEY); | ||||
|                 value(droppedItem); | ||||
|             }; | ||||
|  | ||||
|             //initialize droppable | ||||
|             $(element).droppable(droppableOptions); | ||||
|  | ||||
|             //handle enabling/disabling droppable | ||||
|             if (isEnabled !== undefined) { | ||||
|                 ko.computed({ | ||||
|                     read: function() { | ||||
|                         $(element).droppable(unwrap(isEnabled) ? "enable": "disable"); | ||||
|                     }, | ||||
|                     disposeWhenNodeIsRemoved: element | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             //handle disposal | ||||
|             ko.utils.domNodeDisposal.addDisposeCallback(element, function() { | ||||
|                 $(element).droppable("destroy"); | ||||
|             }); | ||||
|         }, | ||||
|         options: { | ||||
|             accept: "*" | ||||
|         } | ||||
|     }; | ||||
| }); | ||||
| @@ -1,31 +0,0 @@ | ||||
| <div id="bambu_printer_print_options" class="modal hide fade"> | ||||
| 	<div class="modal-header"> | ||||
| 		<h3>{{ _('Bambu Print Options') }}</h3> | ||||
| 	</div> | ||||
|     <div class="modal-body"> | ||||
|         <div class="row-fluid"> | ||||
|             <div class="span6"> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.timelapse"> {{ _('Enable timelapse') }}</label> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.bed_leveling"> {{ _('Enable bed leveling') }}</label> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.flow_cali"> {{ _('Enable flow calibration') }}</label> | ||||
|             </div> | ||||
|             <div class="span6"> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.vibration_cali"> {{ _('Enable vibration calibration') }}</label> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.layer_inspect"> {{ _('Enable first layer inspection') }}</label> | ||||
|                 <label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.use_ams"> {{ _('Use AMS') }}</label> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams"> | ||||
|             {{ _('Filament Assighnment') }}: {{ _('Click') }} <a href="#">{{ _('here') }}</a> {{ _('for usage details.') }} | ||||
|         </div> | ||||
|         <div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams, sortable: {data: ams_mapping, options: {cancel: '.unsortable'}}"> | ||||
|             <div class="btn" data-bind="attr: {title: name}, event: {dblclick: $root.toggle_spool_active}, css: {disabled: (index()<0)}"> | ||||
|                 <i class="fa fa-2x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i> | ||||
|             </div> | ||||
|         </div> | ||||
| 	</div> | ||||
| 	<div class="modal-footer"> | ||||
| 		<button class="btn btn-danger" data-bind="click: cancel_print_options">{{ _('Cancel') }}</button> | ||||
|         <button class="btn btn-primary" data-bind="click: accept_print_options">{{ _('Print') }}</button> | ||||
| 	</div> | ||||
| </div> | ||||
| @@ -1,12 +0,0 @@ | ||||
| <div class="row-fluid" data-bind="foreach: {data: settingsViewModel.settings.plugins.bambu_printer.ams_data, as: 'ams'}"> | ||||
|     <!-- ko if: $data --> | ||||
|     <div class="well" data-bind="foreach: tray"> | ||||
|         <div class="span3 text-center" data-bind="attr: {title: name}"> | ||||
|             <div class="row-fluid" data-bind="css: {'active': ($root.settingsViewModel.settings.plugins.bambu_printer.ams_current_tray() == (($parentContext.$index() * 4) + $index()))}"> | ||||
|                 <i class="fa fa-3x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i><br> | ||||
|                 <div class="text-center" data-bind="text: type"></div> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|     <!-- /ko --> | ||||
| </div> | ||||
| @@ -14,3 +14,4 @@ OctoPrint~=1.10.2 | ||||
| setuptools~=70.0.0 | ||||
| pyserial~=3.5 | ||||
| Flask~=2.2.5 | ||||
| paho-mqtt~=2.1.0 | ||||
|   | ||||
							
								
								
									
										10
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								setup.py
									
									
									
									
									
								
							| @@ -14,26 +14,26 @@ 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.1.8rc1" | ||||
| 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" | ||||
|  | ||||
| # Any additional requirements besides OctoPrint should be listed here | ||||
| plugin_requires = ["paho-mqtt<2", "python-dateutil", "httpx[http2]>=0.27.0"] | ||||
| plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"] | ||||
|  | ||||
| ### -------------------------------------------------------------------------------------------------------------------- | ||||
| ### More advanced options that you usually shouldn't have to touch follow after this point | ||||
|   | ||||
| @@ -7,8 +7,8 @@ from typing import Any | ||||
| from unittest.mock import MagicMock, patch | ||||
|  | ||||
| from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView | ||||
| import octoprint_bambu_printer.printer.pybambu | ||||
| import octoprint_bambu_printer.printer.pybambu.commands | ||||
| 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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user