Compare commits

...

25 Commits

Author SHA1 Message Date
76f706df19 0.1.8rc9
* check for filename in cache subfolder for files started through cloud connected printers
* send 0% progress when in prepare state and progress is 100
* minor UI tweaks
2024-11-09 21:57:17 -05:00
5c8a9787d4 0.1.8rc8
* updates to pybambu module
* update cloud login workflow, #59
2024-11-09 20:01:41 -05:00
e3fda73dd3 0.1.8rc7
* update pybambu module from upstream HA project, groundwork for fixing new cloud authorization process, #59
* potential fix for stuck progress/canceled printing status, #52
2024-11-09 16:15:13 -05:00
5633d6f6ea groundwork for plate processing contained in 3mf file, will store contents of plate_1.json in OctoPrint metadata for that file 2024-11-04 22:32:52 -05:00
884101c0ba bump version 2024-11-04 14:19:27 -05:00
7c87ba9482 fix base path for start print command for non X1 devices 2024-11-04 14:18:46 -05:00
1d9f874560 update user-agent 2024-11-01 20:59:31 -04:00
21e30034d0 switch to any comparison instead of all for allowed suffixes to prevent issues with file names that contain additional periods, ie part_v2.1.gcode.3mf 2024-11-01 20:12:34 -04:00
3c8b904a26 revert access code requirement 2024-10-30 00:38:25 -04:00
55ad4c1718 Merge branch 'rc' of https://github.com/jneilliii/OctoPrint-BambuPrinter into rc 2024-10-30 00:34:44 -04:00
4ef8e40702 remove call to creating connection in printer_worker thread since it's called on initialization. 2024-10-30 00:34:15 -04:00
2537bc8f57 remove call to creating connection in printer_worker thread since it's called on initialization. 2024-10-30 00:28:37 -04:00
28be048300 improve initial connection sequence, wait for bambu client connection before returning firmware information. 2024-10-28 17:56:41 -04:00
eaa0ed94c0 0.1.8rc2
fix references to commands after migrating to internal pybambu module
2024-10-28 13:17:53 -04:00
14af93b1d0 add filaments.json to MANIFEST.in and setup.py to distribute with sdist install 2024-10-27 22:59:54 -04:00
d94df9dd85 0.1.8rc1 (#56)
* bring latest pybambu module in from home assistant integration
* add onBeforePrintStart callback to prevent starting "local" files and display print options dialog.* add onBeforePrintStart callback to prevent starting "local" files and display print options dialog (with AMS mapping support)
* add AMS display in sidebar
2024-10-26 23:41:17 -04:00
d0fd4a5434 0.1.7
add back missing PREPARE printing state and associate printing status
2024-09-27 09:38:23 -04:00
3c218a548d add issue templates, funding, and stale bot 2024-09-12 19:56:40 -04:00
03af51608d 0.1.6
* replace 0 with 1 bytes during reporting print status to trigger state change in OctoPrint sooner.
2024-09-06 01:39:48 -04:00
c00285b1b2 0.1.5
* adjust M220 feed rate modifier calculations
2024-09-05 22:35:34 -04:00
7f1ae5a24b 0.1.4 (#43)
* fix stuck Printing from SD state when canceled in slicer or on printer, #42
2024-09-04 16:48:16 -04:00
5754e81b72 0.1.3
fix file uploads
2024-08-25 14:20:45 -04:00
cd4103cc71 0.1.2 (#40)
* fix issues related to 8dot3 filenames used in M23 command, #39 
* switch to auto reporting temp and sd status
2024-08-18 01:06:57 -04:00
01c6cacf15 0.1.1
* fix M220 command, #35
2024-07-31 00:01:44 -04:00
fda4b86cbc 0.1.0 (#34)
* Add separate class for sftp file system
* Add separate serial IO handling class
* Replace function name mangling with gcode handler registration system
* Add states to virtual Bambu printer that manage state specific interaction
* Add synchronization utilities to work with virtual printer as if it is a binary stream
* Add unittests with mocked Bambu printer to ensure core functionality works as expected
* Fix formatting to be automatically processed by black formatter
* Fix python 3.10 type annotations for readability
2024-07-29 22:49:12 -04:00
49 changed files with 7706 additions and 1486 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
github: [jneilliii]
patreon: jneilliii
custom: ['https://www.paypal.me/jneilliii']

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,26 @@
---
name: Bug report
about: Please make sure to check other issues, including closed ones, prior to submitting a bug report. Debug logs are required and any bug report submitted without them will be ignored and closed.
title: "[BUG]: "
labels: ''
assignees: ''
---
**Describe the Bug**
<!-- A clear and concise description of what the bug is. -->
**Expected Behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Debug Logs**
<!-- If logs are not included in your bug report it will be closed. Enable debug logging for octoprint.plugins.bambu_printer in OctoPrint's logging section of settings and recreate the issue then attach octoprint.log and plugin_bambu_printer_serial.log to this bug report. -->
**Screenshots**
<!-- Please share any relevant screenshots related to the issue. -->
**Printer and Plugin Setting Details**
* Printer model?
* Is your printer connected to Bambu Cloud?
* Is the plugin configured for local access only?

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Create a feature request for an improvement or change you'd like implemented.
title: "[FR]: "
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->

16
.github/stale.yml vendored Normal file
View File

@ -0,0 +1,16 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- enhancement
- bug
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
activity in 14 days. It will be closed if no further activity occurs in 7 days.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

21
.github/workflows/issue-validator.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: issue validator
on:
workflow_dispatch:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Okabe-Junya/issue-validator@v0.4.1
with:
body: '/\[(octoprint\.log)\]|\[(plugin_bambu_printer_serial\.log)\]/g'
body-regex-flags: 'true'
is-auto-close: 'true'
issue-type: 'both'
github-token: ${{ secrets.GITHUB_TOKEN }}

27
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Mark Stale Issues
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
permissions:
actions: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been automatically marked as stale because it has not had activity in 14 days. It will be closed if no further activity occurs in 7 days'
days-before-stale: 14
days-before-close: 7
stale-issue-label: 'stale'
days-before-issue-stale: 14
days-before-pr-stale: -1
days-before-issue-close: 7
days-before-pr-close: -1
exempt-issue-labels: 'bug,enhancement'
- uses: actions/checkout@v4
- uses: gautamkrishnar/keepalive-workflow@v2
with:
use_api: true

2
.gitignore vendored
View File

@ -8,3 +8,5 @@ dist
.DS_Store
*.zip
extras
test/test_output

View File

@ -2,3 +2,4 @@ include README.md
recursive-include octoprint_bambu_printer/templates *
recursive-include octoprint_bambu_printer/translations *
recursive-include octoprint_bambu_printer/static *
include octoprint_bambu_printer/printer/pybambu/filaments.json

View File

@ -1,260 +1,10 @@
# coding=utf-8
from __future__ import absolute_import
import os
import threading
import time
import flask
import datetime
import octoprint.plugin
from octoprint.events import Events
from octoprint.util import get_formatted_size, get_formatted_datetime, is_hidden_path
from octoprint.server.util.flask import no_firstrun_access
from octoprint.server.util.tornado import LargeResponseHandler, UrlProxyHandler, path_validation_factory
from octoprint.access.permissions import Permissions
from urllib.parse import quote as urlquote
from .ftpsclient import IoTFTPSClient
class BambuPrintPlugin(octoprint.plugin.SettingsPlugin,
octoprint.plugin.TemplatePlugin,
octoprint.plugin.AssetPlugin,
octoprint.plugin.EventHandlerPlugin,
octoprint.plugin.SimpleApiPlugin,
octoprint.plugin.BlueprintPlugin):
def get_assets(self):
return {'js': ["js/bambu_printer.js"]}
def get_template_configs(self):
return [{"type": "settings", "custom_bindings": True},
{"type": "generic", "custom_bindings": True, "template": "bambu_timelapse.jinja2"}] #, {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}]
def get_settings_defaults(self):
return {"device_type": "X1C",
"serial": "",
"host": "",
"access_code": "",
"username": "bblp",
"timelapse": False,
"bed_leveling": True,
"flow_cali": False,
"vibration_cali": True,
"layer_inspect": True,
"use_ams": False,
"local_mqtt": True,
"region": "",
"email": "",
"auth_token": "",
"always_use_default_options": False
}
def is_api_adminonly(self):
return True
def get_api_commands(self):
return {"register": ["email", "password", "region", "auth_token"]}
def on_api_command(self, command, data):
if command == "register":
if "email" in data and "password" in data and "region" in data and "auth_token" in data:
self._logger.info(f"Registering user {data['email']}")
from pybambu import BambuCloud
bambu_cloud = BambuCloud(data["region"], data["email"], data["password"], data["auth_token"])
bambu_cloud.login(data["region"], data["email"], data["password"])
return flask.jsonify({"auth_token": bambu_cloud.auth_token, "username": bambu_cloud.username})
def on_event(self, event, payload):
if event == Events.TRANSFER_DONE:
self._printer.commands("M20 L T", force=True)
def support_3mf_files(self):
return {'machinecode': {'3mf': ["3mf"]}}
def upload_to_sd(self, printer, filename, path, sd_upload_started, sd_upload_succeeded, sd_upload_failed, *args, **kwargs):
self._logger.debug(f"Starting upload from {filename} to {filename}")
sd_upload_started(filename, filename)
def process():
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
elapsed = time.monotonic()
try:
ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True)
if ftp.upload_file(path, f"{filename}"):
elapsed = time.monotonic() - elapsed
sd_upload_succeeded(filename, filename, elapsed)
# remove local file after successful upload to Bambu
# self._file_manager.remove_file("local", filename)
else:
raise Exception("upload failed")
except Exception as e:
elapsed = time.monotonic() - elapsed
sd_upload_failed(filename, filename, elapsed)
self._logger.debug(f"Error uploading file {filename}")
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
return filename
def get_template_vars(self):
return {"plugin_version": self._plugin_version}
def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout):
if not port == "BAMBU":
return None
if self._settings.get(["serial"]) == "" or self._settings.get(["host"]) == "" or self._settings.get(["access_code"]) == "":
return None
import logging.handlers
from octoprint.logging.handlers import CleaningTimedRotatingFileHandler
seriallog_handler = CleaningTimedRotatingFileHandler(
self._settings.get_plugin_logfile_path(postfix="serial"),
when="D",
backupCount=3,
)
seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
seriallog_handler.setLevel(logging.DEBUG)
from . import virtual
serial_obj = virtual.BambuPrinter(
self._settings,
self._printer_profile_manager,
data_folder=self.get_plugin_data_folder(),
seriallog_handler=seriallog_handler,
read_timeout=float(read_timeout),
faked_baudrate=baudrate,
)
return serial_obj
def get_additional_port_names(self, *args, **kwargs):
if self._settings.get(["serial"]) != "" and self._settings.get(["host"]) != "" and self._settings.get(["access_code"]) != "":
return ["BAMBU"]
else:
return []
def get_timelapse_file_list(self):
if flask.request.path.startswith('/api/timelapse'):
def process():
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
return_file_list = []
try:
ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True)
if self._settings.get(["device_type"]) in ["X1", "X1C"]:
timelapse_file_list = ftp.list_files("timelapse/", ".mp4") or []
else:
timelapse_file_list = ftp.list_files("timelapse/", ".avi") or []
for entry in timelapse_file_list:
if entry.startswith("/"):
filename = entry[1:].replace("timelapse/", "")
else:
filename = entry.replace("timelapse/", "")
filesize = ftp.ftps_session.size(f"timelapse/{filename}")
date_str = ftp.ftps_session.sendcmd(f"MDTM timelapse/{filename}").replace("213 ", "")
filedate = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(tzinfo=datetime.timezone.utc).timestamp()
return_file_list.append(
{
"bytes": filesize,
"date": get_formatted_datetime(datetime.datetime.fromtimestamp(filedate)),
"name": filename,
"size": get_formatted_size(filesize),
"thumbnail": "/plugin/bambu_printer/thumbnail/" + filename.replace(".mp4", ".jpg").replace(".avi", ".jpg"),
"timestamp": filedate,
"url": f"/plugin/bambu_printer/timelapse/{filename}"
})
self._plugin_manager.send_plugin_message(self._identifier, {'files': return_file_list})
except Exception as e:
self._logger.debug(f"Error getting timelapse files: {e}")
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
def _hook_octoprint_server_api_before_request(self, *args, **kwargs):
return [self.get_timelapse_file_list]
@octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadTimelapse(self, filename):
dest_filename = os.path.join(self.get_plugin_data_folder(), filename)
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
if not os.path.exists(dest_filename):
ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True)
download_result = ftp.download_file(
source=f"timelapse/{filename}",
dest=dest_filename,
)
return flask.redirect("/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302)
@octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadThumbnail(self, filename):
dest_filename = os.path.join(self.get_plugin_data_folder(), filename)
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
if not os.path.exists(dest_filename):
ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True)
download_result = ftp.download_file(
source=f"timelapse/thumbnail/{filename}",
dest=dest_filename,
)
return flask.redirect("/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302)
def is_blueprint_csrf_protected(self):
return True
def route_hook(self, server_routes, *args, **kwargs):
return [
(r"/download/timelapse/(.*)", LargeResponseHandler,
{'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404)}),
(r"/download/thumbnail/(.*)", LargeResponseHandler,
{'path': self.get_plugin_data_folder(), 'as_attachment': True, 'path_validation': path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404)})
]
def get_update_information(self):
return {'bambu_printer': {'displayName': "Bambu Printer",
'displayVersion': self._plugin_version,
'type': "github_release",
'user': "jneilliii",
'repo': "OctoPrint-BambuPrinter",
'current': self._plugin_version,
'stable_branch': {'name': "Stable",
'branch': "master",
'comittish': ["master"]},
'prerelease_branches': [
{'name': "Release Candidate",
'branch': "rc",
'comittish': ["rc", "master"]}
],
'pip': "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip"}}
__plugin_name__ = "Bambu Printer"
__plugin_pythoncompat__ = ">=3.7,<4"
from .bambu_print_plugin import BambuPrintPlugin
def __plugin_load__():
plugin = BambuPrintPlugin()
@ -270,5 +20,5 @@ def __plugin_load__():
"octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd,
"octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information,
"octoprint.server.api.before_request": __plugin_implementation__._hook_octoprint_server_api_before_request,
"octoprint.server.http.routes": __plugin_implementation__.route_hook
"octoprint.server.http.routes": __plugin_implementation__.route_hook,
}

View File

@ -0,0 +1,362 @@
from __future__ import absolute_import, annotations
import json
from pathlib import Path
import threading
from time import perf_counter
from contextlib import contextmanager
import flask
import logging.handlers
from urllib.parse import quote as urlquote
import os
import zipfile
import octoprint.printer
import octoprint.server
import octoprint.plugin
from octoprint.events import Events
import octoprint.settings
from octoprint.util import is_hidden_path
from octoprint.server.util.flask import no_firstrun_access
from octoprint.server.util.tornado import (
LargeResponseHandler,
path_validation_factory,
)
from octoprint.access.permissions import Permissions
from octoprint.logging.handlers import CleaningTimedRotatingFileHandler
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
from octoprint_bambu_printer.printer.pybambu import BambuCloud
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
RemoteSDCardFileList,
)
from .printer.file_system.bambu_timelapse_file_info import (
BambuTimelapseFileInfo,
)
from .printer.bambu_virtual_printer import BambuVirtualPrinter
@contextmanager
def measure_elapsed():
start = perf_counter()
def _get_elapsed():
return perf_counter() - start
yield _get_elapsed
print(f"Total elapsed: {_get_elapsed()}")
class BambuPrintPlugin(
octoprint.plugin.SettingsPlugin,
octoprint.plugin.TemplatePlugin,
octoprint.plugin.AssetPlugin,
octoprint.plugin.EventHandlerPlugin,
octoprint.plugin.SimpleApiPlugin,
octoprint.plugin.BlueprintPlugin,
):
_logger: logging.Logger
_plugin_manager: octoprint.plugin.PluginManager
_bambu_file_system: RemoteSDCardFileList
_timelapse_files_view: CachedFileView
_bambu_cloud: None
def on_settings_initialized(self):
self._bambu_file_system = RemoteSDCardFileList(self._settings)
self._timelapse_files_view = CachedFileView(self._bambu_file_system)
if self._settings.get(["device_type"]) in ["X1", "X1C"]:
self._timelapse_files_view.with_filter("timelapse/", ".mp4")
else:
self._timelapse_files_view.with_filter("timelapse/", ".avi")
def get_assets(self):
return {"js": ["js/jquery-ui.min.js", "js/knockout-sortable.1.2.0.js", "js/bambu_printer.js"],
"css": ["css/bambu_printer.css"]
}
def get_template_configs(self):
return [
{"type": "settings", "custom_bindings": True},
{
"type": "generic",
"custom_bindings": True,
"template": "bambu_timelapse.jinja2",
},
{"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}]
def get_settings_defaults(self):
return {
"device_type": "X1C",
"serial": "",
"host": "",
"access_code": "",
"username": "bblp",
"timelapse": False,
"bed_leveling": True,
"flow_cali": False,
"vibration_cali": True,
"layer_inspect": False,
"use_ams": False,
"local_mqtt": True,
"region": "",
"email": "",
"auth_token": "",
"always_use_default_options": False,
"ams_data": [],
"ams_mapping": [],
"ams_current_tray": 255,
}
def on_settings_save(self, data):
if data.get("local_mqtt", False) is True:
data["auth_token"] = ""
octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
def is_api_adminonly(self):
return True
def get_api_commands(self):
return {"register": ["email", "password", "region", "auth_token"],
"verify": ["auth_type", "password"]}
def on_api_command(self, command, data):
if command == "register":
if (
"email" in data
and "password" in data
and "region" in data
and "auth_token" in data
):
self._logger.info(f"Registering user {data['email']}")
self._bambu_cloud = BambuCloud(
data["region"], data["email"], data["password"], data["auth_token"]
)
auth_response = self._bambu_cloud.login(data["region"], data["email"], data["password"])
return flask.jsonify(
{
"auth_response": auth_response,
}
)
elif command == "verify":
auth_response = None
if (
"auth_type" in data
and "password" in data
and self._bambu_cloud is not None
):
self._logger.info(f"Verifying user {self._bambu_cloud._email}")
if data["auth_type"] == "verifyCode":
auth_response = self._bambu_cloud.login_with_verification_code(data["password"])
elif data["auth_type"] == "tfa":
auth_response = self._bambu_cloud.login_with_2fa_code(data["password"])
else:
self._logger.warning(f"Unknown verification type: {data['auth_type']}")
if auth_response == "success":
return flask.jsonify(
{
"auth_token": self._bambu_cloud.auth_token,
"username": self._bambu_cloud.username
}
)
else:
self._logger.info(f"Error verifying: {auth_response}")
return flask.jsonify(
{
"error": "Unable to verify"
}
)
def on_event(self, event, payload):
if event == Events.TRANSFER_DONE:
self._printer.commands("M20 L T", force=True)
elif event == Events.FILE_ADDED:
if payload["operation"] == "add" and "3mf" in payload["type"]:
file_container = os.path.join(self._settings.getBaseFolder("uploads"), payload["path"])
with zipfile.ZipFile(file_container) as z:
with z.open("Metadata/plate_1.json", "r") as json_data:
plate_data = json.load(json_data)
if plate_data:
self._file_manager.set_additional_metadata("sdcard", payload["path"], "plate_data", plate_data, overwrite=True)
def support_3mf_files(self):
return {"machinecode": {"3mf": ["3mf"]}}
def upload_to_sd(
self,
printer,
filename,
path,
sd_upload_started,
sd_upload_succeeded,
sd_upload_failed,
*args,
**kwargs,
):
self._logger.debug(f"Starting upload from {filename} to {filename}")
sd_upload_started(filename, filename)
def process():
with measure_elapsed() as get_elapsed:
try:
with self._bambu_file_system.get_ftps_client() as ftp:
if ftp.upload_file(path, f"{filename}"):
sd_upload_succeeded(filename, filename, get_elapsed())
else:
raise Exception("upload failed")
except Exception as e:
sd_upload_failed(filename, filename, get_elapsed())
self._logger.exception(e)
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
return filename
def get_template_vars(self):
return {"plugin_version": self._plugin_version}
def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout):
if not port == "BAMBU":
return None
if (
self._settings.get(["serial"]) == ""
or self._settings.get(["host"]) == ""
or self._settings.get(["access_code"]) == ""
):
return None
seriallog_handler = CleaningTimedRotatingFileHandler(
self._settings.get_plugin_logfile_path(postfix="serial"),
when="D",
backupCount=3,
)
seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
seriallog_handler.setLevel(logging.DEBUG)
serial_obj = BambuVirtualPrinter(
self._settings,
self._printer_profile_manager,
data_folder=self.get_plugin_data_folder(),
serial_log_handler=seriallog_handler,
read_timeout=float(read_timeout),
faked_baudrate=baudrate,
)
return serial_obj
def get_additional_port_names(self, *args, **kwargs):
if (
self._settings.get(["serial"]) != ""
and self._settings.get(["host"]) != ""
and self._settings.get(["access_code"]) != ""
):
return ["BAMBU"]
else:
return []
def get_timelapse_file_list(self):
if flask.request.path.startswith("/api/timelapse"):
def process():
return_file_list = []
for file_info in self._timelapse_files_view.get_all_info():
timelapse_info = BambuTimelapseFileInfo.from_file_info(file_info)
return_file_list.append(timelapse_info.to_dict())
self._plugin_manager.send_plugin_message(
self._identifier, {"files": return_file_list}
)
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
def _hook_octoprint_server_api_before_request(self, *args, **kwargs):
return [self.get_timelapse_file_list]
def _download_file(self, file_name: str, source_path: str):
destination = Path(self.get_plugin_data_folder()) / file_name
if destination.exists():
return destination
with self._bambu_file_system.get_ftps_client() as ftp:
ftp.download_file(
source=(Path(source_path) / file_name).as_posix(),
dest=destination.as_posix(),
)
return destination
@octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadTimelapse(self, filename):
self._download_file(filename, "timelapse/")
return flask.redirect(
"/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302
)
@octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadThumbnail(self, filename):
self._download_file(filename, "timelapse/thumbnail/")
return flask.redirect(
"/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302
)
def is_blueprint_csrf_protected(self):
return True
def route_hook(self, server_routes, *args, **kwargs):
return [
(
r"/download/timelapse/(.*)",
LargeResponseHandler,
{
"path": self.get_plugin_data_folder(),
"as_attachment": True,
"path_validation": path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404
),
},
),
(
r"/download/thumbnail/(.*)",
LargeResponseHandler,
{
"path": self.get_plugin_data_folder(),
"as_attachment": True,
"path_validation": path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404
),
},
),
]
def get_update_information(self):
return {
"bambu_printer": {
"displayName": "Bambu Printer",
"displayVersion": self._plugin_version,
"type": "github_release",
"user": "jneilliii",
"repo": "OctoPrint-BambuPrinter",
"current": self._plugin_version,
"stable_branch": {
"name": "Stable",
"branch": "master",
"comittish": ["master"],
},
"prerelease_branches": [
{
"name": "Release Candidate",
"branch": "rc",
"comittish": ["rc", "master"],
}
],
"pip": "https://github.com/jneilliii/OctoPrint-BambuPrinter/archive/{target_version}.zip",
}
}

