* 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
This commit is contained in:
Anton Skrypnyk 2024-07-30 05:49:12 +03:00 committed by GitHub
parent ad862d5ebd
commit fda4b86cbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2723 additions and 1422 deletions

2
.gitignore vendored
View File

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

View File

@ -1,260 +1,10 @@
# coding=utf-8 # 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_name__ = "Bambu Printer"
__plugin_pythoncompat__ = ">=3.7,<4" __plugin_pythoncompat__ = ">=3.7,<4"
from .bambu_print_plugin import BambuPrintPlugin
def __plugin_load__(): def __plugin_load__():
plugin = BambuPrintPlugin() plugin = BambuPrintPlugin()
@ -270,5 +20,5 @@ def __plugin_load__():
"octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd, "octoprint.printer.sdcardupload": __plugin_implementation__.upload_to_sd,
"octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information, "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.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,309 @@
from __future__ import absolute_import, annotations
from pathlib import Path
import threading
from time import perf_counter
from contextlib import contextmanager
import flask
import logging.handlers
from urllib.parse import quote as urlquote
import octoprint.printer
import octoprint.server
import octoprint.plugin
from octoprint.events import Events
import octoprint.settings
from octoprint.util import is_hidden_path
from octoprint.server.util.flask import no_firstrun_access
from octoprint.server.util.tornado import (
LargeResponseHandler,
path_validation_factory,
)
from octoprint.access.permissions import Permissions
from octoprint.logging.handlers import CleaningTimedRotatingFileHandler
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
from pybambu import BambuCloud
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
RemoteSDCardFileList,
)
from .printer.file_system.bambu_timelapse_file_info import (
BambuTimelapseFileInfo,
)
from .printer.bambu_virtual_printer import BambuVirtualPrinter
@contextmanager
def measure_elapsed():
start = perf_counter()
def _get_elapsed():
return perf_counter() - start
yield _get_elapsed
print(f"Total elapsed: {_get_elapsed()}")
class BambuPrintPlugin(
octoprint.plugin.SettingsPlugin,
octoprint.plugin.TemplatePlugin,
octoprint.plugin.AssetPlugin,
octoprint.plugin.EventHandlerPlugin,
octoprint.plugin.SimpleApiPlugin,
octoprint.plugin.BlueprintPlugin,
):
_logger: logging.Logger
_plugin_manager: octoprint.plugin.PluginManager
_bambu_file_system: RemoteSDCardFileList
_timelapse_files_view: CachedFileView
def on_settings_initialized(self):
self._bambu_file_system = RemoteSDCardFileList(self._settings)
self._timelapse_files_view = CachedFileView(self._bambu_file_system)
if self._settings.get(["device_type"]) in ["X1", "X1C"]:
self._timelapse_files_view.with_filter("timelapse/", ".mp4")
else:
self._timelapse_files_view.with_filter("timelapse/", ".avi")
def get_assets(self):
return {"js": ["js/bambu_printer.js"]}
def get_template_configs(self):
return [
{"type": "settings", "custom_bindings": True},
{
"type": "generic",
"custom_bindings": True,
"template": "bambu_timelapse.jinja2",
},
] # , {"type": "generic", "custom_bindings": True, "template": "bambu_printer.jinja2"}]
def get_settings_defaults(self):
return {
"device_type": "X1C",
"serial": "",
"host": "",
"access_code": "",
"username": "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,
}
def is_api_adminonly(self):
return True
def get_api_commands(self):
return {"register": ["email", "password", "region", "auth_token"]}
def on_api_command(self, command, data):
if command == "register":
if (
"email" in data
and "password" in data
and "region" in data
and "auth_token" in data
):
self._logger.info(f"Registering user {data['email']}")
bambu_cloud = BambuCloud(
data["region"], data["email"], data["password"], data["auth_token"]
)
bambu_cloud.login(data["region"], data["email"], data["password"])
return flask.jsonify(
{
"auth_token": bambu_cloud.auth_token,
"username": bambu_cloud.username,
}
)
def on_event(self, event, payload):
if event == Events.TRANSFER_DONE:
self._printer.commands("M20 L T", force=True)
def support_3mf_files(self):
return {"machinecode": {"3mf": ["3mf"]}}
def upload_to_sd(
self,
printer,
filename,
path,
sd_upload_started,
sd_upload_succeeded,
sd_upload_failed,
*args,
**kwargs,
):
self._logger.debug(f"Starting upload from {filename} to {filename}")
sd_upload_started(filename, filename)
def process():
with measure_elapsed() as get_elapsed:
try:
with self._bambu_file_system.get_ftps_client() as ftp:
if ftp.upload_file(path, f"{filename}"):
sd_upload_succeeded(filename, filename, get_elapsed())
else:
raise Exception("upload failed")
except Exception as e:
sd_upload_failed(filename, filename, get_elapsed())
self._logger.exception(e)
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
return filename
def get_template_vars(self):
return {"plugin_version": self._plugin_version}
def virtual_printer_factory(self, comm_instance, port, baudrate, read_timeout):
if not port == "BAMBU":
return None
if (
self._settings.get(["serial"]) == ""
or self._settings.get(["host"]) == ""
or self._settings.get(["access_code"]) == ""
):
return None
seriallog_handler = CleaningTimedRotatingFileHandler(
self._settings.get_plugin_logfile_path(postfix="serial"),
when="D",
backupCount=3,
)
seriallog_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
seriallog_handler.setLevel(logging.DEBUG)
serial_obj = BambuVirtualPrinter(
self._settings,
self._printer_profile_manager,
data_folder=self.get_plugin_data_folder(),
serial_log_handler=seriallog_handler,
read_timeout=float(read_timeout),
faked_baudrate=baudrate,
)
return serial_obj
def get_additional_port_names(self, *args, **kwargs):
if (
self._settings.get(["serial"]) != ""
and self._settings.get(["host"]) != ""
and self._settings.get(["access_code"]) != ""
):
return ["BAMBU"]
else:
return []
def get_timelapse_file_list(self):
if flask.request.path.startswith("/api/timelapse"):
def process():
return_file_list = []
for file_info in self._timelapse_files_view.get_all_info():
timelapse_info = BambuTimelapseFileInfo.from_file_info(file_info)
return_file_list.append(timelapse_info.to_dict())
self._plugin_manager.send_plugin_message(
self._identifier, {"files": return_file_list}
)
thread = threading.Thread(target=process)
thread.daemon = True
thread.start()
def _hook_octoprint_server_api_before_request(self, *args, **kwargs):
return [self.get_timelapse_file_list]
def _download_file(self, file_name: str, source_path: str):
destination = Path(self.get_plugin_data_folder()) / file_name
if destination.exists():
return destination
with self._bambu_file_system.get_ftps_client() as ftp:
ftp.download_file(
source=(Path(source_path) / file_name).as_posix(),
dest=destination.as_posix(),
)
return destination
@octoprint.plugin.BlueprintPlugin.route("/timelapse/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadTimelapse(self, filename):
self._download_file(filename, "timelapse/")
return flask.redirect(
"/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302
)
@octoprint.plugin.BlueprintPlugin.route("/thumbnail/<filename>", methods=["GET"])
@octoprint.server.util.flask.restricted_access
@no_firstrun_access
@Permissions.TIMELAPSE_DOWNLOAD.require(403)
def downloadThumbnail(self, filename):
self._download_file(filename, "timelapse/thumbnail/")
return flask.redirect(
"/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302
)
def is_blueprint_csrf_protected(self):
return True
def route_hook(self, server_routes, *args, **kwargs):
return [
(
r"/download/timelapse/(.*)",
LargeResponseHandler,
{
"path": self.get_plugin_data_folder(),
"as_attachment": True,
"path_validation": path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404
),
},
),
(
r"/download/thumbnail/(.*)",
LargeResponseHandler,
{
"path": self.get_plugin_data_folder(),
"as_attachment": True,
"path_validation": path_validation_factory(
lambda path: not is_hidden_path(path), status_code=404
),
},
),
]
def get_update_information(self):
return {
"bambu_printer": {
"displayName": "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,619 @@
from __future__ import annotations
import collections
from dataclasses import dataclass, field
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 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
# 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._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._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 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
self.lastTempAt = time.monotonic()
self._telemetry.temp[0] = temperatures.nozzle_temp
self._telemetry.targetTemp[0] = temperatures.target_nozzle_temp
self._telemetry.bedTemp = temperatures.bed_temp
self._telemetry.bedTargetTemp = temperatures.target_bed_temp
self._telemetry.chamberTemp = temperatures.chamber_temp
self._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":
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")
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"]),
)
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:
self._log.debug(f"Select project file: {file_path}")
file_info = self._project_files_view.get_file_by_stem(
file_path, [".gcode", ".3mf"]
)
if (
self._selected_project_file is not None
and file_info is not None
and self._selected_project_file.path == file_info.path
):
return True
if file_info is None:
self._log.error(f"Cannot select not existing file: {file_path}")
return False
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) -> None:
self.sendIO("SD card ok")
@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)
else:
self.stop_continuous_status_report()
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
# noinspection PyUnusedLocal
@gcode_executor.register_no_data("M115")
def _report_firmware_info(self) -> bool:
self.sendIO("Bambu Printer Integration")
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[1:])
if percent is None or percent < 1 or percent > 166:
return True
speed_fraction = 100 / percent
acceleration = math.exp((speed_fraction - 1.0191) / -0.814)
feed_rate = (
2.1645 * (acceleration**3)
- 5.3247 * (acceleration**2)
+ 4.342 * acceleration
- 0.181
)
speed_level = 1.539 * (acceleration**2) - 0.7032 * acceleration + 4.0834
speed_command = f"M204.2 K${acceleration:.2f} \nM220 K${feed_rate:.2f} \nM73.2 R${speed_fraction:.2f} \nM1002 set_gcode_claim_speed_level ${speed_level:.0f}\n"
gcode_command["print"]["param"] = speed_command
if self.bambu_client.publish(gcode_command):
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:
self.sendIO(
f"SD printing byte {self.current_print_job.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,94 @@
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 filter in self.folder_view.keys():
result.extend(self.file_system.list_files(*filter, 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_file_data(self, file_path: str | Path) -> FileInfo | None:
file_data = self.get_file_data_cached(file_path)
if file_data is None:
self.update()
file_data = self.get_file_data_cached(file_path)
return file_data
def get_file_data_cached(self, file_path: str | Path) -> FileInfo | None:
if isinstance(file_path, str):
file_path = Path(file_path).as_posix().strip("/")
else:
file_path = file_path.as_posix().strip("/")
if file_path not in self._file_data_cache:
file_path = self._file_alias_cache.get(file_path, file_path)
return self._file_data_cache.get(file_path, None)
def get_file_by_stem(self, file_stem: str, allowed_suffixes: list[str]):
if file_stem == "":
return None
file_stem = Path(file_stem).with_suffix("").stem
file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes)
if file_data is None:
self.update()
file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes)
return file_data
def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]):
for file_path_str in self._file_data_cache.keys():
file_path = Path(file_path_str)
if file_stem == file_path.with_suffix("").stem and all(
suffix in allowed_suffixes for suffix in file_path.suffixes
):
return self.get_file_data_cached(file_path)
return None

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 wrapper for FTPS server interactions
""" """
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import ftplib import ftplib
import os import os
from pathlib import Path
import socket import socket
import ssl import ssl
from typing import Optional, Union, List from typing import Generator, Union
from contextlib import redirect_stdout from contextlib import redirect_stdout
import io import io
import re import re
class ImplicitTLS(ftplib.FTP_TLS): class ImplicitTLS(ftplib.FTP_TLS):
"""ftplib.FTP_TLS sub-class to support implicit SSL FTPS""" """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) conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
if self._prot_p: if self._prot_p:
conn = self.context.wrap_socket(conn, conn = self.context.wrap_socket(
server_hostname=self.host, conn, server_hostname=self.host, session=self.sock.session
session=self.sock.session) # this is the fix ) # this is the fix
return conn, size return conn, size
class IoTFTPSClient: @dataclass
class IoTFTPSConnection:
"""iot ftps ftpsclient""" """iot ftps ftpsclient"""
ftps_host: str ftps_session: ftplib.FTP | ImplicitTLS
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
def __init__( def close(self) -> None:
self, """close the current session from the ftps server"""
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"""
self.ftps_session.close() self.ftps_session.close()
def download_file(self, source: str, dest: str): def download_file(self, source: str, dest: str):
@ -137,7 +95,7 @@ class IoTFTPSClient:
# Taken from ftplib.storbinary but with custom ssl handling # Taken from ftplib.storbinary but with custom ssl handling
# due to the shitty bambu p1p ftps server TODO fix properly. # due to the shitty bambu p1p ftps server TODO fix properly.
with open(source, "rb") as fp: 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: with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn:
while 1: while 1:
@ -152,7 +110,9 @@ class IoTFTPSClient:
callback(buf) callback(buf)
# shutdown ssl layer # 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 # Yeah this is suposed to be conn.unwrap
# But since we operate in prot p mode # But since we operate in prot p mode
# we can close the connection always. # we can close the connection always.
@ -185,19 +145,26 @@ class IoTFTPSClient:
def mkdir(self, path: str) -> str: def mkdir(self, path: str) -> str:
return self.ftps_session.mkd(path) 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""" """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: try:
files = self.ftps_session.nlst(path) list_result = self.ftps_session.nlst(list_path) or []
if not files: for file_list_entry in list_result:
return path = Path(list_path) / Path(file_list_entry).name
if file_pattern: if _extension_acceptable(path):
return [f for f in files if file_pattern in f] yield path
return files
except Exception as ex: except Exception as ex:
print(f"unexpected exception occurred: {ex}") print(f"unexpected exception occurred: {ex}")
pass
return
def list_files_ex(self, path: str) -> Union[list[str], None]: def list_files_ex(self, path: str) -> Union[list[str], None]:
"""list files under a path inside the FTPS server""" """list files under a path inside the FTPS server"""
@ -208,7 +175,8 @@ class IoTFTPSClient:
s = f.getvalue() s = f.getvalue()
files = [] files = []
for row in s.split("\n"): for row in s.split("\n"):
if len(row) <= 0: continue if len(row) <= 0:
continue
attribs = row.split(" ") attribs = row.split(" ")
@ -219,10 +187,70 @@ class IoTFTPSClient:
else: else:
name = attribs[len(attribs) - 1] name = attribs[len(attribs) - 1]
file = ( attribs[0], name ) file = (attribs[0], name)
files.append(file) files.append(file)
return files return files
except Exception as ex: except Exception as ex:
print(f"unexpected exception occurred: [{ex}]") print(f"unexpected exception occurred: [{ex}]")
pass pass
return 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,18 @@
from __future__ import annotations
from dataclasses import dataclass
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
FileInfo,
)
@dataclass
class PrintJob:
file_info: FileInfo
progress: int
@property
def file_position(self):
if self.file_info.size is None:
return 0
return int(self.file_info.size * self.progress / 100)

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,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,58 @@
from __future__ import annotations
from pathlib import Path
from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
from octoprint_bambu_printer.printer.print_job import PrintJob
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class IdleState(APrinterState):
def start_new_print(self):
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_boolean(["device_type"]) in ["X1", "X1C"]
else "file:///"
)
print_command = {
"print": {
"sequence_id": 0,
"command": "project_file",
"param": "Metadata/plate_1.gcode",
"md5": "",
"profile_id": "0",
"project_id": "0",
"subtask_id": "0",
"task_id": "0",
"subtask_name": selected_file.file_name,
"url": f"{filesystem_root}{selected_file.path.as_posix()}",
"bed_type": "auto",
"timelapse": self._printer._settings.get_boolean(["timelapse"]),
"bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]),
"flow_cali": self._printer._settings.get_boolean(["flow_cali"]),
"vibration_cali": self._printer._settings.get_boolean(
["vibration_cali"]
),
"layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]),
"use_ams": self._printer._settings.get_boolean(["use_ams"]),
"ams_mapping": "",
}
}
return print_command

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

