Compare commits
	
		
			27 Commits
		
	
	
		
			0.1.8rc4
			...
			feature/re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					16dc138e9f | ||
| 
						 | 
					f42d3167c5 | ||
| 
						 | 
					4ea98036e5 | ||
| 
						 | 
					0d16732561 | ||
| 
						 | 
					ef305ee6ce | ||
| 
						 | 
					1f7eed6b23 | ||
| 
						 | 
					55b78cea05 | ||
| 
						 | 
					f35f456eb2 | ||
| 
						 | 
					42ba306e4f | ||
| 
						 | 
					19cac21db6 | ||
| 
						 | 
					4faa240b06 | ||
| 
						 | 
					38a6f58306 | ||
| 
						 | 
					ed33fd8fb1 | ||
| 
						 | 
					53e1f88e1a | ||
| 
						 | 
					8178dea15a | ||
| 
						 | 
					73f77ed659 | ||
| 
						 | 
					a13a5a1e2a | ||
| 
						 | 
					06c9d68390 | ||
| 
						 | 
					07f601694d | ||
| 
						 | 
					98a1f59169 | ||
| 
						 | 
					ba2eadb064 | ||
| 
						 | 
					f5017b5631 | ||
| 
						 | 
					956a261a45 | ||
| 
						 | 
					155f3d2bd3 | ||
| 
						 | 
					75b0a11fef | ||
| 
						 | 
					4da769da49 | ||
| 
						 | 
					527ec9ef3c | 
							
								
								
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,3 +0,0 @@
 | 
			
		||||
github: [jneilliii]
 | 
			
		||||
patreon: jneilliii
 | 
			
		||||
custom: ['https://www.paypal.me/jneilliii']
 | 
			
		||||
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							@@ -1,26 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
name: Bug report
 | 
			
		||||
about: Please make sure to check other issues, including closed ones, prior to submitting a bug report. Debug logs are required and any bug report submitted without them will be ignored and closed. 
 | 
			
		||||
title: "[BUG]: "
 | 
			
		||||
labels: ''
 | 
			
		||||
assignees: ''
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Describe the Bug**
 | 
			
		||||
<!-- A clear and concise description of what the bug is. -->
 | 
			
		||||
 | 
			
		||||
**Expected Behavior**
 | 
			
		||||
<!-- A clear and concise description of what you expected to happen. -->
 | 
			
		||||
 | 
			
		||||
**Debug Logs**
 | 
			
		||||
<!-- If logs are not included in your bug report it will be closed. Enable debug logging for octoprint.plugins.bambu_printer in OctoPrint's logging section of settings and recreate the issue then attach octoprint.log and plugin_bambu_printer_serial.log to this bug report. -->
 | 
			
		||||
 | 
			
		||||
**Screenshots**
 | 
			
		||||
<!-- Please share any relevant screenshots related to the issue. -->
 | 
			
		||||
 | 
			
		||||
**Printer and Plugin Setting Details**
 | 
			
		||||
 | 
			
		||||
* Printer model?
 | 
			
		||||
* Is your printer connected to Bambu Cloud? 
 | 
			
		||||
* Is the  plugin configured for local access only?
 | 
			
		||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
								
							@@ -1,20 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
name: Feature request
 | 
			
		||||
about: Create a feature request for an improvement or change you'd like implemented.
 | 
			
		||||
title: "[FR]: "
 | 
			
		||||
labels: ''
 | 
			
		||||
assignees: ''
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
**Is your feature request related to a problem? Please describe.**
 | 
			
		||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
 | 
			
		||||
 | 
			
		||||
**Describe the solution you'd like**
 | 
			
		||||
<!-- A clear and concise description of what you want to happen. -->
 | 
			
		||||
 | 
			
		||||
**Describe alternatives you've considered**
 | 
			
		||||
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
 | 
			
		||||
 | 
			
		||||
**Additional context**
 | 
			
		||||
<!-- Add any other context or screenshots about the feature request here. -->
 | 
			
		||||
							
								
								
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,16 +0,0 @@
 | 
			
		||||
# Number of days of inactivity before an issue becomes stale
 | 
			
		||||
daysUntilStale: 14
 | 
			
		||||
# Number of days of inactivity before a stale issue is closed
 | 
			
		||||
daysUntilClose: 7
 | 
			
		||||
# Issues with these labels will never be considered stale
 | 
			
		||||
exemptLabels:
 | 
			
		||||
  - enhancement
 | 
			
		||||
  - bug
 | 
			
		||||
# Label to use when marking an issue as stale
 | 
			
		||||
staleLabel: stale
 | 
			
		||||
# Comment to post when marking an issue as stale. Set to `false` to disable
 | 
			
		||||
markComment: >
 | 
			
		||||
  This issue has been automatically marked as stale because it has not had
 | 
			
		||||
  activity in 14 days. It will be closed if no further activity occurs in 7 days.
 | 
			
		||||
# Comment to post when closing a stale issue. Set to `false` to disable
 | 
			
		||||
closeComment: false
 | 
			
		||||
							
								
								
									
										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 }}
 | 
			
		||||
							
								
								
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/stale.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,27 +0,0 @@
 | 
			
		||||
name: Mark Stale Issues
 | 
			
		||||
on:
 | 
			
		||||
  workflow_dispatch:
 | 
			
		||||
  schedule:
 | 
			
		||||
  - cron: "0 0 * * *"
 | 
			
		||||
permissions:
 | 
			
		||||
  actions: write
 | 
			
		||||
jobs:
 | 
			
		||||
  stale:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
    - uses: actions/stale@v9
 | 
			
		||||
      with:
 | 
			
		||||
        repo-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
        stale-issue-message: 'This issue has been automatically marked as stale because it has not had activity in 14 days. It will be closed if no further activity occurs in 7 days'
 | 
			
		||||
        days-before-stale: 14
 | 
			
		||||
        days-before-close: 7
 | 
			
		||||
        stale-issue-label: 'stale'
 | 
			
		||||
        days-before-issue-stale: 14
 | 
			
		||||
        days-before-pr-stale: -1
 | 
			
		||||
        days-before-issue-close: 7
 | 
			
		||||
        days-before-pr-close: -1
 | 
			
		||||
        exempt-issue-labels: 'bug,enhancement'
 | 
			
		||||
    - uses: actions/checkout@v4
 | 
			
		||||
    - uses: gautamkrishnar/keepalive-workflow@v2
 | 
			
		||||
      with:
 | 
			
		||||
        use_api: true
 | 
			
		||||
@@ -2,4 +2,3 @@ include README.md
 | 
			
		||||
recursive-include octoprint_bambu_printer/templates *
 | 
			
		||||
recursive-include octoprint_bambu_printer/translations *
 | 
			
		||||
recursive-include octoprint_bambu_printer/static *
 | 
			
		||||
include octoprint_bambu_printer/printer/pybambu/filaments.json
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,4 @@
 | 
			
		||||
from __future__ import absolute_import, annotations
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import threading
 | 
			
		||||