View File

@ -1 +0,0 @@
from .ftpsclient import IoTFTPSClient

View File

@ -0,0 +1,2 @@
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"

View File

@ -0,0 +1,721 @@
from __future__ import annotations
import collections
from dataclasses import dataclass, field, asdict
import math
from pathlib import Path
import queue
import re
import threading
import time
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
from octoprint_bambu_printer.printer.print_job import PrintJob
from octoprint_bambu_printer.printer.pybambu import BambuClient, commands
import logging
import logging.handlers
from octoprint.util import RepeatedTimer
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
from octoprint_bambu_printer.printer.states.idle_state import IdleState
from .printer_serial_io import PrinterSerialIO
from .states.paused_state import PausedState
from .states.printing_state import PrintingState
from .gcode_executor import GCodeExecutor
from .file_system.remote_sd_card_file_list import RemoteSDCardFileList
AMBIENT_TEMPERATURE: float = 21.3
@dataclass
class BambuPrinterTelemetry:
temp: list[float] = field(default_factory=lambda: [AMBIENT_TEMPERATURE])
targetTemp: list[float] = field(default_factory=lambda: [0.0])
bedTemp: float = AMBIENT_TEMPERATURE
bedTargetTemp = 0.0
hasChamber: bool = False
chamberTemp: float = AMBIENT_TEMPERATURE
chamberTargetTemp: float = 0.0
lastTempAt: float = time.monotonic()
firmwareName: str = "Bambu"
extruderCount: int = 1
ams_current_tray: int = 255
# noinspection PyBroadException
class BambuVirtualPrinter:
gcode_executor = GCodeExecutor()
def __init__(
self,
settings,
printer_profile_manager,
data_folder,
serial_log_handler=None,
read_timeout=5.0,
faked_baudrate=115200,
):
self._settings = settings
self._printer_profile_manager = printer_profile_manager
self._faked_baudrate = faked_baudrate
self._data_folder = data_folder
self._last_hms_errors = None
self._log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter")
self.ams_data = self._settings.get(["ams_data"])
self._state_idle = IdleState(self)
self._state_printing = PrintingState(self)
self._state_paused = PausedState(self)
self._current_state = self._state_idle
self._running = True
self._print_status_reporter = None
self._print_temp_reporter = None
self._printer_thread = threading.Thread(
target=self._printer_worker,
name="octoprint.plugins.bambu_printer.printer_state",
)
self._state_change_queue = queue.Queue()
self._current_print_job: PrintJob | None = None
self._serial_io = PrinterSerialIO(
handle_command_callback=self._process_gcode_serial_command,
settings=settings,
serial_log_handler=serial_log_handler,
read_timeout=read_timeout,
write_timeout=10.0,
)
self._telemetry = BambuPrinterTelemetry()
self._telemetry.hasChamber = printer_profile_manager.get_current().get(
"heatedChamber"
)
self.file_system = RemoteSDCardFileList(settings)
self._selected_project_file: FileInfo | None = None
self._project_files_view = (
CachedFileView(self.file_system, on_update=self._list_cached_project_files)
.with_filter("", ".3mf")
.with_filter("cache/", ".3mf")
)
self._serial_io.start()
self._printer_thread.start()
self._bambu_client: BambuClient = self._create_client_connection_async()
@property
def bambu_client(self):
return self._bambu_client
@property
def is_running(self):
return self._running
@property
def current_state(self):
return self._current_state
@property
def current_print_job(self):
return self._current_print_job
@current_print_job.setter
def current_print_job(self, value):
self._current_print_job = value
@property
def selected_file(self):
return self._selected_project_file
@property
def has_selected_file(self):
return self._selected_project_file is not None
@property
def timeout(self):
return self._serial_io._read_timeout
@timeout.setter
def timeout(self, value):
self._log.debug(f"Setting read timeout to {value}s")
self._serial_io._read_timeout = value
@property
def write_timeout(self):
return self._serial_io._write_timeout
@write_timeout.setter
def write_timeout(self, value):
self._log.debug(f"Setting write timeout to {value}s")
self._serial_io._write_timeout = value
@property
def port(self):
return "BAMBU"
@property
def baudrate(self):
return self._faked_baudrate
@property
def project_files(self):
return self._project_files_view
def change_state(self, new_state: APrinterState):
self._state_change_queue.put(new_state)
def _convert2serialize(self, obj):
if isinstance(obj, dict):
return {k: self._convert2serialize(v) for k, v in obj.items()}
elif hasattr(obj, "_ast"):
return self._convert2serialize(obj._ast())
elif not isinstance(obj, str) and hasattr(obj, "__iter__"):
return [self._convert2serialize(v) for v in obj]
elif hasattr(obj, "__dict__"):
return {
k: self._convert2serialize(v)
for k, v in obj.__dict__.items()
if not callable(v) and not k.startswith('_')
}
else:
return obj
def new_update(self, event_type):
if event_type == "event_hms_errors":
self._update_hms_errors()
elif event_type == "event_printer_data_update":
self._update_printer_info()
def _update_printer_info(self):
device_data = self.bambu_client.get_device()
print_job_state = device_data.print_job.gcode_state
temperatures = device_data.temperature
ams_data = self._convert2serialize(device_data.ams.data)
if self.ams_data != ams_data:
self._log.debug(f"Recieveid AMS Update: {ams_data}")
self.ams_data = ams_data
self._settings.set(["ams_data"], ams_data)
self._settings.save(trigger_event=True)
self.lastTempAt = time.monotonic()
self._telemetry.temp[0] = temperatures.nozzle_temp
self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp
self._telemetry.bedTemp = temperatures.bed_temp
self._telemetry.bedTargetTemp = temperatures.target_bed_temp
self._telemetry.chamberTemp = temperatures.chamber_temp
if device_data.push_all_data and "ams" in device_data.push_all_data:
self._telemetry.ams_current_tray = device_data.push_all_data["ams"]["tray_now"] or 255
if self._telemetry.ams_current_tray != self._settings.get_int(["ams_current_tray"]):
self._settings.set_int(["ams_current_tray"], self._telemetry.ams_current_tray)
self._settings.save(trigger_event=True)
self._log.debug(f"Received printer state update: {print_job_state}")
if (
print_job_state == "IDLE"
or print_job_state == "FINISH"
or print_job_state == "FAILED"
):
self.change_state(self._state_idle)
elif print_job_state == "RUNNING" or print_job_state == "PREPARE":
self.change_state(self._state_printing)
elif print_job_state == "PAUSE":
self.change_state(self._state_paused)
else:
self._log.warn(f"Unknown print job state: {print_job_state}")
def _update_hms_errors(self):
bambu_printer = self.bambu_client.get_device()
if (
bambu_printer.hms.errors != self._last_hms_errors
and bambu_printer.hms.errors["Count"] > 0
):
self._log.debug(f"HMS Error: {bambu_printer.hms.errors}")
for n in range(1, bambu_printer.hms.errors["Count"] + 1):
error = bambu_printer.hms.errors[f"{n}-Error"].strip()
self.sendIO(f"// action:notification {error}")
self._last_hms_errors = bambu_printer.hms.errors
def on_disconnect(self, on_disconnect):
self._log.debug(f"on disconnect called")
self.stop_continuous_status_report()
self.stop_continuous_temp_report()
return on_disconnect
def on_connect(self, on_connect):
self._log.debug(f"on connect called")
return on_connect
def _create_client_connection_async(self):
self._create_client_connection()
if self._bambu_client is None:
raise RuntimeError("Connection with Bambu Client not established")
return self._bambu_client
def _create_client_connection(self):
if (
self._settings.get(["device_type"]) == ""
or self._settings.get(["serial"]) == ""
or self._settings.get(["username"]) == ""
or self._settings.get(["access_code"]) == ""
):
msg = "invalid settings to start connection with Bambu Printer"
self._log.debug(msg)
raise ValueError(msg)
self._log.debug(
f"connecting via local mqtt: {self._settings.get_boolean(['local_mqtt'])}"
)
bambu_client = BambuClient(
device_type=self._settings.get(["device_type"]),
serial=self._settings.get(["serial"]),
host=self._settings.get(["host"]),
username=(
"bblp"
if self._settings.get_boolean(["local_mqtt"])
else self._settings.get(["username"])
),
access_code=self._settings.get(["access_code"]),
local_mqtt=self._settings.get_boolean(["local_mqtt"]),
region=self._settings.get(["region"]),
email=self._settings.get(["email"]),
auth_token=self._settings.get(["auth_token"]) if self._settings.get_boolean(["local_mqtt"]) is False else "",
)
bambu_client.on_disconnect = self.on_disconnect(bambu_client.on_disconnect)
bambu_client.on_connect = self.on_connect(bambu_client.on_connect)
bambu_client.connect(callback=self.new_update)
self._log.info(f"bambu connection status: {bambu_client.connected}")
self.sendOk()
self._bambu_client = bambu_client
def __str__(self):
return "BAMBU(read_timeout={read_timeout},write_timeout={write_timeout},options={options})".format(
read_timeout=self.timeout,
write_timeout=self.write_timeout,
options={
"device_type": self._settings.get(["device_type"]),
"host": self._settings.get(["host"]),
},
)
def _reset(self):
with self._serial_io.incoming_lock:
self.lastN = 0
self._running = False
if self._print_status_reporter is not None:
self._print_status_reporter.cancel()
self._print_status_reporter = None
if self._settings.get_boolean(["simulateReset"]):
for item in self._settings.get(["resetLines"]):
self.sendIO(item + "\n")
self._serial_io.reset()
def write(self, data: bytes) -> int:
return self._serial_io.write(data)
def readline(self) -> bytes:
return self._serial_io.readline()
def readlines(self) -> list[bytes]:
return self._serial_io.readlines()
def sendIO(self, line: str):
self._serial_io.send(line)
def sendOk(self):
self._serial_io.sendOk()
def flush(self):
self._serial_io.flush()
self._wait_for_state_change()
##~~ project file functions
def remove_project_selection(self):
self._selected_project_file = None
def select_project_file(self, file_path: str) -> bool:
file_info = self._project_files_view.get_file_by_name(file_path)
if (
self._selected_project_file is not None
and file_info is not None
and self._selected_project_file.path == file_info.path
):
self._log.debug(f"File already selected: {file_path}")
return True
if file_info is None:
self._log.error(f"Cannot select non-existent file: {file_path}")
return False
self._log.debug(f"Select project file: {file_path}")
self._selected_project_file = file_info
self._send_file_selected_message()
return True
##~~ command implementations
@gcode_executor.register_no_data("M21")
def _sd_status(self) -> bool:
self.sendIO("SD card ok")
return True
@gcode_executor.register("M23")
def _select_sd_file(self, data: str) -> bool:
filename = data.split(maxsplit=1)[1].strip()
return self.select_project_file(filename)
def _send_file_selected_message(self):
if self.selected_file is None:
return
self.sendIO(
f"File opened: {self.selected_file.file_name} "
f"Size: {self.selected_file.size}"
)
self.sendIO("File selected")
@gcode_executor.register("M26")
def _set_sd_position(self, data: str) -> bool:
if data == "M26 S0":
return self._cancel_print()
else:
self._log.debug("ignoring M26 command.")
self.sendIO("M26 disabled for Bambu")
return True
@gcode_executor.register("M27")
def _report_sd_print_status(self, data: str) -> bool:
matchS = re.search(r"S([0-9]+)", data)
if matchS:
interval = int(matchS.group(1))
if interval > 0:
self.start_continuous_status_report(interval)
return False
else:
self.stop_continuous_status_report()
return False
self.report_print_job_status()
return True
def start_continuous_status_report(self, interval: int):
if self._print_status_reporter is not None:
self._print_status_reporter.cancel()
self._print_status_reporter = RepeatedTimer(
interval, self.report_print_job_status
)
self._print_status_reporter.start()
def stop_continuous_status_report(self):
if self._print_status_reporter is not None:
self._print_status_reporter.cancel()
self._print_status_reporter = None
@gcode_executor.register("M30")
def _delete_project_file(self, data: str) -> bool:
file_path = data.split(maxsplit=1)[1].strip()
file_info = self.project_files.get_file_data(file_path)
if file_info is not None:
self.file_system.delete_file(file_info.path)
self._update_project_file_list()
else:
self._log.error(f"File not found to delete {file_path}")
return True
@gcode_executor.register("M105")
def _report_temperatures(self, data: str) -> bool:
self._processTemperatureQuery()
return True
@gcode_executor.register("M155")
def _auto_report_temperatures(self, data: str) -> bool:
matchS = re.search(r"S([0-9]+)", data)
if matchS:
interval = int(matchS.group(1))
if interval > 0:
self.start_continuous_temp_report(interval)
else:
self.stop_continuous_temp_report()
self.report_print_job_status()
return True
def start_continuous_temp_report(self, interval: int):
if self._print_temp_reporter is not None:
self._print_temp_reporter.cancel()
self._print_temp_reporter = RepeatedTimer(
interval, self._processTemperatureQuery
)
self._print_temp_reporter.start()
def stop_continuous_temp_report(self):
if self._print_temp_reporter is not None:
self._print_temp_reporter.cancel()
self._print_temp_reporter = None
# noinspection PyUnusedLocal
@gcode_executor.register_no_data("M115")
def _report_firmware_info(self) -> bool:
# wait for connection to be established before sending back firmware info
while self.bambu_client.connected is False:
time.sleep(1)
self.sendIO("Bambu Printer Integration")
self.sendIO("Cap:AUTOREPORT_SD_STATUS:1")
self.sendIO("Cap:AUTOREPORT_TEMP:1")
self.sendIO("Cap:EXTENDED_M20:1")
self.sendIO("Cap:LFN_WRITE:1")
return True
@gcode_executor.register("M117")
def _get_lcd_message(self, data: str) -> bool:
result = re.search(r"M117\s+(.*)", data).group(1)
self.sendIO(f"echo:{result}")
return True
@gcode_executor.register("M118")
def _serial_print(self, data: str) -> bool:
match = re.search(r"M118 (?:(?P<parameter>A1|E1|Pn[012])\s)?(?P<text>.*)", data)
if not match:
self.sendIO("Unrecognized command parameters for M118")
else:
result = match.groupdict()
text = result["text"]
parameter = result["parameter"]
if parameter == "A1":
self.sendIO(f"//{text}")
elif parameter == "E1":
self.sendIO(f"echo:{text}")
else:
self.sendIO(text)
return True
# noinspection PyUnusedLocal
@gcode_executor.register("M220")
def _set_feedrate_percent(self, data: str) -> bool:
if self.bambu_client.connected:
gcode_command = commands.SEND_GCODE_TEMPLATE
percent = int(data.replace("M220 S", ""))
def speed_fraction(speed_percent):
return math.floor(10000 / speed_percent) / 100
def acceleration_magnitude(speed_percent):
return math.exp((speed_fraction(speed_percent) - 1.0191) / -0.8139)
def feed_rate(speed_percent):
return 6.426e-5 * speed_percent ** 2 - 2.484e-3 * speed_percent + 0.654
def linear_interpolate(x, x_points, y_points):
if x <= x_points[0]: return y_points[0]
if x >= x_points[-1]: return y_points[-1]
for i in range(len(x_points) - 1):
if x_points[i] <= x < x_points[i + 1]:
t = (x - x_points[i]) / (x_points[i + 1] - x_points[i])
return y_points[i] * (1 - t) + y_points[i + 1] * t
def scale_to_data_points(func, data_points):
data_points.sort(key=lambda x: x[0])
speeds, values = zip(*data_points)
scaling_factors = [v / func(s) for s, v in zip(speeds, values)]
return lambda x: func(x) * linear_interpolate(x, speeds, scaling_factors)
def speed_adjust(speed_percentage):
if not 30 <= speed_percentage <= 180:
speed_percentage = 100
bambu_params = {
"speed": [50, 100, 124, 166],
"acceleration": [0.3, 1.0, 1.4, 1.6],
"feed_rate": [0.7, 1.0, 1.4, 2.0]
}
acc_mag_scaled = scale_to_data_points(acceleration_magnitude,
list(zip(bambu_params["speed"], bambu_params["acceleration"])))
feed_rate_scaled = scale_to_data_points(feed_rate,
list(zip(bambu_params["speed"], bambu_params["feed_rate"])))
speed_frac = speed_fraction(speed_percentage)
acc_mag = acc_mag_scaled(speed_percentage)
feed = feed_rate_scaled(speed_percentage)
# speed_level = 1.539 * (acc_mag**2) - 0.7032 * acc_mag + 4.0834
return f"M204.2 K{acc_mag:.2f}\nM220 K{feed:.2f}\nM73.2 R{speed_frac:.2f}\n" # M1002 set_gcode_claim_speed_level ${speed_level:.0f}\n
speed_command = speed_adjust(percent)
gcode_command["print"]["param"] = speed_command
if self.bambu_client.publish(gcode_command):
self._log.info(f"{percent}% speed adjustment command sent successfully")
return True
def _process_gcode_serial_command(self, gcode: str, full_command: str):
self._log.debug(f"processing gcode {gcode} command = {full_command}")
handled = self.gcode_executor.execute(self, gcode, full_command)
if handled:
self.sendOk()
return
# post gcode to printer otherwise
if self.bambu_client.connected:
GCODE_COMMAND = commands.SEND_GCODE_TEMPLATE
GCODE_COMMAND["print"]["param"] = full_command + "\n"
if self.bambu_client.publish(GCODE_COMMAND):
self._log.info("command sent successfully")
self.sendOk()
@gcode_executor.register_no_data("M112")
def _shutdown(self):
self._running = True
if self.bambu_client.connected:
self.bambu_client.disconnect()
self.sendIO("echo:EMERGENCY SHUTDOWN DETECTED. KILLED.")
self._serial_io.close()
return True
@gcode_executor.register("M20")
def _update_project_file_list(self, data: str = ""):
self._project_files_view.update() # internally sends list to serial io
return True
def _list_cached_project_files(self):
self.sendIO("Begin file list")
for item in map(
FileInfo.get_gcode_info, self._project_files_view.get_all_cached_info()
):
self.sendIO(item)
self.sendIO("End file list")
self.sendOk()
@gcode_executor.register_no_data("M24")
def _start_resume_sd_print(self):
self._current_state.start_new_print()
return True
@gcode_executor.register_no_data("M25")
def _pause_print(self):
self._current_state.pause_print()
return True
@gcode_executor.register("M524")
def _cancel_print(self):
self._current_state.cancel_print()
return True
def report_print_job_status(self):
if self.current_print_job is not None:
file_position = 1 if self.current_print_job.file_position == 0 else self.current_print_job.file_position
self.sendIO(
f"SD printing byte {file_position}"
f"/{self.current_print_job.file_info.size}"
)
else:
self.sendIO("Not SD printing")
def report_print_finished(self):
if self.current_print_job is None:
return
self._log.debug(
f"SD File Print finishing: {self.current_print_job.file_info.file_name}"
)
self.sendIO("Done printing file")
def finalize_print_job(self):
if self.current_print_job is not None:
self.report_print_job_status()
self.report_print_finished()
self.current_print_job = None
self.report_print_job_status()
self.change_state(self._state_idle)
def _create_temperature_message(self) -> str:
template = "{heater}:{actual:.2f}/ {target:.2f}"
temps = collections.OrderedDict()
temps["T"] = (self._telemetry.temp[0], self._telemetry.targetTemp[0])
temps["B"] = (self._telemetry.bedTemp, self._telemetry.bedTargetTemp)
if self._telemetry.hasChamber:
temps["C"] = (
self._telemetry.chamberTemp,
self._telemetry.chamberTargetTemp,
)
output = " ".join(
map(
lambda x: template.format(heater=x[0], actual=x[1][0], target=x[1][1]),
temps.items(),
)
)
output += " @:64\n"
return output
def _processTemperatureQuery(self) -> bool:
# includeOk = not self._okBeforeCommandOutput
if self.bambu_client.connected:
output = self._create_temperature_message()
self.sendIO(output)
return True
else:
return False
def close(self):
if self.bambu_client.connected:
self.bambu_client.disconnect()
self.change_state(self._state_idle)
self._serial_io.close()
self.stop()
def stop(self):
self._running = False
self._printer_thread.join()
def _wait_for_state_change(self):
self._state_change_queue.join()
def _printer_worker(self):
# self._create_client_connection_async()
self.sendIO("Printer connection complete")
while self._running:
try:
next_state = self._state_change_queue.get(timeout=0.01)
self._trigger_change_state(next_state)
self._state_change_queue.task_done()
except queue.Empty:
continue
except Exception as e:
self._state_change_queue.task_done()
raise e
self._current_state.finalize()
def _trigger_change_state(self, new_state: APrinterState):
if self._current_state == new_state:
return
self._log.debug(
f"Changing state from {self._current_state.__class__.__name__} to {new_state.__class__.__name__}"
)
self._current_state.finalize()
self._current_state = new_state
self._current_state.init()
def _showPrompt(self, text, choices):
self._hidePrompt()
self.sendIO(f"//action:prompt_begin {text}")
for choice in choices:
self.sendIO(f"//action:prompt_button {choice}")
self.sendIO("//action:prompt_show")
def _hidePrompt(self):
self.sendIO("//action:prompt_end")

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from .file_info import FileInfo
from octoprint.util import get_formatted_size, get_formatted_datetime
@dataclass(frozen=True)
class BambuTimelapseFileInfo:
bytes: int
date: str | None
name: str
size: str
thumbnail: str
timestamp: float
url: str
def to_dict(self):
return asdict(self)
@staticmethod
def from_file_info(file_info: FileInfo):
return BambuTimelapseFileInfo(
bytes=file_info.size,
date=get_formatted_datetime(file_info.date),
name=file_info.file_name,
size=get_formatted_size(file_info.size),
thumbnail=f"/plugin/bambu_printer/thumbnail/{file_info.path.stem}.jpg",
timestamp=file_info.timestamp,
url=f"/plugin/bambu_printer/timelapse/{file_info.file_name}",
)

