├── test ├── __init__.py ├── conftest.py ├── test_data_conversions.py └── test_gcode_execution.py ├── setup.cfg ├── octoprint_bambu_printer ├── printer │ ├── states │ │ ├── __init__.py │ │ ├── a_printer_state.py │ │ ├── paused_state.py │ │ ├── idle_state.py │ │ └── printing_state.py │ ├── file_system │ │ ├── __init__.py │ │ ├── file_info.py │ │ ├── bambu_timelapse_file_info.py │ │ ├── remote_sd_card_file_list.py │ │ ├── cached_file_view.py │ │ └── ftps_client.py │ ├── __init__.py │ ├── print_job.py │ ├── printer_serial_io.py │ ├── gcode_executor.py │ └── bambu_virtual_printer.py ├── __init__.py ├── templates │ ├── bambu_timelapse.jinja2 │ └── bambu_printer_settings.jinja2 ├── static │ └── js │ │ └── bambu_printer.js └── bambu_print_plugin.py ├── screenshot.png ├── .github ├── FUNDING.yml ├── stale.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── stale.yml ├── .gitignore ├── MANIFEST.in ├── babel.cfg ├── requirements.txt ├── .editorconfig ├── README.md ├── translations └── README.txt ├── setup.py └── LICENSE /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/states/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jneilliii/OctoPrint-BambuPrinter/HEAD/screenshot.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [jneilliii] 2 | patreon: jneilliii 3 | custom: ['https://www.paypal.me/jneilliii'] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .idea 4 | *.iml 5 | build 6 | dist 7 | *.egg* 8 | .DS_Store 9 | *.zip 10 | extras 11 | 12 | test/test_output -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Gina Häußge " 2 | __license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include octoprint_bambu_printer/templates * 3 | recursive-include octoprint_bambu_printer/translations * 4 | recursive-include octoprint_bambu_printer/static * 5 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: */**.py] 2 | 3 | [jinja2: */**.jinja2] 4 | silent=false 5 | extensions=jinja2.ext.do, octoprint.util.jinja.trycatch 6 | 7 | [javascript: */**.js] 8 | extract_messages = gettext, ngettext 9 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pytest import fixture 3 | 4 | 5 | @fixture 6 | def output_folder(): 7 | folder = Path(__file__).parent / "test_output" 8 | folder.mkdir(parents=True, exist_ok=True) 9 | return folder 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ### 2 | # This file is only here to make sure that something like 3 | # 4 | # pip install -e . 5 | # 6 | # works as expected. Requirements can be found in setup.py. 7 | ### 8 | 9 | . 10 | 11 | pytest~=7.4.4 12 | pybambu~=1.0.1 13 | OctoPrint~=1.10.2 14 | setuptools~=70.0.0 15 | pyserial~=3.5 16 | Flask~=2.2.5 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [**.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [**.js] 17 | indent_style = space 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OctoPrint-BambuPrinter 2 | 3 | This plugin is an attempt to connect BambuLab printers to OctoPrint. It's still a work in progress, and there may be bugs/quirks that you will have to work around while using the plugin and during development. 4 | 5 | ## System Requirements 6 | 7 | * Python 3.9 or higher (OctoPi 1.0.0) 8 | 9 | ## Setup 10 | 11 | Install manually using this URL: 12 | 13 | https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/master.zip 14 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/print_job.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( 5 | FileInfo, 6 | ) 7 | 8 | 9 | @dataclass 10 | class PrintJob: 11 | file_info: FileInfo 12 | progress: int 13 | 14 | @property 15 | def file_position(self): 16 | if self.file_info.size is None: 17 | return 0 18 | return int(self.file_info.size * self.progress / 100) 19 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - enhancement 8 | - bug 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | activity in 14 days. It will be closed if no further activity occurs in 7 days. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Create a feature request for an improvement or change you'd like implemented. 4 | title: "[FR]: " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Describe alternatives you've considered** 17 | 18 | 19 | **Additional context** 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark Stale Issues 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | # permissions: 7 | # actions: write 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | 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' 16 | days-before-stale: 14 17 | days-before-close: 7 18 | stale-issue-label: 'stale' 19 | days-before-issue-stale: 14 20 | days-before-pr-stale: -1 21 | days-before-issue-close: 7 22 | days-before-pr-close: -1 23 | exempt-issue-labels: 'bug,enhancement' 24 | # - uses: actions/checkout@v4 25 | # - uses: gautamkrishnar/keepalive-workflow@v2 26 | # with: 27 | # use_api: true 28 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/file_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict, dataclass 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from octoprint.util.files import unix_timestamp_to_m20_timestamp 8 | 9 | 10 | @dataclass(frozen=True) 11 | class FileInfo: 12 | dosname: str 13 | path: Path 14 | size: int 15 | date: datetime 16 | 17 | @property 18 | def file_name(self): 19 | return self.path.name 20 | 21 | @property 22 | def timestamp(self) -> float: 23 | return self.date.timestamp() 24 | 25 | @property 26 | def timestamp_m20(self) -> str: 27 | return unix_timestamp_to_m20_timestamp(int(self.timestamp)) 28 | 29 | def get_gcode_info(self) -> str: 30 | return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"' 31 | 32 | def to_dict(self): 33 | return asdict(self) 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | 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. 4 | title: "[BUG]: " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the Bug** 11 | 12 | 13 | **Expected Behavior** 14 | 15 | 16 | **Debug Logs** 17 | 18 | 19 | **Screenshots** 20 | 21 | 22 | **Printer and Plugin Setting Details** 23 | 24 | * Printer model? 25 | * Is your printer connected to Bambu Cloud? 26 | * Is the plugin configured for local access only? 27 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | __plugin_name__ = "Bambu Printer" 4 | __plugin_pythoncompat__ = ">=3.7,<4" 5 | 6 | from .bambu_print_plugin import BambuPrintPlugin 7 | 8 | 9 | def __plugin_load__(): 10 | plugin = BambuPrintPlugin() 11 | 12 | global __plugin_implementation__ 13 | __plugin_implementation__ = plugin 14 | 15 | global __plugin_hooks__ 16 | __plugin_hooks__ = { 17 | "octoprint.comm.transport.serial.factory": __plugin_implementation__.virtual_printer_factory, 18 | "octoprint.comm.transport.serial.additional_port_names": __plugin_implementation__.get_additional_port_names, 19 | "octoprint.filemanager.extension_tree": __plugin_implementation__.support_3mf_files, 20 | "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, 21 | "octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, 22 | "octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request, 23 | "octoprint.server.http.routes": __plugin_implementation__.route_hook, 24 | } 25 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/bambu_timelapse_file_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict, dataclass 4 | from pathlib import Path 5 | 6 | from .file_info import FileInfo 7 | 8 | from octoprint.util import get_formatted_size, get_formatted_datetime 9 | 10 | 11 | @dataclass(frozen=True) 12 | class BambuTimelapseFileInfo: 13 | bytes: int 14 | date: str | None 15 | name: str 16 | size: str 17 | thumbnail: str 18 | timestamp: float 19 | url: str 20 | 21 | def to_dict(self): 22 | return asdict(self) 23 | 24 | @staticmethod 25 | def from_file_info(file_info: FileInfo): 26 | return BambuTimelapseFileInfo( 27 | bytes=file_info.size, 28 | date=get_formatted_datetime(file_info.date), 29 | name=file_info.file_name, 30 | size=get_formatted_size(file_info.size), 31 | thumbnail=f"/plugin/bambu_printer/thumbnail/{file_info.path.stem}.jpg", 32 | timestamp=file_info.timestamp, 33 | url=f"/plugin/bambu_printer/timelapse/{file_info.file_name}", 34 | ) 35 | -------------------------------------------------------------------------------- /test/test_data_conversions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | from pathlib import Path 4 | 5 | from octoprint.util import get_formatted_size, get_formatted_datetime 6 | from octoprint_bambu_printer.printer.file_system.bambu_timelapse_file_info import ( 7 | BambuTimelapseFileInfo, 8 | ) 9 | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo 10 | 11 | 12 | def test_timelapse_info_valid(): 13 | file_name = "part.mp4" 14 | file_size = 1000 15 | file_date = datetime(2020, 1, 1) 16 | file_timestamp = file_date.timestamp() 17 | 18 | file_info = FileInfo(file_name, Path(file_name), file_size, file_date) 19 | timelapse = BambuTimelapseFileInfo.from_file_info(file_info) 20 | 21 | assert timelapse.to_dict() == { 22 | "bytes": file_size, 23 | "date": get_formatted_datetime(datetime.fromtimestamp(file_timestamp)), 24 | "name": file_name, 25 | "size": get_formatted_size(file_size), 26 | "thumbnail": "/plugin/bambu_printer/thumbnail/" 27 | + file_name.replace(".mp4", ".jpg").replace(".avi", ".jpg"), 28 | "timestamp": file_timestamp, 29 | "url": f"/plugin/bambu_printer/timelapse/{file_name}", 30 | } 31 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/states/a_printer_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from octoprint_bambu_printer.printer.bambu_virtual_printer import ( 8 | BambuVirtualPrinter, 9 | ) 10 | 11 | 12 | class APrinterState: 13 | def __init__(self, printer: BambuVirtualPrinter) -> None: 14 | self._log = logging.getLogger( 15 | "octoprint.plugins.bambu_printer.BambuPrinter.states" 16 | ) 17 | self._printer = printer 18 | 19 | def init(self): 20 | pass 21 | 22 | def finalize(self): 23 | pass 24 | 25 | def handle_gcode(self, gcode): 26 | self._log.debug(f"{self.__class__.__name__} gcode execution disabled") 27 | 28 | def update_print_job_info(self): 29 | self._log_skip_state_transition("start_new_print") 30 | 31 | def start_new_print(self): 32 | self._log_skip_state_transition("start_new_print") 33 | 34 | def pause_print(self): 35 | self._log_skip_state_transition("pause_print") 36 | 37 | def cancel_print(self): 38 | self._log_skip_state_transition("cancel_print") 39 | 40 | def resume_print(self): 41 | self._log_skip_state_transition("resume_print") 42 | 43 | def _log_skip_state_transition(self, method): 44 | self._log.debug( 45 | f"skipping {self.__class__.__name__} state transition for '{method}'" 46 | ) 47 | -------------------------------------------------------------------------------- /translations/README.txt: -------------------------------------------------------------------------------- 1 | Your plugin's translations will reside here. The provided setup.py supports a 2 | couple of additional commands to make managing your translations easier: 3 | 4 | babel_extract 5 | Extracts any translateable messages (marked with Jinja's `_("...")` or 6 | JavaScript's `gettext("...")`) and creates the initial `messages.pot` file. 7 | babel_refresh 8 | Reruns extraction and updates the `messages.pot` file. 9 | babel_new --locale= 10 | Creates a new translation folder for locale ``. 11 | babel_compile 12 | Compiles the translations into `mo` files, ready to be used within 13 | OctoPrint. 14 | babel_pack --locale= [ --author= ] 15 | Packs the translation for locale `` up as an installable 16 | language pack that can be manually installed by your plugin's users. This is 17 | interesting for languages you can not guarantee to keep up to date yourself 18 | with each new release of your plugin and have to depend on contributors for. 19 | 20 | If you want to bundle translations with your plugin, create a new folder 21 | `octoprint_bambu_printer/translations`. When that folder exists, 22 | an additional command becomes available: 23 | 24 | babel_bundle --locale= 25 | Moves the translation for locale `` to octoprint_bambu_printer/translations, 26 | effectively bundling it with your plugin. This is interesting for languages 27 | you can guarantee to keep up to date yourself with each new release of your 28 | plugin. 29 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/states/paused_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from octoprint_bambu_printer.printer.bambu_virtual_printer import ( 6 | BambuVirtualPrinter, 7 | ) 8 | 9 | import threading 10 | 11 | import pybambu.commands 12 | from octoprint.util import RepeatedTimer 13 | 14 | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState 15 | 16 | 17 | class PausedState(APrinterState): 18 | 19 | def __init__(self, printer: BambuVirtualPrinter) -> None: 20 | super().__init__(printer) 21 | self._pausedLock = threading.Event() 22 | self._paused_repeated_report = None 23 | 24 | def init(self): 25 | if not self._pausedLock.is_set(): 26 | self._pausedLock.set() 27 | 28 | self._printer.sendIO("// action:paused") 29 | self._printer.start_continuous_status_report(3) 30 | 31 | def finalize(self): 32 | if self._pausedLock.is_set(): 33 | self._pausedLock.clear() 34 | if self._paused_repeated_report is not None: 35 | self._paused_repeated_report.join() 36 | self._paused_repeated_report = None 37 | 38 | def start_new_print(self): 39 | if self._printer.bambu_client.connected: 40 | if self._printer.bambu_client.publish(pybambu.commands.RESUME): 41 | self._log.info("print resumed") 42 | else: 43 | self._log.info("print resume failed") 44 | 45 | def cancel_print(self): 46 | if self._printer.bambu_client.connected: 47 | if self._printer.bambu_client.publish(pybambu.commands.STOP): 48 | self._log.info("print cancelled") 49 | self._printer.finalize_print_job() 50 | else: 51 | self._log.info("print cancel failed") 52 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/states/idle_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo 4 | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState 5 | 6 | 7 | class IdleState(APrinterState): 8 | 9 | def start_new_print(self): 10 | selected_file = self._printer.selected_file 11 | if selected_file is None: 12 | self._log.warn("Cannot start print job if file was not selected") 13 | return 14 | 15 | print_command = self._get_print_command_for_file(selected_file) 16 | self._log.debug(f"Sending print command: {print_command}") 17 | if self._printer.bambu_client.publish(print_command): 18 | self._log.info(f"Started print for {selected_file.file_name}") 19 | else: 20 | self._log.warn(f"Failed to start print for {selected_file.file_name}") 21 | 22 | def _get_print_command_for_file(self, selected_file: FileInfo): 23 | 24 | # URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf" 25 | filesystem_root = ( 26 | "file:///mnt/sdcard/" 27 | if self._printer._settings.get(["device_type"]) in ["X1", "X1C"] 28 | else "file:///" 29 | ) 30 | 31 | print_command = { 32 | "print": { 33 | "sequence_id": 0, 34 | "command": "project_file", 35 | "param": "Metadata/plate_1.gcode", 36 | "md5": "", 37 | "profile_id": "0", 38 | "project_id": "0", 39 | "subtask_id": "0", 40 | "task_id": "0", 41 | "subtask_name": selected_file.file_name, 42 | "url": f"{filesystem_root}{selected_file.path.as_posix()}", 43 | "bed_type": "auto", 44 | "timelapse": self._printer._settings.get_boolean(["timelapse"]), 45 | "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), 46 | "flow_cali": self._printer._settings.get_boolean(["flow_cali"]), 47 | "vibration_cali": self._printer._settings.get_boolean( 48 | ["vibration_cali"] 49 | ), 50 | "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), 51 | "use_ams": self._printer._settings.get_boolean(["use_ams"]), 52 | "ams_mapping": "", 53 | } 54 | } 55 | 56 | return print_command 57 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/remote_sd_card_file_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from pathlib import Path 5 | from typing import Iterable, Iterator 6 | import logging.handlers 7 | 8 | from octoprint.util import get_dos_filename 9 | 10 | from .ftps_client import IoTFTPSClient, IoTFTPSConnection 11 | from .file_info import FileInfo 12 | 13 | 14 | class RemoteSDCardFileList: 15 | 16 | def __init__(self, settings) -> None: 17 | self._settings = settings 18 | self._selected_project_file: FileInfo | None = None 19 | self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") 20 | 21 | def delete_file(self, file_path: Path) -> None: 22 | try: 23 | with self.get_ftps_client() as ftp: 24 | if ftp.delete_file(file_path.as_posix()): 25 | self._logger.debug(f"{file_path} deleted") 26 | else: 27 | raise RuntimeError(f"Deleting file {file_path} failed") 28 | except Exception as e: 29 | self._logger.exception(e) 30 | 31 | def list_files( 32 | self, 33 | folder: str, 34 | extensions: str | list[str] | None, 35 | ftp: IoTFTPSConnection, 36 | existing_files=None, 37 | ): 38 | if existing_files is None: 39 | existing_files = [] 40 | 41 | return list( 42 | self.get_file_info_for_names( 43 | ftp, ftp.list_files(folder, extensions), existing_files 44 | ) 45 | ) 46 | 47 | def _get_ftp_file_info( 48 | self, 49 | ftp: IoTFTPSConnection, 50 | file_path: Path, 51 | existing_files: list[str] | None = None, 52 | ): 53 | file_size = ftp.get_file_size(file_path.as_posix()) 54 | date = ftp.get_file_date(file_path.as_posix()) 55 | file_name = file_path.name.lower() 56 | dosname = get_dos_filename(file_name, existing_filenames=existing_files).lower() 57 | return FileInfo( 58 | dosname, 59 | file_path, 60 | file_size if file_size is not None else 0, 61 | date, 62 | ) 63 | 64 | def get_file_info_for_names( 65 | self, 66 | ftp: IoTFTPSConnection, 67 | files: Iterable[Path], 68 | existing_files: list[str] | None = None, 69 | ) -> Iterator[FileInfo]: 70 | if existing_files is None: 71 | existing_files = [] 72 | 73 | for entry in files: 74 | try: 75 | file_info = self._get_ftp_file_info(ftp, entry, existing_files) 76 | yield file_info 77 | existing_files.append(file_info.file_name) 78 | existing_files.append(file_info.dosname) 79 | except Exception as e: 80 | self._logger.exception(e, exc_info=False) 81 | 82 | def get_ftps_client(self): 83 | host = self._settings.get(["host"]) 84 | access_code = self._settings.get(["access_code"]) 85 | return IoTFTPSClient( 86 | f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True 87 | ) 88 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/states/printing_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from octoprint_bambu_printer.printer.bambu_virtual_printer import ( 8 | BambuVirtualPrinter, 9 | ) 10 | 11 | import threading 12 | 13 | import pybambu 14 | import pybambu.models 15 | import pybambu.commands 16 | 17 | from octoprint_bambu_printer.printer.print_job import PrintJob 18 | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState 19 | 20 | 21 | class PrintingState(APrinterState): 22 | 23 | def __init__(self, printer: BambuVirtualPrinter) -> None: 24 | super().__init__(printer) 25 | self._current_print_job = None 26 | self._is_printing = False 27 | self._sd_printing_thread = None 28 | 29 | def init(self): 30 | self._is_printing = True 31 | self._printer.remove_project_selection() 32 | self.update_print_job_info() 33 | self._start_worker_thread() 34 | 35 | def finalize(self): 36 | if self._sd_printing_thread is not None and self._sd_printing_thread.is_alive(): 37 | self._is_printing = False 38 | self._sd_printing_thread.join() 39 | self._sd_printing_thread = None 40 | self._printer.current_print_job = None 41 | 42 | def _start_worker_thread(self): 43 | if self._sd_printing_thread is None: 44 | self._is_printing = True 45 | self._sd_printing_thread = threading.Thread(target=self._printing_worker) 46 | self._sd_printing_thread.start() 47 | 48 | def _printing_worker(self): 49 | while ( 50 | self._is_printing 51 | and self._printer.current_print_job is not None 52 | and self._printer.current_print_job.progress < 100 53 | ): 54 | self.update_print_job_info() 55 | self._printer.report_print_job_status() 56 | time.sleep(3) 57 | 58 | self.update_print_job_info() 59 | if ( 60 | self._printer.current_print_job is not None 61 | and self._printer.current_print_job.progress >= 100 62 | ): 63 | self._printer.finalize_print_job() 64 | 65 | def update_print_job_info(self): 66 | print_job_info = self._printer.bambu_client.get_device().print_job 67 | task_name: str = print_job_info.subtask_name 68 | project_file_info = self._printer.project_files.get_file_by_stem( 69 | task_name, [".gcode", ".3mf"] 70 | ) 71 | if project_file_info is None: 72 | self._log.debug(f"No 3mf file found for {print_job_info}") 73 | self._current_print_job = None 74 | self._printer.change_state(self._printer._state_idle) 75 | return 76 | 77 | progress = print_job_info.print_percentage 78 | self._printer.current_print_job = PrintJob(project_file_info, progress) 79 | self._printer.select_project_file(project_file_info.path.as_posix()) 80 | 81 | def pause_print(self): 82 | if self._printer.bambu_client.connected: 83 | if self._printer.bambu_client.publish(pybambu.commands.PAUSE): 84 | self._log.info("print paused") 85 | else: 86 | self._log.info("print pause failed") 87 | 88 | def cancel_print(self): 89 | if self._printer.bambu_client.connected: 90 | if self._printer.bambu_client.publish(pybambu.commands.STOP): 91 | self._log.info("print cancelled") 92 | self._printer.finalize_print_job() 93 | else: 94 | self._log.info("print cancel failed") 95 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/cached_file_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable 4 | 5 | if TYPE_CHECKING: 6 | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( 7 | RemoteSDCardFileList, 8 | ) 9 | 10 | from dataclasses import dataclass, field 11 | from pathlib import Path 12 | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo 13 | 14 | 15 | @dataclass 16 | class CachedFileView: 17 | file_system: RemoteSDCardFileList 18 | folder_view: dict[tuple[str, str | list[str] | None], None] = field( 19 | default_factory=dict 20 | ) # dict preserves order, but set does not. We use only dict keys as storage 21 | on_update: Callable[[], None] | None = None 22 | 23 | def __post_init__(self): 24 | self._file_alias_cache: dict[str, str] = {} 25 | self._file_data_cache: dict[str, FileInfo] = {} 26 | 27 | def with_filter( 28 | self, folder: str, extensions: str | list[str] | None = None 29 | ) -> "CachedFileView": 30 | self.folder_view[(folder, extensions)] = None 31 | return self 32 | 33 | def list_all_views(self): 34 | existing_files: list[str] = [] 35 | result: list[FileInfo] = [] 36 | 37 | with self.file_system.get_ftps_client() as ftp: 38 | for filter in self.folder_view.keys(): 39 | result.extend(self.file_system.list_files(*filter, ftp, existing_files)) 40 | return result 41 | 42 | def update(self): 43 | file_info_list = self.list_all_views() 44 | self._update_file_list_cache(file_info_list) 45 | if self.on_update: 46 | self.on_update() 47 | 48 | def _update_file_list_cache(self, files: list[FileInfo]): 49 | self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files} 50 | self._file_data_cache = {info.path.as_posix(): info for info in files} 51 | 52 | def get_all_info(self): 53 | self.update() 54 | return self.get_all_cached_info() 55 | 56 | def get_all_cached_info(self): 57 | return list(self._file_data_cache.values()) 58 | 59 | def get_file_data(self, file_path: str | Path) -> FileInfo | None: 60 | file_data = self.get_file_data_cached(file_path) 61 | if file_data is None: 62 | self.update() 63 | file_data = self.get_file_data_cached(file_path) 64 | return file_data 65 | 66 | def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None: 67 | if isinstance(file_path, str): 68 | file_path = Path(file_path).as_posix().strip("/") 69 | else: 70 | file_path = file_path.as_posix().strip("/") 71 | 72 | if file_path not in self._file_data_cache: 73 | file_path = self._file_alias_cache.get(file_path, file_path) 74 | return self._file_data_cache.get(file_path, None) 75 | 76 | def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]): 77 | if file_stem == "": 78 | return None 79 | 80 | file_stem = Path(file_stem).with_suffix("").stem 81 | file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) 82 | if file_data is None: 83 | self.update() 84 | file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes) 85 | return file_data 86 | 87 | def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]): 88 | for file_path_str in list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys()): 89 | file_path = Path(file_path_str) 90 | if file_stem == file_path.with_suffix("").stem and all( 91 | suffix in allowed_suffixes for suffix in file_path.suffixes 92 | ): 93 | return self.get_file_data_cached(file_path) 94 | return None 95 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/templates/bambu_timelapse.jinja2: -------------------------------------------------------------------------------- 1 |
2 |