View File

@ -0,0 +1,92 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from octoprint_bambu_printer.printer.bambu_virtual_printer import (
BambuVirtualPrinter,
)
import threading
import pybambu
import pybambu.models
import pybambu.commands
from octoprint_bambu_printer.printer.print_job import PrintJob
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class PrintingState(APrinterState):
def __init__(self, printer: BambuVirtualPrinter) -> None:
super().__init__(printer)
self._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
def _start_worker_thread(self):
if self._sd_printing_thread is None:
self._is_printing = True
self._sd_printing_thread = threading.Thread(target=self._printing_worker)
self._sd_printing_thread.start()
def _printing_worker(self):
while (
self._is_printing
and self._printer.current_print_job is not None
and self._printer.current_print_job.progress < 100
):
self.update_print_job_info()
self._printer.report_print_job_status()
time.sleep(3)
self.update_print_job_info()
if (
self._printer.current_print_job is not None
and self._printer.current_print_job.progress >= 100
):
self._printer.finalize_print_job()
def update_print_job_info(self):
print_job_info = self._printer.bambu_client.get_device().print_job
task_name: str = print_job_info.subtask_name
project_file_info = self._printer.project_files.get_file_by_stem(
task_name, [".gcode", ".3mf"]
)
if project_file_info is None:
self._log.debug(f"No 3mf file found for {print_job_info}")
self._current_print_job = None
self._printer.change_state(self._printer._state_idle)
return
progress = print_job_info.print_percentage
self._printer.current_print_job = PrintJob(project_file_info, progress)
self._printer.select_project_file(project_file_info.path.as_posix())
def pause_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.PAUSE):
self._log.info("print paused")
else:
self._log.info("print pause failed")
def cancel_print(self):
if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.STOP):
self._log.info("print cancelled")
self._printer.finalize_print_job()
else:
self._log.info("print cancel failed")

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ plugin_package = "octoprint_bambu_printer"
plugin_name = "OctoPrint-BambuPrinter" plugin_name = "OctoPrint-BambuPrinter"
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module # 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.0"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# module # module

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