View File

@ -0,0 +1,98 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
RemoteSDCardFileList,
)
from dataclasses import dataclass, field
from pathlib import Path
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
@dataclass
class CachedFileView:
file_system: RemoteSDCardFileList
folder_view: dict[tuple[str, str | list[str] | None], None] = field(
default_factory=dict
) # dict preserves order, but set does not. We use only dict keys as storage
on_update: Callable[[], None] | None = None
def __post_init__(self):
self._file_alias_cache: dict[str, str] = {}
self._file_data_cache: dict[str, FileInfo] = {}
def with_filter(
self, folder: str, extensions: str | list[str] | None = None
) -> "CachedFileView":
self.folder_view[(folder, extensions)] = None
return self
def list_all_views(self):
existing_files: list[str] = []
result: list[FileInfo] = []
with self.file_system.get_ftps_client() as ftp:
for key in self.folder_view.keys():
result.extend(self.file_system.list_files(*key, ftp, existing_files))
return result
def update(self):
file_info_list = self.list_all_views()
self._update_file_list_cache(file_info_list)
if self.on_update:
self.on_update()
def _update_file_list_cache(self, files: list[FileInfo]):
self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files}
self._file_data_cache = {info.path.as_posix(): info for info in files}
def get_all_info(self):
self.update()
return self.get_all_cached_info()
def get_all_cached_info(self):
return list(self._file_data_cache.values())
def get_keys_as_list(self):
return list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys())
def get_file_data(self, file_path: str | Path) -> FileInfo | None:
file_data = self.get_file_data_cached(file_path)
if file_data is None:
self.update()
file_data = self.get_file_data_cached(file_path)
return file_data
def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None:
if isinstance(file_path, str):
file_path = Path(file_path).as_posix().strip("/")
else:
file_path = file_path.as_posix().strip("/")
if file_path not in self._file_data_cache:
file_path = self._file_alias_cache.get(file_path, file_path)
return self._file_data_cache.get(file_path, None)
def get_file_by_name(self, file_name: str):
if file_name == "":
return None
file_list = self.get_keys_as_list()
if not file_name in file_list:
if f"{file_name}.3mf" in file_list:
file_name = f"{file_name}.3mf"
elif f"{file_name}.gcode.3mf" in file_list:
file_name = f"{file_name}.gcode.3mf"
elif f"cache/{file_name}.3mf" in file_list:
file_name = f"cache/{file_name}.3mf"
elif f"cache/{file_name}.gcode.3mf" in file_list:
file_name = f"cache/{file_name}.gcode.3mf"
file_data = self.get_file_data_cached(file_name)
if file_data is None:
self.update()
return self.get_file_by_name(file_name)
return file_data

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from octoprint.util.files import unix_timestamp_to_m20_timestamp
@dataclass(frozen=True)
class FileInfo:
dosname: str
path: Path
size: int
date: datetime
@property
def file_name(self):
return self.path.name
@property
def timestamp(self) -> float:
return self.date.timestamp()
@property
def timestamp_m20(self) -> str:
return unix_timestamp_to_m20_timestamp(int(self.timestamp))
def get_gcode_info(self) -> str:
return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"'
def to_dict(self):
return asdict(self)

View File

@ -24,16 +24,21 @@ SOFTWARE.
wrapper for FTPS server interactions
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import ftplib
import os
from pathlib import Path
import socket
import ssl
from typing import Optional, Union, List
from typing import Generator, Union
from contextlib import redirect_stdout
import io
import re
class ImplicitTLS(ftplib.FTP_TLS):
"""ftplib.FTP_TLS sub-class to support implicit SSL FTPS"""
@ -57,67 +62,20 @@ class ImplicitTLS(ftplib.FTP_TLS):
conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
if self._prot_p:
conn = self.context.wrap_socket(conn,
server_hostname=self.host,
session=self.sock.session) # this is the fix
conn = self.context.wrap_socket(
conn, server_hostname=self.host, session=self.sock.session
) # this is the fix
return conn, size
class IoTFTPSClient:
@dataclass
class IoTFTPSConnection:
"""iot ftps ftpsclient"""
ftps_host: str
ftps_port: int
ftps_user: str
ftps_pass: str
ssl_implicit: bool
ftps_session: Union[ftplib.FTP, ImplicitTLS]
last_error: Optional[str] = None
welcome: str
ftps_session: ftplib.FTP | ImplicitTLS
def __init__(
self,
ftps_host: str,
ftps_port: Optional[int] = 21,
ftps_user: Optional[str] = "",
ftps_pass: Optional[str] = "",
ssl_implicit: Optional[bool] = False,
) -> None:
self.ftps_host = ftps_host
self.ftps_port = ftps_port
self.ftps_user = ftps_user
self.ftps_pass = ftps_pass
self.ssl_implicit = ssl_implicit
self.instantiate_ftps_session()
def __repr__(self) -> str:
return (
"IoT FTPS Client\n"
"--------------------\n"
f"host: {self.ftps_host}\n"
f"port: {self.ftps_port}\n"
f"user: {self.ftps_user}\n"
f"ssl: {self.ssl_implicit}"
)
def instantiate_ftps_session(self) -> None:
"""init ftps_session based on input params"""
self.ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP()
self.ftps_session.set_debuglevel(0)
self.welcome = self.ftps_session.connect(
host=self.ftps_host, port=self.ftps_port)
if self.ftps_user and self.ftps_pass:
self.ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass)
else:
self.ftps_session.login()
if self.ssl_implicit:
self.ftps_session.prot_p()
def disconnect(self) -> None:
"""disconnect the current session from the ftps server"""
def close(self) -> None:
"""close the current session from the ftps server"""
self.ftps_session.close()
def download_file(self, source: str, dest: str):
@ -137,7 +95,7 @@ class IoTFTPSClient:
# Taken from ftplib.storbinary but with custom ssl handling
# due to the shitty bambu p1p ftps server TODO fix properly.
with open(source, "rb") as fp:
self.ftps_session.voidcmd('TYPE I')
self.ftps_session.voidcmd("TYPE I")
with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn:
while 1:
@ -152,12 +110,14 @@ class IoTFTPSClient:
callback(buf)
# shutdown ssl layer
if ftplib._SSLSocket is not None and isinstance(conn, ftplib._SSLSocket):
if ftplib._SSLSocket is not None and isinstance(
conn, ftplib._SSLSocket
):
# Yeah this is suposed to be conn.unwrap
# But since we operate in prot p mode
# we can close the connection always.
# This is cursed but it works.
if "vsFTPd" in self.welcome:
if "vsFTPd" in self.ftps_session.welcome:
conn.unwrap()
else:
conn.shutdown(socket.SHUT_RDWR)
@ -185,19 +145,26 @@ class IoTFTPSClient:
def mkdir(self, path: str) -> str:
return self.ftps_session.mkd(path)
def list_files(self, path: str, file_pattern: Optional[str] = None) -> Union[List[str], None]:
def list_files(
self, list_path: str, extensions: str | list[str] | None = None
) -> Generator[Path]:
"""list files under a path inside the FTPS server"""
if extensions is None:
_extension_acceptable = lambda p: True
else:
if isinstance(extensions, str):
extensions = [extensions]
_extension_acceptable = lambda p: any(s in p.suffixes for s in extensions)
try:
files = self.ftps_session.nlst(path)
if not files:
return
if file_pattern:
return [f for f in files if file_pattern in f]
return files
list_result = self.ftps_session.nlst(list_path) or []
for file_list_entry in list_result:
path = Path(list_path) / Path(file_list_entry).name
if _extension_acceptable(path):
yield path
except Exception as ex:
print(f"unexpected exception occurred: {ex}")
pass
return
def list_files_ex(self, path: str) -> Union[list[str], None]:
"""list files under a path inside the FTPS server"""
@ -208,7 +175,8 @@ class IoTFTPSClient:
s = f.getvalue()
files = []
for row in s.split("\n"):
if len(row) <= 0: continue
if len(row) <= 0:
continue
attribs = row.split(" ")
@ -219,10 +187,70 @@ class IoTFTPSClient:
else:
name = attribs[len(attribs) - 1]
file = ( attribs[0], name )
file = (attribs[0], name)
files.append(file)
return files
except Exception as ex:
print(f"unexpected exception occurred: [{ex}]")
pass
return
def get_file_size(self, file_path: str):
try:
return self.ftps_session.size(file_path)
except Exception as e:
raise RuntimeError(
f'Cannot get file size for "{file_path}" due to error: {str(e)}'
)
def get_file_date(self, file_path: str) -> datetime:
try:
date_response = self.ftps_session.sendcmd(f"MDTM {file_path}").replace(
"213 ", ""
)
date = datetime.strptime(date_response, "%Y%m%d%H%M%S").replace(
tzinfo=timezone.utc
)
return date
except Exception as e:
raise RuntimeError(
f'Cannot get file date for "{file_path}" due to error: {str(e)}'
)
@dataclass
class IoTFTPSClient:
ftps_host: str
ftps_port: int = 21
ftps_user: str = ""
ftps_pass: str = ""
ssl_implicit: bool = False
welcome: str = ""
_connection: IoTFTPSConnection | None = None
def __enter__(self):
session = self.open_ftps_session()
self._connection = IoTFTPSConnection(session)
return self._connection
def __exit__(self, type, value, traceback):
if self._connection is not None:
self._connection.close()
self._connection = None
def open_ftps_session(self) -> ftplib.FTP | ImplicitTLS:
"""init ftps_session based on input params"""
ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP()
ftps_session.set_debuglevel(0)
self.welcome = ftps_session.connect(host=self.ftps_host, port=self.ftps_port)
if self.ftps_user and self.ftps_pass:
ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass)
else:
ftps_session.login()
if self.ssl_implicit:
ftps_session.prot_p()
return ftps_session

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Iterable, Iterator
import logging.handlers
from octoprint.util import get_dos_filename
from .ftps_client import IoTFTPSClient, IoTFTPSConnection
from .file_info import FileInfo
class RemoteSDCardFileList:
def __init__(self, settings) -> None:
self._settings = settings
self._selected_project_file: FileInfo | None = None
self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter")
def delete_file(self, file_path: Path) -> None:
try:
with self.get_ftps_client() as ftp:
if ftp.delete_file(file_path.as_posix()):
self._logger.debug(f"{file_path} deleted")
else:
raise RuntimeError(f"Deleting file {file_path} failed")
except Exception as e:
self._logger.exception(e)
def list_files(
self,
folder: str,
extensions: str | list[str] | None,
ftp: IoTFTPSConnection,
existing_files=None,
):
if existing_files is None:
existing_files = []
return list(
self.get_file_info_for_names(
ftp, ftp.list_files(folder, extensions), existing_files
)
)
def _get_ftp_file_info(
self,
ftp: IoTFTPSConnection,
file_path: Path,
existing_files: list[str] | None = None,
):
file_size = ftp.get_file_size(file_path.as_posix())
date = ftp.get_file_date(file_path.as_posix())
file_name = file_path.name.lower()
dosname = get_dos_filename(file_name, existing_filenames=existing_files).lower()
return FileInfo(
dosname,
file_path,
file_size if file_size is not None else 0,
date,
)
def get_file_info_for_names(
self,
ftp: IoTFTPSConnection,
files: Iterable[Path],
existing_files: list[str] | None = None,
) -> Iterator[FileInfo]:
if existing_files is None:
existing_files = []
for entry in files:
try:
file_info = self._get_ftp_file_info(ftp, entry, existing_files)
yield file_info
existing_files.append(file_info.file_name)
existing_files.append(file_info.dosname)
except Exception as e:
self._logger.exception(e, exc_info=False)
def get_ftps_client(self):
host = self._settings.get(["host"])
access_code = self._settings.get(["access_code"])
return IoTFTPSClient(
f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True
)

View File