from time import perf_counter
 | 
			
		||||
@@ -24,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,
 | 
			
		||||
@@ -39,12 +37,7 @@ from .printer.bambu_virtual_printer import BambuVirtualPrinter
 | 
			
		||||
@contextmanager
 | 
			
		||||
def measure_elapsed():
 | 
			
		||||
    start = perf_counter()
 | 
			
		||||
 | 
			
		||||
    def _get_elapsed():
 | 
			
		||||
        return perf_counter() - start
 | 
			
		||||
 | 
			
		||||
    yield _get_elapsed
 | 
			
		||||
    print(f"Total elapsed: {_get_elapsed()}")
 | 
			
		||||
    yield lambda: perf_counter() - start
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BambuPrintPlugin(
 | 
			
		||||
@@ -69,9 +62,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 [
 | 
			
		||||
@@ -81,7 +72,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 {
 | 
			
		||||
@@ -101,9 +92,6 @@ class BambuPrintPlugin(
 | 
			
		||||
            "email": "",
 | 
			
		||||
            "auth_token": "",
 | 
			
		||||
            "always_use_default_options": False,
 | 
			
		||||
            "ams_data": [],
 | 
			
		||||
            "ams_mapping": [],
 | 
			
		||||
            "ams_current_tray": 255,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def is_api_adminonly(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -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,7 +11,7 @@ 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
 | 
			
		||||
 | 
			
		||||
@@ -43,7 +43,6 @@ class BambuPrinterTelemetry:
 | 
			
		||||
    lastTempAt: float = time.monotonic()
 | 
			
		||||
    firmwareName: str = "Bambu"
 | 
			
		||||
    extruderCount: int = 1
 | 
			
		||||
    ams_current_tray: int = 255
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# noinspection PyBroadException
 | 
			
		||||
@@ -65,7 +64,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)
 | 
			
		||||
@@ -73,8 +71,6 @@ class BambuVirtualPrinter:
 | 
			
		||||
        self._current_state = self._state_idle
 | 
			
		||||
 | 
			
		||||
        self._running = True
 | 
			
		||||
        self._print_status_reporter = None
 | 
			
		||||
        self._print_temp_reporter = None
 | 
			
		||||
        self._printer_thread = threading.Thread(
 | 
			
		||||
            target=self._printer_worker,
 | 
			
		||||
            name="octoprint.plugins.bambu_printer.printer_state",
 | 
			
		||||
@@ -170,22 +166,6 @@ class BambuVirtualPrinter:
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    def new_update(self, event_type):
 | 
			
		||||
        if event_type == "event_hms_errors":
 | 
			
		||||
            self._update_hms_errors()
 | 
			
		||||
@@ -196,13 +176,6 @@ class BambuVirtualPrinter:
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
@@ -210,21 +183,14 @@ class BambuVirtualPrinter:
 | 
			
		||||
        self._telemetry.bedTemp = temperatures.bed_temp
 | 
			
		||||
        self._telemetry.bedTargetTemp = temperatures.target_bed_temp
 | 
			
		||||
        self._telemetry.chamberTemp = temperatures.chamber_temp
 | 
			
		||||
        if device_data.push_all_data and "ams" in device_data.push_all_data:
 | 
			
		||||
            self._telemetry.ams_current_tray = device_data.push_all_data["ams"]["tray_now"] or 255
 | 
			
		||||
 | 
			
		||||
        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":
 | 
			
		||||
        elif print_job_state == "RUNNING":
 | 
			
		||||
            self.change_state(self._state_printing)
 | 
			
		||||
        elif print_job_state == "PAUSE":
 | 
			
		||||
            self.change_state(self._state_paused)
 | 
			
		||||
@@ -245,8 +211,6 @@ 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):
 | 
			
		||||
@@ -310,9 +274,9 @@ class BambuVirtualPrinter:
 | 
			
		||||
            self.lastN = 0
 | 
			
		||||
            self._running = False
 | 
			
		||||
 | 
			
		||||
            if self._print_status_reporter is not None:
 | 
			
		||||
                self._print_status_reporter.cancel()
 | 
			
		||||
                self._print_status_reporter = None
 | 
			
		||||
            if self._sdstatus_reporter is not None:
 | 
			
		||||
                self._sdstatus_reporter.cancel()
 | 
			
		||||
                self._sdstatus_reporter = None
 | 
			
		||||
 | 
			
		||||
            if self._settings.get_boolean(["simulateReset"]):
 | 
			
		||||
                for item in self._settings.get(["resetLines"]):
 | 
			
		||||
@@ -346,18 +310,9 @@ class BambuVirtualPrinter:
 | 
			
		||||
 | 
			
		||||
    def select_project_file(self, file_path: str) -> bool:
 | 
			
		||||
        self._log.debug(f"Select project file: {file_path}")
 | 
			
		||||
        file_info = self._project_files_view.get_file_by_stem(
 | 
			
		||||
            file_path, [".gcode", ".3mf"]
 | 
			
		||||
        )
 | 
			
		||||
        if (
 | 
			
		||||
            self._selected_project_file is not None
 | 
			
		||||
            and file_info is not None
 | 
			
		||||
            and self._selected_project_file.path == file_info.path
 | 
			
		||||
        ):
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        file_info = self._project_files_view.get_cached_file_data(file_path)
 | 
			
		||||
        if file_info is None:
 | 
			
		||||
            self._log.error(f"Cannot select non-existent file: {file_path}")
 | 
			
		||||
            self._log.error(f"Cannot select not existing file: {file_path}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        self._selected_project_file = file_info
 | 
			
		||||
@@ -367,13 +322,13 @@ class BambuVirtualPrinter:
 | 
			
		||||
    ##~~ command implementations
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register_no_data("M21")
 | 
			
		||||
    def _sd_status(self) -> bool:
 | 
			
		||||
    def _sd_status(self) -> None:
 | 
			
		||||
        self.sendIO("SD card ok")
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M23")
 | 
			
		||||
    def _select_sd_file(self, data: str) -> bool:
 | 
			
		||||
        filename = data.split(maxsplit=1)[1].strip()
 | 
			
		||||
        self._list_project_files()
 | 
			
		||||
        return self.select_project_file(filename)
 | 
			
		||||
 | 
			
		||||
    def _send_file_selected_message(self):
 | 
			
		||||
@@ -400,84 +355,38 @@ class BambuVirtualPrinter:
 | 
			
		||||
        matchS = re.search(r"S([0-9]+)", data)
 | 
			
		||||
        if matchS:
 | 
			
		||||
            interval = int(matchS.group(1))
 | 
			
		||||
            if self._sdstatus_reporter is not None:
 | 
			
		||||
                self._sdstatus_reporter.cancel()
 | 
			
		||||
 | 
			
		||||
            if interval > 0:
 | 
			
		||||
                self.start_continuous_status_report(interval)
 | 
			
		||||
                return False
 | 
			
		||||
                self._sdstatus_reporter = RepeatedTimer(
 | 
			
		||||
                    interval, self.report_print_job_status
 | 
			
		||||
                )
 | 
			
		||||
                self._sdstatus_reporter.start()
 | 
			
		||||
            else:
 | 
			
		||||
                self.stop_continuous_status_report()
 | 
			
		||||
                return False
 | 
			
		||||
                self._sdstatus_reporter = None
 | 
			
		||||
 | 
			
		||||
        self.report_print_job_status()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def start_continuous_status_report(self, interval: int):
 | 
			
		||||
        if self._print_status_reporter is not None:
 | 
			
		||||
            self._print_status_reporter.cancel()
 | 
			
		||||
 | 
			
		||||
        self._print_status_reporter = RepeatedTimer(
 | 
			
		||||
            interval, self.report_print_job_status
 | 
			
		||||
        )
 | 
			
		||||
        self._print_status_reporter.start()
 | 
			
		||||
 | 
			
		||||
    def stop_continuous_status_report(self):
 | 
			
		||||
        if self._print_status_reporter is not None:
 | 
			
		||||
            self._print_status_reporter.cancel()
 | 
			
		||||
            self._print_status_reporter = None
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M30")
 | 
			
		||||
    def _delete_project_file(self, data: str) -> bool:
 | 
			
		||||
        file_path = data.split(maxsplit=1)[1].strip()
 | 
			
		||||
        file_info = self.project_files.get_file_data(file_path)
 | 
			
		||||
        if file_info is not None:
 | 
			
		||||
            self.file_system.delete_file(file_info.path)
 | 
			
		||||
            self._update_project_file_list()
 | 
			
		||||
        else:
 | 
			
		||||
            self._log.error(f"File not found to delete {file_path}")
 | 
			
		||||
    def _delete_sd_file(self, data: str) -> bool:
 | 
			
		||||
        file_path = data.split(None, 1)[1].strip()
 | 
			
		||||
        self._list_project_files()
 | 
			
		||||
        self.file_system.delete_file(Path(file_path))
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M105")
 | 
			
		||||
    def _report_temperatures(self, data: str) -> bool:
 | 
			
		||||
        self._processTemperatureQuery()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M155")
 | 
			
		||||
    def _auto_report_temperatures(self, data: str) -> bool:
 | 
			
		||||
        matchS = re.search(r"S([0-9]+)", data)
 | 
			
		||||
        if matchS:
 | 
			
		||||
            interval = int(matchS.group(1))
 | 
			
		||||
            if interval > 0:
 | 
			
		||||
                self.start_continuous_temp_report(interval)
 | 
			
		||||
            else:
 | 
			
		||||
                self.stop_continuous_temp_report()
 | 
			
		||||
 | 
			
		||||
        self.report_print_job_status()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def start_continuous_temp_report(self, interval: int):
 | 
			
		||||
        if self._print_temp_reporter is not None:
 | 
			
		||||
            self._print_temp_reporter.cancel()
 | 
			
		||||
 | 
			
		||||
        self._print_temp_reporter = RepeatedTimer(
 | 
			
		||||
            interval, self._processTemperatureQuery
 | 
			
		||||
        )
 | 
			
		||||
        self._print_temp_reporter.start()
 | 
			
		||||
 | 
			
		||||
    def stop_continuous_temp_report(self):
 | 
			
		||||
        if self._print_temp_reporter is not None:
 | 
			
		||||
            self._print_temp_reporter.cancel()
 | 
			
		||||
            self._print_temp_reporter = None
 | 
			
		||||
        return self._processTemperatureQuery()
 | 
			
		||||
 | 
			
		||||
    # noinspection PyUnusedLocal
 | 
			
		||||
    @gcode_executor.register_no_data("M115")
 | 
			
		||||
    def _report_firmware_info(self) -> bool:
 | 
			
		||||
        # wait for connection to be established before sending back firmware info
 | 
			
		||||
        while self.bambu_client.connected is False:
 | 
			
		||||
            time.sleep(1)
 | 
			
		||||
        self.sendIO("Bambu Printer Integration")
 | 
			
		||||
        self.sendIO("Cap:AUTOREPORT_SD_STATUS:1")
 | 
			
		||||
        self.sendIO("Cap:AUTOREPORT_TEMP:1")
 | 
			
		||||
        self.sendIO("Cap:EXTENDED_M20:1")
 | 
			
		||||
        self.sendIO("Cap:LFN_WRITE:1")
 | 
			
		||||
        self.sendIO("Cap:LFN_WRITE:1")
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M117")
 | 
			
		||||
@@ -509,53 +418,21 @@ class BambuVirtualPrinter:
 | 
			
		||||
    def _set_feedrate_percent(self, data: str) -> bool:
 | 
			
		||||
        if self.bambu_client.connected:
 | 
			
		||||
            gcode_command = commands.SEND_GCODE_TEMPLATE
 | 
			
		||||
            percent = int(data.replace("M220 S", ""))
 | 
			
		||||
            percent = int(data[1:])
 | 
			
		||||
 | 
			
		||||
            def speed_fraction(speed_percent):
 | 
			
		||||
                return math.floor(10000 / speed_percent) / 100
 | 
			
		||||
            if percent is None or percent < 1 or percent > 166:
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
            def acceleration_magnitude(speed_percent):
 | 
			
		||||
                return math.exp((speed_fraction(speed_percent) - 1.0191) / -0.8139)
 | 
			
		||||
 | 
			
		||||
            def feed_rate(speed_percent):
 | 
			
		||||
                return 6.426e-5 * speed_percent ** 2 - 2.484e-3 * speed_percent + 0.654
 | 
			
		||||
 | 
			
		||||
            def linear_interpolate(x, x_points, y_points):
 | 
			
		||||
                if x <= x_points[0]: return y_points[0]
 | 
			
		||||
                if x >= x_points[-1]: return y_points[-1]
 | 
			
		||||
                for i in range(len(x_points) - 1):
 | 
			
		||||
                    if x_points[i] <= x < x_points[i + 1]:
 | 
			
		||||
                        t = (x - x_points[i]) / (x_points[i + 1] - x_points[i])
 | 
			
		||||
                        return y_points[i] * (1 - t) + y_points[i + 1] * t
 | 
			
		||||
 | 
			
		||||
            def scale_to_data_points(func, data_points):
 | 
			
		||||
                data_points.sort(key=lambda x: x[0])
 | 
			
		||||
                speeds, values = zip(*data_points)
 | 
			
		||||
                scaling_factors = [v / func(s) for s, v in zip(speeds, values)]
 | 
			
		||||
                return lambda x: func(x) * linear_interpolate(x, speeds, scaling_factors)
 | 
			
		||||
 | 
			
		||||
            def speed_adjust(speed_percentage):
 | 
			
		||||
                if not 30 <= speed_percentage <= 180:
 | 
			
		||||
                    speed_percentage = 100
 | 
			
		||||
 | 
			
		||||
                bambu_params = {
 | 
			
		||||
                    "speed": [50, 100, 124, 166],
 | 
			
		||||
                    "acceleration": [0.3, 1.0, 1.4, 1.6],
 | 
			
		||||
                    "feed_rate": [0.7, 1.0, 1.4, 2.0]
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                acc_mag_scaled = scale_to_data_points(acceleration_magnitude,
 | 
			
		||||
                                                      list(zip(bambu_params["speed"], bambu_params["acceleration"])))
 | 
			
		||||
                feed_rate_scaled = scale_to_data_points(feed_rate,
 | 
			
		||||
                                                        list(zip(bambu_params["speed"], bambu_params["feed_rate"])))
 | 
			
		||||
 | 
			
		||||
                speed_frac = speed_fraction(speed_percentage)
 | 
			
		||||
                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
 | 
			
		||||
 | 
			
		||||
            speed_command = speed_adjust(percent)
 | 
			
		||||
            speed_fraction = 100 / percent
 | 
			
		||||
            acceleration = math.exp((speed_fraction - 1.0191) / -0.814)
 | 
			
		||||
            feed_rate = (
 | 
			
		||||
                2.1645 * (acceleration**3)
 | 
			
		||||
                - 5.3247 * (acceleration**2)
 | 
			
		||||
                + 4.342 * acceleration
 | 
			
		||||
                - 0.181
 | 
			
		||||
            )
 | 
			
		||||
            speed_level = 1.539 * (acceleration**2) - 0.7032 * acceleration + 4.0834
 | 
			
		||||
            speed_command = f"M204.2 K${acceleration:.2f} \nM220 K${feed_rate:.2f} \nM73.2 R${speed_fraction:.2f} \nM1002 set_gcode_claim_speed_level ${speed_level:.0f}\n"
 | 
			
		||||
 | 
			
		||||
            gcode_command["print"]["param"] = speed_command
 | 
			
		||||
            if self.bambu_client.publish(gcode_command):
 | 
			
		||||
@@ -587,8 +464,8 @@ class BambuVirtualPrinter:
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register("M20")
 | 
			
		||||
    def _update_project_file_list(self, data: str = ""):
 | 
			
		||||
        self._project_files_view.update()  # internally sends list to serial io
 | 
			
		||||
    def _list_project_files(self, data: str = ""):
 | 
			
		||||
        self._project_files_view.update()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def _list_cached_project_files(self):
 | 
			
		||||
@@ -598,11 +475,10 @@ class BambuVirtualPrinter:
 | 
			
		||||
        ):
 | 
			
		||||
            self.sendIO(item)
 | 
			
		||||
        self.sendIO("End file list")
 | 
			
		||||
        self.sendOk()
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register_no_data("M24")
 | 
			
		||||
    def _start_resume_sd_print(self):
 | 
			
		||||
        self._current_state.start_new_print()
 | 
			
		||||
    def _start_print(self):
 | 
			
		||||
        self._current_state.start_resume_print()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    @gcode_executor.register_no_data("M25")
 | 
			
		||||
@@ -616,31 +492,14 @@ class BambuVirtualPrinter:
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def report_print_job_status(self):
 | 
			
		||||
        if self.current_print_job is not None:
 | 
			
		||||
            file_position = 1 if self.current_print_job.file_position == 0 else self.current_print_job.file_position
 | 
			
		||||
        print_job = self.current_print_job
 | 
			
		||||
        if print_job is not None:
 | 
			
		||||
            self.sendIO(
 | 
			
		||||
                f"SD printing byte {file_position}"
 | 
			
		||||
                f"/{self.current_print_job.file_info.size}"
 | 
			
		||||
                f"SD printing byte {print_job.file_position}/{print_job.file_info.size}"
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            self.sendIO("Not SD printing")
 | 
			
		||||
 | 
			
		||||
    def report_print_finished(self):
 | 
			
		||||
        if self.current_print_job is None:
 | 
			
		||||
            return
 | 
			
		||||
        self._log.debug(
 | 
			
		||||
            f"SD File Print finishing: {self.current_print_job.file_info.file_name}"
 | 
			
		||||
        )
 | 
			
		||||
        self.sendIO("Done printing file")
 | 
			
		||||
 | 
			
		||||
    def finalize_print_job(self):
 | 
			
		||||
        if self.current_print_job is not None:
 | 
			
		||||
            self.report_print_job_status()
 | 
			
		||||
            self.report_print_finished()
 | 
			
		||||
            self.current_print_job = None
 | 
			
		||||
            self.report_print_job_status()
 | 
			
		||||
        self.change_state(self._state_idle)
 | 
			
		||||
 | 
			
		||||
    def _create_temperature_message(self) -> str:
 | 
			
		||||
        template = "{heater}:{actual:.2f}/ {target:.2f}"
 | 
			
		||||
        temps = collections.OrderedDict()
 | 
			
		||||
@@ -685,7 +544,7 @@ class BambuVirtualPrinter:
 | 
			
		||||
        self._state_change_queue.join()
 | 
			
		||||
 | 
			
		||||
    def _printer_worker(self):
 | 
			
		||||
        # self._create_client_connection_async()
 | 
			
		||||
        self._create_client_connection_async()
 | 
			
		||||
        self.sendIO("Printer connection complete")
 | 
			
		||||
        while self._running:
 | 
			
		||||
            try:
 | 
			
		||||
 
 | 
			
		||||
@@ -15,9 +15,7 @@ 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
 | 
			
		||||
    folder_view: set[tuple[str, str | list[str] | None]] = field(default_factory=set)
 | 
			
		||||
    on_update: Callable[[], None] | None = None
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self):
 | 
			
		||||
@@ -27,7 +25,7 @@ class CachedFileView:
 | 
			
		||||
    def with_filter(
 | 
			
		||||
        self, folder: str, extensions: str | list[str] | None = None
 | 
			
		||||
    ) -> "CachedFileView":
 | 
			
		||||
        self.folder_view[(folder, extensions)] = None
 | 
			
		||||
        self.folder_view.add((folder, extensions))
 | 
			
		||||
        return self
 | 
			
		||||
 | 
			
		||||
    def list_all_views(self):
 | 
			
		||||
@@ -35,7 +33,7 @@ class CachedFileView:
 | 
			
		||||
        result: list[FileInfo] = []
 | 
			
		||||
 | 
			
		||||
        with self.file_system.get_ftps_client() as ftp:
 | 
			
		||||
            for filter in self.folder_view.keys():
 | 
			
		||||
            for filter in self.folder_view:
 | 
			
		||||
                result.extend(self.file_system.list_files(*filter, ftp, existing_files))
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
@@ -46,8 +44,8 @@ class CachedFileView:
 | 
			
		||||
            self.on_update()
 | 
			
		||||
 | 
			
		||||
    def _update_file_list_cache(self, files: list[FileInfo]):
 | 
			
		||||
        self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files}
 | 
			
		||||
        self._file_data_cache = {info.path.as_posix(): info for info in files}
 | 
			
		||||
        self._file_alias_cache = {info.dosname: info.file_name for info in files}
 | 
			
		||||
        self._file_data_cache = {info.file_name: info for info in files}
 | 
			
		||||
 | 
			
		||||
    def get_all_info(self):
 | 
			
		||||
        self.update()
 | 
			
		||||
@@ -56,39 +54,26 @@ class CachedFileView:
 | 
			
		||||
    def get_all_cached_info(self):
 | 
			
		||||
        return list(self._file_data_cache.values())
 | 
			
		||||
 | 
			
		||||
    def get_file_data(self, file_path: str | Path) -> FileInfo | None:
 | 
			
		||||
        file_data = self.get_file_data_cached(file_path)
 | 
			
		||||
        if file_data is None:
 | 
			
		||||
            self.update()
 | 
			
		||||
            file_data = self.get_file_data_cached(file_path)
 | 
			
		||||
        return file_data
 | 
			
		||||
 | 
			
		||||
    def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None:
 | 
			
		||||
        if isinstance(file_path, str):
 | 
			
		||||
            file_path = Path(file_path).as_posix().strip("/")
 | 
			
		||||
        else:
 | 
			
		||||
            file_path = file_path.as_posix().strip("/")
 | 
			
		||||
 | 
			
		||||
        if file_path not in self._file_data_cache:
 | 
			
		||||
            file_path = self._file_alias_cache.get(file_path, file_path)
 | 
			
		||||
        return self._file_data_cache.get(file_path, None)
 | 
			
		||||
 | 
			
		||||
    def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]):
 | 
			
		||||
    def get_file_by_suffix(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)
 | 
			
		||||
        file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes)
 | 
			
		||||
        if file_data is None:
 | 
			
		||||
            self.update()
 | 
			
		||||
            file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes)
 | 
			
		||||
            file_data = self._get_file_by_suffix_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_cached_file_data(self, file_name: str) -> FileInfo | None:
 | 
			
		||||
        file_name = Path(file_name).name
 | 
			
		||||
        file_name = self._file_alias_cache.get(file_name, file_name)
 | 
			
		||||
        return self._file_data_cache.get(file_name, None)
 | 
			
		||||
 | 
			
		||||
    def _get_file_by_suffix_cached(self, file_stem: str, allowed_suffixes: list[str]):
 | 
			
		||||
        for suffix in allowed_suffixes:
 | 
			
		||||
            file_data = self.get_cached_file_data(
 | 
			
		||||
                Path(file_stem).with_suffix(suffix).as_posix()
 | 
			
		||||
            )
 | 
			
		||||
            if file_data is not None:
 | 
			
		||||
                return file_data
 | 
			
		||||
        return None
 | 
			
		||||
 
 | 
			
		||||