{{ _('Bambu Timelapses') }}

3 | 4 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 32 | 37 | 42 | 43 | 44 |
{{ _('Details') }}{{ _('Action') }}
25 |
26 | 27 | 28 | 29 | 30 |
31 |
33 |

34 |

{{ _('Recorded:') }}

35 |

{{ _('Size:') }}

36 |
38 |
39 | 40 |
41 |
45 | 56 |
57 | 58 | 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | ######################################################################################################################## 4 | ### Do not forget to adjust the following variables to your own plugin. 5 | 6 | # The plugin's identifier, has to be unique 7 | plugin_identifier = "bambu_printer" 8 | 9 | # The plugin's python package, should be "octoprint_", has to be unique 10 | plugin_package = "octoprint_bambu_printer" 11 | 12 | # The plugin's human readable name. Can be overwritten within OctoPrint's internal data via __plugin_name__ in the 13 | # plugin module 14 | plugin_name = "OctoPrint-BambuPrinter" 15 | 16 | # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module 17 | plugin_version = "0.1.7" 18 | 19 | # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin 20 | # module 21 | plugin_description = """Connects OctoPrint to BambuLabs printers.""" 22 | 23 | # The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module 24 | plugin_author = "jneilliii" 25 | 26 | # The plugin's author's mail address. 27 | plugin_author_email = "jneilliii+github@gmail.com" 28 | 29 | # The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module 30 | plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter" 31 | 32 | # The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module 33 | plugin_license = "AGPLv3" 34 | 35 | # Any additional requirements besides OctoPrint should be listed here 36 | plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"] 37 | 38 | ### -------------------------------------------------------------------------------------------------------------------- 39 | ### More advanced options that you usually shouldn't have to touch follow after this point 40 | ### -------------------------------------------------------------------------------------------------------------------- 41 | 42 | # Additional package data to install for this plugin. The subfolders "templates", "static" and "translations" will 43 | # already be installed automatically if they exist. Note that if you add something here you'll also need to update 44 | # MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your 45 | # files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598 46 | plugin_additional_data = [] 47 | 48 | # Any additional python packages you need to install with your plugin that are not contained in .* 49 | plugin_additional_packages = [] 50 | 51 | # Any python packages within .* you do NOT want to install with your plugin 52 | plugin_ignored_packages = [] 53 | 54 | # Additional parameters for the call to setuptools.setup. If your plugin wants to register additional entry points, 55 | # define dependency links or other things like that, this is the place to go. Will be merged recursively with the 56 | # default setup parameters as provided by octoprint_setuptools.create_plugin_setup_parameters using 57 | # octoprint.util.dict_merge. 58 | # 59 | # Example: 60 | # plugin_requires = ["someDependency==dev"] 61 | # additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]} 62 | # "python_requires": ">=3,<4" blocks installation on Python 2 systems, to prevent confused users and provide a helpful error. 63 | # Remove it if you would like to support Python 2 as well as 3 (not recommended). 64 | additional_setup_parameters = {"python_requires": ">=3.9,<4"} 65 | 66 | ######################################################################################################################## 67 | 68 | from setuptools import setup 69 | 70 | try: 71 | import octoprint_setuptools 72 | except: 73 | print( 74 | "Could not import OctoPrint's setuptools, are you sure you are running that under " 75 | "the same python installation that OctoPrint is installed under?" 76 | ) 77 | import sys 78 | 79 | sys.exit(-1) 80 | 81 | setup_parameters = octoprint_setuptools.create_plugin_setup_parameters( 82 | identifier=plugin_identifier, 83 | package=plugin_package, 84 | name=plugin_name, 85 | version=plugin_version, 86 | description=plugin_description, 87 | author=plugin_author, 88 | mail=plugin_author_email, 89 | url=plugin_url, 90 | license=plugin_license, 91 | requires=plugin_requires, 92 | additional_packages=plugin_additional_packages, 93 | ignored_packages=plugin_ignored_packages, 94 | additional_data=plugin_additional_data, 95 | ) 96 | 97 | if len(additional_setup_parameters): 98 | from octoprint.util import dict_merge 99 | 100 | setup_parameters = dict_merge(setup_parameters, additional_setup_parameters) 101 | 102 | setup(**setup_parameters) 103 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/templates/bambu_printer_settings.jinja2: -------------------------------------------------------------------------------- 1 |

Bambu Printer Settings {{ _('Version') }} {{ plugin_bambu_printer_plugin_version }}