@ -0,0 +1,319 @@
import itertools
import logging
from inspect import signature
import traceback
GCODE_DOCUMENTATION = {
"G0": "Linear Move",
"G1": "Linear Move",
"G2": "Arc or Circle Move",
"G3": "Arc or Circle Move",
"G4": "Dwell",
"G5": "Bézier cubic spline",
"G6": "Direct Stepper Move",
"G10": "Retract",
"G11": "Recover",
"G12": "Clean the Nozzle",
"G17": "CNC Workspace Planes",
"G18": "CNC Workspace Planes",
"G19": "CNC Workspace Planes",
"G20": "Inch Units",
"G21": "Millimeter Units",
"G26": "Mesh Validation Pattern",
"G27": "Park toolhead",
"G28": "Auto Home",
"G29": "Bed Leveling",
"G29": "Bed Leveling (3-Point)",
"G29": "Bed Leveling (Linear)",
"G29": "Bed Leveling (Manual)",
"G29": "Bed Leveling (Bilinear)",
"G29": "Bed Leveling (Unified)",
"G30": "Single Z-Probe",
"G31": "Dock Sled",
"G32": "Undock Sled",
"G33": "Delta Auto Calibration",
"G34": "Z Steppers Auto-Alignment",
"G34": "Mechanical Gantry Calibration",
"G35": "Tramming Assistant",
"G38.2": "Probe target",
"G38.3": "Probe target",
"G38.4": "Probe target",
"G38.5": "Probe target",
"G42": "Move to mesh coordinate",
"G53": "Move in Machine Coordinates",
"G60": "Save Current Position",
"G61": "Return to Saved Position",
"G76": "Probe temperature calibration",
"G80": "Cancel Current Motion Mode",
"G90": "Absolute Positioning",
"G91": "Relative Positioning",
"G92": "Set Position",
"G425": "Backlash Calibration",
"M0": "Unconditional stop",
"M1": "Unconditional stop",
"M3": "Spindle CW / Laser On",
"M4": "Spindle CCW / Laser On",
"M5": "Spindle / Laser Off",
"M7": "Coolant Controls",
"M8": "Coolant Controls",
"M9": "Coolant Controls",
"M10": "Vacuum / Blower Control",
"M11": "Vacuum / Blower Control",
"M16": "Expected Printer Check",
"M17": "Enable Steppers",
"M18": "Disable steppers",
"M84": "Disable steppers",
"M20": "List SD Card",
"M21": "Init SD card",
"M22": "Release SD card",
"M23": "Select SD file",
"M24": "Start or Resume SD print",
"M25": "Pause SD print",
"M26": "Set SD position",
"M27": "Report SD print status",
"M28": "Start SD write",
"M29": "Stop SD write",
"M30": "Delete SD file",
"M31": "Print time",
"M32": "Select and Start",
"M33": "Get Long Path",
"M34": "SDCard Sorting",
"M42": "Set Pin State",
"M43": "Debug Pins",
"M48": "Probe Repeatability Test",
"M73": "Set Print Progress",
"M75": "Start Print Job Timer",
"M76": "Pause Print Job Timer",
"M77": "Stop Print Job Timer",
"M78": "Print Job Stats",
"M80": "Power On",
"M81": "Power Off",
"M82": "E Absolute",
"M83": "E Relative",
"M85": "Inactivity Shutdown",
"M86": "Hotend Idle Timeout",
"M87": "Disable Hotend Idle Timeout",
"M92": "Set Axis Steps-per-unit",
"M100": "Free Memory",
"M102": "Configure Bed Distance Sensor",
"M104": "Set Hotend Temperature",
"M105": "Report Temperatures",
"M106": "Set Fan Speed",
"M107": "Fan Off",
"M108": "Break and Continue",
"M109": "Wait for Hotend Temperature",
"M110": "Set / Get Line Number",
"M111": "Debug Level",
"M112": "Full Shutdown",
"M113": "Host Keepalive",
"M114": "Get Current Position",
"M115": "Firmware Info",
"M117": "Set LCD Message",
"M118": "Serial print",
"M119": "Endstop States",
"M120": "Enable Endstops",
"M121": "Disable Endstops",
"M122": "TMC Debugging",
"M123": "Fan Tachometers",
"M125": "Park Head",
"M126": "Baricuda 1 Open",
"M127": "Baricuda 1 Close",
"M128": "Baricuda 2 Open",
"M129": "Baricuda 2 Close",
"M140": "Set Bed Temperature",
"M141": "Set Chamber Temperature",
"M143": "Set Laser Cooler Temperature",
"M145": "Set Material Preset",
"M149": "Set Temperature Units",
"M150": "Set RGB(W) Color",
"M154": "Position Auto-Report",
"M155": "Temperature Auto-Report",
"M163": "Set Mix Factor",
"M164": "Save Mix",
"M165": "Set Mix",
"M166": "Gradient Mix",
"M190": "Wait for Bed Temperature",
"M191": "Wait for Chamber Temperature",
"M192": "Wait for Probe temperature",
"M193": "Set Laser Cooler Temperature",
"M200": "Set Filament Diameter",
"M201": "Print / Travel Move Limits",
"M203": "Set Max Feedrate",
"M204": "Set Starting Acceleration",
"M205": "Set Advanced Settings",
"M206": "Set Home Offsets",
"M207": "Set Firmware Retraction",
"M208": "Firmware Recover",
"M209": "Set Auto Retract",
"M211": "Software Endstops",
"M217": "Filament swap parameters",
"M218": "Set Hotend Offset",
"M220": "Set Feedrate Percentage",
"M221": "Set Flow Percentage",
"M226": "Wait for Pin State",
"M240": "Trigger Camera",
"M250": "LCD Contrast",
"M255": "LCD Sleep/Backlight Timeout",
"M256": "LCD Brightness",
"M260": "I2C Send",
"M261": "I2C Request",
"M280": "Servo Position",
"M281": "Edit Servo Angles",
"M282": "Detach Servo",
"M290": "Babystep",
"M300": "Play Tone",
"M301": "Set Hotend PID",
"M302": "Cold Extrude",
"M303": "PID autotune",
"M304": "Set Bed PID",
"M305": "User Thermistor Parameters",
"M306": "Model Predictive Temp. Control",
"M350": "Set micro-stepping",
"M351": "Set Microstep Pins",
"M355": "Case Light Control",
"M360": "SCARA Theta A",
"M361": "SCARA Theta-B",
"M362": "SCARA Psi-A",
"M363": "SCARA Psi-B",
"M364": "SCARA Psi-C",
"M380": "Activate Solenoid",
"M381": "Deactivate Solenoids",
"M400": "Finish Moves",
"M401": "Deploy Probe",
"M402": "Stow Probe",
"M403": "MMU2 Filament Type",
"M404": "Set Filament Diameter",
"M405": "Filament Width Sensor On",
"M406": "Filament Width Sensor Off",
"M407": "Filament Width",
"M410": "Quickstop",
"M412": "Filament Runout",
"M413": "Power-loss Recovery",
"M420": "Bed Leveling State",
"M421": "Set Mesh Value",
"M422": "Set Z Motor XY",
"M423": "X Twist Compensation",
"M425": "Backlash compensation",
"M428": "Home Offsets Here",
"M430": "Power Monitor",
"M486": "Cancel Objects",
"M493": "Fixed-Time Motion",
"M500": "Save Settings",
"M501": "Restore Settings",
"M502": "Factory Reset",
"M503": "Report Settings",
"M504": "Validate EEPROM contents",
"M510": "Lock Machine",
"M511": "Unlock Machine",
"M512": "Set Passcode",
"M524": "Abort SD print",
"M540": "Endstops Abort SD",
"M569": "Set TMC stepping mode",
"M575": "Serial baud rate",
"M592": "Nonlinear Extrusion Control",
"M593": "ZV Input Shaping",
"M600": "Filament Change",
"M603": "Configure Filament Change",
"M605": "Multi Nozzle Mode",
"M665": "Delta Configuration",
"M665": "SCARA Configuration",
"M666": "Set Delta endstop adjustments",
"M666": "Set dual endstop offsets",
"M672": "Duet Smart Effector sensitivity",
"M701": "Load filament",
"M702": "Unload filament",
"M710": "Controller Fan settings",
"M808": "Repeat Marker",
"M851": "XYZ Probe Offset",
"M852": "Bed Skew Compensation",
"M871": "Probe temperature config",
"M876": "Handle Prompt Response",
"M900": "Linear Advance Factor",
"M906": "Stepper Motor Current",
"M907": "Set Motor Current",
"M908": "Set Trimpot Pins",
"M909": "DAC Print Values",
"M910": "Commit DAC to EEPROM",
"M911": "TMC OT Pre-Warn Condition",
"M912": "Clear TMC OT Pre-Warn",
"M913": "Set Hybrid Threshold Speed",
"M914": "TMC Bump Sensitivity",
"M915": "TMC Z axis calibration",
"M916": "L6474 Thermal Warning Test",
"M917": "L6474 Overcurrent Warning Test",
"M918": "L6474 Speed Warning Test",
"M919": "TMC Chopper Timing",
"M928": "Start SD Logging",
"M951": "Magnetic Parking Extruder",
"M993": "Back up flash settings to SD",
"M994": "Restore flash from SD",
"M995": "Touch Screen Calibration",
"M997": "Firmware update",
"M999": "STOP Restart",
"M7219": "MAX7219 Control",
}
class GCodeExecutor:
def __init__(self):
self._log = logging.getLogger(
"octoprint.plugins.bambu_printer.BambuPrinter.gcode_executor"
)
self.handler_names = set()
self.gcode_handlers = {}
self.gcode_handlers_no_data = {}
def __contains__(self, item):
return item in self.gcode_handlers or item in self.gcode_handlers_no_data
def _get_required_args_count(self, func):
sig = signature(func)
required_count = sum(
1
for p in sig.parameters.values()
if (p.kind == p.POSITIONAL_OR_KEYWORD or p.kind == p.POSITIONAL_ONLY)
and p.default == p.empty
)
return required_count
def register(self, gcode):
def decorator(func):
required_count = self._get_required_args_count(func)
if required_count == 1:
self.gcode_handlers_no_data[gcode] = func
elif required_count == 2:
self.gcode_handlers[gcode] = func
else:
raise ValueError(
f"Cannot register function with {required_count} required parameters"
)
return func
return decorator
def register_no_data(self, gcode):
def decorator(func):
self.gcode_handlers_no_data[gcode] = func
return func
return decorator
def execute(self, printer, gcode, data):
gcode_info = self._gcode_with_info(gcode)
try:
if gcode in self.gcode_handlers:
self._log.debug(f"Executing {gcode_info}")
return self.gcode_handlers[gcode](printer, data)
elif gcode in self.gcode_handlers_no_data:
self._log.debug(f"Executing {gcode_info}")
return self.gcode_handlers_no_data[gcode](printer)
else:
self._log.debug(f"ignoring {gcode_info} command.")
return False
except Exception as e:
self._log.error(f"Error during gcode {gcode_info}")
raise
def _gcode_with_info(self, gcode):
return f"{gcode} ({GCODE_DOCUMENTATION.get(gcode, 'Info not specified')})"

View File

@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
FileInfo,
)
@dataclass
class PrintJob:
file_info: FileInfo
progress: int
remaining_time: int
current_layer: int
total_layers: int
@property
def file_position(self):
if self.file_info.size is None:
return 0
return int(self.file_info.size * self.progress / 100)

View File

@ -0,0 +1,257 @@
from __future__ import annotations
from io import BufferedIOBase
import logging
import queue
import re
import threading
import traceback
from types import TracebackType
from typing import Callable
from octoprint.util import to_bytes, to_unicode
from serial import SerialTimeoutException
class PrinterSerialIO(threading.Thread, BufferedIOBase):
command_regex = re.compile(r"^([GM])(\d+)")
def __init__(
self,
handle_command_callback: Callable[[str, str], None],
settings,
serial_log_handler=None,
read_timeout=5.0,
write_timeout=10.0,
) -> None:
super().__init__(
name="octoprint.plugins.bambu_printer.printer_worker", daemon=True
)
self._handle_command_callback = handle_command_callback
self._settings = settings
self._log = self._init_logger(serial_log_handler)
self._read_timeout = read_timeout
self._write_timeout = write_timeout
self.current_line = 0
self._received_lines = 0
self._wait_interval = 5.0
self._running = True
self._rx_buffer_size = 64
self._incoming_lock = threading.RLock()
self.input_bytes = queue.Queue(self._rx_buffer_size)
self.output_bytes = queue.Queue()
self._error_detected: Exception | None = None
def _init_logger(self, log_handler):
log = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter.serial")
if log_handler is not None:
log.addHandler(log_handler)
log.debug("-" * 78)
return log
@property
def incoming_lock(self):
return self._incoming_lock
def run(self) -> None:
buffer = b""
while self._running:
try:
data = self.input_bytes.get(block=True, timeout=0.01)
data = to_bytes(data, encoding="ascii", errors="replace")
buffer += data
line, buffer = self._read_next_line(buffer)
while line is not None:
self._received_lines += 1
self._process_input_gcode_line(line)
line, buffer = self._read_next_line(buffer)
self.input_bytes.task_done()
except queue.Empty:
continue
except Exception as e:
self._error_detected = e
self.input_bytes.task_done()
self._clearQueue(self.input_bytes)
self._log.info(
"\n".join(traceback.format_exception_only(type(e), e)[-50:])
)
self._running = False
self._log.debug("Closing IO read loop")
def _read_next_line(self, buffer: bytes):
new_line_pos = buffer.find(b"\n") + 1
if new_line_pos > 0:
line = buffer[:new_line_pos]
buffer = buffer[new_line_pos:]
return line, buffer
else:
return None, buffer
def close(self):
self.flush()
self._running = False
self.join()
def flush(self):
self.input_bytes.join()
self.raise_if_error()
def raise_if_error(self):
if self._error_detected is not None:
raise self._error_detected
def write(self, data: bytes) -> int:
data = to_bytes(data, errors="replace")
u_data = to_unicode(data, errors="replace")
with self._incoming_lock:
if self.is_closed():
return 0
try:
self._log.debug(f"<<< {u_data}")
self.input_bytes.put(data, timeout=self._write_timeout)
return len(data)
except queue.Full:
self._log.error(
"Incoming queue is full, raising SerialTimeoutException"
)
raise SerialTimeoutException()
def readline(self) -> bytes:
try:
# fetch a line from the queue, wait no longer than timeout
line = to_unicode(
self.output_bytes.get(timeout=self._read_timeout), errors="replace"
)
self._log.debug(f">>> {line.strip()}")
self.output_bytes.task_done()
return to_bytes(line)
except queue.Empty:
# queue empty? return empty line
return b""
def readlines(self):
result = []
next_line = self.readline()
while next_line != b"":
result.append(next_line)
next_line = self.readline()
return result
def send(self, line: str) -> None:
if self.output_bytes is not None:
self.output_bytes.put(line)
def sendOk(self):
self.send("ok")
def reset(self):
self._clearQueue(self.input_bytes)
self._clearQueue(self.output_bytes)
def is_closed(self):
return not self._running
def _process_input_gcode_line(self, data: bytes):
if b"*" in data:
checksum = int(data[data.rfind(b"*") + 1 :])
data = data[: data.rfind(b"*")]
if not checksum == self._calculate_checksum(data):
self._triggerResend(expected=self.current_line + 1)
return
self.current_line += 1
elif self._settings.get_boolean(["forceChecksum"]):
self.send(self._format_error("checksum_missing"))
return
line = self._process_linenumber_marker(data)
if line is None:
return
command = to_unicode(line, encoding="ascii", errors="replace").strip()
command_match = self.command_regex.match(command)
if command_match is not None:
gcode = command_match.group(0)
self._handle_command_callback(gcode, command)
else:
self._log.warn(f'Not a valid gcode command "{command}"')
def _process_linenumber_marker(self, data: bytes):
linenumber = 0
if data.startswith(b"N") and b"M110" in data:
linenumber = int(re.search(b"N([0-9]+)", data).group(1))
self.lastN = linenumber
self.current_line = linenumber
self.sendOk()
return None
elif data.startswith(b"N"):
linenumber = int(re.search(b"N([0-9]+)", data).group(1))
expected = self.lastN + 1
if linenumber != expected:
self._triggerResend(actual=linenumber)
return None
else:
self.lastN = linenumber
data = data.split(None, 1)[1].strip()
return data
def _triggerResend(
self,
expected: int | None = None,
actual: int | None = None,
checksum: int | None = None,
) -> None:
with self._incoming_lock:
if expected is None:
expected = self.lastN + 1
else:
self.lastN = expected - 1
if actual is None:
if checksum:
self.send(self._format_error("checksum_mismatch"))
else:
self.send(self._format_error("checksum_missing"))
else:
self.send(self._format_error("lineno_mismatch", expected, actual))
def request_resend():
self.send("Resend:%d" % expected)
self.sendOk()
request_resend()
def _calculate_checksum(self, line: bytes) -> int:
checksum = 0
for c in bytearray(line):
checksum ^= c
return checksum
def _format_error(self, error: str, *args, **kwargs) -> str:
errors = {
"checksum_mismatch": "Checksum mismatch",
"checksum_missing": "Missing checksum",
"lineno_mismatch": "expected line {} got {}",
"lineno_missing": "No Line Number with checksum, Last Line: {}",
"maxtemp": "MAXTEMP triggered!",
"mintemp": "MINTEMP triggered!",
"command_unknown": "Unknown command {}",
}
return f"Error: {errors.get(error).format(*args, **kwargs)}"
def _clearQueue(self, q: queue.Queue):
try:
while q.get(block=False):
q.task_done()
continue
except queue.Empty:
pass

View File

@ -0,0 +1,4 @@
"""Initialise the Bambu Client"""
# TODO: Once complete, move pybambu to PyPi
from .bambu_client import BambuClient
from .bambu_cloud import BambuCloud

View File