@@ -117,7 +117,7 @@ class IoTFTPSConnection:
 | 
			
		||||
                        # But since we operate in prot p mode
 | 
			
		||||
                        # we can close the connection always.
 | 
			
		||||
                        # This is cursed but it works.
 | 
			
		||||
                        if "vsFTPd" in self.ftps_session.welcome:
 | 
			
		||||
                        if "vsFTPd" in self.welcome:
 | 
			
		||||
                            conn.unwrap()
 | 
			
		||||
                        else:
 | 
			
		||||
                            conn.shutdown(socket.SHUT_RDWR)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,8 @@ import logging.handlers
 | 
			
		||||
 | 
			
		||||
from octoprint.util import get_dos_filename
 | 
			
		||||
 | 
			
		||||
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
 | 
			
		||||
 | 
			
		||||
from .ftps_client import IoTFTPSClient, IoTFTPSConnection
 | 
			
		||||
from .file_info import FileInfo
 | 
			
		||||
 | 
			
		||||
@@ -21,7 +23,7 @@ class RemoteSDCardFileList:
 | 
			
		||||
    def delete_file(self, file_path: Path) -> None:
 | 
			
		||||
        try:
 | 
			
		||||
            with self.get_ftps_client() as ftp:
 | 
			
		||||
                if ftp.delete_file(file_path.as_posix()):
 | 
			
		||||
                if ftp.delete_file(str(file_path)):
 | 
			
		||||
                    self._logger.debug(f"{file_path} deleted")
 | 
			
		||||
                else:
 | 
			
		||||
                    raise RuntimeError(f"Deleting file {file_path} failed")
 | 
			
		||||
 
 | 
			
		||||