2 | 3 |
4 |
5 | 6 |
7 | 9 |
10 |
11 |
12 | 13 |
14 | 15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 | 25 |
26 | 27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 |
37 | 38 |
39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 | 48 |
49 |
50 | 51 | {{ _('Login') }} 52 |
53 |
54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 |
62 | 63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | {#
73 | 74 |
75 | 76 |
77 |
#} 78 |
79 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/static/js/bambu_printer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * View model for OctoPrint-BambuPrinter 3 | * 4 | * Author: jneilliii 5 | * License: AGPLv3 6 | */ 7 | 8 | $(function () { 9 | function Bambu_printerViewModel(parameters) { 10 | var self = this; 11 | 12 | self.settingsViewModel = parameters[0]; 13 | self.filesViewModel = parameters[1]; 14 | self.loginStateViewModel = parameters[2]; 15 | self.accessViewModel = parameters[3]; 16 | self.timelapseViewModel = parameters[4]; 17 | 18 | self.getAuthToken = function (data) { 19 | self.settingsViewModel.settings.plugins.bambu_printer.auth_token(""); 20 | OctoPrint.simpleApiCommand("bambu_printer", "register", { 21 | "email": self.settingsViewModel.settings.plugins.bambu_printer.email(), 22 | "password": $("#bambu_cloud_password").val(), 23 | "region": self.settingsViewModel.settings.plugins.bambu_printer.region(), 24 | "auth_token": self.settingsViewModel.settings.plugins.bambu_printer.auth_token() 25 | }) 26 | .done(function (response) { 27 | console.log(response); 28 | self.settingsViewModel.settings.plugins.bambu_printer.auth_token(response.auth_token); 29 | self.settingsViewModel.settings.plugins.bambu_printer.username(response.username); 30 | }); 31 | }; 32 | 33 | // initialize list helper 34 | self.listHelper = new ItemListHelper( 35 | "timelapseFiles", 36 | { 37 | name: function (a, b) { 38 | // sorts ascending 39 | if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) 40 | return -1; 41 | if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) 42 | return 1; 43 | return 0; 44 | }, 45 | date: function (a, b) { 46 | // sorts descending 47 | if (a["date"] > b["date"]) return -1; 48 | if (a["date"] < b["date"]) return 1; 49 | return 0; 50 | }, 51 | size: function (a, b) { 52 | // sorts descending 53 | if (a["bytes"] > b["bytes"]) return -1; 54 | if (a["bytes"] < b["bytes"]) return 1; 55 | return 0; 56 | } 57 | }, 58 | {}, 59 | "name", 60 | [], 61 | [], 62 | CONFIG_TIMELAPSEFILESPERPAGE 63 | ); 64 | 65 | self.onDataUpdaterPluginMessage = function(plugin, data) { 66 | if (plugin != "bambu_printer") { 67 | return; 68 | } 69 | 70 | if (data.files !== undefined) { 71 | console.log(data.files); 72 | self.listHelper.updateItems(data.files); 73 | self.listHelper.resetPage(); 74 | } 75 | }; 76 | 77 | self.onBeforeBinding = function () { 78 | $('#bambu_timelapse').appendTo("#timelapse"); 79 | }; 80 | 81 | self.showTimelapseThumbnail = function(data) { 82 | $("#bambu_printer_timelapse_thumbnail").attr("src", data.thumbnail); 83 | $("#bambu_printer_timelapse_preview").modal('show'); 84 | }; 85 | 86 | /*$('#files div.upload-buttons > span.fileinput-button:first, #files div.folder-button').remove(); 87 | $('#files div.upload-buttons > span.fileinput-button:first').removeClass('span6').addClass('input-block-level'); 88 | 89 | self.onBeforePrintStart = function(start_print_command) { 90 | let confirmation_html = '' + 91 | '
\n' + 92 | '
\n' + 93 | ' \n' + 94 | '
\n' + 95 | ' \n' + 96 | '
\n' + 97 | '
\n' + 98 | '
'; 99 | 100 | if(!self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options()){ 101 | confirmation_html += '\n' + 102 | '
\n' + 103 | '
\n' + 104 | ' \n' + 105 | ' \n' + 106 | ' \n' + 107 | '
\n' + 108 | '
\n' + 109 | ' \n' + 110 | ' \n' + 111 | ' \n' + 112 | '
\n' + 113 | '
\n'; 114 | } 115 | 116 | showConfirmationDialog({ 117 | title: "Bambu Print Options", 118 | html: confirmation_html, 119 | cancel: gettext("Cancel"), 120 | proceed: [gettext("Print"), gettext("Always")], 121 | onproceed: function (idx) { 122 | if(idx === 1){ 123 | self.settingsViewModel.settings.plugins.bambu_printer.timelapse($('#bambu_printer_timelapse').is(':checked')); 124 | self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling($('#bambu_printer_bed_leveling').is(':checked')); 125 | self.settingsViewModel.settings.plugins.bambu_printer.flow_cali($('#bambu_printer_flow_cali').is(':checked')); 126 | self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali($('#bambu_printer_vibration_cali').is(':checked')); 127 | self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect($('#bambu_printer_layer_inspect').is(':checked')); 128 | self.settingsViewModel.settings.plugins.bambu_printer.use_ams($('#bambu_printer_use_ams').is(':checked')); 129 | self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options(true); 130 | self.settingsViewModel.saveData(); 131 | } 132 | // replace this with our own print command API call? 133 | start_print_command(); 134 | }, 135 | nofade: true 136 | }); 137 | return false; 138 | };*/ 139 | } 140 | 141 | OCTOPRINT_VIEWMODELS.push({ 142 | construct: Bambu_printerViewModel, 143 | // ViewModels your plugin depends on, e.g. loginStateViewModel, settingsViewModel, ... 144 | dependencies: ["settingsViewModel", "filesViewModel", "loginStateViewModel", "accessViewModel", "timelapseViewModel"], 145 | // Elements to bind to, e.g. #settings_plugin_bambu_printer, #tab_plugin_bambu_printer, ... 146 | elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse"] 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/printer_serial_io.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from io import BufferedIOBase 4 | import logging 5 | import queue 6 | import re 7 | import threading 8 | import traceback 9 | from types import TracebackType 10 | from typing import Callable 11 | 12 | from octoprint.util import to_bytes, to_unicode 13 | from serial import SerialTimeoutException 14 | 15 | 16 | class PrinterSerialIO(threading.Thread, BufferedIOBase): 17 | command_regex = re.compile(r"^([GM])(\d+)") 18 | 19 | def __init__( 20 | self, 21 | handle_command_callback: Callable[[str, str], None], 22 | settings, 23 | serial_log_handler=None, 24 | read_timeout=5.0, 25 | write_timeout=10.0, 26 | ) -> None: 27 | super().__init__( 28 | name="octoprint.plugins.bambu_printer.printer_worker", daemon=True 29 | ) 30 | self._handle_command_callback = handle_command_callback 31 | self._settings = settings 32 | self._log = self._init_logger(serial_log_handler) 33 | 34 | self._read_timeout = read_timeout 35 | self._write_timeout = write_timeout 36 | 37 | self.current_line = 0 38 | self._received_lines = 0 39 | self._wait_interval = 5.0 40 | self._running = True 41 | 42 | self._rx_buffer_size = 64 43 | self._incoming_lock = threading.RLock() 44 | 45 | self.input_bytes = queue.Queue(self._rx_buffer_size) 46 | self.output_bytes = queue.Queue() 47 | self._error_detected: Exception | None = None 48 | 49 | def _init_logger(self, log_handler): 50 | log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter.serial") 51 | if log_handler is not None: 52 | log.addHandler(log_handler) 53 | log.debug("-" * 78) 54 | return log 55 | 56 | @property 57 | def incoming_lock(self): 58 | return self._incoming_lock 59 | 60 | def run(self) -> None: 61 | buffer = b"" 62 | 63 | while self._running: 64 | try: 65 | data = self.input_bytes.get(block=True, timeout=0.01) 66 | data = to_bytes(data, encoding="ascii", errors="replace") 67 | 68 | buffer += data 69 | line, buffer = self._read_next_line(buffer) 70 | while line is not None: 71 | self._received_lines += 1 72 | self._process_input_gcode_line(line) 73 | line, buffer = self._read_next_line(buffer) 74 | self.input_bytes.task_done() 75 | except queue.Empty: 76 | continue 77 | except Exception as e: 78 | self._error_detected = e 79 | self.input_bytes.task_done() 80 | self._clearQueue(self.input_bytes) 81 | self._log.info( 82 | "\n".join(traceback.format_exception_only(type(e), e)[-50:]) 83 | ) 84 | self._running = False 85 | 86 | self._log.debug("Closing IO read loop") 87 | 88 | def _read_next_line(self, buffer: bytes): 89 | new_line_pos = buffer.find(b"\n") + 1 90 | if new_line_pos > 0: 91 | line = buffer[:new_line_pos] 92 | buffer = buffer[new_line_pos:] 93 | return line, buffer 94 | else: 95 | return None, buffer 96 | 97 | def close(self): 98 | self.flush() 99 | self._running = False 100 | self.join() 101 | 102 | def flush(self): 103 | self.input_bytes.join() 104 | self.raise_if_error() 105 | 106 | def raise_if_error(self): 107 | if self._error_detected is not None: 108 | raise self._error_detected 109 | 110 | def write(self, data: bytes) -> int: 111 | data = to_bytes(data, errors="replace") 112 | u_data = to_unicode(data, errors="replace") 113 | 114 | with self._incoming_lock: 115 | if self.is_closed(): 116 | return 0 117 | 118 | try: 119 | self._log.debug(f"<<< {u_data}") 120 | self.input_bytes.put(data, timeout=self._write_timeout) 121 | return len(data) 122 | except queue.Full: 123 | self._log.error( 124 | "Incoming queue is full, raising SerialTimeoutException" 125 | ) 126 | raise SerialTimeoutException() 127 | 128 | def readline(self) -> bytes: 129 | try: 130 | # fetch a line from the queue, wait no longer than timeout 131 | line = to_unicode( 132 | self.output_bytes.get(timeout=self._read_timeout), errors="replace" 133 | ) 134 | self._log.debug(f">>> {line.strip()}") 135 | self.output_bytes.task_done() 136 | return to_bytes(line) 137 | except queue.Empty: 138 | # queue empty? return empty line 139 | return b"" 140 | 141 | def readlines(self): 142 | result = [] 143 | next_line = self.readline() 144 | while next_line != b"": 145 | result.append(next_line) 146 | next_line = self.readline() 147 | return result 148 | 149 | def send(self, line: str) -> None: 150 | if self.output_bytes is not None: 151 | self.output_bytes.put(line) 152 | 153 | def sendOk(self): 154 | self.send("ok") 155 | 156 | def reset(self): 157 | self._clearQueue(self.input_bytes) 158 | self._clearQueue(self.output_bytes) 159 | 160 | def is_closed(self): 161 | return not self._running 162 | 163 | def _process_input_gcode_line(self, data: bytes): 164 | if b"*" in data: 165 | checksum = int(data[data.rfind(b"*") + 1 :]) 166 | data = data[: data.rfind(b"*")] 167 | if not checksum == self._calculate_checksum(data): 168 | self._triggerResend(expected=self.current_line + 1) 169 | return 170 | 171 | self.current_line += 1 172 | elif self._settings.get_boolean(["forceChecksum"]): 173 | self.send(self._format_error("checksum_missing")) 174 | return 175 | 176 | line = self._process_linenumber_marker(data) 177 | if line is None: 178 | return 179 | 180 | command = to_unicode(line, encoding="ascii", errors="replace").strip() 181 | command_match = self.command_regex.match(command) 182 | if command_match is not None: 183 | gcode = command_match.group(0) 184 | self._handle_command_callback(gcode, command) 185 | else: 186 | self._log.warn(f'Not a valid gcode command "{command}"') 187 | 188 | def _process_linenumber_marker(self, data: bytes): 189 | linenumber = 0 190 | if data.startswith(b"N") and b"M110" in data: 191 | linenumber = int(re.search(b"N([0-9]+)", data).group(1)) 192 | self.lastN = linenumber 193 | self.current_line = linenumber 194 | self.sendOk() 195 | return None 196 | elif data.startswith(b"N"): 197 | linenumber = int(re.search(b"N([0-9]+)", data).group(1)) 198 | expected = self.lastN + 1 199 | if linenumber != expected: 200 | self._triggerResend(actual=linenumber) 201 | return None 202 | else: 203 | self.lastN = linenumber 204 | data = data.split(None, 1)[1].strip() 205 | return data 206 | 207 | def _triggerResend( 208 | self, 209 | expected: int | None = None, 210 | actual: int | None = None, 211 | checksum: int | None = None, 212 | ) -> None: 213 | with self._incoming_lock: 214 | if expected is None: 215 | expected = self.lastN + 1 216 | else: 217 | self.lastN = expected - 1 218 | 219 | if actual is None: 220 | if checksum: 221 | self.send(self._format_error("checksum_mismatch")) 222 | else: 223 | self.send(self._format_error("checksum_missing")) 224 | else: 225 | self.send(self._format_error("lineno_mismatch", expected, actual)) 226 | 227 | def request_resend(): 228 | self.send("Resend:%d" % expected) 229 | self.sendOk() 230 | 231 | request_resend() 232 | 233 | def _calculate_checksum(self, line: bytes) -> int: 234 | checksum = 0 235 | for c in bytearray(line): 236 | checksum ^= c 237 | return checksum 238 | 239 | def _format_error(self, error: str, *args, **kwargs) -> str: 240 | errors = { 241 | "checksum_mismatch": "Checksum mismatch", 242 | "checksum_missing": "Missing checksum", 243 | "lineno_mismatch": "expected line {} got {}", 244 | "lineno_missing": "No Line Number with checksum, Last Line: {}", 245 | "maxtemp": "MAXTEMP triggered!", 246 | "mintemp": "MINTEMP triggered!", 247 | "command_unknown": "Unknown command {}", 248 | } 249 | return f"Error: {errors.get(error).format(*args, **kwargs)}" 250 | 251 | def _clearQueue(self, q: queue.Queue): 252 | try: 253 | while q.get(block=False): 254 | q.task_done() 255 | continue 256 | except queue.Empty: 257 | pass 258 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/file_system/ftps_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on: 3 | 4 | MIT License 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | wrapper for FTPS server interactions 25 | """ 26 | 27 | from __future__ import annotations 28 | from dataclasses import dataclass 29 | from datetime import datetime, timezone 30 | import ftplib 31 | import os 32 | from pathlib import Path 33 | import socket 34 | import ssl 35 | from typing import Generator, Union 36 | 37 | from contextlib import redirect_stdout 38 | import io 39 | import re 40 | 41 | 42 | class ImplicitTLS(ftplib.FTP_TLS): 43 | """ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" 44 | 45 | def __init__(self, *args, **kwargs): 46 | super().__init__(*args, **kwargs) 47 | self._sock = None 48 | 49 | @property 50 | def sock(self): 51 | """return socket""" 52 | return self._sock 53 | 54 | @sock.setter 55 | def sock(self, value): 56 | """wrap and set SSL socket""" 57 | if value is not None and not isinstance(value, ssl.SSLSocket): 58 | value = self.context.wrap_socket(value) 59 | self._sock = value 60 | 61 | def ntransfercmd(self, cmd, rest=None): 62 | conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) 63 | 64 | if self._prot_p: 65 | conn = self.context.wrap_socket( 66 | conn, server_hostname=self.host, session=self.sock.session 67 | ) # this is the fix 68 | return conn, size 69 | 70 | 71 | @dataclass 72 | class IoTFTPSConnection: 73 | """iot ftps ftpsclient""" 74 | 75 | ftps_session: ftplib.FTP | ImplicitTLS 76 | 77 | def close(self) -> None: 78 | """close the current session from the ftps server""" 79 | self.ftps_session.close() 80 | 81 | def download_file(self, source: str, dest: str): 82 | """download a file to a path on the local filesystem""" 83 | with open(dest, "wb") as file: 84 | self.ftps_session.retrbinary(f"RETR {source}", file.write) 85 | 86 | def upload_file(self, source: str, dest: str, callback=None) -> bool: 87 | """upload a file to a path inside the FTPS server""" 88 | 89 | file_size = os.path.getsize(source) 90 | 91 | block_size = max(file_size // 100, 8192) 92 | rest = None 93 | 94 | try: 95 | # Taken from ftplib.storbinary but with custom ssl handling 96 | # due to the shitty bambu p1p ftps server TODO fix properly. 97 | with open(source, "rb") as fp: 98 | self.ftps_session.voidcmd("TYPE I") 99 | 100 | with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn: 101 | while 1: 102 | buf = fp.read(block_size) 103 | 104 | if not buf: 105 | break 106 | 107 | conn.sendall(buf) 108 | 109 | if callback: 110 | callback(buf) 111 | 112 | # shutdown ssl layer 113 | if ftplib._SSLSocket is not None and isinstance( 114 | conn, ftplib._SSLSocket 115 | ): 116 | # Yeah this is suposed to be conn.unwrap 117 | # But since we operate in prot p mode 118 | # we can close the connection always. 119 | # This is cursed but it works. 120 | if "vsFTPd" in self.ftps_session.welcome: 121 | conn.unwrap() 122 | else: 123 | conn.shutdown(socket.SHUT_RDWR) 124 | 125 | return True 126 | except Exception as ex: 127 | print(f"unexpected exception occurred: {ex}") 128 | pass 129 | return False 130 | 131 | def delete_file(self, path: str) -> bool: 132 | """delete a file from under a path inside the FTPS server""" 133 | try: 134 | self.ftps_session.delete(path) 135 | return True 136 | except Exception as ex: 137 | print(f"unexpected exception occurred: {ex}") 138 | pass 139 | return False 140 | 141 | def move_file(self, source: str, dest: str): 142 | """move a file inside the FTPS server to another path inside the FTPS server""" 143 | self.ftps_session.rename(source, dest) 144 | 145 | def mkdir(self, path: str) -> str: 146 | return self.ftps_session.mkd(path) 147 | 148 | def list_files( 149 | self, list_path: str, extensions: str | list[str] | None = None 150 | ) -> Generator[Path]: 151 | """list files under a path inside the FTPS server""" 152 | 153 | if extensions is None: 154 | _extension_acceptable = lambda p: True 155 | else: 156 | if isinstance(extensions, str): 157 | extensions = [extensions] 158 | _extension_acceptable = lambda p: any(s in p.suffixes for s in extensions) 159 | 160 | try: 161 | list_result = self.ftps_session.nlst(list_path) or [] 162 | for file_list_entry in list_result: 163 | path = Path(list_path) / Path(file_list_entry).name 164 | if _extension_acceptable(path): 165 | yield path 166 | except Exception as ex: 167 | print(f"unexpected exception occurred: {ex}") 168 | 169 | def list_files_ex(self, path: str) -> Union[list[str], None]: 170 | """list files under a path inside the FTPS server""" 171 | try: 172 | f = io.StringIO() 173 | with redirect_stdout(f): 174 | self.ftps_session.dir(path) 175 | s = f.getvalue() 176 | files = [] 177 | for row in s.split("\n"): 178 | if len(row) <= 0: 179 | continue 180 | 181 | attribs = row.split(" ") 182 | 183 | match = re.search(r".*\ (\d\d\:\d\d|\d\d\d\d)\ (.*)", row) 184 | name = "" 185 | if match: 186 | name = match.groups(1)[1] 187 | else: 188 | name = attribs[len(attribs) - 1] 189 | 190 | file = (attribs[0], name) 191 | files.append(file) 192 | return files 193 | except Exception as ex: 194 | print(f"unexpected exception occurred: [{ex}]") 195 | pass 196 | return 197 | 198 | def get_file_size(self, file_path: str): 199 | try: 200 | return self.ftps_session.size(file_path) 201 | except Exception as e: 202 | raise RuntimeError( 203 | f'Cannot get file size for "{file_path}" due to error: {str(e)}' 204 | ) 205 | 206 | def get_file_date(self, file_path: str) -> datetime: 207 | try: 208 | date_response = self.ftps_session.sendcmd(f"MDTM {file_path}").replace( 209 | "213 ", "" 210 | ) 211 | date = datetime.strptime(date_response, "%Y%m%d%H%M%S").replace( 212 | tzinfo=timezone.utc 213 | ) 214 | return date 215 | except Exception as e: 216 | raise RuntimeError( 217 | f'Cannot get file date for "{file_path}" due to error: {str(e)}' 218 | ) 219 | 220 | 221 | @dataclass 222 | class IoTFTPSClient: 223 | ftps_host: str 224 | ftps_port: int = 21 225 | ftps_user: str = "" 226 | ftps_pass: str = "" 227 | ssl_implicit: bool = False 228 | welcome: str = "" 229 | _connection: IoTFTPSConnection | None = None 230 | 231 | def __enter__(self): 232 | session = self.open_ftps_session() 233 | self._connection = IoTFTPSConnection(session) 234 | return self._connection 235 | 236 | def __exit__(self, type, value, traceback): 237 | if self._connection is not None: 238 | self._connection.close() 239 | self._connection = None 240 | 241 | def open_ftps_session(self) -> ftplib.FTP | ImplicitTLS: 242 | """init ftps_session based on input params""" 243 | ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP() 244 | ftps_session.set_debuglevel(0) 245 | 246 | self.welcome = ftps_session.connect(host=self.ftps_host, port=self.ftps_port) 247 | 248 | if self.ftps_user and self.ftps_pass: 249 | ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass) 250 | else: 251 | ftps_session.login() 252 | 253 | if self.ssl_implicit: 254 | ftps_session.prot_p() 255 | 256 | return ftps_session 257 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/gcode_executor.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import logging 3 | from inspect import signature 4 | import traceback 5 | 6 | 7 | GCODE_DOCUMENTATION = { 8 | "G0": "Linear Move", 9 | "G1": "Linear Move", 10 | "G2": "Arc or Circle Move", 11 | "G3": "Arc or Circle Move", 12 | "G4": "Dwell", 13 | "G5": "Bézier cubic spline", 14 | "G6": "Direct Stepper Move", 15 | "G10": "Retract", 16 | "G11": "Recover", 17 | "G12": "Clean the Nozzle", 18 | "G17": "CNC Workspace Planes", 19 | "G18": "CNC Workspace Planes", 20 | "G19": "CNC Workspace Planes", 21 | "G20": "Inch Units", 22 | "G21": "Millimeter Units", 23 | "G26": "Mesh Validation Pattern", 24 | "G27": "Park toolhead", 25 | "G28": "Auto Home", 26 | "G29": "Bed Leveling", 27 | "G29": "Bed Leveling (3-Point)", 28 | "G29": "Bed Leveling (Linear)", 29 | "G29": "Bed Leveling (Manual)", 30 | "G29": "Bed Leveling (Bilinear)", 31 | "G29": "Bed Leveling (Unified)", 32 | "G30": "Single Z-Probe", 33 | "G31": "Dock Sled", 34 | "G32": "Undock Sled", 35 | "G33": "Delta Auto Calibration", 36 | "G34": "Z Steppers Auto-Alignment", 37 | "G34": "Mechanical Gantry Calibration", 38 | "G35": "Tramming Assistant", 39 | "G38.2": "Probe target", 40 | "G38.3": "Probe target", 41 | "G38.4": "Probe target", 42 | "G38.5": "Probe target", 43 | "G42": "Move to mesh coordinate", 44 | "G53": "Move in Machine Coordinates", 45 | "G60": "Save Current Position", 46 | "G61": "Return to Saved Position", 47 | "G76": "Probe temperature calibration", 48 | "G80": "Cancel Current Motion Mode", 49 | "G90": "Absolute Positioning", 50 | "G91": "Relative Positioning", 51 | "G92": "Set Position", 52 | "G425": "Backlash Calibration", 53 | "M0": "Unconditional stop", 54 | "M1": "Unconditional stop", 55 | "M3": "Spindle CW / Laser On", 56 | "M4": "Spindle CCW / Laser On", 57 | "M5": "Spindle / Laser Off", 58 | "M7": "Coolant Controls", 59 | "M8": "Coolant Controls", 60 | "M9": "Coolant Controls", 61 | "M10": "Vacuum / Blower Control", 62 | "M11": "Vacuum / Blower Control", 63 | "M16": "Expected Printer Check", 64 | "M17": "Enable Steppers", 65 | "M18": "Disable steppers", 66 | "M84": "Disable steppers", 67 | "M20": "List SD Card", 68 | "M21": "Init SD card", 69 | "M22": "Release SD card", 70 | "M23": "Select SD file", 71 | "M24": "Start or Resume SD print", 72 | "M25": "Pause SD print", 73 | "M26": "Set SD position", 74 | "M27": "Report SD print status", 75 | "M28": "Start SD write", 76 | "M29": "Stop SD write", 77 | "M30": "Delete SD file", 78 | "M31": "Print time", 79 | "M32": "Select and Start", 80 | "M33": "Get Long Path", 81 | "M34": "SDCard Sorting", 82 | "M42": "Set Pin State", 83 | "M43": "Debug Pins", 84 | "M48": "Probe Repeatability Test", 85 | "M73": "Set Print Progress", 86 | "M75": "Start Print Job Timer", 87 | "M76": "Pause Print Job Timer", 88 | "M77": "Stop Print Job Timer", 89 | "M78": "Print Job Stats", 90 | "M80": "Power On", 91 | "M81": "Power Off", 92 | "M82": "E Absolute", 93 | "M83": "E Relative", 94 | "M85": "Inactivity Shutdown", 95 | "M86": "Hotend Idle Timeout", 96 | "M87": "Disable Hotend Idle Timeout", 97 | "M92": "Set Axis Steps-per-unit", 98 | "M100": "Free Memory", 99 | "M102": "Configure Bed Distance Sensor", 100 | "M104": "Set Hotend Temperature", 101 | "M105": "Report Temperatures", 102 | "M106": "Set Fan Speed", 103 | "M107": "Fan Off", 104 | "M108": "Break and Continue", 105 | "M109": "Wait for Hotend Temperature", 106 | "M110": "Set / Get Line Number", 107 | "M111": "Debug Level", 108 | "M112": "Full Shutdown", 109 | "M113": "Host Keepalive", 110 | "M114": "Get Current Position", 111 | "M115": "Firmware Info", 112 | "M117": "Set LCD Message", 113 | "M118": "Serial print", 114 | "M119": "Endstop States", 115 | "M120": "Enable Endstops", 116 | "M121": "Disable Endstops", 117 | "M122": "TMC Debugging", 118 | "M123": "Fan Tachometers", 119 | "M125": "Park Head", 120 | "M126": "Baricuda 1 Open", 121 | "M127": "Baricuda 1 Close", 122 | "M128": "Baricuda 2 Open", 123 | "M129": "Baricuda 2 Close", 124 | "M140": "Set Bed Temperature", 125 | "M141": "Set Chamber Temperature", 126 | "M143": "Set Laser Cooler Temperature", 127 | "M145": "Set Material Preset", 128 | "M149": "Set Temperature Units", 129 | "M150": "Set RGB(W) Color", 130 | "M154": "Position Auto-Report", 131 | "M155": "Temperature Auto-Report", 132 | "M163": "Set Mix Factor", 133 | "M164": "Save Mix", 134 | "M165": "Set Mix", 135 | "M166": "Gradient Mix", 136 | "M190": "Wait for Bed Temperature", 137 | "M191": "Wait for Chamber Temperature", 138 | "M192": "Wait for Probe temperature", 139 | "M193": "Set Laser Cooler Temperature", 140 | "M200": "Set Filament Diameter", 141 | "M201": "Print / Travel Move Limits", 142 | "M203": "Set Max Feedrate", 143 | "M204": "Set Starting Acceleration", 144 | "M205": "Set Advanced Settings", 145 | "M206": "Set Home Offsets", 146 | "M207": "Set Firmware Retraction", 147 | "M208": "Firmware Recover", 148 | "M209": "Set Auto Retract", 149 | "M211": "Software Endstops", 150 | "M217": "Filament swap parameters", 151 | "M218": "Set Hotend Offset", 152 | "M220": "Set Feedrate Percentage", 153 | "M221": "Set Flow Percentage", 154 | "M226": "Wait for Pin State", 155 | "M240": "Trigger Camera", 156 | "M250": "LCD Contrast", 157 | "M255": "LCD Sleep/Backlight Timeout", 158 | "M256": "LCD Brightness", 159 | "M260": "I2C Send", 160 | "M261": "I2C Request", 161 | "M280": "Servo Position", 162 | "M281": "Edit Servo Angles", 163 | "M282": "Detach Servo", 164 | "M290": "Babystep", 165 | "M300": "Play Tone", 166 | "M301": "Set Hotend PID", 167 | "M302": "Cold Extrude", 168 | "M303": "PID autotune", 169 | "M304": "Set Bed PID", 170 | "M305": "User Thermistor Parameters", 171 | "M306": "Model Predictive Temp. Control", 172 | "M350": "Set micro-stepping", 173 | "M351": "Set Microstep Pins", 174 | "M355": "Case Light Control", 175 | "M360": "SCARA Theta A", 176 | "M361": "SCARA Theta-B", 177 | "M362": "SCARA Psi-A", 178 | "M363": "SCARA Psi-B", 179 | "M364": "SCARA Psi-C", 180 | "M380": "Activate Solenoid", 181 | "M381": "Deactivate Solenoids", 182 | "M400": "Finish Moves", 183 | "M401": "Deploy Probe", 184 | "M402": "Stow Probe", 185 | "M403": "MMU2 Filament Type", 186 | "M404": "Set Filament Diameter", 187 | "M405": "Filament Width Sensor On", 188 | "M406": "Filament Width Sensor Off", 189 | "M407": "Filament Width", 190 | "M410": "Quickstop", 191 | "M412": "Filament Runout", 192 | "M413": "Power-loss Recovery", 193 | "M420": "Bed Leveling State", 194 | "M421": "Set Mesh Value", 195 | "M422": "Set Z Motor XY", 196 | "M423": "X Twist Compensation", 197 | "M425": "Backlash compensation", 198 | "M428": "Home Offsets Here", 199 | "M430": "Power Monitor", 200 | "M486": "Cancel Objects", 201 | "M493": "Fixed-Time Motion", 202 | "M500": "Save Settings", 203 | "M501": "Restore Settings", 204 | "M502": "Factory Reset", 205 | "M503": "Report Settings", 206 | "M504": "Validate EEPROM contents", 207 | "M510": "Lock Machine", 208 | "M511": "Unlock Machine", 209 | "M512": "Set Passcode", 210 | "M524": "Abort SD print", 211 | "M540": "Endstops Abort SD", 212 | "M569": "Set TMC stepping mode", 213 | "M575": "Serial baud rate", 214 | "M592": "Nonlinear Extrusion Control", 215 | "M593": "ZV Input Shaping", 216 | "M600": "Filament Change", 217 | "M603": "Configure Filament Change", 218 | "M605": "Multi Nozzle Mode", 219 | "M665": "Delta Configuration", 220 | "M665": "SCARA Configuration", 221 | "M666": "Set Delta endstop adjustments", 222 | "M666": "Set dual endstop offsets", 223 | "M672": "Duet Smart Effector sensitivity", 224 | "M701": "Load filament", 225 | "M702": "Unload filament", 226 | "M710": "Controller Fan settings", 227 | "M808": "Repeat Marker", 228 | "M851": "XYZ Probe Offset", 229 | "M852": "Bed Skew Compensation", 230 | "M871": "Probe temperature config", 231 | "M876": "Handle Prompt Response", 232 | "M900": "Linear Advance Factor", 233 | "M906": "Stepper Motor Current", 234 | "M907": "Set Motor Current", 235 | "M908": "Set Trimpot Pins", 236 | "M909": "DAC Print Values", 237 | "M910": "Commit DAC to EEPROM", 238 | "M911": "TMC OT Pre-Warn Condition", 239 | "M912": "Clear TMC OT Pre-Warn", 240 | "M913": "Set Hybrid Threshold Speed", 241 | "M914": "TMC Bump Sensitivity", 242 | "M915": "TMC Z axis calibration", 243 | "M916": "L6474 Thermal Warning Test", 244 | "M917": "L6474 Overcurrent Warning Test", 245 | "M918": "L6474 Speed Warning Test", 246 | "M919": "TMC Chopper Timing", 247 | "M928": "Start SD Logging", 248 | "M951": "Magnetic Parking Extruder", 249 | "M993": "Back up flash settings to SD", 250 | "M994": "Restore flash from SD", 251 | "M995": "Touch Screen Calibration", 252 | "M997": "Firmware update", 253 | "M999": "STOP Restart", 254 | "M7219": "MAX7219 Control", 255 | } 256 | 257 | 258 | class GCodeExecutor: 259 | def __init__(self): 260 | self._log = logging.getLogger( 261 | "octoprint.plugins.bambu_printer.BambuPrinter.gcode_executor" 262 | ) 263 | self.handler_names = set() 264 | self.gcode_handlers = {} 265 | self.gcode_handlers_no_data = {} 266 | 267 | def __contains__(self, item): 268 | return item in self.gcode_handlers or item in self.gcode_handlers_no_data 269 | 270 | def _get_required_args_count(self, func): 271 | sig = signature(func) 272 | required_count = sum( 273 | 1 274 | for p in sig.parameters.values() 275 | if (p.kind == p.POSITIONAL_OR_KEYWORD or p.kind == p.POSITIONAL_ONLY) 276 | and p.default == p.empty 277 | ) 278 | return required_count 279 | 280 | def register(self, gcode): 281 | def decorator(func): 282 | required_count = self._get_required_args_count(func) 283 | if required_count == 1: 284 | self.gcode_handlers_no_data[gcode] = func 285 | elif required_count == 2: 286 | self.gcode_handlers[gcode] = func 287 | else: 288 | raise ValueError( 289 | f"Cannot register function with {required_count} required parameters" 290 | ) 291 | return func 292 | 293 | return decorator 294 | 295 | def register_no_data(self, gcode): 296 | def decorator(func): 297 | self.gcode_handlers_no_data[gcode] = func 298 | return func 299 | 300 | return decorator 301 | 302 | def execute(self, printer, gcode, data): 303 | gcode_info = self._gcode_with_info(gcode) 304 | try: 305 | if gcode in self.gcode_handlers: 306 | self._log.debug(f"Executing {gcode_info}") 307 | return self.gcode_handlers[gcode](printer, data) 308 | elif gcode in self.gcode_handlers_no_data: 309 | self._log.debug(f"Executing {gcode_info}") 310 | return self.gcode_handlers_no_data[gcode](printer) 311 | else: 312 | self._log.debug(f"ignoring {gcode_info} command.") 313 | return False 314 | except Exception as e: 315 | self._log.error(f"Error during gcode {gcode_info}") 316 | raise 317 | 318 | def _gcode_with_info(self, gcode): 319 | return f"{gcode} ({GCODE_DOCUMENTATION.get(gcode, 'Info not specified')})" 320 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/bambu_print_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, annotations 2 | from pathlib import Path 3 | import threading 4 | from time import perf_counter 5 | from contextlib import contextmanager 6 | import flask 7 | import logging.handlers 8 | from urllib.parse import quote as urlquote 9 | 10 | import octoprint.printer 11 | import octoprint.server 12 | import octoprint.plugin 13 | from octoprint.events import Events 14 | import octoprint.settings 15 | from octoprint.util import is_hidden_path 16 | from octoprint.server.util.flask import no_firstrun_access 17 | from octoprint.server.util.tornado import ( 18 | LargeResponseHandler, 19 | path_validation_factory, 20 | ) 21 | from octoprint.access.permissions import Permissions 22 | from octoprint.logging.handlers import CleaningTimedRotatingFileHandler 23 | 24 | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView 25 | from pybambu import BambuCloud 26 | 27 | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( 28 | RemoteSDCardFileList, 29 | ) 30 | 31 | from .printer.file_system.bambu_timelapse_file_info import ( 32 | BambuTimelapseFileInfo, 33 | ) 34 | from .printer.bambu_virtual_printer import BambuVirtualPrinter 35 | 36 | 37 | @contextmanager 38 | def measure_elapsed(): 39 | start = perf_counter() 40 | 41 | def _get_elapsed(): 42 | return perf_counter() - start 43 | 44 | yield _get_elapsed 45 | print(f"Total elapsed: {_get_elapsed()}") 46 | 47 | 48 | class BambuPrintPlugin( 49 | octoprint.plugin.SettingsPlugin, 50 | octoprint.plugin.TemplatePlugin, 51 | octoprint.plugin.AssetPlugin, 52 | octoprint.plugin.EventHandlerPlugin, 53 | octoprint.plugin.SimpleApiPlugin, 54 | octoprint.plugin.BlueprintPlugin, 55 | ): 56 | _logger: logging.Logger 57 | _plugin_manager: octoprint.plugin.PluginManager 58 | _bambu_file_system: RemoteSDCardFileList 59 | _timelapse_files_view: CachedFileView 60 | 61 | def on_settings_initialized(self): 62 | self._bambu_file_system = RemoteSDCardFileList(self._settings) 63 | self._timelapse_files_view = CachedFileView(self._bambu_file_system) 64 | if self._settings.get(["device_type"]) in ["X1", "X1C"]: 65 | self._timelapse_files_view.with_filter("timelapse/", ".mp4") 66 | else: 67 | self._timelapse_files_view.with_filter("timelapse/", ".avi") 68 | 69 | def get_assets(self): 70 | return {"js": ["js/bambu_printer.js"]} 71 | 72 | def get_template_configs(self): 73 | return [ 74 | {"type": "settings", "custom_bindings": True}, 75 | { 76 | "type": "generic", 77 | "custom_bindings": True, 78 | "template": "bambu_timelapse.jinja2", 79 | }, 80 | ] # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}] 81 | 82 | def get_settings_defaults(self): 83 | return { 84 | "device_type": "X1C", 85 | "serial": "", 86 | "host": "", 87 | "access_code": "", 88 | "username": "bblp", 89 | "timelapse": False, 90 | "bed_leveling": True, 91 | "flow_cali": False, 92 | "vibration_cali": True, 93 | "layer_inspect": False, 94 | "use_ams": False, 95 | "local_mqtt": True, 96 | "region": "", 97 | "email": "", 98 | "auth_token": "", 99 | "always_use_default_options": False, 100 | } 101 | 102 | def is_api_adminonly(self): 103 | return True 104 | 105 | def get_api_commands(self): 106 | return {"register": ["email", "password", "region", "auth_token"]} 107 | 108 | def on_api_command(self, command, data): 109 | if command == "register": 110 | if ( 111 | "email" in data 112 | and "password" in data 113 | and "region" in data 114 | and "auth_token" in data 115 | ): 116 | self._logger.info(f"Registering user {data['email']}") 117 | bambu_cloud = BambuCloud( 118 | data["region"], data["email"], data["password"], data["auth_token"] 119 | ) 120 | bambu_cloud.login(data["region"], data["email"], data["password"]) 121 | return flask.jsonify( 122 | { 123 | "auth_token": bambu_cloud.auth_token, 124 | "username": bambu_cloud.username, 125 | } 126 | ) 127 | 128 | def on_event(self, event, payload): 129 | if event == Events.TRANSFER_DONE: 130 | self._printer.commands("M20 L T", force=True) 131 | 132 | def support_3mf_files(self): 133 | return {"machinecode": {"3mf": ["3mf"]}} 134 | 135 | def upload_to_sd( 136 | self, 137 | printer, 138 | filename, 139 | path, 140 | sd_upload_started, 141 | sd_upload_succeeded, 142 | sd_upload_failed, 143 | *args, 144 | **kwargs, 145 | ): 146 | self._logger.debug(f"Starting upload from {filename} to {filename}") 147 | sd_upload_started(filename, filename) 148 | 149 | def process(): 150 | with measure_elapsed() as get_elapsed: 151 | try: 152 | with self._bambu_file_system.get_ftps_client() as ftp: 153 | if ftp.upload_file(path, f"{filename}"): 154 | sd_upload_succeeded(filename, filename, get_elapsed()) 155 | else: 156 | raise Exception("upload failed") 157 | except Exception as e: 158 | sd_upload_failed(filename, filename, get_elapsed()) 159 | self._logger.exception(e) 160 | 161 | thread = threading.Thread(target=process) 162 | thread.daemon = True 163 | thread.start() 164 | return filename 165 | 166 | def get_template_vars(self): 167 | return {"plugin_version": self._plugin_version} 168 | 169 | def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout): 170 | if not port == "BAMBU": 171 | return None 172 | if ( 173 | self._settings.get(["serial"]) == "" 174 | or self._settings.get(["host"]) == "" 175 | or self._settings.get(["access_code"]) == "" 176 | ): 177 | return None 178 | seriallog_handler = CleaningTimedRotatingFileHandler( 179 | self._settings.get_plugin_logfile_path(postfix="serial"), 180 | when="D", 181 | backupCount=3, 182 | ) 183 | seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) 184 | seriallog_handler.setLevel(logging.DEBUG) 185 | 186 | serial_obj = BambuVirtualPrinter( 187 | self._settings, 188 | self._printer_profile_manager, 189 | data_folder=self.get_plugin_data_folder(), 190 | serial_log_handler=seriallog_handler, 191 | read_timeout=float(read_timeout), 192 | faked_baudrate=baudrate, 193 | ) 194 | return serial_obj 195 | 196 | def get_additional_port_names(self, *args, **kwargs): 197 | if ( 198 | self._settings.get(["serial"]) != "" 199 | and self._settings.get(["host"]) != "" 200 | and self._settings.get(["access_code"]) != "" 201 | ): 202 | return ["BAMBU"] 203 | else: 204 | return [] 205 | 206 | def get_timelapse_file_list(self): 207 | if flask.request.path.startswith("/api/timelapse"): 208 | 209 | def process(): 210 | return_file_list = [] 211 | for file_info in self._timelapse_files_view.get_all_info(): 212 | timelapse_info = BambuTimelapseFileInfo.from_file_info(file_info) 213 | return_file_list.append(timelapse_info.to_dict()) 214 | self._plugin_manager.send_plugin_message( 215 | self._identifier, {"files": return_file_list} 216 | ) 217 | 218 | thread = threading.Thread(target=process) 219 | thread.daemon = True 220 | thread.start() 221 | 222 | def _hook_octoprint_server_api_before_request(self, *args, **kwargs): 223 | return [self.get_timelapse_file_list] 224 | 225 | def _download_file(self, file_name: str, source_path: str): 226 | destination = Path(self.get_plugin_data_folder()) / file_name 227 | if destination.exists(): 228 | return destination 229 | 230 | with self._bambu_file_system.get_ftps_client() as ftp: 231 | ftp.download_file( 232 | source=(Path(source_path) / file_name).as_posix(), 233 | dest=destination.as_posix(), 234 | ) 235 | return destination 236 | 237 | @octoprint.plugin.BlueprintPlugin.route("/timelapse/", methods=["GET"]) 238 | @octoprint.server.util.flask.restricted_access 239 | @no_firstrun_access 240 | @Permissions.TIMELAPSE_DOWNLOAD.require(403) 241 | def downloadTimelapse(self, filename): 242 | self._download_file(filename, "timelapse/") 243 | return flask.redirect( 244 | "/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302 245 | ) 246 | 247 | @octoprint.plugin.BlueprintPlugin.route("/thumbnail/", methods=["GET"]) 248 | @octoprint.server.util.flask.restricted_access 249 | @no_firstrun_access 250 | @Permissions.TIMELAPSE_DOWNLOAD.require(403) 251 | def downloadThumbnail(self, filename): 252 | self._download_file(filename, "timelapse/thumbnail/") 253 | return flask.redirect( 254 | "/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302 255 | ) 256 | 257 | def is_blueprint_csrf_protected(self): 258 | return True 259 | 260 | def route_hook(self, server_routes, *args, **kwargs): 261 | return [ 262 | ( 263 | r"/download/timelapse/(.*)", 264 | LargeResponseHandler, 265 | { 266 | "path": self.get_plugin_data_folder(), 267 | "as_attachment": True, 268 | "path_validation": path_validation_factory( 269 | lambda path: not is_hidden_path(path), status_code=404 270 | ), 271 | }, 272 | ), 273 | ( 274 | r"/download/thumbnail/(.*)", 275 | LargeResponseHandler, 276 | { 277 | "path": self.get_plugin_data_folder(), 278 | "as_attachment": True, 279 | "path_validation": path_validation_factory( 280 | lambda path: not is_hidden_path(path), status_code=404 281 | ), 282 | }, 283 | ), 284 | ] 285 | 286 | def get_update_information(self): 287 | return { 288 | "bambu_printer": { 289 | "displayName": "Bambu Printer", 290 | "displayVersion": self._plugin_version, 291 | "type": "github_release", 292 | "user": "jneilliii", 293 | "repo": "OctoPrint-BambuPrinter", 294 | "current": self._plugin_version, 295 | "stable_branch": { 296 | "name": "Stable", 297 | "branch": "master", 298 | "comittish": ["master"], 299 | }, 300 | "prerelease_branches": [ 301 | { 302 | "name": "Release Candidate", 303 | "branch": "rc", 304 | "comittish": ["rc", "master"], 305 | } 306 | ], 307 | "pip": "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip", 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /test/test_gcode_execution.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime, timezone 3 | import logging 4 | from pathlib import Path 5 | import sys 6 | from typing import Any 7 | from unittest.mock import MagicMock, patch 8 | 9 | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView 10 | import pybambu 11 | import pybambu.commands 12 | from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter 13 | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo 14 | from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient 15 | from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import ( 16 | RemoteSDCardFileList, 17 | ) 18 | from octoprint_bambu_printer.printer.states.idle_state import IdleState 19 | from octoprint_bambu_printer.printer.states.paused_state import PausedState 20 | from octoprint_bambu_printer.printer.states.printing_state import PrintingState 21 | from pytest import fixture 22 | 23 | 24 | @fixture 25 | def output_test_folder(output_folder: Path): 26 | folder = output_folder / "test_gcode" 27 | folder.mkdir(parents=True, exist_ok=True) 28 | return folder 29 | 30 | 31 | @fixture 32 | def log_test(): 33 | log = logging.getLogger("gcode_unittest") 34 | log.setLevel(logging.DEBUG) 35 | return log 36 | 37 | 38 | class DictGetter: 39 | def __init__(self, options: dict, default_value=None) -> None: 40 | self.options: dict[str | tuple[str, ...], Any] = options 41 | self._default_value = default_value 42 | 43 | def __call__(self, key: str | list[str] | tuple[str, ...]): 44 | if isinstance(key, list): 45 | key = tuple(key) 46 | return self.options.get(key, self._default_value) 47 | 48 | 49 | @fixture 50 | def settings(output_test_folder): 51 | _settings = MagicMock() 52 | _settings.get.side_effect = DictGetter( 53 | { 54 | "serial": "BAMBU", 55 | "host": "localhost", 56 | "access_code": "12345", 57 | } 58 | ) 59 | _settings.get_boolean.side_effect = DictGetter({"forceChecksum": False}) 60 | 61 | log_file_path = output_test_folder / "log.txt" 62 | log_file_path.touch() 63 | _settings.get_plugin_logfile_path.return_value = log_file_path.as_posix() 64 | return _settings 65 | 66 | 67 | @fixture 68 | def profile_manager(): 69 | _profile_manager = MagicMock() 70 | _profile_manager.get_current.side_effect = MagicMock() 71 | _profile_manager.get_current().get.side_effect = DictGetter( 72 | { 73 | "heatedChamber": False, 74 | } 75 | ) 76 | return _profile_manager 77 | 78 | 79 | def _ftp_date_format(dt: datetime): 80 | return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S") 81 | 82 | 83 | @fixture 84 | def project_files_info_ftp(): 85 | return { 86 | "print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))), 87 | "print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), 88 | } 89 | 90 | 91 | @fixture 92 | def cache_files_info_ftp(): 93 | return { 94 | "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), 95 | "cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), 96 | "cache/long file path with spaces.gcode.3mf": ( 97 | 1200, 98 | _ftp_date_format(datetime(2024, 5, 7)), 99 | ), 100 | } 101 | 102 | 103 | @fixture 104 | def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp): 105 | all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp) 106 | ftps_session = MagicMock() 107 | ftps_session.size.side_effect = DictGetter( 108 | {file: info[0] for file, info in all_file_info.items()} 109 | ) 110 | 111 | ftps_session.sendcmd.side_effect = DictGetter( 112 | {f"MDTM {file}": info[1] for file, info in all_file_info.items()} 113 | ) 114 | 115 | ftps_session.nlst.side_effect = DictGetter( 116 | { 117 | "": list(map(lambda p: Path(p).name, project_files_info_ftp)) 118 | + ["Mock folder"], 119 | "cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp)) 120 | + ["Mock folder"], 121 | "timelapse/": ["video.mp4", "video.avi"], 122 | } 123 | ) 124 | IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session) 125 | yield ftps_session 126 | 127 | 128 | @fixture(scope="function") 129 | def print_job_mock(): 130 | print_job = MagicMock() 131 | print_job.subtask_name = "" 132 | print_job.print_percentage = 0 133 | return print_job 134 | 135 | 136 | @fixture(scope="function") 137 | def temperatures_mock(): 138 | temperatures = MagicMock() 139 | temperatures.nozzle_temp = 0 140 | temperatures.target_nozzle_temp = 0 141 | temperatures.bed_temp = 0 142 | temperatures.target_bed_temp = 0 143 | temperatures.chamber_temp = 0 144 | return temperatures 145 | 146 | 147 | @fixture(scope="function") 148 | def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient: 149 | bambu_client = MagicMock() 150 | bambu_client.connected = True 151 | device_mock = MagicMock() 152 | device_mock.print_job = print_job_mock 153 | device_mock.temperatures = temperatures_mock 154 | bambu_client.get_device.return_value = device_mock 155 | return bambu_client 156 | 157 | 158 | @fixture(scope="function") 159 | def printer( 160 | output_test_folder, 161 | settings, 162 | profile_manager, 163 | log_test, 164 | ftps_session_mock, 165 | bambu_client_mock, 166 | ): 167 | async def _mock_connection(self): 168 | pass 169 | 170 | BambuVirtualPrinter._create_client_connection_async = _mock_connection 171 | printer_test = BambuVirtualPrinter( 172 | settings, 173 | profile_manager, 174 | data_folder=output_test_folder, 175 | serial_log_handler=log_test, 176 | read_timeout=0.01, 177 | faked_baudrate=115200, 178 | ) 179 | printer_test._bambu_client = bambu_client_mock 180 | printer_test.flush() 181 | printer_test.readlines() 182 | yield printer_test 183 | printer_test.close() 184 | 185 | 186 | def test_initial_state(printer: BambuVirtualPrinter): 187 | assert isinstance(printer.current_state, IdleState) 188 | 189 | 190 | def test_list_sd_card(printer: BambuVirtualPrinter): 191 | printer.write(b"M20\n") # GCode for listing SD card 192 | printer.flush() 193 | result = printer.readlines() 194 | assert result[0] == b"Begin file list" 195 | assert result[1].endswith(b'"print.3mf"') 196 | assert result[2].endswith(b'"print2.3mf"') 197 | assert result[3].endswith(b'"print.3mf"') 198 | assert result[4].endswith(b'"print3.gcode.3mf"') 199 | assert result[-3] == b"End file list" 200 | assert result[-2] == b"ok" 201 | assert result[-1] == b"ok" 202 | 203 | 204 | def test_list_ftp_paths_p1s(settings, ftps_session_mock): 205 | file_system = RemoteSDCardFileList(settings) 206 | file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi") 207 | 208 | timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"] 209 | ftps_session_mock.size.side_effect = DictGetter( 210 | {file: 100 for file in timelapse_files} 211 | ) 212 | ftps_session_mock.sendcmd.side_effect = DictGetter( 213 | { 214 | f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) 215 | for file in timelapse_files 216 | } 217 | ) 218 | ftps_session_mock.nlst.side_effect = DictGetter( 219 | {"timelapse/": [Path(f).name for f in timelapse_files]} 220 | ) 221 | 222 | timelapse_paths = list(map(Path, timelapse_files)) 223 | result_files = file_view.get_all_info() 224 | assert len(timelapse_files) == len(result_files) and all( 225 | file_info.path in timelapse_paths for file_info in result_files 226 | ) 227 | 228 | 229 | def test_list_ftp_paths_x1(settings, ftps_session_mock): 230 | file_system = RemoteSDCardFileList(settings) 231 | file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4") 232 | 233 | timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"] 234 | ftps_session_mock.size.side_effect = DictGetter( 235 | {file: 100 for file in timelapse_files} 236 | ) 237 | ftps_session_mock.sendcmd.side_effect = DictGetter( 238 | { 239 | f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7)) 240 | for file in timelapse_files 241 | } 242 | ) 243 | ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files}) 244 | 245 | timelapse_paths = list(map(Path, timelapse_files)) 246 | result_files = file_view.get_all_info() 247 | assert len(timelapse_files) == len(result_files) and all( 248 | file_info.path in timelapse_paths for file_info in result_files 249 | ) 250 | 251 | 252 | def test_delete_sd_file_gcode(printer: BambuVirtualPrinter): 253 | with patch( 254 | "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" 255 | ) as delete_function: 256 | printer.write(b"M30 print.3mf\n") 257 | printer.flush() 258 | result = printer.readlines() 259 | assert result[-1] == b"ok" 260 | delete_function.assert_called_with("print.3mf") 261 | 262 | printer.write(b"M30 cache/print.3mf\n") 263 | printer.flush() 264 | result = printer.readlines() 265 | assert result[-1] == b"ok" 266 | delete_function.assert_called_with("cache/print.3mf") 267 | 268 | 269 | def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter): 270 | with patch( 271 | "octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file" 272 | ) as delete_function: 273 | file_info = printer.project_files.get_file_data("cache/print.3mf") 274 | assert file_info is not None 275 | 276 | printer.write(b"M30 " + file_info.dosname.encode() + b"\n") 277 | printer.flush() 278 | assert printer.readlines()[-1] == b"ok" 279 | assert delete_function.call_count == 1 280 | delete_function.assert_called_with("cache/print.3mf") 281 | 282 | printer.write(b"M30 cache/print.3mf\n") 283 | printer.flush() 284 | assert printer.readlines()[-1] == b"ok" 285 | assert delete_function.call_count == 2 286 | delete_function.assert_called_with("cache/print.3mf") 287 | 288 | 289 | def test_select_project_file_by_stem(printer: BambuVirtualPrinter): 290 | printer.write(b"M23 print3\n") 291 | printer.flush() 292 | result = printer.readlines() 293 | assert printer.selected_file is not None 294 | assert printer.selected_file.path == Path("cache/print3.gcode.3mf") 295 | assert result[-2] == b"File selected" 296 | assert result[-1] == b"ok" 297 | 298 | 299 | def test_select_project_long_name_file_with_multiple_extensions( 300 | printer: BambuVirtualPrinter, 301 | ): 302 | printer.write(b"M23 long file path with spaces.gcode.3mf\n") 303 | printer.flush() 304 | result = printer.readlines() 305 | assert printer.selected_file is not None 306 | assert printer.selected_file.path == Path( 307 | "cache/long file path with spaces.gcode.3mf" 308 | ) 309 | assert result[-2] == b"File selected" 310 | assert result[-1] == b"ok" 311 | 312 | 313 | def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): 314 | printer.write(b"M24\n") 315 | printer.flush() 316 | result = printer.readlines() 317 | assert result[0] == b"ok" 318 | assert isinstance(printer.current_state, IdleState) 319 | 320 | 321 | def test_non_existing_file_not_selected(printer: BambuVirtualPrinter): 322 | assert printer.selected_file is None 323 | 324 | printer.write(b"M23 non_existing.3mf\n") 325 | printer.flush() 326 | result = printer.readlines() 327 | assert result[-2] != b"File selected" 328 | assert result[-1] == b"ok" 329 | assert printer.selected_file is None 330 | 331 | 332 | def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock): 333 | assert printer.selected_file is None 334 | 335 | printer.write(b"M20\n") 336 | printer.flush() 337 | printer.readlines() 338 | 339 | printer.write(b"M23 print.3mf\n") 340 | printer.flush() 341 | result = printer.readlines() 342 | assert result[-2] == b"File selected" 343 | assert result[-1] == b"ok" 344 | 345 | assert printer.selected_file is not None 346 | assert printer.selected_file.file_name == "print.3mf" 347 | 348 | print_job_mock.subtask_name = "print.3mf" 349 | 350 | printer.write(b"M24\n") 351 | printer.flush() 352 | result = printer.readlines() 353 | assert result[-1] == b"ok" 354 | 355 | # emulate printer reporting it's status 356 | print_job_mock.gcode_state = "RUNNING" 357 | printer.new_update("event_printer_data_update") 358 | printer.flush() 359 | assert isinstance(printer.current_state, PrintingState) 360 | 361 | 362 | def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock): 363 | print_job_mock.subtask_name = "print.3mf" 364 | 365 | printer.write(b"M20\n") 366 | printer.write(b"M23 print.3mf\n") 367 | printer.write(b"M24\n") 368 | printer.flush() 369 | 370 | print_job_mock.gcode_state = "RUNNING" 371 | printer.new_update("event_printer_data_update") 372 | printer.flush() 373 | assert isinstance(printer.current_state, PrintingState) 374 | 375 | printer.write(b"M25\n") # pausing the print 376 | printer.flush() 377 | result = printer.readlines() 378 | assert result[-1] == b"ok" 379 | 380 | print_job_mock.gcode_state = "PAUSE" 381 | printer.new_update("event_printer_data_update") 382 | printer.flush() 383 | assert isinstance(printer.current_state, PausedState) 384 | bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE) 385 | 386 | 387 | def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock): 388 | print_job_mock.subtask_name = "print.3mf" 389 | print_job_mock.gcode_state = "RUNNING" 390 | printer.new_update("event_printer_data_update") 391 | printer.flush() 392 | assert isinstance(printer.current_state, PrintingState) 393 | 394 | print_job_mock.gcode_state = "PAUSE" 395 | printer.new_update("event_printer_data_update") 396 | printer.flush() 397 | assert isinstance(printer.current_state, PausedState) 398 | 399 | print_job_mock.gcode_state = "IDLE" 400 | printer.new_update("event_printer_data_update") 401 | printer.flush() 402 | assert isinstance(printer.current_state, IdleState) 403 | 404 | print_job_mock.gcode_state = "FINISH" 405 | printer.new_update("event_printer_data_update") 406 | printer.flush() 407 | assert isinstance(printer.current_state, IdleState) 408 | 409 | print_job_mock.gcode_state = "FAILED" 410 | printer.new_update("event_printer_data_update") 411 | printer.flush() 412 | assert isinstance(printer.current_state, IdleState) 413 | 414 | 415 | def test_printer_info_check(printer: BambuVirtualPrinter): 416 | printer.write(b"M27\n") # printer get info 417 | printer.flush() 418 | 419 | result = printer.readlines() 420 | assert result[-1] == b"ok" 421 | assert isinstance(printer.current_state, IdleState) 422 | 423 | 424 | def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock): 425 | print_job_mock.subtask_name = "print.3mf" 426 | 427 | printer.write(b"M20\nM23 print.3mf\nM24\n") 428 | printer.flush() 429 | print_job_mock.gcode_state = "RUNNING" 430 | print_job_mock.print_percentage = 50 431 | printer.new_update("event_printer_data_update") 432 | printer.flush() 433 | printer.readlines() 434 | assert isinstance(printer.current_state, PrintingState) 435 | 436 | printer.write(b"M26 S0\n") 437 | printer.flush() 438 | result = printer.readlines() 439 | assert result[-1] == b"ok" 440 | assert isinstance(printer.current_state, IdleState) 441 | 442 | 443 | def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock): 444 | print_job_mock.subtask_name = "print.3mf" 445 | 446 | printer.write(b"M20\nM23 print.3mf\nM24\n") 447 | printer.flush() 448 | print_job_mock.gcode_state = "RUNNING" 449 | printer.new_update("event_printer_data_update") 450 | printer.flush() 451 | 452 | printer.write(b"M25\n") 453 | printer.flush() 454 | print_job_mock.gcode_state = "PAUSE" 455 | printer.new_update("event_printer_data_update") 456 | printer.flush() 457 | 458 | printer.readlines() 459 | assert isinstance(printer.current_state, PausedState) 460 | 461 | printer.write(b"M26 S0\n") 462 | printer.flush() 463 | result = printer.readlines() 464 | assert result[-1] == b"ok" 465 | assert isinstance(printer.current_state, IdleState) 466 | 467 | 468 | def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock): 469 | gcode = b"G28\nG1 X10 Y10\n" 470 | printer.write(gcode) 471 | printer.flush() 472 | result = printer.readlines() 473 | assert result[-1] == b"ok" 474 | 475 | gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE 476 | gcode_command["print"]["param"] = "G28\n" 477 | bambu_client_mock.publish.assert_called_with(gcode_command) 478 | 479 | gcode_command["print"]["param"] = "G1 X10 Y10\n" 480 | bambu_client_mock.publish.assert_called_with(gcode_command) 481 | 482 | 483 | def test_file_selection_does_not_affect_current_print( 484 | printer: BambuVirtualPrinter, print_job_mock 485 | ): 486 | print_job_mock.subtask_name = "print.3mf" 487 | 488 | printer.write(b"M23 print.3mf\nM24\n") 489 | printer.flush() 490 | print_job_mock.gcode_state = "RUNNING" 491 | printer.new_update("event_printer_data_update") 492 | printer.flush() 493 | assert isinstance(printer.current_state, PrintingState) 494 | assert printer.current_print_job is not None 495 | assert printer.current_print_job.file_info.file_name == "print.3mf" 496 | assert printer.current_print_job.progress == 0 497 | 498 | printer.write(b"M23 print2.3mf\n") 499 | printer.flush() 500 | assert printer.current_print_job is not None 501 | assert printer.current_print_job.file_info.file_name == "print.3mf" 502 | assert printer.current_print_job.progress == 0 503 | 504 | 505 | def test_finished_print_job_reset_after_new_file_selected( 506 | printer: BambuVirtualPrinter, print_job_mock 507 | ): 508 | print_job_mock.subtask_name = "print.3mf" 509 | 510 | printer.write(b"M23 print.3mf\nM24\n") 511 | printer.flush() 512 | print_job_mock.gcode_state = "RUNNING" 513 | printer.new_update("event_printer_data_update") 514 | printer.flush() 515 | assert isinstance(printer.current_state, PrintingState) 516 | assert printer.current_print_job is not None 517 | assert printer.current_print_job.file_info.file_name == "print.3mf" 518 | assert printer.current_print_job.progress == 0 519 | 520 | print_job_mock.print_percentage = 100 521 | printer.current_state.update_print_job_info() 522 | assert isinstance(printer.current_state, PrintingState) 523 | assert printer.current_print_job.progress == 100 524 | 525 | print_job_mock.gcode_state = "FINISH" 526 | printer.new_update("event_printer_data_update") 527 | printer.flush() 528 | assert isinstance(printer.current_state, IdleState) 529 | assert printer.current_print_job is None 530 | assert printer.selected_file is not None 531 | assert printer.selected_file.file_name == "print.3mf" 532 | 533 | printer.write(b"M23 print2.3mf\n") 534 | printer.flush() 535 | assert printer.current_print_job is None 536 | assert printer.selected_file is not None 537 | assert printer.selected_file.file_name == "print2.3mf" 538 | 539 | 540 | def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock): 541 | print_job_mock.subtask_name = "print.3mf" 542 | print_job_mock.gcode_state = "RUNNING" 543 | print_job_mock.print_percentage = 99 544 | printer.new_update("event_printer_data_update") 545 | printer.flush() 546 | assert isinstance(printer.current_state, PrintingState) 547 | assert printer.current_print_job is not None 548 | assert printer.current_print_job.file_info.file_name == "print.3mf" 549 | assert printer.current_print_job.progress == 99 550 | 551 | print_job_mock.print_percentage = 100 552 | print_job_mock.gcode_state = "FINISH" 553 | printer.new_update("event_printer_data_update") 554 | printer.flush() 555 | result = printer.readlines() 556 | assert result[-3].endswith(b"1000/1000") 557 | assert result[-2] == b"Done printing file" 558 | assert result[-1] == b"Not SD printing" 559 | assert isinstance(printer.current_state, IdleState) 560 | assert printer.current_print_job is None 561 | assert printer.selected_file is not None 562 | assert printer.selected_file.file_name == "print.3mf" 563 | -------------------------------------------------------------------------------- /octoprint_bambu_printer/printer/bambu_virtual_printer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | from dataclasses import dataclass, field 5 | import math 6 | from pathlib import Path 7 | import queue 8 | import re 9 | import threading 10 | import time 11 | from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView 12 | from octoprint_bambu_printer.printer.file_system.file_info import FileInfo 13 | from octoprint_bambu_printer.printer.print_job import PrintJob 14 | from pybambu import BambuClient, commands 15 | import logging 16 | import logging.handlers 17 | 18 | from octoprint.util import RepeatedTimer 19 | 20 | from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState 21 | from octoprint_bambu_printer.printer.states.idle_state import IdleState 22 | 23 | from .printer_serial_io import PrinterSerialIO 24 | from .states.paused_state import PausedState 25 | from .states.printing_state import PrintingState 26 | 27 | from .gcode_executor import GCodeExecutor 28 | from .file_system.remote_sd_card_file_list import RemoteSDCardFileList 29 | 30 | 31 | AMBIENT_TEMPERATURE: float = 21.3 32 | 33 | 34 | @dataclass 35 | class BambuPrinterTelemetry: 36 | temp: list[float] = field(default_factory=lambda: [AMBIENT_TEMPERATURE]) 37 | targetTemp: list[float] = field(default_factory=lambda: [0.0]) 38 | bedTemp: float = AMBIENT_TEMPERATURE 39 | bedTargetTemp = 0.0 40 | hasChamber: bool = False 41 | chamberTemp: float = AMBIENT_TEMPERATURE 42 | chamberTargetTemp: float = 0.0 43 | lastTempAt: float = time.monotonic() 44 | firmwareName: str = "Bambu" 45 | extruderCount: int = 1 46 | 47 | 48 | # noinspection PyBroadException 49 | class BambuVirtualPrinter: 50 | gcode_executor = GCodeExecutor() 51 | 52 | def __init__( 53 | self, 54 | settings, 55 | printer_profile_manager, 56 | data_folder, 57 | serial_log_handler=None, 58 | read_timeout=5.0, 59 | faked_baudrate=115200, 60 | ): 61 | self._settings = settings 62 | self._printer_profile_manager = printer_profile_manager 63 | self._faked_baudrate = faked_baudrate 64 | self._data_folder = data_folder 65 | self._last_hms_errors = None 66 | self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter") 67 | 68 | self._state_idle = IdleState(self) 69 | self._state_printing = PrintingState(self) 70 | self._state_paused = PausedState(self) 71 | self._current_state = self._state_idle 72 | 73 | self._running = True 74 | self._print_status_reporter = None 75 | self._print_temp_reporter = None 76 | self._printer_thread = threading.Thread( 77 | target=self._printer_worker, 78 | name="octoprint.plugins.bambu_printer.printer_state", 79 | ) 80 | self._state_change_queue = queue.Queue() 81 | 82 | self._current_print_job: PrintJob | None = None 83 | 84 | self._serial_io = PrinterSerialIO( 85 | handle_command_callback=self._process_gcode_serial_command, 86 | settings=settings, 87 | serial_log_handler=serial_log_handler, 88 | read_timeout=read_timeout, 89 | write_timeout=10.0, 90 | ) 91 | 92 | self._telemetry = BambuPrinterTelemetry() 93 | self._telemetry.hasChamber = printer_profile_manager.get_current().get( 94 | "heatedChamber" 95 | ) 96 | 97 | self.file_system = RemoteSDCardFileList(settings) 98 | self._selected_project_file: FileInfo | None = None 99 | self._project_files_view = ( 100 | CachedFileView(self.file_system, on_update=self._list_cached_project_files) 101 | .with_filter("", ".3mf") 102 | .with_filter("cache/", ".3mf") 103 | ) 104 | 105 | self._serial_io.start() 106 | self._printer_thread.start() 107 | 108 | self._bambu_client: BambuClient = self._create_client_connection_async() 109 | 110 | @property 111 | def bambu_client(self): 112 | return self._bambu_client 113 | 114 | @property 115 | def is_running(self): 116 | return self._running 117 | 118 | @property 119 | def current_state(self): 120 | return self._current_state 121 | 122 | @property 123 | def current_print_job(self): 124 | return self._current_print_job 125 | 126 | @current_print_job.setter 127 | def current_print_job(self, value): 128 | self._current_print_job = value 129 | 130 | @property 131 | def selected_file(self): 132 | return self._selected_project_file 133 | 134 | @property 135 | def has_selected_file(self): 136 | return self._selected_project_file is not None 137 | 138 | @property 139 | def timeout(self): 140 | return self._serial_io._read_timeout 141 | 142 | @timeout.setter 143 | def timeout(self, value): 144 | self._log.debug(f"Setting read timeout to {value}s") 145 | self._serial_io._read_timeout = value 146 | 147 | @property 148 | def write_timeout(self): 149 | return self._serial_io._write_timeout 150 | 151 | @write_timeout.setter 152 | def write_timeout(self, value): 153 | self._log.debug(f"Setting write timeout to {value}s") 154 | self._serial_io._write_timeout = value 155 | 156 | @property 157 | def port(self): 158 | return "BAMBU" 159 | 160 | @property 161 | def baudrate(self): 162 | return self._faked_baudrate 163 | 164 | @property 165 | def project_files(self): 166 | return self._project_files_view 167 | 168 | def change_state(self, new_state: APrinterState): 169 | self._state_change_queue.put(new_state) 170 | 171 | def new_update(self, event_type): 172 | if event_type == "event_hms_errors": 173 | self._update_hms_errors() 174 | elif event_type == "event_printer_data_update": 175 | self._update_printer_info() 176 | 177 | def _update_printer_info(self): 178 | device_data = self.bambu_client.get_device() 179 | print_job_state = device_data.print_job.gcode_state 180 | temperatures = device_data.temperature 181 | 182 | self.lastTempAt = time.monotonic() 183 | self._telemetry.temp[0] = temperatures.nozzle_temp 184 | self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp 185 | self._telemetry.bedTemp = temperatures.bed_temp 186 | self._telemetry.bedTargetTemp = temperatures.target_bed_temp 187 | self._telemetry.chamberTemp = temperatures.chamber_temp 188 | 189 | self._log.debug(f"Received printer state update: {print_job_state}") 190 | if ( 191 | print_job_state == "IDLE" 192 | or print_job_state == "FINISH" 193 | or print_job_state == "FAILED" 194 | ): 195 | self.change_state(self._state_idle) 196 | elif print_job_state == "RUNNING" or print_job_state == "PREPARE": 197 | self.change_state(self._state_printing) 198 | elif print_job_state == "PAUSE": 199 | self.change_state(self._state_paused) 200 | else: 201 | self._log.warn(f"Unknown print job state: {print_job_state}") 202 | 203 | def _update_hms_errors(self): 204 | bambu_printer = self.bambu_client.get_device() 205 | if ( 206 | bambu_printer.hms.errors != self._last_hms_errors 207 | and bambu_printer.hms.errors["Count"] > 0 208 | ): 209 | self._log.debug(f"HMS Error: {bambu_printer.hms.errors}") 210 | for n in range(1, bambu_printer.hms.errors["Count"] + 1): 211 | error = bambu_printer.hms.errors[f"{n}-Error"].strip() 212 | self.sendIO(f"// action:notification {error}") 213 | self._last_hms_errors = bambu_printer.hms.errors 214 | 215 | def on_disconnect(self, on_disconnect): 216 | self._log.debug(f"on disconnect called") 217 | return on_disconnect 218 | 219 | def on_connect(self, on_connect): 220 | self._log.debug(f"on connect called") 221 | return on_connect 222 | 223 | def _create_client_connection_async(self): 224 | self._create_client_connection() 225 | if self._bambu_client is None: 226 | raise RuntimeError("Connection with Bambu Client not established") 227 | return self._bambu_client 228 | 229 | def _create_client_connection(self): 230 | if ( 231 | self._settings.get(["device_type"]) == "" 232 | or self._settings.get(["serial"]) == "" 233 | or self._settings.get(["username"]) == "" 234 | or self._settings.get(["access_code"]) == "" 235 | ): 236 | msg = "invalid settings to start connection with Bambu Printer" 237 | self._log.debug(msg) 238 | raise ValueError(msg) 239 | 240 | self._log.debug( 241 | f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}" 242 | ) 243 | bambu_client = BambuClient( 244 | device_type=self._settings.get(["device_type"]), 245 | serial=self._settings.get(["serial"]), 246 | host=self._settings.get(["host"]), 247 | username=( 248 | "bblp" 249 | if self._settings.get_boolean(["local_mqtt"]) 250 | else self._settings.get(["username"]) 251 | ), 252 | access_code=self._settings.get(["access_code"]), 253 | local_mqtt=self._settings.get_boolean(["local_mqtt"]), 254 | region=self._settings.get(["region"]), 255 | email=self._settings.get(["email"]), 256 | auth_token=self._settings.get(["auth_token"]), 257 | ) 258 | bambu_client.on_disconnect = self.on_disconnect(bambu_client.on_disconnect) 259 | bambu_client.on_connect = self.on_connect(bambu_client.on_connect) 260 | bambu_client.connect(callback=self.new_update) 261 | self._log.info(f"bambu connection status: {bambu_client.connected}") 262 | self.sendOk() 263 | self._bambu_client = bambu_client 264 | 265 | def __str__(self): 266 | return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format( 267 | read_timeout=self.timeout, 268 | write_timeout=self.write_timeout, 269 | options={ 270 | "device_type": self._settings.get(["device_type"]), 271 | "host": self._settings.get(["host"]), 272 | }, 273 | ) 274 | 275 | def _reset(self): 276 | with self._serial_io.incoming_lock: 277 | self.lastN = 0 278 | self._running = False 279 | 280 | if self._print_status_reporter is not None: 281 | self._print_status_reporter.cancel() 282 | self._print_status_reporter = None 283 | 284 | if self._settings.get_boolean(["simulateReset"]): 285 | for item in self._settings.get(["resetLines"]): 286 | self.sendIO(item + "\n") 287 | 288 | self._serial_io.reset() 289 | 290 | def write(self, data: bytes) -> int: 291 | return self._serial_io.write(data) 292 | 293 | def readline(self) -> bytes: 294 | return self._serial_io.readline() 295 | 296 | def readlines(self) -> list[bytes]: 297 | return self._serial_io.readlines() 298 | 299 | def sendIO(self, line: str): 300 | self._serial_io.send(line) 301 | 302 | def sendOk(self): 303 | self._serial_io.sendOk() 304 | 305 | def flush(self): 306 | self._serial_io.flush() 307 | self._wait_for_state_change() 308 | 309 | ##~~ project file functions 310 | 311 | def remove_project_selection(self): 312 | self._selected_project_file = None 313 | 314 | def select_project_file(self, file_path: str) -> bool: 315 | self._log.debug(f"Select project file: {file_path}") 316 | file_info = self._project_files_view.get_file_by_stem( 317 | file_path, [".gcode", ".3mf"] 318 | ) 319 | if ( 320 | self._selected_project_file is not None 321 | and file_info is not None 322 | and self._selected_project_file.path == file_info.path 323 | ): 324 | return True 325 | 326 | if file_info is None: 327 | self._log.error(f"Cannot select not existing file: {file_path}") 328 | return False 329 | 330 | self._selected_project_file = file_info 331 | self._send_file_selected_message() 332 | return True 333 | 334 | ##~~ command implementations 335 | 336 | @gcode_executor.register_no_data("M21") 337 | def _sd_status(self) -> None: 338 | self.sendIO("SD card ok") 339 | 340 | @gcode_executor.register("M23") 341 | def _select_sd_file(self, data: str) -> bool: 342 | filename = data.split(maxsplit=1)[1].strip() 343 | return self.select_project_file(filename) 344 | 345 | def _send_file_selected_message(self): 346 | if self.selected_file is None: 347 | return 348 | 349 | self.sendIO( 350 | f"File opened: {self.selected_file.file_name} " 351 | f"Size: {self.selected_file.size}" 352 | ) 353 | self.sendIO("File selected") 354 | 355 | @gcode_executor.register("M26") 356 | def _set_sd_position(self, data: str) -> bool: 357 | if data == "M26 S0": 358 | return self._cancel_print() 359 | else: 360 | self._log.debug("ignoring M26 command.") 361 | self.sendIO("M26 disabled for Bambu") 362 | return True 363 | 364 | @gcode_executor.register("M27") 365 | def _report_sd_print_status(self, data: str) -> bool: 366 | matchS = re.search(r"S([0-9]+)", data) 367 | if matchS: 368 | interval = int(matchS.group(1)) 369 | if interval > 0: 370 | self.start_continuous_status_report(interval) 371 | return False 372 | else: 373 | self.stop_continuous_status_report() 374 | return False 375 | 376 | self.report_print_job_status() 377 | return True 378 | 379 | def start_continuous_status_report(self, interval: int): 380 | if self._print_status_reporter is not None: 381 | self._print_status_reporter.cancel() 382 | 383 | self._print_status_reporter = RepeatedTimer( 384 | interval, self.report_print_job_status 385 | ) 386 | self._print_status_reporter.start() 387 | 388 | def stop_continuous_status_report(self): 389 | if self._print_status_reporter is not None: 390 | self._print_status_reporter.cancel() 391 | self._print_status_reporter = None 392 | 393 | @gcode_executor.register("M30") 394 | def _delete_project_file(self, data: str) -> bool: 395 | file_path = data.split(maxsplit=1)[1].strip() 396 | file_info = self.project_files.get_file_data(file_path) 397 | if file_info is not None: 398 | self.file_system.delete_file(file_info.path) 399 | self._update_project_file_list() 400 | else: 401 | self._log.error(f"File not found to delete {file_path}") 402 | return True 403 | 404 | @gcode_executor.register("M105") 405 | def _report_temperatures(self, data: str) -> bool: 406 | self._processTemperatureQuery() 407 | return True 408 | 409 | @gcode_executor.register("M155") 410 | def _auto_report_temperatures(self, data: str) -> bool: 411 | matchS = re.search(r"S([0-9]+)", data) 412 | if matchS: 413 | interval = int(matchS.group(1)) 414 | if interval > 0: 415 | self.start_continuous_temp_report(interval) 416 | else: 417 | self.stop_continuous_temp_report() 418 | 419 | self.report_print_job_status() 420 | return True 421 | 422 | def start_continuous_temp_report(self, interval: int): 423 | if self._print_temp_reporter is not None: 424 | self._print_temp_reporter.cancel() 425 | 426 | self._print_temp_reporter = RepeatedTimer( 427 | interval, self._processTemperatureQuery 428 | ) 429 | self._print_temp_reporter.start() 430 | 431 | def stop_continuous_temp_report(self): 432 | if self._print_temp_reporter is not None: 433 | self._print_temp_reporter.cancel() 434 | self._print_temp_reporter = None 435 | 436 | # noinspection PyUnusedLocal 437 | @gcode_executor.register_no_data("M115") 438 | def _report_firmware_info(self) -> bool: 439 | self.sendIO("Bambu Printer Integration") 440 | self.sendIO("Cap:AUTOREPORT_SD_STATUS:1") 441 | self.sendIO("Cap:AUTOREPORT_TEMP:1") 442 | self.sendIO("Cap:EXTENDED_M20:1") 443 | self.sendIO("Cap:LFN_WRITE:1") 444 | return True 445 | 446 | @gcode_executor.register("M117") 447 | def _get_lcd_message(self, data: str) -> bool: 448 | result = re.search(r"M117\s+(.*)", data).group(1) 449 | self.sendIO(f"echo:{result}") 450 | return True 451 | 452 | @gcode_executor.register("M118") 453 | def _serial_print(self, data: str) -> bool: 454 | match = re.search(r"M118 (?:(?PA1|E1|Pn[012])\s)?(?P.*)", data) 455 | if not match: 456 | self.sendIO("Unrecognized command parameters for M118") 457 | else: 458 | result = match.groupdict() 459 | text = result["text"] 460 | parameter = result["parameter"] 461 | 462 | if parameter == "A1": 463 | self.sendIO(f"//{text}") 464 | elif parameter == "E1": 465 | self.sendIO(f"echo:{text}") 466 | else: 467 | self.sendIO(text) 468 | return True 469 | 470 | # noinspection PyUnusedLocal 471 | @gcode_executor.register("M220") 472 | def _set_feedrate_percent(self, data: str) -> bool: 473 | if self.bambu_client.connected: 474 | gcode_command = commands.SEND_GCODE_TEMPLATE 475 | percent = int(data.replace("M220 S", "")) 476 | 477 | def speed_fraction(speed_percent): 478 | return math.floor(10000 / speed_percent) / 100 479 | 480 | def acceleration_magnitude(speed_percent): 481 | return math.exp((speed_fraction(speed_percent) - 1.0191) / -0.8139) 482 | 483 | def feed_rate(speed_percent): 484 | return 6.426e-5 * speed_percent ** 2 - 2.484e-3 * speed_percent + 0.654 485 | 486 | def linear_interpolate(x, x_points, y_points): 487 | if x <= x_points[0]: return y_points[0] 488 | if x >= x_points[-1]: return y_points[-1] 489 | for i in range(len(x_points) - 1): 490 | if x_points[i] <= x < x_points[i + 1]: 491 | t = (x - x_points[i]) / (x_points[i + 1] - x_points[i]) 492 | return y_points[i] * (1 - t) + y_points[i + 1] * t 493 | 494 | def scale_to_data_points(func, data_points): 495 | data_points.sort(key=lambda x: x[0]) 496 | speeds, values = zip(*data_points) 497 | scaling_factors = [v / func(s) for s, v in zip(speeds, values)] 498 | return lambda x: func(x) * linear_interpolate(x, speeds, scaling_factors) 499 | 500 | def speed_adjust(speed_percentage): 501 | if not 30 <= speed_percentage <= 180: 502 | speed_percentage = 100 503 | 504 | bambu_params = { 505 | "speed": [50, 100, 124, 166], 506 | "acceleration": [0.3, 1.0, 1.4, 1.6], 507 | "feed_rate": [0.7, 1.0, 1.4, 2.0] 508 | } 509 | 510 | acc_mag_scaled = scale_to_data_points(acceleration_magnitude, 511 | list(zip(bambu_params["speed"], bambu_params["acceleration"]))) 512 | feed_rate_scaled = scale_to_data_points(feed_rate, 513 | list(zip(bambu_params["speed"], bambu_params["feed_rate"]))) 514 | 515 | speed_frac = speed_fraction(speed_percentage) 516 | acc_mag = acc_mag_scaled(speed_percentage) 517 | feed = feed_rate_scaled(speed_percentage) 518 | # speed_level = 1.539 * (acc_mag**2) - 0.7032 * acc_mag + 4.0834 519 | 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 520 | 521 | speed_command = speed_adjust(percent) 522 | 523 | gcode_command["print"]["param"] = speed_command 524 | if self.bambu_client.publish(gcode_command): 525 | self._log.info(f"{percent}% speed adjustment command sent successfully") 526 | return True 527 | 528 | def _process_gcode_serial_command(self, gcode: str, full_command: str): 529 | self._log.debug(f"processing gcode {gcode} command = {full_command}") 530 | handled = self.gcode_executor.execute(self, gcode, full_command) 531 | if handled: 532 | self.sendOk() 533 | return 534 | 535 | # post gcode to printer otherwise 536 | if self.bambu_client.connected: 537 | GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE 538 | GCODE_COMMAND["print"]["param"] = full_command + "\n" 539 | if self.bambu_client.publish(GCODE_COMMAND): 540 | self._log.info("command sent successfully") 541 | self.sendOk() 542 | 543 | @gcode_executor.register_no_data("M112") 544 | def _shutdown(self): 545 | self._running = True 546 | if self.bambu_client.connected: 547 | self.bambu_client.disconnect() 548 | self.sendIO("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.") 549 | self._serial_io.close() 550 | return True 551 | 552 | @gcode_executor.register("M20") 553 | def _update_project_file_list(self, data: str = ""): 554 | self._project_files_view.update() # internally sends list to serial io 555 | return True 556 | 557 | def _list_cached_project_files(self): 558 | self.sendIO("Begin file list") 559 | for item in map( 560 | FileInfo.get_gcode_info, self._project_files_view.get_all_cached_info() 561 | ): 562 | self.sendIO(item) 563 | self.sendIO("End file list") 564 | self.sendOk() 565 | 566 | @gcode_executor.register_no_data("M24") 567 | def _start_resume_sd_print(self): 568 | self._current_state.start_new_print() 569 | return True 570 | 571 | @gcode_executor.register_no_data("M25") 572 | def _pause_print(self): 573 | self._current_state.pause_print() 574 | return True 575 | 576 | @gcode_executor.register("M524") 577 | def _cancel_print(self): 578 | self._current_state.cancel_print() 579 | return True 580 | 581 | def report_print_job_status(self): 582 | if self.current_print_job is not None: 583 | file_position = 1 if self.current_print_job.file_position == 0 else self.current_print_job.file_position 584 | self.sendIO( 585 | f"SD printing byte {file_position}" 586 | f"/{self.current_print_job.file_info.size}" 587 | ) 588 | else: 589 | self.sendIO("Not SD printing") 590 | 591 | def report_print_finished(self): 592 | if self.current_print_job is None: 593 | return 594 | self._log.debug( 595 | f"SD File Print finishing: {self.current_print_job.file_info.file_name}" 596 | ) 597 | self.sendIO("Done printing file") 598 | 599 | def finalize_print_job(self): 600 | if self.current_print_job is not None: 601 | self.report_print_job_status() 602 | self.report_print_finished() 603 | self.current_print_job = None 604 | self.report_print_job_status() 605 | self.change_state(self._state_idle) 606 | 607 | def _create_temperature_message(self) -> str: 608 | template = "{heater}:{actual:.2f}/ {target:.2f}" 609 | temps = collections.OrderedDict() 610 | temps["T"] = (self._telemetry.temp[0], self._telemetry.targetTemp[0]) 611 | temps["B"] = (self._telemetry.bedTemp, self._telemetry.bedTargetTemp) 612 | if self._telemetry.hasChamber: 613 | temps["C"] = ( 614 | self._telemetry.chamberTemp, 615 | self._telemetry.chamberTargetTemp, 616 | ) 617 | 618 | output = " ".join( 619 | map( 620 | lambda x: template.format(heater=x[0], actual=x[1][0], target=x[1][1]), 621 | temps.items(), 622 | ) 623 | ) 624 | output += " @:64\n" 625 | return output 626 | 627 | def _processTemperatureQuery(self) -> bool: 628 | # includeOk = not self._okBeforeCommandOutput 629 | if self.bambu_client.connected: 630 | output = self._create_temperature_message() 631 | self.sendIO(output) 632 | return True 633 | else: 634 | return False 635 | 636 | def close(self): 637 | if self.bambu_client.connected: 638 | self.bambu_client.disconnect() 639 | self.change_state(self._state_idle) 640 | self._serial_io.close() 641 | self.stop() 642 | 643 | def stop(self): 644 | self._running = False 645 | self._printer_thread.join() 646 | 647 | def _wait_for_state_change(self): 648 | self._state_change_queue.join() 649 | 650 | def _printer_worker(self): 651 | self._create_client_connection_async() 652 | self.sendIO("Printer connection complete") 653 | while self._running: 654 | try: 655 | next_state = self._state_change_queue.get(timeout=0.01) 656 | self._trigger_change_state(next_state) 657 | self._state_change_queue.task_done() 658 | except queue.Empty: 659 | continue 660 | except Exception as e: 661 | self._state_change_queue.task_done() 662 | raise e 663 | self._current_state.finalize() 664 | 665 | def _trigger_change_state(self, new_state: APrinterState): 666 | if self._current_state == new_state: 667 | return 668 | self._log.debug( 669 | f"Changing state from {self._current_state.__class__.__name__} to {new_state.__class__.__name__}" 670 | ) 671 | 672 | self._current_state.finalize() 673 | self._current_state = new_state 674 | self._current_state.init() 675 | 676 | def _showPrompt(self, text, choices): 677 | self._hidePrompt() 678 | self.sendIO(f"//action:prompt_begin {text}") 679 | for choice in choices: 680 | self.sendIO(f"//action:prompt_button {choice}") 681 | self.sendIO("//action:prompt_show") 682 | 683 | def _hidePrompt(self): 684 | self.sendIO("//action:prompt_end") 685 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------