@ -0,0 +1,566 @@
from __future__ import annotations
import asyncio
import queue
import json
import math
import re
import socket
import ssl
import struct
import threading
import time
from dataclasses import dataclass
from typing import Any
import paho.mqtt.client as mqtt
from .bambu_cloud import BambuCloud
from .const import (
LOGGER,
Features,
)
from .models import Device, SlicerSettings
from .commands import (
GET_VERSION,
PUSH_ALL,
START_PUSH,
)
class WatchdogThread(threading.Thread):
def __init__(self, client):
self._client = client
self._watchdog_fired = False
self._stop_event = threading.Event()
self._last_received_data = time.time()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Watchdog-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def received_data(self):
self._last_received_data = time.time()
def run(self):
LOGGER.info("Watchdog thread started.")
WATCHDOG_TIMER = 30
while True:
# Wait out the remainder of the watchdog delay or 1s, whichever is higher.
interval = time.time() - self._last_received_data
wait_time = max(1, WATCHDOG_TIMER - interval)
if self._stop_event.wait(wait_time):
# Stop event has been set. Exit thread.
break
interval = time.time() - self._last_received_data
if not self._watchdog_fired and (interval > WATCHDOG_TIMER):
LOGGER.debug(f"Watchdog fired. No data received for {math.floor(interval)} seconds for {self._client._serial}.")
self._watchdog_fired = True
self._client._on_watchdog_fired()
elif interval < WATCHDOG_TIMER:
self._watchdog_fired = False
LOGGER.info("Watchdog thread exited.")
class ChamberImageThread(threading.Thread):
def __init__(self, client):
self._client = client
self._stop_event = threading.Event()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Chamber-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def run(self):
LOGGER.debug("Chamber image thread started.")
auth_data = bytearray()
username = 'bblp'
access_code = self._client._access_code
hostname = self._client.host
port = 6000
MAX_CONNECT_ATTEMPTS = 12
connect_attempts = 0
auth_data += struct.pack("<I", 0x40) # '@'\0\0\0
auth_data += struct.pack("<I", 0x3000) # \0'0'\0\0
auth_data += struct.pack("<I", 0) # \0\0\0\0
auth_data += struct.pack("<I", 0) # \0\0\0\0
for i in range(0, len(username)):
auth_data += struct.pack("<c", username[i].encode('ascii'))
for i in range(0, 32 - len(username)):
auth_data += struct.pack("<x")
for i in range(0, len(access_code)):
auth_data += struct.pack("<c", access_code[i].encode('ascii'))
for i in range(0, 32 - len(access_code)):
auth_data += struct.pack("<x")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
jpeg_start = bytearray([0xff, 0xd8, 0xff, 0xe0])
jpeg_end = bytearray([0xff, 0xd9])
read_chunk_size = 4096 # 4096 is the max we'll get even if we increase this.
# Payload format for each image is:
# 16 byte header:
# Bytes 0:3 = little endian payload size for the jpeg image (does not include this header).
# Bytes 4:7 = 0x00000000
# Bytes 8:11 = 0x00000001
# Bytes 12:15 = 0x00000000
# These first 16 bytes are always delivered by themselves.
#
# Bytes 16:19 = jpeg_start magic bytes
# Bytes 20:payload_size-2 = jpeg image bytes
# Bytes payload_size-2:payload_size = jpeg_end magic bytes
#
# Further attempts to receive data will get SSLWantReadError until a new image is ready (1-2 seconds later)
while connect_attempts < MAX_CONNECT_ATTEMPTS and not self._stop_event.is_set():
connect_attempts += 1
try:
with socket.create_connection((hostname, port)) as sock:
try:
sslSock = ctx.wrap_socket(sock, server_hostname=hostname)
sslSock.write(auth_data)
img = None
payload_size = 0
status = sslSock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
LOGGER.debug(f"SOCKET STATUS: {status}")
if status != 0:
LOGGER.error(f"Socket error: {status}")
except socket.error as e:
LOGGER.error(f"Socket error: {e}")
# Sleep to allow printer to stabilize during boot when it may fail these connection attempts repeatedly.
time.sleep(1)
continue
sslSock.setblocking(False)
while not self._stop_event.is_set():
try:
dr = sslSock.recv(read_chunk_size)
#LOGGER.debug(f"Received {len(dr)} bytes.")
except ssl.SSLWantReadError:
#LOGGER.debug("SSLWantReadError")
time.sleep(1)
continue
except Exception as e:
LOGGER.error("A Chamber Image thread inner exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1)
continue
if img is not None and len(dr) > 0:
img += dr
if len(img) > payload_size:
# We got more data than we expected.
LOGGER.error(f"Unexpected image payload received: {len(img)} > {payload_size}")
# Reset buffer
img = None
elif len(img) == payload_size:
# We should have the full image now.
if img[:4] != jpeg_start:
LOGGER.error("JPEG start magic bytes missing.")
elif img[-2:] != jpeg_end:
LOGGER.error("JPEG end magic bytes missing.")
else:
# Content is as expected. Send it.
self._client.on_jpeg_received(img)
# Reset buffer
img = None
# else:
# Otherwise we need to continue looping without reseting the buffer to receive the remaining data
# and without delaying.
elif len(dr) == 16:
# We got the header bytes. Get the expected payload size from it and create the image buffer bytearray.
# Reset connect_attempts now we know the connect was successful.
connect_attempts = 0
img = bytearray()
payload_size = int.from_bytes(dr[0:3], byteorder='little')
elif len(dr) == 0:
# This occurs if the wrong access code was provided.
LOGGER.error("Chamber image connection rejected by the printer. Check provided access code and IP address.")
# Sleep for a short while and then re-attempt the connection.
time.sleep(5)
break
else:
LOGGER.error(f"UNEXPECTED DATA RECEIVED: {len(dr)}")
time.sleep(1)
except OSError as e:
if e.errno == 113:
LOGGER.debug("Host is unreachable")
else:
LOGGER.error("A Chamber Image thread outer exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
if not self._stop_event.is_set():
time.sleep(1) # Avoid a tight loop if this is a persistent error.
except Exception as e:
LOGGER.error(f"A Chamber Image thread outer exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
if not self._stop_event.is_set():
time.sleep(1) # Avoid a tight loop if this is a persistent error.
LOGGER.debug("Chamber image thread exited.")
class MqttThread(threading.Thread):
def __init__(self, client):
self._client = client
self._stop_event = threading.Event()
super().__init__()
self.daemon = True
self.setName(f"{self._client._device.info.device_type}-Mqtt-{threading.get_native_id()}")
def stop(self):
self._stop_event.set()
def run(self):
LOGGER.info("MQTT listener thread started.")
exceptionSeen = ""
while True:
try:
host = self._client.host if self._client.local_mqtt else self._client.bambu_cloud.cloud_mqtt_host
LOGGER.debug(f"Connect: Attempting Connection to {host}")
self._client.client.connect(host, self._client._port, keepalive=5)
LOGGER.debug("Starting listen loop")
self._client.client.loop_forever()
LOGGER.debug("Ended listen loop.")
break
except TimeoutError as e:
if exceptionSeen != "TimeoutError":
LOGGER.debug(f"TimeoutError: {e}.")
exceptionSeen = "TimeoutError"
time.sleep(5)
except ConnectionError as e:
if exceptionSeen != "ConnectionError":
LOGGER.debug(f"ConnectionError: {e}.")
exceptionSeen = "ConnectionError"
time.sleep(5)
except OSError as e:
if e.errno == 113:
if exceptionSeen != "OSError113":
LOGGER.debug(f"OSError: {e}.")
exceptionSeen = "OSError113"
time.sleep(5)
else:
LOGGER.error("A listener loop thread exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1) # Avoid a tight loop if this is a persistent error.
except Exception as e:
LOGGER.error("A listener loop thread exception occurred:")
LOGGER.error(f"Exception. Type: {type(e)} Args: {e}")
time.sleep(1) # Avoid a tight loop if this is a persistent error.
if self._client.client is None:
break
self._client.client.disconnect()
LOGGER.info("MQTT listener thread exited.")
@dataclass
class BambuClient:
"""Initialize Bambu Client to connect to MQTT Broker"""
_watchdog = None
_camera = None
_usage_hours: float
def __init__(self, device_type: str, serial: str, host: str, local_mqtt: bool, region: str, email: str,
username: str, auth_token: str, access_code: str, usage_hours: float = 0, manual_refresh_mode: bool = False, chamber_image: bool = True):
self.callback = None
self.host = host
self.local_mqtt = local_mqtt
self._serial = serial
self._auth_token = auth_token
self._access_code = access_code
self._username = username
self._connected = False
self._device_type = device_type
self._usage_hours = usage_hours
self._port = 1883
self._refreshed = False
self._manual_refresh_mode = manual_refresh_mode
self._device = Device(self)
self.bambu_cloud = BambuCloud(region, email, username, auth_token)
self.slicer_settings = SlicerSettings(self)
self.use_chamber_image = chamber_image
@property
def connected(self):
"""Return if connected to server"""
return self._connected
@property
def manual_refresh_mode(self):
"""Return if the integration is running in poll mode"""
return self._manual_refresh_mode
async def set_manual_refresh_mode(self, on):
self._manual_refresh_mode = on
if self._manual_refresh_mode:
# Disconnect from the server. User must manually hit the refresh button to connect to refresh and then it will immediately disconnect.
self.disconnect()
else:
# Reconnect normally
self.connect(self.callback)
def setup_tls(self):
self.client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE)
self.client.tls_insecure_set(True)
def connect(self, callback):
"""Connect to the MQTT Broker"""
self.client = mqtt.Client()
self.callback = callback
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = self.on_message
# Set aggressive reconnect polling.
self.client.reconnect_delay_set(min_delay=1, max_delay=1)
# Run the blocking tls_set method in a separate thread
self.setup_tls()
self._port = 8883
if self.local_mqtt:
self.client.username_pw_set("bblp", password=self._access_code)
else:
self.client.username_pw_set(self._username, password=self._auth_token)
LOGGER.debug("Starting MQTT listener thread")
self._mqtt = MqttThread(self)
self._mqtt.start()
def subscribe_and_request_info(self):
LOGGER.debug("Loading slicer settings...")
self.slicer_settings.update()
LOGGER.debug("Now subscribing...")
self.subscribe()
LOGGER.debug("On Connect: Getting version info")
self.publish(GET_VERSION)
LOGGER.debug("On Connect: Request push all")
self.publish(PUSH_ALL)
def on_connect(self,
client_: mqtt.Client,
userdata: None,
flags: dict[str, Any],
result_code: int,
properties: mqtt.Properties | None = None, ):
"""Handle connection"""
LOGGER.info("On Connect: Connected to printer")
self._on_connect()
def _on_connect(self):
self._connected = True
self.subscribe_and_request_info()
LOGGER.debug("Starting watchdog thread")
self._watchdog = WatchdogThread(self)
self._watchdog.start()
if not self._device.supports_feature(Features.CAMERA_RTSP):
if self._device.supports_feature(Features.CAMERA_IMAGE):
if self.use_chamber_image:
LOGGER.debug("Starting Chamber Image thread")
self._camera = ChamberImageThread(self)
self._camera.start()
elif (self.host == "") or (self._access_code == ""):
LOGGER.debug("Skipping camera setup as local access details not provided.")
def try_on_connect(self,
client_: mqtt.Client,
userdata: None,
flags: dict[str, Any],
result_code: int,
properties: mqtt.Properties | None = None, ):
"""Handle connection"""
LOGGER.info("On Connect: Connected to printer")
self._connected = True
LOGGER.debug("Now test subscribing...")
self.subscribe()
# For the initial configuration connection attempt, we just need version info.
LOGGER.debug("On Connect: Getting version info")
self.publish(GET_VERSION)
def on_disconnect(self,
client_: mqtt.Client,
userdata: None,
result_code: int):
"""Called when MQTT Disconnects"""
LOGGER.warn(f"On Disconnect: Printer disconnected with error code: {result_code}")
self._on_disconnect()
def _on_disconnect(self):
LOGGER.debug("_on_disconnect: Lost connection to the printer")
self._connected = False
self._device.info.set_online(False)
if self._watchdog is not None:
LOGGER.debug("Stopping watchdog thread")
self._watchdog.stop()
self._watchdog.join()
if self._camera is not None:
LOGGER.debug("Stopping camera thread")
self._camera.stop()
self._camera.join()
def _on_watchdog_fired(self):
LOGGER.info("Watch dog fired")
self._device.info.set_online(False)
self.publish(START_PUSH)
def on_jpeg_received(self, bytes):
self._device.chamber_image.set_jpeg(bytes)
def on_message(self, client, userdata, message):
"""Return the payload when received"""
try:
# X1 mqtt payload is inconsistent. Adjust it for consistent logging.
clean_msg = re.sub(r"\\n *", "", str(message.payload))
if self._refreshed:
LOGGER.debug(f"Received data: {clean_msg}")
json_data = json.loads(message.payload)
if json_data.get("event"):
# These are events from the bambu cloud mqtt feed and allow us to detect when a local
# device has connected/disconnected (e.g. turned on/off)
if json_data.get("event").get("event") == "client.connected":
LOGGER.debug("Client connected event received.")
self._on_disconnect() # We aren't guaranteed to recieve a client.disconnected event.
self._on_connect()
elif json_data.get("event").get("event") == "client.disconnected":
LOGGER.debug("Client disconnected event received.")
self._on_disconnect()
else:
self._device.info.set_online(True)
self._watchdog.received_data()
if json_data.get("print"):
self._device.print_update(data=json_data.get("print"))
# Once we receive data, if in manual refresh mode, we disconnect again.
if self._manual_refresh_mode:
self.disconnect()
if json_data.get("print").get("msg", 0) == 0:
self._refreshed= False
elif json_data.get("info") and json_data.get("info").get("command") == "get_version":
LOGGER.debug("Got Version Data")
self._device.info_update(data=json_data.get("info"))
except Exception as e:
LOGGER.error("An exception occurred processing a message:", exc_info=e)
def subscribe(self):
"""Subscribe to report topic"""
LOGGER.debug(f"Subscribing: device/{self._serial}/report")
self.client.subscribe(f"device/{self._serial}/report")
def publish(self, msg):
"""Publish a custom message"""
result = self.client.publish(f"device/{self._serial}/request", json.dumps(msg))
status = result[0]
if status == 0:
LOGGER.debug(f"Sent {msg} to topic device/{self._serial}/request")
return True
LOGGER.error(f"Failed to send message to topic device/{self._serial}/request")
return False
async def refresh(self):
"""Force refresh data"""
if self._manual_refresh_mode:
self.connect(self.callback)
else:
LOGGER.debug("Force Refresh: Getting Version Info")
self._refreshed = True
self.publish(GET_VERSION)
LOGGER.debug("Force Refresh: Request Push All")
self._refreshed = True
self.publish(PUSH_ALL)
self.slicer_settings.update()
def get_device(self):
"""Return device"""
return self._device
def disconnect(self):
"""Disconnect the Bambu Client from server"""
LOGGER.debug(" Disconnect: Client Disconnecting")
if self.client is not None:
self.client.disconnect()
self.client = None
async def try_connection(self):
"""Test if we can connect to an MQTT broker."""
LOGGER.debug("Try Connection")
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_message(client, userdata, message):
json_data = json.loads(message.payload)
LOGGER.debug(f"Try Connection: Got '{json_data}'")
if json_data.get("info") and json_data.get("info").get("command") == "get_version":
LOGGER.debug("Got Version Command Data")
self._device.info_update(data=json_data.get("info"))
result.put(True)
self.client = mqtt.Client()
self.client.on_connect = self.try_on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = on_message
# Run the blocking tls_set method in a separate thread
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self.setup_tls)
if self.local_mqtt:
self.client.username_pw_set("bblp", password=self._access_code)
else:
self.client.username_pw_set(self._username, password=self._auth_token)
self._port = 8883
LOGGER.debug("Test connection: Connecting to %s", self.host)
try:
self.client.connect(self.host, self._port)
self.client.loop_start()
if result.get(timeout=10):
return True
except OSError as e:
return False
except queue.Empty:
return False
finally:
self.disconnect()
async def __aenter__(self):
"""Async enter.
Returns:
The BambuLab object.
"""
return self
async def __aexit__(self, *_exc_info):
"""Async exit.
Args:
_exc_info: Exec type.
"""
self.disconnect()

View File

@ -0,0 +1,413 @@
from __future__ import annotations
import base64
import json
from curl_cffi import requests
from dataclasses import dataclass
from .const import (
LOGGER,
BambuUrl
)
from .utils import get_Url
IMPERSONATE_BROWSER='chrome'
@dataclass
class BambuCloud:
def __init__(self, region: str, email: str, username: str, auth_token: str):
self._region = region
self._email = email
self._username = username
self._auth_token = auth_token
self._tfaKey = None
def _get_headers_with_auth_token(self) -> dict:
headers = {}
headers['Authorization'] = f"Bearer {self._auth_token}"
return headers
def _get_authentication_token(self) -> dict:
LOGGER.debug("Getting accessToken from Bambu Cloud")
# First we need to find out how Bambu wants us to login.
data = {
"account": self._email,
"password": self._password,
"apiError": ""
}
response = requests.post(get_Url(BambuUrl.LOGIN, self._region), json=data, impersonate=IMPERSONATE_BROWSER)
if response.status_code >= 400:
LOGGER.error(f"Login attempt failed with error code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise ValueError(response.status_code)
LOGGER.debug(f"Response: {response.status_code}")
auth_json = response.json()
accessToken = auth_json.get('accessToken', '')
if accessToken != '':
# We were provided the accessToken directly.
return accessToken
loginType = auth_json.get("loginType", None)
if loginType is None:
LOGGER.error(f"loginType not present")
LOGGER.error(f"Response not understood: '{response.text}'")
return None
elif loginType == 'verifyCode':
LOGGER.debug(f"Received verifyCode response")
elif loginType == 'tfa':
# Store the tfaKey for later use
LOGGER.debug(f"Received tfa response")
self._tfaKey = auth_json.get("tfaKey")
else:
LOGGER.debug(f"Did not understand json. loginType = '{loginType}'")
LOGGER.error(f"Response not understood: '{response.text}'")
return loginType
def _get_email_verification_code(self):
# Send the verification code request
data = {
"email": self._email,
"type": "codeLogin"
}
LOGGER.debug("Requesting verification code")
response = requests.post(get_Url(BambuUrl.EMAIL_CODE, self._region), json=data, impersonate=IMPERSONATE_BROWSER)
if response.status_code == 200:
LOGGER.debug("Verification code requested successfully.")
else:
LOGGER.error(f"Received error trying to send verification code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise ValueError(response.status_code)
def _get_authentication_token_with_verification_code(self, code) -> dict:
LOGGER.debug("Attempting to connect with provided verification code.")
data = {
"account": self._email,
"code": code
}
response = requests.post(get_Url(BambuUrl.LOGIN, self._region), json=data, impersonate=IMPERSONATE_BROWSER)
LOGGER.debug(f"Response: {response.status_code}")
if response.status_code == 200:
LOGGER.debug("Authentication successful.")
elif response.status_code == 400:
LOGGER.debug(f"Response: '{response.json()}'")
if response.json()['code'] == 1:
# Code has expired. Request a new one.
self._get_email_verification_code()
return 'codeExpired'
elif response.json()['code'] == 2:
# Code was incorrect. Let the user try again.
return 'codeIncorrect'
else:
LOGGER.error(f"Response not understood: '{response.json()}'")
raise ValueError(response.json()['code'])
else:
LOGGER.error(f"Received error trying to authenticate with verification code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise ValueError(response.status_code)
return response.json()['accessToken']
def _get_authentication_token_with_2fa_code(self, code: str) -> dict:
LOGGER.debug("Attempting to connect with provided 2FA code.")
data = {
"tfaKey": self._tfaKey,
"tfaCode": code
}
response = requests.post(get_Url(BambuUrl.TFA_LOGIN, self._region), json=data, impersonate=IMPERSONATE_BROWSER)
LOGGER.debug(f"Response: {response.status_code}")
if response.status_code == 200:
LOGGER.debug("Authentication successful.")
else:
LOGGER.error(f"Received error trying to authenticate with verification code: {response.status_code}")
LOGGER.debug(f"Response: '{response.text}'")
raise ValueError(response.status_code)
cookies = response.cookies.get_dict()
token_from_tfa = cookies.get("token")
LOGGER.debug(f"token_from_tfa: {token_from_tfa}")
return token_from_tfa
def _get_username_from_authentication_token(self) -> str:
# User name is in 2nd portion of the auth token (delimited with periods)
b64_string = self._auth_token.split(".")[1]
# String must be multiples of 4 chars in length. For decode pad with = character
b64_string += "=" * ((4 - len(b64_string) % 4) % 4)
jsonAuthToken = json.loads(base64.b64decode(b64_string))
# Gives json payload with "username":"u_<digits>" within it
return jsonAuthToken['username']
# Retrieves json description of devices in the form:
# {
# 'message': 'success',
# 'code': None,
# 'error': None,
# 'devices': [
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu P1S',
# 'online': True,
# 'print_status': 'SUCCESS',
# 'dev_model_name': 'C12',
# 'dev_product_name': 'P1S',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# },
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu P1P',
# 'online': True,
# 'print_status': 'RUNNING',
# 'dev_model_name': 'C11',
# 'dev_product_name': 'P1P',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# },
# {
# 'dev_id': 'REDACTED',
# 'name': 'Bambu X1C',
# 'online': True,
# 'print_status': 'RUNNING',
# 'dev_model_name': 'BL-P001',
# 'dev_product_name': 'X1 Carbon',
# 'dev_access_code': 'REDACTED',
# 'nozzle_diameter': 0.4
# }
# ]
# }
def test_authentication(self, region: str, email: str, username: str, auth_token: str) -> bool:
self._region = region
self._email = email
self._username = username
self._auth_token = auth_token
try:
self.get_device_list()
except:
return False
return True
def login(self, region: str, email: str, password: str) -> str:
self._region = region
self._email = email
self._password = password
result = self._get_authentication_token()
if result == 'verifyCode':
return result
elif result == 'tfa':
return result
elif result is None:
LOGGER.error("Unable to authenticate.")
return None
else:
self._auth_token = result
self._username = self._get_username_from_authentication_token()
return 'success'
def login_with_verification_code(self, code: str):
result = self._get_authentication_token_with_verification_code(code)
if result == 'codeExpired' or result == 'codeIncorrect':
return result
self._auth_token = result
self._username = self._get_username_from_authentication_token()
return 'success'
def login_with_2fa_code(self, code: str):
result = self._get_authentication_token_with_2fa_code(code)
self._auth_token = result
self._username = self._get_username_from_authentication_token()
return 'success'
def get_device_list(self) -> dict:
LOGGER.debug("Getting device list from Bambu Cloud")
response = requests.get(get_Url(BambuUrl.BIND, self._region), headers=self._get_headers_with_auth_token(), timeout=10, impersonate=IMPERSONATE_BROWSER)
if response.status_code >= 400:
LOGGER.debug(f"Received error: {response.status_code}")
raise ValueError(response.status_code)
return response.json()['devices']
# The slicer settings are of the following form:
#
# {
# "message": "success",
# "code": null,
# "error": null,
# "print": {
# "public": [
# {
# "setting_id": "GP004",
# "version": "01.09.00.15",
# "name": "0.20mm Standard @BBL X1C",
# "update_time": "2024-07-04 11:27:08",
# "nickname": null
# },
# ...
# }
# "private": []
# },
# "printer": {
# "public": [
# {
# "setting_id": "GM001",
# "version": "01.09.00.15",
# "name": "Bambu Lab X1 Carbon 0.4 nozzle",
# "update_time": "2024-07-04 11:25:07",
# "nickname": null
# },
# ...
# ],
# "private": []
# },
# "filament": {
# "public": [
# {
# "setting_id": "GFSA01",
# "version": "01.09.00.15",
# "name": "Bambu PLA Matte @BBL X1C",
# "update_time": "2024-07-04 11:29:21",
# "nickname": null,
# "filament_id": "GFA01"
# },
# ...
# ],
# "private": [
# {
# "setting_id": "PFUS46ea5c221cabe5",
# "version": "1.9.0.14",
# "name": "Fillamentum PLA Extrafill @Bambu Lab X1 Carbon 0.4 nozzle",
# "update_time": "2024-07-10 06:48:17",
# "base_id": null,
# "filament_id": "Pc628b24",
# "filament_type": "PLA",
# "filament_is_support": "0",
# "nozzle_temperature": [
# 190,
# 240
# ],
# "nozzle_hrc": "3",
# "filament_vendor": "Fillamentum"
# },
# ...
# ]
# },
# "settings": {}
# }
def get_slicer_settings(self) -> dict:
LOGGER.debug("Getting slicer settings from Bambu Cloud")
response = requests.get(get_Url(BambuUrl.SLICER_SETTINGS, self._region), headers=self._get_headers_with_auth_token(), timeout=10, impersonate=IMPERSONATE_BROWSER)
if response.status_code >= 400:
LOGGER.error(f"Slicer settings load failed: {response.status_code}")
LOGGER.error(f"Slicer settings load failed: '{response.text}'")
return None
return response.json()
# The task list is of the following form with a 'hits' array with typical 20 entries.
#
# "total": 531,
# "hits": [
# {
# "id": 35237965,
# "designId": 0,
# "designTitle": "",
# "instanceId": 0,
# "modelId": "REDACTED",
# "title": "REDACTED",
# "cover": "REDACTED",
# "status": 4,
# "feedbackStatus": 0,
# "startTime": "2023-12-21T19:02:16Z",
# "endTime": "2023-12-21T19:02:35Z",
# "weight": 34.62,
# "length": 1161,
# "costTime": 10346,
# "profileId": 35276233,
# "plateIndex": 1,
# "plateName": "",
# "deviceId": "REDACTED",
# "amsDetailMapping": [
# {
# "ams": 4,
# "sourceColor": "F4D976FF",
# "targetColor": "F4D976FF",
# "filamentId": "GFL99",
# "filamentType": "PLA",
# "targetFilamentType": "",
# "weight": 34.62
# }
# ],
# "mode": "cloud_file",
# "isPublicProfile": false,
# "isPrintable": true,
# "deviceModel": "P1P",
# "deviceName": "Bambu P1P",
# "bedType": "textured_plate"
# },
def get_tasklist(self) -> dict:
url = get_Url(BambuUrl.TASKS, self._region)
response = requests.get(url, headers=self._get_headers_with_auth_token(), timeout=10, impersonate=IMPERSONATE_BROWSER)
if response.status_code >= 400:
LOGGER.debug(f"Received error: {response.status_code}")
LOGGER.debug(f"Received error: '{response.text}'")
raise ValueError(response.status_code)
return response.json()
def get_latest_task_for_printer(self, deviceId: str) -> dict:
LOGGER.debug(f"Getting latest task from Bambu Cloud")
data = self.get_tasklist_for_printer(deviceId)
if len(data) != 0:
return data[0]
LOGGER.debug("No tasks found for printer")
return None
def get_tasklist_for_printer(self, deviceId: str) -> dict:
LOGGER.debug(f"Getting task list from Bambu Cloud")
tasks = []
data = self.get_tasklist()
for task in data['hits']:
if task['deviceId'] == deviceId:
tasks.append(task)
return tasks
def get_device_type_from_device_product_name(self, device_product_name: str):
if device_product_name == "X1 Carbon":
return "X1C"
return device_product_name.replace(" ", "")
def download(self, url: str) -> bytearray:
LOGGER.debug(f"Downloading cover image: {url}")
response = requests.get(url, timeout=10, impersonate=IMPERSONATE_BROWSER)
if response.status_code >= 400:
LOGGER.debug(f"Received error: {response.status_code}")
raise ValueError(response.status_code)
return response.content
@property
def username(self):
return self._username
@property
def auth_token(self):
return self._auth_token
@property
def cloud_mqtt_host(self):
return "cn.mqtt.bambulab.com" if self._region == "China" else "us.mqtt.bambulab.com"

View File

@ -0,0 +1,24 @@
"""MQTT Commands"""
CHAMBER_LIGHT_ON = {
"system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "on",
"led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}}
CHAMBER_LIGHT_OFF = {
"system": {"sequence_id": "0", "command": "ledctrl", "led_node": "chamber_light", "led_mode": "off",
"led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}}
SPEED_PROFILE_TEMPLATE = {"print": {"sequence_id": "0", "command": "print_speed", "param": ""}}
GET_VERSION = {"info": {"sequence_id": "0", "command": "get_version"}}
PAUSE = {"print": {"sequence_id": "0", "command": "pause"}}
RESUME = {"print": {"sequence_id": "0", "command": "resume"}}
STOP = {"print": {"sequence_id": "0", "command": "stop"}}
PUSH_ALL = {"pushing": {"sequence_id": "0", "command": "pushall"}}
START_PUSH = { "pushing": {"sequence_id": "0", "command": "start"}}
SEND_GCODE_TEMPLATE = {"print": {"sequence_id": "0", "command": "gcode_line", "param": ""}} # param = GCODE_EACH_LINE_SEPARATED_BY_\n
# X1 only currently
GET_ACCESSORIES = {"system": {"sequence_id": "0", "command": "get_accessories", "accessory_type": "none"}}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
{
"GFA00": "Bambu PLA Basic",
"GFA01": "Bambu PLA Matte",
"GFA02": "Bambu PLA Metal",
"GFA05": "Bambu PLA Silk",
"GFA07": "Bambu PLA Marble",
"GFA08": "Bambu PLA Sparkle",
"GFA09": "Bambu PLA Tough",
"GFA11": "Bambu PLA Aero",
"GFA12": "Bambu PLA Glow",
"GFA13": "Bambu PLA Dynamic",
"GFA15": "Bambu PLA Galaxy",
"GFA50": "Bambu PLA-CF",
"GFB00": "Bambu ABS",
"GFB01": "Bambu ASA",
"GFB02": "Bambu ASA-Aero",
"GFB50": "Bambu ABS-GF",
"GFB60": "PolyLite ABS",
"GFB61": "PolyLite ASA",
"GFB98": "Generic ASA",
"GFB99": "Generic ABS",
"GFC00": "Bambu PC",
"GFC99": "Generic PC",
"GFG00": "Bambu PETG Basic",
"GFG01": "Bambu PETG Translucent",
"GFG02": "Bambu PETG HF",
"GFG50": "Bambu PETG-CF",
"GFG60": "PolyLite PETG",
"GFG97": "Generic PCTG",
"GFG98": "Generic PETG-CF",
"GFG99": "Generic PETG",
"GFL00": "PolyLite PLA",
"GFL01": "PolyTerra PLA",
"GFL03": "eSUN PLA+",
"GFL04": "Overture PLA",
"GFL05": "Overture Matte PLA",
"GFL95": "Generic PLA High Speed",
"GFL96": "Generic PLA Silk",
"GFL98": "Generic PLA-CF",
"GFL99": "Generic PLA",
"GFN03": "Bambu PA-CF",
"GFN04": "Bambu PAHT-CF",
"GFN05": "Bambu PA6-CF",
"GFN08": "Bambu PA6-GF",
"GFN96": "Generic PPA-GF",
"GFN97": "Generic PPA-CF",
"GFN98": "Generic PA-CF",
"GFN99": "Generic PA",
"GFP95": "Generic PP-GF",
"GFP96": "Generic PP-CF",
"GFP97": "Generic PP",
"GFP98": "Generic PE-CF",
"GFP99": "Generic PE",
"GFR98": "Generic PHA",
"GFR99": "Generic EVA",
"GFS00": "Bambu Support W",
"GFS01": "Bambu Support G",
"GFS02": "Bambu Support For PLA",
"GFS03": "Bambu Support For PA/PET",
"GFS04": "Bambu PVA",
"GFS05": "Bambu Support For PLA/PETG",
"GFS06": "Bambu Support for ABS",
"GFS97": "Generic BVOH",
"GFS98": "Generic HIPS",
"GFS99": "Generic PVA",
"GFT01": "Bambu PET-CF",
"GFT97": "Generic PPS",
"GFT98": "Generic PPS-CF",
"GFU00": "Bambu TPU 95A HF",
"GFU01": "Bambu TPU 95A",
"GFU99": "Generic TPU"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,235 @@
import math
from datetime import datetime, timedelta
from .const import (
CURRENT_STAGE_IDS,
SPEED_PROFILE,
FILAMENT_NAMES,
HMS_ERRORS,
HMS_AMS_ERRORS,
PRINT_ERROR_ERRORS,
HMS_SEVERITY_LEVELS,
HMS_MODULES,
LOGGER,
FansEnum,
BAMBU_URL
)
from .commands import SEND_GCODE_TEMPLATE
def search(lst, predicate, default={}):
"""Search an array for a string"""
for item in lst:
if predicate(item):
return item
return default
def fan_percentage(speed):
"""Converts a fan speed to percentage"""
if not speed:
return 0
percentage = (int(speed) / 15) * 100
return round(percentage / 10) * 10
def fan_percentage_to_gcode(fan: FansEnum, percentage: int):
"""Converts a fan speed percentage to the gcode command to set that"""
if fan == FansEnum.PART_COOLING:
fanString = "P1"
elif fan == FansEnum.AUXILIARY:
fanString = "P2"
elif fan == FansEnum.CHAMBER:
fanString = "P3"
percentage = round(percentage / 10) * 10
speed = math.ceil(255 * percentage / 100)
command = SEND_GCODE_TEMPLATE
command['print']['param'] = f"M106 {fanString} S{speed}\n"
return command
def to_whole(number):
if not number:
return 0
return round(number)
def get_filament_name(idx, custom_filaments: dict):
"""Converts a filament idx to a human-readable name"""
result = FILAMENT_NAMES.get(idx, "unknown")
if result == "unknown" and idx != "":
result = custom_filaments.get(idx, "unknown")
# if result == "unknown" and idx != "":
# LOGGER.debug(f"UNKNOWN FILAMENT IDX: '{idx}'")
return result
def get_speed_name(id):
"""Return the human-readable name for a speed id"""
return SPEED_PROFILE.get(int(id), "standard")
def get_current_stage(id) -> str:
"""Return the human-readable description for a stage action"""
return CURRENT_STAGE_IDS.get(int(id), "unknown")
def get_HMS_error_text(hms_code: str):
"""Return the human-readable description for an HMS error"""
ams_code = get_generic_AMS_HMS_error_code(hms_code)
ams_error = HMS_AMS_ERRORS.get(ams_code, "")
if ams_error != "":
# 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error
ams_index = int(hms_code[3:4], 16) + 1
ams_slot = int(hms_code[6:7], 16) + 1
ams_error = ams_error.replace('AMS1', f"AMS{ams_index}")
ams_error = ams_error.replace('slot 1', f"slot {ams_slot}")
return ams_error
return HMS_ERRORS.get(hms_code, "unknown")
def get_print_error_text(print_error_code: str):
"""Return the human-readable description for a print error"""
hex_conversion = f'0{int(print_error_code):x}'
print_error_code = hex_conversion[slice(0,4,1)] + "_" + hex_conversion[slice(4,8,1)]
print_error = PRINT_ERROR_ERRORS.get(print_error_code.upper(), "")
if print_error != "":
return print_error
return PRINT_ERROR_ERRORS.get(print_error_code, "unknown")
def get_HMS_severity(code: int) -> str:
uint_code = code >> 16
if code > 0 and uint_code in HMS_SEVERITY_LEVELS:
return HMS_SEVERITY_LEVELS[uint_code]
return HMS_SEVERITY_LEVELS["default"]
def get_HMS_module(attr: int) -> str:
uint_attr = (attr >> 24) & 0xFF
if attr > 0 and uint_attr in HMS_MODULES:
return HMS_MODULES[uint_attr]
return HMS_MODULES["default"]
def get_generic_AMS_HMS_error_code(hms_code: str):
code1 = int(hms_code[0:4], 16)
code2 = int(hms_code[5:9], 16)
code3 = int(hms_code[10:14], 16)
code4 = int(hms_code[15:19], 16)
# 070X_xYxx_xxxx_xxxx = AMS X (0 based index) Slot Y (0 based index) has the error
ams_code = f"{code1 & 0xFFF8:0>4X}_{code2 & 0xF8FF:0>4X}_{code3:0>4X}_{code4:0>4X}"
ams_error = HMS_AMS_ERRORS.get(ams_code, "")
if ams_error != "":
return ams_code
return f"{code1:0>4X}_{code2:0>4X}_{code3:0>4X}_{code4:0>4X}"
def get_printer_type(modules, default):
# Known possible values:
#
# A1/P1 printers are of the form:
# {
# "name": "esp32",
# "project_name": "C11",
# "sw_ver": "01.07.23.47",
# "hw_ver": "AP04",
# "sn": "**REDACTED**",
# "flag": 0
# },
# P1P = AP04 / C11
# P1S = AP04 / C12
# A1Mini = AP05 / N1 or AP04 / N1 or AP07 / N1
# A1 = AP05 / N2S
#
# X1C printers are of the form:
# {
# "hw_ver": "AP05",
# "name": "rv1126",
# "sn": "**REDACTED**",
# "sw_ver": "00.00.28.55"
# },
# X1C = AP05
#
# X1E printers are of the form:
# {
# "flag": 0,
# "hw_ver": "AP02",
# "name": "ap",
# "sn": "**REDACTED**",
# "sw_ver": "00.00.32.14"
# }
# X1E = AP02
apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0)
if len(apNode.keys()) > 1:
hw_ver = apNode['hw_ver']
project_name = apNode.get('project_name', '')
if hw_ver == 'AP02':
return 'X1E'
elif project_name == 'N1':
return 'A1MINI'
elif hw_ver == 'AP04':
if project_name == 'C11':
return 'P1P'
if project_name == 'C12':
return 'P1S'
elif hw_ver == 'AP05':
if project_name == 'N2S':
return 'A1'
if project_name == '':
return 'X1C'
LOGGER.debug(f"UNKNOWN DEVICE: hw_ver='{hw_ver}' / project_name='{project_name}'")
return default
def get_hw_version(modules, default):
"""Retrieve hardware version of printer"""
apNode = search(modules, lambda x: x.get('hw_ver', "").find("AP0") == 0)
if len(apNode.keys()) > 1:
return apNode.get("hw_ver")
return default
def get_sw_version(modules, default):
"""Retrieve software version of printer"""
ota = search(modules, lambda x: x.get('name', "") == "ota")
if len(ota.keys()) > 1:
return ota.get("sw_ver")
return default
def get_start_time(timestamp):
"""Return start time of a print"""
if timestamp == 0:
return None
return datetime.fromtimestamp(timestamp)
def get_end_time(remaining_time):
"""Calculate the end time of a print"""
end_time = round_minute(datetime.now() + timedelta(minutes=remaining_time))
return end_time
def round_minute(date: datetime = None, round_to: int = 1):
""" Round datetime object to minutes"""
if not date:
date = datetime.now()
date = date.replace(second=0, microsecond=0)
delta = date.minute % round_to
return date.replace(minute=date.minute - delta)
def get_Url(url: str, region: str):
urlstr = BAMBU_URL[url]
if region == "China":
urlstr = urlstr.replace('.com', '.cn')
return urlstr

View File

@ -0,0 +1,46 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.bambu_virtual_printer import (
BambuVirtualPrinter,
)
class APrinterState:
def __init__(self, printer: BambuVirtualPrinter) -> None:
self._log = logging.getLogger(
"octoprint.plugins.bambu_printer.BambuPrinter.states"
)
self._printer = printer
def init(self):
pass
def finalize(self):
pass
def handle_gcode(self, gcode):
self._log.debug(f"{self.__class__.__name__} gcode execution disabled")
def update_print_job_info(self):
self._log_skip_state_transition("start_new_print")
def start_new_print(self):
self._log_skip_state_transition("start_new_print")
def pause_print(self):
self._log_skip_state_transition("pause_print")
def cancel_print(self):
self._log_skip_state_transition("cancel_print")
def resume_print(self):
self._log_skip_state_transition("resume_print")
def _log_skip_state_transition(self, method):
self._log.debug(
f"skipping {self.__class__.__name__} state transition for '{method}'"
)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class IdleState(APrinterState):
def start_new_print(self):
selected_file = self._printer.selected_file
if selected_file is None:
self._log.warn("Cannot start print job if file was not selected")
return
print_command = self._get_print_command_for_file(selected_file)
self._log.debug(f"Sending print command: {print_command}")
if self._printer.bambu_client.publish(print_command):
self._log.info(f"Started print for {selected_file.file_name}")
else:
self._log.warn(f"Failed to start print for {selected_file.file_name}")
def _get_print_command_for_file(self, selected_file: FileInfo):
# URL to print. Root path, protocol can vary. E.g., if sd card, "ftp:///myfile.3mf", "ftp:///cache/myotherfile.3mf"
filesystem_root = (
"file:///mnt/sdcard/"
if self._printer._settings.get(["device_type"]) in ["X1", "X1C"]
else "file:///sdcard/"
)
print_command = {
"print": {
"sequence_id": 0,
"command": "project_file",
"param": "Metadata/plate_1.gcode",
"md5": "",
"profile_id": "0",
"project_id": "0",
"subtask_id": "0",
"task_id": "0",
"subtask_name": selected_file.file_name,
"url": f"{filesystem_root}{selected_file.path.as_posix()}",
"bed_type": "auto",
"timelapse": self._printer._settings.get_boolean(["timelapse"]),
"bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]),
"flow_cali": self._printer._settings.get_boolean(["flow_cali"]),
"vibration_cali": self._printer._settings.get_boolean(
["vibration_cali"]
),
"layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]),
"use_ams": self._printer._settings.get_boolean(["use_ams"]),
"ams_mapping": self._printer._settings.get(["ams_mapping"]),
}
}
return print_command

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.bambu_virtual_printer import (
BambuVirtualPrinter,
)
import threading
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint.util import RepeatedTimer
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class PausedState(APrinterState):
def __init__(self, printer: BambuVirtualPrinter) -> None:
super().__init__(printer)
self._pausedLock = threading.Event()
self._paused_repeated_report = None
def init(self):
if not self._pausedLock.is_set():
self._pausedLock.set()
self._printer.sendIO("// action:paused")
self._printer.start_continuous_status_report(3)
def finalize(self):
if self._pausedLock.is_set():
self._pausedLock.clear()
if self._paused_repeated_report is not None:
self._paused_repeated_report.join()
self._paused_repeated_report = None
def start_new_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.RESUME):
self._log.info("print resumed")
else:
self._log.info("print resume failed")
def cancel_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
self._log.info("print cancelled")
self._printer.finalize_print_job()
else:
self._log.info("print cancel failed")

View File

@ -0,0 +1,102 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.bambu_virtual_printer import (
BambuVirtualPrinter,
)
import threading
import octoprint_bambu_printer.printer.pybambu
import octoprint_bambu_printer.printer.pybambu.models
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint_bambu_printer.printer.print_job import PrintJob
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class PrintingState(APrinterState):
def __init__(self, printer: BambuVirtualPrinter) -> None:
super().__init__(printer)
self._printer.current_print_job = None
self._is_printing = False
self._sd_printing_thread = None
def init(self):
self._is_printing = True
self._printer.remove_project_selection()
self.update_print_job_info()
self._start_worker_thread()
def finalize(self):
if self._sd_printing_thread is not None and self._sd_printing_thread.is_alive():
self._is_printing = False
self._sd_printing_thread.join()
self._sd_printing_thread = None
self._printer.current_print_job = None
def _start_worker_thread(self):
self._is_printing = True
if self._sd_printing_thread is None:
self._sd_printing_thread = threading.Thread(target=self._printing_worker)
self._sd_printing_thread.start()
else:
self._sd_printing_thread.join()
def _printing_worker(self):
self._log.debug(f"_printing_worker before while loop: {self._printer.current_print_job}")
while (
self._is_printing
and self._printer.current_print_job is not None
and self._printer.current_print_job.progress < 100
):
self.update_print_job_info()
self._printer.report_print_job_status()
time.sleep(3)
self._log.debug(f"_printing_worker after while loop: {self._printer.current_print_job}")
self.update_print_job_info()
if (
self._printer.current_print_job is not None
and self._printer.current_print_job.progress >= 100
):
self._printer.finalize_print_job()
def update_print_job_info(self):
print_job_info = self._printer.bambu_client.get_device().print_job
subtask_name: str = print_job_info.subtask_name
gcode_file: str = print_job_info.gcode_file
self._log.info(f"update_print_job_info: {print_job_info}")
project_file_info = self._printer.project_files.get_file_by_name(subtask_name) or self._printer.project_files.get_file_by_name(gcode_file)
if project_file_info is None:
self._log.debug(f"No 3mf file found for {print_job_info}")
self._printer.current_print_job = None
self._printer.change_state(self._printer._state_idle)
return
progress = print_job_info.print_percentage
if print_job_info.gcode_state == "PREPARE" and progress == 100:
progress = 0
self._printer.current_print_job = PrintJob(project_file_info, progress, print_job_info.remaining_time, print_job_info.current_layer, print_job_info.total_layers)
self._printer.select_project_file(project_file_info.path.as_posix())
def pause_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.PAUSE):
self._log.info("print paused")
else:
self._log.info("print pause failed")
def cancel_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(octoprint_bambu_printer.printer.pybambu.commands.STOP):
self._log.info("print cancelled")
self._printer.finalize_print_job()
else:
self._log.info("print cancel failed")

View File

@ -0,0 +1,24 @@
#sidebar_plugin_bambu_printer div.well {
min-height: 70px;
}
#sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid {
padding-top: 10px;
}
#sidebar_plugin_bambu_printer div.well div.span3.text-center div.row-fluid.active {
border: 2px solid;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
#bambu_printer_print_options div.well {
min-height: 60px;
}
#bambu_printer_print_options div.modal-body {
overflow: inherit !important;
}

View File

@ -15,18 +15,69 @@ $(function () {
self.accessViewModel = parameters[3];
self.timelapseViewModel = parameters[4];
self.use_ams = true;
self.ams_mapping = ko.observableArray([]);
self.job_info = ko.observable();
self.auth_type = ko.observable("");
self.show_password = ko.pureComputed(function(){
return self.settingsViewModel.settings.plugins.bambu_printer.auth_token() === '';
});
self.show_verification = ko.pureComputed(function(){
return self.auth_type() !== '';
});
self.ams_mapping_computed = function(){
var output_list = [];
var index = 0;
ko.utils.arrayForEach(self.settingsViewModel.settings.plugins.bambu_printer.ams_data(), function(item){
if(item){
output_list = output_list.concat(item.tray());
}
});
ko.utils.arrayForEach(output_list, function(item){
item["index"] = ko.observable(index);
index++;
});
return output_list;
};
self.getAuthToken = function (data) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
self.auth_type("");
OctoPrint.simpleApiCommand("bambu_printer", "register", {
"email": self.settingsViewModel.settings.plugins.bambu_printer.email(),
"password": $("#bambu_cloud_password").val(),
"region": self.settingsViewModel.settings.plugins.bambu_printer.region(),
"auth_token": self.settingsViewModel.settings.plugins.bambu_printer.auth_token()
})
.done(function (response) {
self.auth_type(response.auth_response);
});
};
self.verifyCode = function (data) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
OctoPrint.simpleApiCommand("bambu_printer", "verify", {
"password": $("#bambu_cloud_verify_code").val(),
"auth_type": self.auth_type(),
})
.done(function (response) {
console.log(response);
self.settingsViewModel.settings.plugins.bambu_printer.auth_token(response.auth_token);
self.settingsViewModel.settings.plugins.bambu_printer.username(response.username);
if (response.auth_token) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token(response.auth_token);
self.settingsViewModel.settings.plugins.bambu_printer.username(response.username);
self.auth_type("");
} else if (response.error) {
self.settingsViewModel.settings.plugins.bambu_printer.auth_token("");
$("#bambu_cloud_verify_code").val("");
}
});
};
@ -68,81 +119,72 @@ $(function () {
}
if (data.files !== undefined) {
console.log(data.files);
self.listHelper.updateItems(data.files);
self.listHelper.resetPage();
}
if (data.job_info !== undefined) {
self.job_info(data.job_info);
}
};
self.onBeforeBinding = function () {
$('#bambu_timelapse').appendTo("#timelapse");
};
self.onAfterBinding = function () {
console.log(self.ams_mapping_computed());
};
self.showTimelapseThumbnail = function(data) {
$("#bambu_printer_timelapse_thumbnail").attr("src", data.thumbnail);
$("#bambu_printer_timelapse_preview").modal('show');
};
/*$('#files div.upload-buttons > span.fileinput-button:first, #files div.folder-button').remove();
$('#files div.upload-buttons > span.fileinput-button:first').removeClass('span6').addClass('input-block-level');
self.onBeforePrintStart = function(start_print_command) {
let confirmation_html = '' +
' <div class="row-fluid form-vertical">\n' +
' <div class="control-group">\n' +
' <label class="control-label">' + gettext("Plate Number") + '</label>\n' +
' <div class="controls">\n' +
' <input type="number" min="1" value="1" id="bambu_printer_plate_number" class="input-mini">\n' +
' </div>\n' +
' </div>\n' +
' </div>';
if(!self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options()){
confirmation_html += '\n' +
' <div class="row-fluid">\n' +
' <div class="span6">\n' +
' <label class="checkbox"><input id="bambu_printer_timelapse" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.timelapse()) ? ' checked' : '') + '> ' + gettext("Enable timelapse") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_bed_leveling" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling()) ? ' checked' : '') + '> ' + gettext("Enable bed leveling") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_flow_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.flow_cali()) ? ' checked' : '') + '> ' + gettext("Enable flow calibration") + '</label>\n' +
' </div>\n' +
' <div class="span6">\n' +
' <label class="checkbox"><input id="bambu_printer_vibration_cali" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali()) ? ' checked' : '') + '> ' + gettext("Enable vibration calibration") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_layer_inspect" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect()) ? ' checked' : '') + '> ' + gettext("Enable first layer inspection") + '</label>\n' +
' <label class="checkbox"><input id="bambu_printer_use_ams" type="checkbox"' + ((self.settingsViewModel.settings.plugins.bambu_printer.use_ams()) ? ' checked' : '') + '> ' + gettext("Use AMS") + '</label>\n' +
' </div>\n' +
' </div>\n';
self.onBeforePrintStart = function(start_print_command, data) {
self.ams_mapping(self.ams_mapping_computed());
self.start_print_command = start_print_command;
self.use_ams = self.settingsViewModel.settings.plugins.bambu_printer.use_ams();
// prevent starting locally stored files, once data is added to core OctoPrint this
// could be adjusted to include additional processing like get sliced file's
// spool assignments and colors from plate_#.json inside 3mf file.
if(data && data.origin !== "sdcard") {
return false;
}
showConfirmationDialog({
title: "Bambu Print Options",
html: confirmation_html,
cancel: gettext("Cancel"),
proceed: [gettext("Print"), gettext("Always")],
onproceed: function (idx) {
if(idx === 1){
self.settingsViewModel.settings.plugins.bambu_printer.timelapse($('#bambu_printer_timelapse').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.bed_leveling($('#bambu_printer_bed_leveling').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.flow_cali($('#bambu_printer_flow_cali').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.vibration_cali($('#bambu_printer_vibration_cali').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.layer_inspect($('#bambu_printer_layer_inspect').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.use_ams($('#bambu_printer_use_ams').is(':checked'));
self.settingsViewModel.settings.plugins.bambu_printer.always_use_default_options(true);
self.settingsViewModel.saveData();
}
// replace this with our own print command API call?
start_print_command();
},
nofade: true
});
$("#bambu_printer_print_options").modal('show');
return false;
};*/
};
self.toggle_spool_active = function(data) {
if(data.index() >= 0){
data.original_index = ko.observable(data.index());
data.index(-1);
} else {
data.index(data.original_index());
}
};
self.cancel_print_options = function() {
self.settingsViewModel.settings.plugins.bambu_printer.use_ams(self.use_ams);
$("#bambu_printer_print_options").modal('hide');
};
self.accept_print_options = function() {
console.log("starting print!!!!");
console.log(self.ams_mapping());
$("#bambu_printer_print_options").modal('hide');
var flattened_ams_mapping = ko.utils.arrayMap(self.ams_mapping(), function(item) {
return item.index();
});
self.settingsViewModel.settings.plugins.bambu_printer.ams_mapping(flattened_ams_mapping);
self.settingsViewModel.saveData(undefined, self.start_print_command);
// self.settingsViewModel.saveData();
};
}
OCTOPRINT_VIEWMODELS.push({
construct: Bambu_printerViewModel,
// ViewModels your plugin depends on, e.g. loginStateViewModel, settingsViewModel, ...
dependencies: ["settingsViewModel", "filesViewModel", "loginStateViewModel", "accessViewModel", "timelapseViewModel"],
// Elements to bind to, e.g. #settings_plugin_bambu_printer, #tab_plugin_bambu_printer, ...
elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse"]
elements: ["#bambu_printer_print_options", "#settings_plugin_bambu_printer", "#bambu_timelapse", "#sidebar_plugin_bambu_printer"]
});
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,490 @@
// knockout-sortable 1.2.0 | (c) 2019 Ryan Niemeyer | http://www.opensource.org/licenses/mit-license
;(function(factory) {
if (typeof define === "function" && define.amd) {
// AMD anonymous module
define(["knockout", "jquery", "jquery-ui/ui/widgets/sortable", "jquery-ui/ui/widgets/draggable", "jquery-ui/ui/widgets/droppable"], factory);
} else if (typeof require === "function" && typeof exports === "object" && typeof module === "object") {
// CommonJS module
var ko = require("knockout"),
jQuery = require("jquery");
require("jquery-ui/ui/widgets/sortable");
require("jquery-ui/ui/widgets/draggable");
require("jquery-ui/ui/widgets/droppable");
factory(ko, jQuery);
} else {
// No module loader (plain <script> tag) - put directly in global namespace
factory(window.ko, window.jQuery);
}
})(function(ko, $) {
var ITEMKEY = "ko_sortItem",
INDEXKEY = "ko_sourceIndex",
LISTKEY = "ko_sortList",
PARENTKEY = "ko_parentList",
DRAGKEY = "ko_dragItem",
unwrap = ko.utils.unwrapObservable,
dataGet = ko.utils.domData.get,
dataSet = ko.utils.domData.set,
version = $.ui && $.ui.version,
//1.8.24 included a fix for how events were triggered in nested sortables. indexOf checks will fail if version starts with that value (0 vs. -1)
hasNestedSortableFix = version && version.indexOf("1.6.") && version.indexOf("1.7.") && (version.indexOf("1.8.") || version === "1.8.24");
//internal afterRender that adds meta-data to children
var addMetaDataAfterRender = function(elements, data) {
ko.utils.arrayForEach(elements, function(element) {
if (element.nodeType === 1) {
dataSet(element, ITEMKEY, data);
dataSet(element, PARENTKEY, dataGet(element.parentNode, LISTKEY));
}
});
};
//prepare the proper options for the template binding
var prepareTemplateOptions = function(valueAccessor, dataName) {
var result = {},
options = {},
actualAfterRender;
//build our options to pass to the template engine
if (ko.utils.peekObservable(valueAccessor()).data) {
options = unwrap(valueAccessor() || {});
result[dataName] = options.data;
if (options.hasOwnProperty("template")) {
result.name = options.template;
}
} else {
result[dataName] = valueAccessor();
}
ko.utils.arrayForEach(["afterAdd", "afterRender", "as", "beforeRemove", "includeDestroyed", "templateEngine", "templateOptions", "nodes"], function (option) {
if (options.hasOwnProperty(option)) {
result[option] = options[option];
} else if (ko.bindingHandlers.sortable.hasOwnProperty(option)) {
result[option] = ko.bindingHandlers.sortable[option];
}
});
//use an afterRender function to add meta-data
if (dataName === "foreach") {
if (result.afterRender) {
//wrap the existing function, if it was passed
actualAfterRender = result.afterRender;
result.afterRender = function(element, data) {
addMetaDataAfterRender.call(data, element, data);
actualAfterRender.call(data, element, data);
};
} else {
result.afterRender = addMetaDataAfterRender;
}
}
//return options to pass to the template binding
return result;
};
var updateIndexFromDestroyedItems = function(index, items) {
var unwrapped = unwrap(items);
if (unwrapped) {
for (var i = 0; i <= index; i++) {
//add one for every destroyed item we find before the targetIndex in the target array
if (unwrapped[i] && unwrap(unwrapped[i]._destroy)) {
index++;
}
}
}
return index;
};
//remove problematic leading/trailing whitespace from templates
var stripTemplateWhitespace = function(element, name) {
var templateSource,
templateElement;
//process named templates
if (name) {
templateElement = document.getElementById(name);
if (templateElement) {
templateSource = new ko.templateSources.domElement(templateElement);
templateSource.text($.trim(templateSource.text()));
}
}
else {
//remove leading/trailing non-elements from anonymous templates
$(element).contents().each(function() {
if (this && this.nodeType !== 1) {
element.removeChild(this);
}
});
}
};
//connect items with observableArrays
ko.bindingHandlers.sortable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var $element = $(element),
value = unwrap(valueAccessor()) || {},
templateOptions = prepareTemplateOptions(valueAccessor, "foreach"),
sortable = {},
startActual, updateActual;
stripTemplateWhitespace(element, templateOptions.name);
//build a new object that has the global options with overrides from the binding
$.extend(true, sortable, ko.bindingHandlers.sortable);
if (value.options && sortable.options) {
ko.utils.extend(sortable.options, value.options);
delete value.options;
}
ko.utils.extend(sortable, value);
//if allowDrop is an observable or a function, then execute it in a computed observable
if (sortable.connectClass && (ko.isObservable(sortable.allowDrop) || typeof sortable.allowDrop == "function")) {
ko.computed({
read: function() {
var value = unwrap(sortable.allowDrop),
shouldAdd = typeof value == "function" ? value.call(this, templateOptions.foreach) : value;
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, shouldAdd);
},
disposeWhenNodeIsRemoved: element
}, this);
} else {
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, sortable.allowDrop);
}
//wrap the template binding
ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
//keep a reference to start/update functions that might have been passed in
startActual = sortable.options.start;
updateActual = sortable.options.update;
//ensure draggable table row cells maintain their width while dragging (unless a helper is provided)
if ( !sortable.options.helper ) {
sortable.options.helper = function(e, ui) {
if (ui.is("tr")) {
ui.children().each(function() {
$(this).width($(this).width());
});
}
return ui;
};
}
//initialize sortable binding after template binding has rendered in update function
var createTimeout = setTimeout(function() {
var dragItem;
var originalReceive = sortable.options.receive;
$element.sortable(ko.utils.extend(sortable.options, {
start: function(event, ui) {
//track original index
var el = ui.item[0];
dataSet(el, INDEXKEY, ko.utils.arrayIndexOf(ui.item.parent().children(), el));
//make sure that fields have a chance to update model
ui.item.find("input:focus").change();
if (startActual) {
startActual.apply(this, arguments);
}
},
receive: function(event, ui) {
//optionally apply an existing receive handler
if (typeof originalReceive === "function") {
originalReceive.call(this, event, ui);
}
dragItem = dataGet(ui.item[0], DRAGKEY);
if (dragItem) {
//copy the model item, if a clone option is provided
if (dragItem.clone) {
dragItem = dragItem.clone();
}
//configure a handler to potentially manipulate item before drop
if (sortable.dragged) {
dragItem = sortable.dragged.call(this, dragItem, event, ui) || dragItem;
}
}
},
update: function(event, ui) {
var sourceParent, targetParent, sourceIndex, targetIndex, arg,
el = ui.item[0],
parentEl = ui.item.parent()[0],
item = dataGet(el, ITEMKEY) || dragItem;
if (!item) {
$(el).remove();
}
dragItem = null;
//make sure that moves only run once, as update fires on multiple containers
if (item && (this === parentEl) || (!hasNestedSortableFix && $.contains(this, parentEl))) {
//identify parents
sourceParent = dataGet(el, PARENTKEY);
sourceIndex = dataGet(el, INDEXKEY);
targetParent = dataGet(el.parentNode, LISTKEY);
targetIndex = ko.utils.arrayIndexOf(ui.item.parent().children(), el);
//take destroyed items into consideration
if (!templateOptions.includeDestroyed) {
sourceIndex = updateIndexFromDestroyedItems(sourceIndex, sourceParent);
targetIndex = updateIndexFromDestroyedItems(targetIndex, targetParent);
}
//build up args for the callbacks
if (sortable.beforeMove || sortable.afterMove) {
arg = {
item: item,
sourceParent: sourceParent,
sourceParentNode: sourceParent && ui.sender || el.parentNode,
sourceIndex: sourceIndex,
targetParent: targetParent,
targetIndex: targetIndex,
cancelDrop: false
};
//execute the configured callback prior to actually moving items
if (sortable.beforeMove) {
sortable.beforeMove.call(this, arg, event, ui);
}
}
//call cancel on the correct list, so KO can take care of DOM manipulation
if (sourceParent) {
$(sourceParent === targetParent ? this : ui.sender || this).sortable("cancel");
}
//for a draggable item just remove the element
else {
$(el).remove();
}
//if beforeMove told us to cancel, then we are done
if (arg && arg.cancelDrop) {
return;
}
//if the strategy option is unset or false, employ the order strategy involving removal and insertion of items
if (!sortable.hasOwnProperty("strategyMove") || sortable.strategyMove === false) {
//do the actual move
if (targetIndex >= 0) {
if (sourceParent) {
sourceParent.splice(sourceIndex, 1);
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
//if using deferred updates on knockout 3.4, force updates
if (ko.options && ko.options.deferUpdates) {
ko.tasks.runEarly();
}
}
targetParent.splice(targetIndex, 0, item);
}
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
}
else { //employ the strategy of moving items
if (targetIndex >= 0) {
if (sourceParent) {
if (sourceParent !== targetParent) {
// moving from one list to another
sourceParent.splice(sourceIndex, 1);
targetParent.splice(targetIndex, 0, item);
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
ui.item.remove();
}
else {
// moving within same list
var underlyingList = unwrap(sourceParent);
// notify 'beforeChange' subscribers
if (sourceParent.valueWillMutate) {
sourceParent.valueWillMutate();
}
// move from source index ...
underlyingList.splice(sourceIndex, 1);
// ... to target index
underlyingList.splice(targetIndex, 0, item);
// notify subscribers
if (sourceParent.valueHasMutated) {
sourceParent.valueHasMutated();
}
}
}
else {
// drop new element from outside
targetParent.splice(targetIndex, 0, item);
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
ui.item.remove();
}
}
}
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
//allow binding to accept a function to execute after moving the item
if (sortable.afterMove) {
sortable.afterMove.call(this, arg, event, ui);
}
}
if (updateActual) {
updateActual.apply(this, arguments);
}
},
connectWith: sortable.connectClass ? "." + sortable.connectClass : false
}));
//handle enabling/disabling sorting
if (sortable.isEnabled !== undefined) {
ko.computed({
read: function() {
$element.sortable(unwrap(sortable.isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
}, 0);
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
//only call destroy if sortable has been created
if ($element.data("ui-sortable") || $element.data("sortable")) {
$element.sortable("destroy");
}
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, false);
//do not create the sortable if the element has been removed from DOM
clearTimeout(createTimeout);
});
return { 'controlsDescendantBindings': true };
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "foreach");
//attach meta-data
dataSet(element, LISTKEY, templateOptions.foreach);
//call template binding's update with correct options
ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: 'ko_container',
allowDrop: true,
afterMove: null,
beforeMove: null,
options: {}
};
//create a draggable that is appropriate for dropping into a sortable
ko.bindingHandlers.draggable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var value = unwrap(valueAccessor()) || {},
options = value.options || {},
draggableOptions = ko.utils.extend({}, ko.bindingHandlers.draggable.options),
templateOptions = prepareTemplateOptions(valueAccessor, "data"),
connectClass = value.connectClass || ko.bindingHandlers.draggable.connectClass,
isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.draggable.isEnabled;
value = "data" in value ? value.data : value;
//set meta-data
dataSet(element, DRAGKEY, value);
//override global options with override options passed in
ko.utils.extend(draggableOptions, options);
//setup connection to a sortable
draggableOptions.connectToSortable = connectClass ? "." + connectClass : false;
//initialize draggable
$(element).draggable(draggableOptions);
//handle enabling/disabling sorting
if (isEnabled !== undefined) {
ko.computed({
read: function() {
$(element).draggable(unwrap(isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
$(element).draggable("destroy");
});
return ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "data");
return ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: ko.bindingHandlers.sortable.connectClass,
options: {
helper: "clone"
}
};
// Simple Droppable Implementation
// binding that updates (function or observable)
ko.bindingHandlers.droppable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var value = unwrap(valueAccessor()) || {},
options = value.options || {},
droppableOptions = ko.utils.extend({}, ko.bindingHandlers.droppable.options),
isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.droppable.isEnabled;
//override global options with override options passed in
ko.utils.extend(droppableOptions, options);
//get reference to drop method
value = "data" in value ? value.data : valueAccessor();
//set drop method
droppableOptions.drop = function(event, ui) {
var droppedItem = dataGet(ui.draggable[0], DRAGKEY) || dataGet(ui.draggable[0], ITEMKEY);
value(droppedItem);
};
//initialize droppable
$(element).droppable(droppableOptions);
//handle enabling/disabling droppable
if (isEnabled !== undefined) {
ko.computed({
read: function() {
$(element).droppable(unwrap(isEnabled) ? "enable": "disable");
},
disposeWhenNodeIsRemoved: element
});
}
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
$(element).droppable("destroy");
});
},
options: {
accept: "*"
}
};
});