@@ -10,9 +10,6 @@ from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import
 | 
			
		||||
class PrintJob:
 | 
			
		||||
    file_info: FileInfo
 | 
			
		||||
    progress: int
 | 
			
		||||
    remaining_time: int
 | 
			
		||||
    current_layer: int
 | 
			
		||||
    total_layers: int
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def file_position(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
@@ -1,33 +1,26 @@
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
 | 
			
		||||
from octoprint_bambu_printer.printer.print_job import PrintJob
 | 
			
		||||
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IdleState(APrinterState):
 | 
			
		||||
 | 
			
		||||
    def start_new_print(self):
 | 
			
		||||
    def start_resume_print(self):
 | 
			
		||||
        selected_file = self._printer.selected_file
 | 
			
		||||
        if selected_file is None:
 | 
			
		||||
            self._log.warn("Cannot start print job if file was not selected")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        print_command = self._get_print_command_for_file(selected_file)
 | 
			
		||||
        self._log.debug(f"Sending print command: {print_command}")
 | 
			
		||||
        print_command = self._get_print_command_for_file(selected_file.file_name)
 | 
			
		||||
        if self._printer.bambu_client.publish(print_command):
 | 
			
		||||
            self._log.info(f"Started print for {selected_file.file_name}")
 | 
			
		||||
            self._printer.change_state(self._printer._state_printing)
 | 
			
		||||
        else:
 | 
			
		||||
            self._log.warn(f"Failed to start print for {selected_file.file_name}")
 | 
			
		||||
            self._printer.change_state(self._printer._state_idle)
 | 
			
		||||
 | 
			
		||||
    def _get_print_command_for_file(self, selected_file: FileInfo):
 | 
			
		||||
 | 
			
		||||
        # URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf"
 | 
			
		||||
        filesystem_root = (
 | 
			
		||||
            "file:///mnt/sdcard/"
 | 
			
		||||
            if self._printer._settings.get(["device_type"]) in ["X1", "X1C"]
 | 
			
		||||
            else "file:///"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _get_print_command_for_file(self, selected_file):
 | 
			
		||||
        print_command = {
 | 
			
		||||
            "print": {
 | 
			
		||||
                "sequence_id": 0,
 | 
			
		||||
@@ -38,9 +31,14 @@ class IdleState(APrinterState):
 | 
			
		||||
                "project_id": "0",
 | 
			
		||||
                "subtask_id": "0",
 | 
			
		||||
                "task_id": "0",
 | 
			
		||||
                "subtask_name": selected_file.file_name,
 | 
			
		||||
                "url": f"{filesystem_root}{selected_file.path.as_posix()}",
 | 
			
		||||
                "bed_type": "auto",
 | 
			
		||||
                "subtask_name": f"{selected_file}",
 | 
			
		||||
                "file": f"{selected_file}",
 | 
			
		||||
                "url": (
 | 
			
		||||
                    f"file:///mnt/sdcard/{selected_file}"
 | 
			
		||||
                    if self._printer._settings.get_boolean(["device_type"])
 | 
			
		||||
                    in ["X1", "X1C"]
 | 
			
		||||
                    else f"file:///sdcard/{selected_file}"
 | 
			
		||||
                ),
 | 
			
		||||
                "timelapse": self._printer._settings.get_boolean(["timelapse"]),
 | 
			
		||||
                "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]),
 | 
			
		||||
                "flow_cali": self._printer._settings.get_boolean(["flow_cali"]),
 | 
			
		||||
@@ -49,7 +47,6 @@ 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"]),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -19,33 +19,35 @@ class PausedState(APrinterState):
 | 
			
		||||
    def __init__(self, printer: BambuVirtualPrinter) -> None:
 | 
			
		||||
        super().__init__(printer)
 | 
			
		||||
        self._pausedLock = threading.Event()
 | 
			
		||||
        self._paused_repeated_report = None
 | 
			
		||||
 | 
			
		||||
    def init(self):
 | 
			
		||||
        if not self._pausedLock.is_set():
 | 
			
		||||
            self._pausedLock.set()
 | 
			
		||||
 | 
			
		||||
        self._printer.sendIO("// action:paused")
 | 
			
		||||
        self._printer.start_continuous_status_report(3)
 | 
			
		||||
        self._sendPaused()
 | 
			
		||||
 | 
			
		||||
    def finalize(self):
 | 
			
		||||
        if self._pausedLock.is_set():
 | 
			
		||||
            self._pausedLock.clear()
 | 
			
		||||
            if self._paused_repeated_report is not None:
 | 
			
		||||
                self._paused_repeated_report.join()
 | 
			
		||||
                self._paused_repeated_report = None
 | 
			
		||||
 | 
			
		||||
    def start_new_print(self):
 | 
			
		||||
    def _sendPaused(self):
 | 
			
		||||
        if self._printer.current_print_job is None:
 | 
			
		||||
            self._log.warn("job paused, but no print job available?")
 | 
			
		||||
            return
 | 
			
		||||
        paused_timer = RepeatedTimer(
 | 
			
		||||
            interval=3.0,
 | 
			
		||||
            function=self._printer.report_print_job_status,
 | 
			
		||||
            daemon=True,
 | 
			
		||||
            run_first=True,
 | 
			
		||||
            condition=self._pausedLock.is_set,
 | 
			
		||||
        )
 | 
			
		||||
        paused_timer.start()
 | 
			
		||||
 | 
			
		||||
    def start_resume_print(self):
 | 
			
		||||
        if self._printer.bambu_client.connected:
 | 
			
		||||
            if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.RESUME):
 | 
			
		||||
            if self._printer.bambu_client.publish(pybambu.commands.RESUME):
 | 
			
		||||
                self._log.info("print resumed")
 | 
			
		||||
                self._printer.change_state(self._printer._state_printing)
 | 
			
		||||
            else:
 | 
			
		||||
                self._log.info("print resume failed")
 | 
			
		||||
 | 
			
		||||
    def cancel_print(self):
 | 
			
		||||
        if self._printer.bambu_client.connected:
 | 
			
		||||
            if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
 | 
			
		||||
                self._log.info("print cancelled")
 | 
			
		||||
                self._printer.finalize_print_job()
 | 
			
		||||
            else:
 | 
			
		||||
                self._log.info("print cancel failed")
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -22,7 +22,6 @@ class PrintingState(APrinterState):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, printer: BambuVirtualPrinter) -> None:
 | 
			
		||||
        super().__init__(printer)
 | 
			
		||||
        self._current_print_job = None
 | 
			
		||||
        self._is_printing = False
 | 
			
		||||
        self._sd_printing_thread = None
 | 
			
		||||
 | 
			
		||||
@@ -37,7 +36,6 @@ class PrintingState(APrinterState):
 | 
			
		||||
            self._is_printing = False
 | 
			
		||||
            self._sd_printing_thread.join()
 | 
			
		||||
            self._sd_printing_thread = None
 | 
			
		||||
        self._printer.current_print_job = None
 | 
			
		||||
 | 
			
		||||
    def _start_worker_thread(self):
 | 
			
		||||
        if self._sd_printing_thread is None:
 | 
			
		||||
@@ -55,40 +53,52 @@ class PrintingState(APrinterState):
 | 
			
		||||
            self._printer.report_print_job_status()
 | 
			
		||||
            time.sleep(3)
 | 
			
		||||
 | 
			
		||||
        self.update_print_job_info()
 | 
			
		||||
        if (
 | 
			
		||||
            self._printer.current_print_job is not None
 | 
			
		||||
            and self._printer.current_print_job.progress >= 100
 | 
			
		||||
        ):
 | 
			
		||||
            self._printer.finalize_print_job()
 | 
			
		||||
        if self._printer.current_print_job is None:
 | 
			
		||||
 | 
			
		||||
            self._log.warn("Printing state was triggered with empty print job")
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if self._printer.current_print_job.progress >= 100:
 | 
			
		||||
            self._finish_print()
 | 
			
		||||
 | 
			
		||||
    def update_print_job_info(self):
 | 
			
		||||
        print_job_info = self._printer.bambu_client.get_device().print_job
 | 
			
		||||
        task_name: str = print_job_info.subtask_name
 | 
			
		||||
        project_file_info = self._printer.project_files.get_file_by_stem(
 | 
			
		||||
            task_name, [".gcode", ".3mf"]
 | 
			
		||||
        project_file_info = self._printer.project_files.get_file_by_suffix(
 | 
			
		||||
            task_name, [".3mf", ".gcode.3mf"]
 | 
			
		||||
        )
 | 
			
		||||
        if project_file_info is None:
 | 
			
		||||
            self._log.debug(f"No 3mf file found for {print_job_info}")
 | 
			
		||||
            self._current_print_job = None
 | 
			
		||||
            self._printer.change_state(self._printer._state_idle)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        progress = print_job_info.print_percentage
 | 
			
		||||
        self._printer.current_print_job = PrintJob(project_file_info, progress, print_job_info.remaining_time, print_job_info.current_layer, print_job_info.total_layers)
 | 
			
		||||
        self._printer.select_project_file(project_file_info.path.as_posix())
 | 
			
		||||
        self._printer.current_print_job = PrintJob(project_file_info, progress)
 | 
			
		||||
        self._printer.select_project_file(project_file_info.file_name)
 | 
			
		||||
 | 
			
		||||
    def pause_print(self):
 | 
			
		||||
        if self._printer.bambu_client.connected:
 | 
			
		||||
            if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.PAUSE):
 | 
			
		||||
            if self._printer.bambu_client.publish(pybambu.commands.PAUSE):
 | 
			
		||||
                self._log.info("print paused")
 | 
			
		||||
                self._printer.change_state(self._printer._state_paused)
 | 
			
		||||
            else:
 | 
			
		||||
                self._log.info("print pause failed")
 | 
			
		||||
 | 
			
		||||
    def cancel_print(self):
 | 
			
		||||
        if self._printer.bambu_client.connected:
 | 
			
		||||
            if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
 | 
			
		||||
            if self._printer.bambu_client.publish(pybambu.commands.STOP):
 | 
			
		||||
                self._log.info("print cancelled")
 | 
			
		||||
                self._printer.finalize_print_job()
 | 
			
		||||
                self._finish_print()
 | 
			
		||||
                self._printer.change_state(self._printer._state_idle)
 | 
			
		||||
            else:
 | 
			
		||||
                self._log.info("print cancel failed")
 | 
			
		||||
 | 
			
		||||
    def _finish_print(self):
 | 
			
		||||
        if self._printer.current_print_job is not None:
 | 
			
		||||
            self._log.debug(
 | 
			
		||||
                f"SD File Print finishing: {self._printer.current_print_job.file_info.file_name}"
 | 
			
		||||
            )
 | 
			
		||||
            self._printer.sendIO("Done printing file")
 | 
			
		||||
            self._printer.current_print_job = None
 | 
			
		||||
 | 
			
		||||
        self._printer.change_state(self._printer._state_idle)
 | 
			
		||||
 
 | 
			
		||||
@@ -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,29 +15,6 @@ $(function () {
 | 
			
		||||
        self.accessViewModel = parameters[3];
 | 
			
		||||
        self.timelapseViewModel = parameters[4];
 | 
			
		||||
 | 
			
		||||
        self.use_ams = true;
 | 
			
		||||
        self.ams_mapping = ko.observableArray([]);
 | 
			
		||||
 | 
			
		||||
        self.job_info = ko.observable();
 | 
			
		||||
 | 
			
		||||
        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", {
 | 
			
		||||
@@ -91,72 +68,81 @@ $(function () {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (data.files !== undefined) {
 | 
			
		||||
                console.log(data.files);
 | 
			
		||||
                self.listHelper.updateItems(data.files);
 | 
			
		||||
                self.listHelper.resetPage();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (data.job_info !== undefined) {
 | 
			
		||||
                self.job_info(data.job_info);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        self.onBeforeBinding = 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());
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        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();
 | 
			
		||||
            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();
 | 
			
		||||
                    }
 | 
			
		||||
                    // 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,15 +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>
 | 
			
		||||
<div class="row-fluid" data-bind="visible: job_info">
 | 
			
		||||
    <div class="span6">{{ _('Layer') }}:</div><div class="span6" data-bind="text: function(){return (job_info.current_layer() + ' of ' + job_info.total_layers);}"></div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -7,10 +7,3 @@
 | 
			
		||||
###
 | 
			
		||||
 | 
			
		||||
.
 | 
			
		||||
 | 
			
		||||
pytest~=7.4.4
 | 
			
		||||
pybambu~=1.0.1
 | 
			
		||||
OctoPrint~=1.10.2
 | 
			
		||||
setuptools~=70.0.0
 | 
			
		||||
pyserial~=3.5
 | 
			
		||||
Flask~=2.2.5
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								setup.py
									
									
									
									
									
								
							@@ -14,7 +14,7 @@ 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.8rc4"
 | 
			
		||||
plugin_version = "0.0.23"
 | 
			
		||||
 | 
			
		||||
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
 | 
			
		||||
# module
 | 
			
		||||
@@ -33,7 +33,7 @@ plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter"
 | 
			
		||||
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
 | 
			
		||||
@@ -43,7 +43,7 @@ plugin_requires = ["paho-mqtt<2", "python-dateutil", "httpx[http2]>=0.27.0"]
 | 
			
		||||
# already be installed automatically if they exist. Note that if you add something here you'll also need to update
 | 
			
		||||
# MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your
 | 
			
		||||
# files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598
 | 
			
		||||
plugin_additional_data = ["octoprint_bambu_printer/printer/pybambu/filaments.json"]
 | 
			
		||||
plugin_additional_data = []
 | 
			
		||||
 | 
			
		||||
# Any additional python packages you need to install with your plugin that are not contained in <plugin_package>.*
 | 
			
		||||
plugin_additional_packages = []
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,12 @@ from __future__ import annotations
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
import logging
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import sys
 | 
			
		||||
from typing import Any
 | 
			
		||||
from unittest.mock import MagicMock, patch
 | 
			
		||||
from unittest.mock import MagicMock
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
@@ -30,9 +29,7 @@ def output_test_folder(output_folder: Path):
 | 
			
		||||
 | 
			
		||||
@fixture
 | 
			
		||||
def log_test():
 | 
			
		||||
    log = logging.getLogger("gcode_unittest")
 | 
			
		||||
    log.setLevel(logging.DEBUG)
 | 
			
		||||
    return log
 | 
			
		||||
    return logging.getLogger("gcode_unittest")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DictGetter:
 | 
			
		||||
@@ -92,11 +89,7 @@ def project_files_info_ftp():
 | 
			
		||||
def cache_files_info_ftp():
 | 
			
		||||
    return {
 | 
			
		||||
        "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
 | 
			
		||||
        "cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
 | 
			
		||||
        "cache/long file path with spaces.gcode.3mf": (
 | 
			
		||||
            1200,
 | 
			
		||||
            _ftp_date_format(datetime(2024, 5, 7)),
 | 
			
		||||
        ),
 | 
			
		||||
        "cache/print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -194,11 +187,8 @@ def test_list_sd_card(printer: BambuVirtualPrinter):
 | 
			
		||||
    assert result[0] == b"Begin file list"
 | 
			
		||||
    assert result[1].endswith(b'"print.3mf"')
 | 
			
		||||
    assert result[2].endswith(b'"print2.3mf"')
 | 
			
		||||
    assert result[3].endswith(b'"print.3mf"')
 | 
			
		||||
    assert result[4].endswith(b'"print3.gcode.3mf"')
 | 
			
		||||
    assert result[-3] == b"End file list"
 | 
			
		||||
    assert result[-2] == b"ok"
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
    assert result[3] == b"End file list"
 | 
			
		||||
    assert result[4] == b"ok"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_list_ftp_paths_p1s(settings, ftps_session_mock):
 | 
			
		||||
@@ -249,67 +239,6 @@ def test_list_ftp_paths_x1(settings, ftps_session_mock):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_delete_sd_file_gcode(printer: BambuVirtualPrinter):
 | 
			
		||||
    with patch(
 | 
			
		||||
        "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
 | 
			
		||||
    ) as delete_function:
 | 
			
		||||
        printer.write(b"M30 print.3mf\n")
 | 
			
		||||
        printer.flush()
 | 
			
		||||
        result = printer.readlines()
 | 
			
		||||
        assert result[-1] == b"ok"
 | 
			
		||||
        delete_function.assert_called_with("print.3mf")
 | 
			
		||||
 | 
			
		||||
        printer.write(b"M30 cache/print.3mf\n")
 | 
			
		||||
        printer.flush()
 | 
			
		||||
        result = printer.readlines()
 | 
			
		||||
        assert result[-1] == b"ok"
 | 
			
		||||
        delete_function.assert_called_with("cache/print.3mf")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter):
 | 
			
		||||
    with patch(
 | 
			
		||||
        "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
 | 
			
		||||
    ) as delete_function:
 | 
			
		||||
        file_info = printer.project_files.get_file_data("cache/print.3mf")
 | 
			
		||||
        assert file_info is not None
 | 
			
		||||
 | 
			
		||||
        printer.write(b"M30 " + file_info.dosname.encode() + b"\n")
 | 
			
		||||
        printer.flush()
 | 
			
		||||
        assert printer.readlines()[-1] == b"ok"
 | 
			
		||||
        assert delete_function.call_count == 1
 | 
			
		||||
        delete_function.assert_called_with("cache/print.3mf")
 | 
			
		||||
 | 
			
		||||
        printer.write(b"M30 cache/print.3mf\n")
 | 
			
		||||
        printer.flush()
 | 
			
		||||
        assert printer.readlines()[-1] == b"ok"
 | 
			
		||||
        assert delete_function.call_count == 2
 | 
			
		||||
        delete_function.assert_called_with("cache/print.3mf")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_select_project_file_by_stem(printer: BambuVirtualPrinter):
 | 
			
		||||
    printer.write(b"M23 print3\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert printer.selected_file is not None
 | 
			
		||||
    assert printer.selected_file.path == Path("cache/print3.gcode.3mf")
 | 
			
		||||
    assert result[-2] == b"File selected"
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_select_project_long_name_file_with_multiple_extensions(
 | 
			
		||||
    printer: BambuVirtualPrinter,
 | 
			
		||||
):
 | 
			
		||||
    printer.write(b"M23 long file path with spaces.gcode.3mf\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert printer.selected_file is not None
 | 
			
		||||
    assert printer.selected_file.path == Path(
 | 
			
		||||
        "cache/long file path with spaces.gcode.3mf"
 | 
			
		||||
    )
 | 
			
		||||
    assert result[-2] == b"File selected"
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_cannot_start_print_without_file(printer: BambuVirtualPrinter):
 | 
			
		||||
    printer.write(b"M24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
@@ -349,13 +278,9 @@ def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_jo
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
 | 
			
		||||
    # emulate printer reporting it's status
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[0] == b"ok"
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -366,26 +291,18 @@ def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_
 | 
			
		||||
    printer.write(b"M23 print.3mf\n")
 | 
			
		||||
    printer.write(b"M24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    printer.readlines()
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M25\n")  # pausing the print
 | 
			
		||||
    bambu_client_mock.publish.return_value = True
 | 
			
		||||
    printer.write(b"M25\n")  # GCode for pausing the print
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
 | 
			
		||||
    print_job_mock.gcode_state = "PAUSE"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    assert result[0] == b"ok"
 | 
			
		||||
    assert isinstance(printer.current_state, PausedState)
 | 
			
		||||
    bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock):
 | 
			
		||||
    print_job_mock.subtask_name = "print.3mf"
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
@@ -421,45 +338,10 @@ def test_printer_info_check(printer: BambuVirtualPrinter):
 | 
			
		||||
    assert isinstance(printer.current_state, IdleState)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock):
 | 
			
		||||
    print_job_mock.subtask_name = "print.3mf"
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M20\nM23 print.3mf\nM24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    print_job_mock.print_percentage = 50
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    printer.readlines()
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M26 S0\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
    assert isinstance(printer.current_state, IdleState)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock):
 | 
			
		||||
    print_job_mock.subtask_name = "print.3mf"
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M20\nM23 print.3mf\nM24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
def test_abort_print(printer: BambuVirtualPrinter):
 | 
			
		||||
    printer.write(b"M26\n")  # GCode for aborting the print
 | 
			
		||||
    printer.flush()
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M25\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    print_job_mock.gcode_state = "PAUSE"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
 | 
			
		||||
    printer.readlines()
 | 
			
		||||
    assert isinstance(printer.current_state, PausedState)
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M26 S0\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[-1] == b"ok"
 | 
			
		||||
    assert isinstance(printer.current_state, IdleState)
 | 
			
		||||
@@ -487,9 +369,7 @@ def test_file_selection_does_not_affect_current_print(
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M23 print.3mf\nM24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    printer.readlines()
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
    assert printer.current_print_job is not None
 | 
			
		||||
    assert printer.current_print_job.file_info.file_name == "print.3mf"
 | 
			
		||||
@@ -509,9 +389,7 @@ def test_finished_print_job_reset_after_new_file_selected(
 | 
			
		||||
 | 
			
		||||
    printer.write(b"M23 print.3mf\nM24\n")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    printer.readlines()
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
    assert printer.current_print_job is not None
 | 
			
		||||
    assert printer.current_print_job.file_info.file_name == "print.3mf"
 | 
			
		||||
@@ -535,28 +413,3 @@ def test_finished_print_job_reset_after_new_file_selected(
 | 
			
		||||
    assert printer.current_print_job is None
 | 
			
		||||
    assert printer.selected_file is not None
 | 
			
		||||
    assert printer.selected_file.file_name == "print2.3mf"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock):
 | 
			
		||||
    print_job_mock.subtask_name = "print.3mf"
 | 
			
		||||
    print_job_mock.gcode_state = "RUNNING"
 | 
			
		||||
    print_job_mock.print_percentage = 99
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    assert isinstance(printer.current_state, PrintingState)
 | 
			
		||||
    assert printer.current_print_job is not None
 | 
			
		||||
    assert printer.current_print_job.file_info.file_name == "print.3mf"
 | 
			
		||||
    assert printer.current_print_job.progress == 99
 | 
			
		||||
 | 
			
		||||
    print_job_mock.print_percentage = 100
 | 
			
		||||
    print_job_mock.gcode_state = "FINISH"
 | 
			
		||||
    printer.new_update("event_printer_data_update")
 | 
			
		||||
    printer.flush()
 | 
			
		||||
    result = printer.readlines()
 | 
			
		||||
    assert result[-3].endswith(b"1000/1000")
 | 
			
		||||
    assert result[-2] == b"Done printing file"
 | 
			
		||||
    assert result[-1] == b"Not SD printing"
 | 
			
		||||
    assert isinstance(printer.current_state, IdleState)
 | 
			
		||||
    assert printer.current_print_job is None
 | 
			
		||||
    assert printer.selected_file is not None
 | 
			
		||||
    assert printer.selected_file.file_name == "print.3mf"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user