View File

@ -0,0 +1,31 @@
<div id="bambu_printer_print_options" class="modal hide fade">
<div class="modal-header">
<h3>{{ _('Bambu Print Options') }}</h3>
</div>
<div class="modal-body">
<div class="row-fluid">
<div class="span6">
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.timelapse"> {{ _('Enable timelapse') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.bed_leveling"> {{ _('Enable bed leveling') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.flow_cali"> {{ _('Enable flow calibration') }}</label>
</div>
<div class="span6">
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.vibration_cali"> {{ _('Enable vibration calibration') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.layer_inspect"> {{ _('Enable first layer inspection') }}</label>
<label class="checkbox"><input type="checkbox" data-bind="checked: settingsViewModel.settings.plugins.bambu_printer.use_ams"> {{ _('Use AMS') }}</label>
</div>
</div>
<div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams">
{{ _('Filament Assighnment') }}: {{ _('Click') }} <a href="#">{{ _('here') }}</a> {{ _('for usage details.') }}
</div>
<div class="row-fluid" data-bind="visible: settingsViewModel.settings.plugins.bambu_printer.use_ams, sortable: {data: ams_mapping, options: {cancel: '.unsortable'}}">
<div class="btn" data-bind="attr: {title: name}, event: {dblclick: $root.toggle_spool_active}, css: {disabled: (index()<0)}">
<i class="fa fa-2x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" data-bind="click: cancel_print_options">{{ _('Cancel') }}</button>
<button class="btn btn-primary" data-bind="click: accept_print_options">{{ _('Print') }}</button>
</div>
</div>

View File

@ -40,15 +40,24 @@
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()">
<label class="control-label">{{ _('Email') }}</label>
<div class="controls">
<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.email" title="{{ _('Registered email address') }}"></input>
<input type="text" class="input-block-level" data-bind="value: settingsViewModel.settings.plugins.bambu_printer.email" title="{{ _('Registered email address') }}" autocomplete="off"></input>
</div>
</div>
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt()">
<div class="control-group" data-bind="visible: !settingsViewModel.settings.plugins.bambu_printer.local_mqtt() && show_password()">
<label class="control-label">{{ _('Password') }}</label>
<div class="controls">
<div class="input-block-level input-append" data-bind="css: {'input-append': !show_verification()}">
<input id="bambu_cloud_password" type="password" class="input-text input-block-level" title="{{ _('Password to generate verification code') }}" autocomplete="new-password"></input>
<span class="btn btn-primary add-on" data-bind="visible: !show_verification(), click: getAuthToken">{{ _('Login') }}</span>
</div>
</div>
</div>
<div class="control-group" data-bind="visible: show_verification()">
<label class="control-label">{{ _('Verify') }}</label>
<div class="controls">
<div class="input-block-level input-append">
<input id="bambu_cloud_password" type="password" class="input-text input-block-level" title="{{ _('Password to generate Auth Token') }}"></input>
<span class="btn btn-primary add-on" data-bind="click: getAuthToken">{{ _('Login') }}</span>
<input id="bambu_cloud_verify_code" type="password" class="input-text input-block-level" title="{{ _('Verification code to generate auth token') }}"></input>
<span class="btn btn-primary add-on" data-bind="click: verifyCode">{{ _('Verify') }}</span>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
<div class="row-fluid" data-bind="foreach: {data: settingsViewModel.settings.plugins.bambu_printer.ams_data, as: 'ams'}">
<!-- ko if: $data -->
<div class="well" data-bind="foreach: tray">
<div class="span3 text-center" data-bind="attr: {title: name}">
<div class="row-fluid" data-bind="css: {'active': ($root.settingsViewModel.settings.plugins.bambu_printer.ams_current_tray() == (($parentContext.$index() * 4) + $index()))}">
<i class="fa fa-3x fa-dot-circle" data-bind="css: {'fas': !empty(), 'far': empty()}, style: {'color': ('#'+color())}"></i><br>
<div class="text-center" data-bind="text: type"></div>
</div>
</div>
</div>
<!-- /ko -->
</div>
<div class="row-fluid" data-bind="visible: job_info">
<div class="span6">{{ _('Layer') }}:</div><div class="span6" data-bind="text: function(){return (job_info.current_layer() + ' of ' + job_info.total_layers);}"></div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -7,3 +7,10 @@
###
.
pytest~=7.4.4
pybambu~=1.0.1
OctoPrint~=1.10.2
setuptools~=70.0.0
pyserial~=3.5
Flask~=2.2.5

View File

@ -14,7 +14,7 @@ plugin_package = "octoprint_bambu_printer"
plugin_name = "OctoPrint-BambuPrinter"
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
plugin_version = "0.0.23"
plugin_version = "0.1.8rc9"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# module
@ -33,7 +33,7 @@ plugin_url = "https://github.com/jneilliii/OctoPrint-BambuPrinter"
plugin_license = "AGPLv3"
# Any additional requirements besides OctoPrint should be listed here
plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"]
plugin_requires = ["paho-mqtt<2", "python-dateutil", "curl_cffi"]
### --------------------------------------------------------------------------------------------------------------------
### More advanced options that you usually shouldn't have to touch follow after this point
@ -43,7 +43,7 @@ plugin_requires = ["paho-mqtt<2", "python-dateutil", "pybambu>=1.0.1"]
# already be installed automatically if they exist. Note that if you add something here you'll also need to update
# MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your
# files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598
plugin_additional_data = []
plugin_additional_data = ["octoprint_bambu_printer/printer/pybambu/filaments.json"]
# Any additional python packages you need to install with your plugin that are not contained in <plugin_package>.*
plugin_additional_packages = []

0
test/__init__.py Normal file
View File

9
test/conftest.py Normal file
View File

@ -0,0 +1,9 @@
from pathlib import Path
from pytest import fixture
@fixture
def output_folder():
folder = Path(__file__).parent / "test_output"
folder.mkdir(parents=True, exist_ok=True)
return folder

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from octoprint.util import get_formatted_size, get_formatted_datetime
from octoprint_bambu_printer.printer.file_system.bambu_timelapse_file_info import (
BambuTimelapseFileInfo,
)
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
def test_timelapse_info_valid():
file_name = "part.mp4"
file_size = 1000
file_date = datetime(2020, 1, 1)
file_timestamp = file_date.timestamp()
file_info = FileInfo(file_name, Path(file_name), file_size, file_date)
timelapse = BambuTimelapseFileInfo.from_file_info(file_info)
assert timelapse.to_dict() == {
"bytes": file_size,
"date": get_formatted_datetime(datetime.fromtimestamp(file_timestamp)),
"name": file_name,
"size": get_formatted_size(file_size),
"thumbnail": "/plugin/bambu_printer/thumbnail/"
+ file_name.replace(".mp4", ".jpg").replace(".avi", ".jpg"),
"timestamp": file_timestamp,
"url": f"/plugin/bambu_printer/timelapse/{file_name}",
}

View File

@ -0,0 +1,562 @@
from __future__ import annotations
from datetime import datetime, timezone
import logging
from pathlib import Path
import sys
from typing import Any
from unittest.mock import MagicMock, patch
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
import octoprint_bambu_printer.printer.pybambu
import octoprint_bambu_printer.printer.pybambu.commands
from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
RemoteSDCardFileList,
)
from octoprint_bambu_printer.printer.states.idle_state import IdleState
from octoprint_bambu_printer.printer.states.paused_state import PausedState
from octoprint_bambu_printer.printer.states.printing_state import PrintingState
from pytest import fixture
@fixture
def output_test_folder(output_folder: Path):
folder = output_folder / "test_gcode"
folder.mkdir(parents=True, exist_ok=True)
return folder
@fixture
def log_test():
log = logging.getLogger("gcode_unittest")
log.setLevel(logging.DEBUG)
return log
class DictGetter:
def __init__(self, options: dict, default_value=None) -> None:
self.options: dict[str | tuple[str, ...], Any] = options
self._default_value = default_value
def __call__(self, key: str | list[str] | tuple[str, ...]):
if isinstance(key, list):
key = tuple(key)
return self.options.get(key, self._default_value)
@fixture
def settings(output_test_folder):
_settings = MagicMock()
_settings.get.side_effect = DictGetter(
{
"serial": "BAMBU",
"host": "localhost",
"access_code": "12345",
}
)
_settings.get_boolean.side_effect = DictGetter({"forceChecksum": False})
log_file_path = output_test_folder / "log.txt"
log_file_path.touch()
_settings.get_plugin_logfile_path.return_value = log_file_path.as_posix()
return _settings
@fixture
def profile_manager():
_profile_manager = MagicMock()
_profile_manager.get_current.side_effect = MagicMock()
_profile_manager.get_current().get.side_effect = DictGetter(
{
"heatedChamber": False,
}
)
return _profile_manager
def _ftp_date_format(dt: datetime):
return dt.replace(tzinfo=timezone.utc).strftime("%Y%m%d%H%M%S")
@fixture
def project_files_info_ftp():
return {
"print.3mf": (1000, _ftp_date_format(datetime(2024, 5, 6))),
"print2.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
}
@fixture
def cache_files_info_ftp():
return {
"cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
"cache/print3.gcode.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
"cache/long file path with spaces.gcode.3mf": (
1200,
_ftp_date_format(datetime(2024, 5, 7)),
),
}
@fixture
def ftps_session_mock(project_files_info_ftp, cache_files_info_ftp):
all_file_info = dict(**project_files_info_ftp, **cache_files_info_ftp)
ftps_session = MagicMock()
ftps_session.size.side_effect = DictGetter(
{file: info[0] for file, info in all_file_info.items()}
)
ftps_session.sendcmd.side_effect = DictGetter(
{f"MDTM {file}": info[1] for file, info in all_file_info.items()}
)
ftps_session.nlst.side_effect = DictGetter(
{
"": list(map(lambda p: Path(p).name, project_files_info_ftp))
+ ["Mock folder"],
"cache/": list(map(lambda p: Path(p).name, cache_files_info_ftp))
+ ["Mock folder"],
"timelapse/": ["video.mp4", "video.avi"],
}
)
IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session)
yield ftps_session
@fixture(scope="function")
def print_job_mock():
print_job = MagicMock()
print_job.subtask_name = ""
print_job.print_percentage = 0
return print_job
@fixture(scope="function")
def temperatures_mock():
temperatures = MagicMock()
temperatures.nozzle_temp = 0
temperatures.target_nozzle_temp = 0
temperatures.bed_temp = 0
temperatures.target_bed_temp = 0
temperatures.chamber_temp = 0
return temperatures
@fixture(scope="function")
def bambu_client_mock(print_job_mock, temperatures_mock) -> pybambu.BambuClient:
bambu_client = MagicMock()
bambu_client.connected = True
device_mock = MagicMock()
device_mock.print_job = print_job_mock
device_mock.temperatures = temperatures_mock
bambu_client.get_device.return_value = device_mock
return bambu_client
@fixture(scope="function")
def printer(
output_test_folder,
settings,
profile_manager,
log_test,
ftps_session_mock,
bambu_client_mock,
):
async def _mock_connection(self):
pass
BambuVirtualPrinter._create_client_connection_async = _mock_connection
printer_test = BambuVirtualPrinter(
settings,
profile_manager,
data_folder=output_test_folder,
serial_log_handler=log_test,
read_timeout=0.01,
faked_baudrate=115200,
)
printer_test._bambu_client = bambu_client_mock
printer_test.flush()
printer_test.readlines()
yield printer_test
printer_test.close()
def test_initial_state(printer: BambuVirtualPrinter):
assert isinstance(printer.current_state, IdleState)
def test_list_sd_card(printer: BambuVirtualPrinter):
printer.write(b"M20\n") # GCode for listing SD card
printer.flush()
result = printer.readlines()
assert result[0] == b"Begin file list"
assert result[1].endswith(b'"print.3mf"')
assert result[2].endswith(b'"print2.3mf"')
assert result[3].endswith(b'"print.3mf"')
assert result[4].endswith(b'"print3.gcode.3mf"')
assert result[-3] == b"End file list"
assert result[-2] == b"ok"
assert result[-1] == b"ok"
def test_list_ftp_paths_p1s(settings, ftps_session_mock):
file_system = RemoteSDCardFileList(settings)
file_view = CachedFileView(file_system).with_filter("timelapse/", ".avi")
timelapse_files = ["timelapse/video.avi", "timelapse/video2.avi"]
ftps_session_mock.size.side_effect = DictGetter(
{file: 100 for file in timelapse_files}
)
ftps_session_mock.sendcmd.side_effect = DictGetter(
{
f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7))
for file in timelapse_files
}
)
ftps_session_mock.nlst.side_effect = DictGetter(
{"timelapse/": [Path(f).name for f in timelapse_files]}
)
timelapse_paths = list(map(Path, timelapse_files))
result_files = file_view.get_all_info()
assert len(timelapse_files) == len(result_files) and all(
file_info.path in timelapse_paths for file_info in result_files
)
def test_list_ftp_paths_x1(settings, ftps_session_mock):
file_system = RemoteSDCardFileList(settings)
file_view = CachedFileView(file_system).with_filter("timelapse/", ".mp4")
timelapse_files = ["timelapse/video.mp4", "timelapse/video2.mp4"]
ftps_session_mock.size.side_effect = DictGetter(
{file: 100 for file in timelapse_files}
)
ftps_session_mock.sendcmd.side_effect = DictGetter(
{
f"MDTM {file}": _ftp_date_format(datetime(2024, 5, 7))
for file in timelapse_files
}
)
ftps_session_mock.nlst.side_effect = DictGetter({"timelapse/": timelapse_files})
timelapse_paths = list(map(Path, timelapse_files))
result_files = file_view.get_all_info()
assert len(timelapse_files) == len(result_files) and all(
file_info.path in timelapse_paths for file_info in result_files
)
def test_delete_sd_file_gcode(printer: BambuVirtualPrinter):
with patch(
"octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
) as delete_function:
printer.write(b"M30 print.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
delete_function.assert_called_with("print.3mf")
printer.write(b"M30 cache/print.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
delete_function.assert_called_with("cache/print.3mf")
def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter):
with patch(
"octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
) as delete_function:
file_info = printer.project_files.get_file_data("cache/print.3mf")
assert file_info is not None
printer.write(b"M30 " + file_info.dosname.encode() + b"\n")
printer.flush()
assert printer.readlines()[-1] == b"ok"
assert delete_function.call_count == 1
delete_function.assert_called_with("cache/print.3mf")
printer.write(b"M30 cache/print.3mf\n")
printer.flush()
assert printer.readlines()[-1] == b"ok"
assert delete_function.call_count == 2
delete_function.assert_called_with("cache/print.3mf")
def test_select_project_file_by_stem(printer: BambuVirtualPrinter):
printer.write(b"M23 print3\n")
printer.flush()
result = printer.readlines()
assert printer.selected_file is not None
assert printer.selected_file.path == Path("cache/print3.gcode.3mf")
assert result[-2] == b"File selected"
assert result[-1] == b"ok"
def test_select_project_long_name_file_with_multiple_extensions(
printer: BambuVirtualPrinter,
):
printer.write(b"M23 long file path with spaces.gcode.3mf\n")
printer.flush()
result = printer.readlines()
assert printer.selected_file is not None
assert printer.selected_file.path == Path(
"cache/long file path with spaces.gcode.3mf"
)
assert result[-2] == b"File selected"
assert result[-1] == b"ok"
def test_cannot_start_print_without_file(printer: BambuVirtualPrinter):
printer.write(b"M24\n")
printer.flush()
result = printer.readlines()
assert result[0] == b"ok"
assert isinstance(printer.current_state, IdleState)
def test_non_existing_file_not_selected(printer: BambuVirtualPrinter):
assert printer.selected_file is None
printer.write(b"M23 non_existing.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-2] != b"File selected"
assert result[-1] == b"ok"
assert printer.selected_file is None
def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_job_mock):
assert printer.selected_file is None
printer.write(b"M20\n")
printer.flush()
printer.readlines()
printer.write(b"M23 print.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-2] == b"File selected"
assert result[-1] == b"ok"
assert printer.selected_file is not None
assert printer.selected_file.file_name == "print.3mf"
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M24\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
# emulate printer reporting it's status
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M20\n")
printer.write(b"M23 print.3mf\n")
printer.write(b"M24\n")
printer.flush()
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
printer.write(b"M25\n") # pausing the print
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
print_job_mock.gcode_state = "PAUSE"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PausedState)
bambu_client_mock.publish.assert_called_with(pybambu.commands.PAUSE)
def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
print_job_mock.gcode_state = "PAUSE"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PausedState)
print_job_mock.gcode_state = "IDLE"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, IdleState)
print_job_mock.gcode_state = "FINISH"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, IdleState)
print_job_mock.gcode_state = "FAILED"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, IdleState)
def test_printer_info_check(printer: BambuVirtualPrinter):
printer.write(b"M27\n") # printer get info
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
assert isinstance(printer.current_state, IdleState)
def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M20\nM23 print.3mf\nM24\n")
printer.flush()
print_job_mock.gcode_state = "RUNNING"
print_job_mock.print_percentage = 50
printer.new_update("event_printer_data_update")
printer.flush()
printer.readlines()
assert isinstance(printer.current_state, PrintingState)
printer.write(b"M26 S0\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
assert isinstance(printer.current_state, IdleState)
def test_abort_print_during_pause(printer: BambuVirtualPrinter, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M20\nM23 print.3mf\nM24\n")
printer.flush()
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
printer.write(b"M25\n")
printer.flush()
print_job_mock.gcode_state = "PAUSE"
printer.new_update("event_printer_data_update")
printer.flush()
printer.readlines()
assert isinstance(printer.current_state, PausedState)
printer.write(b"M26 S0\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
assert isinstance(printer.current_state, IdleState)
def test_regular_move(printer: BambuVirtualPrinter, bambu_client_mock):
gcode = b"G28\nG1 X10 Y10\n"
printer.write(gcode)
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
gcode_command = pybambu.commands.SEND_GCODE_TEMPLATE
gcode_command["print"]["param"] = "G28\n"
bambu_client_mock.publish.assert_called_with(gcode_command)
gcode_command["print"]["param"] = "G1 X10 Y10\n"
bambu_client_mock.publish.assert_called_with(gcode_command)
def test_file_selection_does_not_affect_current_print(
printer: BambuVirtualPrinter, print_job_mock
):
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M23 print.3mf\nM24\n")
printer.flush()
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf"
assert printer.current_print_job.progress == 0
printer.write(b"M23 print2.3mf\n")
printer.flush()
assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf"
assert printer.current_print_job.progress == 0
def test_finished_print_job_reset_after_new_file_selected(
printer: BambuVirtualPrinter, print_job_mock
):
print_job_mock.subtask_name = "print.3mf"
printer.write(b"M23 print.3mf\nM24\n")
printer.flush()
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf"
assert printer.current_print_job.progress == 0
print_job_mock.print_percentage = 100
printer.current_state.update_print_job_info()
assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job.progress == 100
print_job_mock.gcode_state = "FINISH"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, IdleState)
assert printer.current_print_job is None
assert printer.selected_file is not None
assert printer.selected_file.file_name == "print.3mf"
printer.write(b"M23 print2.3mf\n")
printer.flush()
assert printer.current_print_job is None
assert printer.selected_file is not None
assert printer.selected_file.file_name == "print2.3mf"
def test_finish_detected_correctly(printer: BambuVirtualPrinter, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
print_job_mock.gcode_state = "RUNNING"
print_job_mock.print_percentage = 99
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf"
assert printer.current_print_job.progress == 99
print_job_mock.print_percentage = 100
print_job_mock.gcode_state = "FINISH"
printer.new_update("event_printer_data_update")
printer.flush()
result = printer.readlines()
assert result[-3].endswith(b"1000/1000")
assert result[-2] == b"Done printing file"
assert result[-1] == b"Not SD printing"
assert isinstance(printer.current_state, IdleState)
assert printer.current_print_job is None
assert printer.selected_file is not None
assert printer.selected_file.file_name == "print.3mf"