Compare commits

..

4 Commits

Author SHA1 Message Date
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
10 changed files with 347 additions and 120 deletions

View File

@ -37,7 +37,12 @@ from .printer.bambu_virtual_printer import BambuVirtualPrinter
@contextmanager @contextmanager
def measure_elapsed(): def measure_elapsed():
start = perf_counter() start = perf_counter()
yield lambda: perf_counter() - start
def _get_elapsed():
return perf_counter() - start
yield _get_elapsed
print(f"Total elapsed: {_get_elapsed()}")
class BambuPrintPlugin( class BambuPrintPlugin(

View File

@ -71,6 +71,8 @@ class BambuVirtualPrinter:
self._current_state = self._state_idle self._current_state = self._state_idle
self._running = True self._running = True
self._print_status_reporter = None
self._print_temp_reporter = None
self._printer_thread = threading.Thread( self._printer_thread = threading.Thread(
target=self._printer_worker, target=self._printer_worker,
name="octoprint.plugins.bambu_printer.printer_state", name="octoprint.plugins.bambu_printer.printer_state",
@ -184,6 +186,7 @@ class BambuVirtualPrinter:
self._telemetry.bedTargetTemp = temperatures.target_bed_temp self._telemetry.bedTargetTemp = temperatures.target_bed_temp
self._telemetry.chamberTemp = temperatures.chamber_temp self._telemetry.chamberTemp = temperatures.chamber_temp
self._log.debug(f"Received printer state update: {print_job_state}")
if ( if (
print_job_state == "IDLE" print_job_state == "IDLE"
or print_job_state == "FINISH" or print_job_state == "FINISH"
@ -274,9 +277,9 @@ class BambuVirtualPrinter:
self.lastN = 0 self.lastN = 0
self._running = False self._running = False
if self._sdstatus_reporter is not None: if self._print_status_reporter is not None:
self._sdstatus_reporter.cancel() self._print_status_reporter.cancel()
self._sdstatus_reporter = None self._print_status_reporter = None
if self._settings.get_boolean(["simulateReset"]): if self._settings.get_boolean(["simulateReset"]):
for item in self._settings.get(["resetLines"]): for item in self._settings.get(["resetLines"]):
@ -310,7 +313,16 @@ class BambuVirtualPrinter:
def select_project_file(self, file_path: str) -> bool: def select_project_file(self, file_path: str) -> bool:
self._log.debug(f"Select project file: {file_path}") self._log.debug(f"Select project file: {file_path}")
file_info = self._project_files_view.get_cached_file_data(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: if file_info is None:
self._log.error(f"Cannot select not existing file: {file_path}") self._log.error(f"Cannot select not existing file: {file_path}")
return False return False
@ -328,7 +340,6 @@ class BambuVirtualPrinter:
@gcode_executor.register("M23") @gcode_executor.register("M23")
def _select_sd_file(self, data: str) -> bool: def _select_sd_file(self, data: str) -> bool:
filename = data.split(maxsplit=1)[1].strip() filename = data.split(maxsplit=1)[1].strip()
self._list_project_files()
return self.select_project_file(filename) return self.select_project_file(filename)
def _send_file_selected_message(self): def _send_file_selected_message(self):
@ -355,38 +366,81 @@ class BambuVirtualPrinter:
matchS = re.search(r"S([0-9]+)", data) matchS = re.search(r"S([0-9]+)", data)
if matchS: if matchS:
interval = int(matchS.group(1)) interval = int(matchS.group(1))
if self._sdstatus_reporter is not None:
self._sdstatus_reporter.cancel()
if interval > 0: if interval > 0:
self._sdstatus_reporter = RepeatedTimer( self.start_continuous_status_report(interval)
interval, self.report_print_job_status return False
)
self._sdstatus_reporter.start()
else: else:
self._sdstatus_reporter = None self.stop_continuous_status_report()
return False
self.report_print_job_status() self.report_print_job_status()
return True 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") @gcode_executor.register("M30")
def _delete_sd_file(self, data: str) -> bool: def _delete_project_file(self, data: str) -> bool:
file_path = data.split(None, 1)[1].strip() file_path = data.split(maxsplit=1)[1].strip()
self._list_project_files() file_info = self.project_files.get_file_data(file_path)
self.file_system.delete_file(Path(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 return True
@gcode_executor.register("M105") @gcode_executor.register("M105")
def _report_temperatures(self, data: str) -> bool: def _report_temperatures(self, data: str) -> bool:
return self._processTemperatureQuery() 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 # noinspection PyUnusedLocal
@gcode_executor.register_no_data("M115") @gcode_executor.register_no_data("M115")
def _report_firmware_info(self) -> bool: def _report_firmware_info(self) -> bool:
self.sendIO("Bambu Printer Integration") 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:EXTENDED_M20:1")
self.sendIO("Cap:LFN_WRITE:1") self.sendIO("Cap:LFN_WRITE:1")
self.sendIO("Cap:LFN_WRITE:1")
return True return True
@gcode_executor.register("M117") @gcode_executor.register("M117")
@ -418,7 +472,7 @@ class BambuVirtualPrinter:
def _set_feedrate_percent(self, data: str) -> bool: def _set_feedrate_percent(self, data: str) -> bool:
if self.bambu_client.connected: if self.bambu_client.connected:
gcode_command = commands.SEND_GCODE_TEMPLATE gcode_command = commands.SEND_GCODE_TEMPLATE
percent = int(data[1:]) percent = int(data.replace("M220 S", ""))
if percent is None or percent < 1 or percent > 166: if percent is None or percent < 1 or percent > 166:
return True return True
@ -464,8 +518,8 @@ class BambuVirtualPrinter:
return True return True
@gcode_executor.register("M20") @gcode_executor.register("M20")
def _list_project_files(self, data: str = ""): def _update_project_file_list(self, data: str = ""):
self._project_files_view.update() self._project_files_view.update() # internally sends list to serial io
return True return True
def _list_cached_project_files(self): def _list_cached_project_files(self):
@ -475,10 +529,11 @@ class BambuVirtualPrinter:
): ):
self.sendIO(item) self.sendIO(item)
self.sendIO("End file list") self.sendIO("End file list")
self.sendOk()
@gcode_executor.register_no_data("M24") @gcode_executor.register_no_data("M24")
def _start_print(self): def _start_resume_sd_print(self):
self._current_state.start_resume_print() self._current_state.start_new_print()
return True return True
@gcode_executor.register_no_data("M25") @gcode_executor.register_no_data("M25")
@ -492,14 +547,30 @@ class BambuVirtualPrinter:
return True return True
def report_print_job_status(self): def report_print_job_status(self):
print_job = self.current_print_job if self.current_print_job is not None:
if print_job is not None:
self.sendIO( self.sendIO(
f"SD printing byte {print_job.file_position}/{print_job.file_info.size}" f"SD printing byte {self.current_print_job.file_position}"
f"/{self.current_print_job.file_info.size}"
) )
else: else:
self.sendIO("Not SD printing") 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: def _create_temperature_message(self) -> str:
template = "{heater}:{actual:.2f}/ {target:.2f}" template = "{heater}:{actual:.2f}/ {target:.2f}"
temps = collections.OrderedDict() temps = collections.OrderedDict()

View File

@ -15,7 +15,9 @@ from octoprint_bambu_printer.printer.file_system.file_info import FileInfo
@dataclass @dataclass
class CachedFileView: class CachedFileView:
file_system: RemoteSDCardFileList file_system: RemoteSDCardFileList
folder_view: set[tuple[str, str | list[str] | None]] = field(default_factory=set) 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 on_update: Callable[[], None] | None = None
def __post_init__(self): def __post_init__(self):
@ -25,7 +27,7 @@ class CachedFileView:
def with_filter( def with_filter(
self, folder: str, extensions: str | list[str] | None = None self, folder: str, extensions: str | list[str] | None = None
) -> "CachedFileView": ) -> "CachedFileView":
self.folder_view.add((folder, extensions)) self.folder_view[(folder, extensions)] = None
return self return self
def list_all_views(self): def list_all_views(self):
@ -33,7 +35,7 @@ class CachedFileView:
result: list[FileInfo] = [] result: list[FileInfo] = []
with self.file_system.get_ftps_client() as ftp: with self.file_system.get_ftps_client() as ftp:
for filter in self.folder_view: for filter in self.folder_view.keys():
result.extend(self.file_system.list_files(*filter, ftp, existing_files)) result.extend(self.file_system.list_files(*filter, ftp, existing_files))
return result return result
@ -44,8 +46,8 @@ class CachedFileView:
self.on_update() self.on_update()
def _update_file_list_cache(self, files: list[FileInfo]): def _update_file_list_cache(self, files: list[FileInfo]):
self._file_alias_cache = {info.dosname: info.file_name for info in files} self._file_alias_cache = {info.dosname: info.path.as_posix() for info in files}
self._file_data_cache = {info.file_name: info for info in files} self._file_data_cache = {info.path.as_posix(): info for info in files}
def get_all_info(self): def get_all_info(self):
self.update() self.update()
@ -54,26 +56,39 @@ class CachedFileView:
def get_all_cached_info(self): def get_all_cached_info(self):
return list(self._file_data_cache.values()) return list(self._file_data_cache.values())
def get_file_by_suffix(self, file_stem: str, allowed_suffixes: list[str]): 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 == "": if file_stem == "":
return None return None
file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes) 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: if file_data is None:
self.update() self.update()
file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes) file_data = self._get_file_by_stem_cached(file_stem, allowed_suffixes)
return file_data return file_data
def get_cached_file_data(self, file_name: str) -> FileInfo | None: def _get_file_by_stem_cached(self, file_stem: str, allowed_suffixes: list[str]):
file_name = Path(file_name).name for file_path_str in list(self._file_data_cache.keys()) + list(self._file_alias_cache.keys()):
file_name = self._file_alias_cache.get(file_name, file_name) file_path = Path(file_path_str)
return self._file_data_cache.get(file_name, None) if file_stem == file_path.with_suffix("").stem and all(
suffix in allowed_suffixes for suffix in file_path.suffixes
def _get_file_by_suffix_cached(self, file_stem: str, allowed_suffixes: list[str]): ):
for suffix in allowed_suffixes: return self.get_file_data_cached(file_path)
file_data = self.get_cached_file_data(
Path(file_stem).with_suffix(suffix).as_posix()
)
if file_data is not None:
return file_data
return None return None

View File

@ -117,7 +117,7 @@ class IoTFTPSConnection:
# 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.
# This is cursed but it works. # This is cursed but it works.
if "vsFTPd" in self.welcome: if "vsFTPd" in self.ftps_session.welcome:
conn.unwrap() conn.unwrap()
else: else:
conn.shutdown(socket.SHUT_RDWR) conn.shutdown(socket.SHUT_RDWR)

View File

@ -7,8 +7,6 @@ import logging.handlers
from octoprint.util import get_dos_filename from octoprint.util import get_dos_filename
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
from .ftps_client import IoTFTPSClient, IoTFTPSConnection from .ftps_client import IoTFTPSClient, IoTFTPSConnection
from .file_info import FileInfo from .file_info import FileInfo
@ -23,7 +21,7 @@ class RemoteSDCardFileList:
def delete_file(self, file_path: Path) -> None: def delete_file(self, file_path: Path) -> None:
try: try:
with self.get_ftps_client() as ftp: with self.get_ftps_client() as ftp:
if ftp.delete_file(str(file_path)): if ftp.delete_file(file_path.as_posix()):
self._logger.debug(f"{file_path} deleted") self._logger.debug(f"{file_path} deleted")
else: else:
raise RuntimeError(f"Deleting file {file_path} failed") raise RuntimeError(f"Deleting file {file_path} failed")

View File

@ -1,26 +1,35 @@
from __future__ import annotations 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.print_job import PrintJob
from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState from octoprint_bambu_printer.printer.states.a_printer_state import APrinterState
class IdleState(APrinterState): class IdleState(APrinterState):
def start_resume_print(self): def start_new_print(self):
selected_file = self._printer.selected_file selected_file = self._printer.selected_file
if selected_file is None: if selected_file is None:
self._log.warn("Cannot start print job if file was not selected") self._log.warn("Cannot start print job if file was not selected")
return return
print_command = self._get_print_command_for_file(selected_file.file_name) 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): if self._printer.bambu_client.publish(print_command):
self._log.info(f"Started print for {selected_file.file_name}") self._log.info(f"Started print for {selected_file.file_name}")
self._printer.change_state(self._printer._state_printing)
else: else:
self._log.warn(f"Failed to start print for {selected_file.file_name}") self._log.warn(f"Failed to start print for {selected_file.file_name}")
self._printer.change_state(self._printer._state_idle)
def _get_print_command_for_file(self, selected_file): 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:///"
)
print_command = { print_command = {
"print": { "print": {
"sequence_id": 0, "sequence_id": 0,
@ -31,14 +40,9 @@ class IdleState(APrinterState):
"project_id": "0", "project_id": "0",
"subtask_id": "0", "subtask_id": "0",
"task_id": "0", "task_id": "0",
"subtask_name": f"{selected_file}", "subtask_name": selected_file.file_name,
"file": f"{selected_file}", "url": f"{filesystem_root}{selected_file.path.as_posix()}",
"url": ( "bed_type": "auto",
f"file:///mnt/sdcard/{selected_file}"
if self._printer._settings.get_boolean(["device_type"])
in ["X1", "X1C"]
else f"file:///sdcard/{selected_file}"
),
"timelapse": self._printer._settings.get_boolean(["timelapse"]), "timelapse": self._printer._settings.get_boolean(["timelapse"]),
"bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]), "bed_leveling": self._printer._settings.get_boolean(["bed_leveling"]),
"flow_cali": self._printer._settings.get_boolean(["flow_cali"]), "flow_cali": self._printer._settings.get_boolean(["flow_cali"]),
@ -47,6 +51,7 @@ class IdleState(APrinterState):
), ),
"layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]), "layer_inspect": self._printer._settings.get_boolean(["layer_inspect"]),
"use_ams": self._printer._settings.get_boolean(["use_ams"]), "use_ams": self._printer._settings.get_boolean(["use_ams"]),
"ams_mapping": "",
} }
} }

View File

@ -19,35 +19,33 @@ class PausedState(APrinterState):
def __init__(self, printer: BambuVirtualPrinter) -> None: def __init__(self, printer: BambuVirtualPrinter) -> None:
super().__init__(printer) super().__init__(printer)
self._pausedLock = threading.Event() self._pausedLock = threading.Event()
self._paused_repeated_report = None
def init(self): def init(self):
if not self._pausedLock.is_set(): if not self._pausedLock.is_set():
self._pausedLock.set() self._pausedLock.set()
self._printer.sendIO("// action:paused") self._printer.sendIO("// action:paused")
self._sendPaused() self._printer.start_continuous_status_report(3)
def finalize(self): def finalize(self):
if self._pausedLock.is_set(): if self._pausedLock.is_set():
self._pausedLock.clear() self._pausedLock.clear()
if self._paused_repeated_report is not None:
self._paused_repeated_report.join()
self._paused_repeated_report = None
def _sendPaused(self): def start_new_print(self):
if self._printer.current_print_job is None:
self._log.warn("job paused, but no print job available?")
return
paused_timer = RepeatedTimer(
interval=3.0,
function=self._printer.report_print_job_status,
daemon=True,
run_first=True,
condition=self._pausedLock.is_set,
)
paused_timer.start()
def start_resume_print(self):
if self._printer.bambu_client.connected: if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.RESUME): if self._printer.bambu_client.publish(pybambu.commands.RESUME):
self._log.info("print resumed") self._log.info("print resumed")
self._printer.change_state(self._printer._state_printing)
else: else:
self._log.info("print resume failed") 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

@ -53,34 +53,33 @@ class PrintingState(APrinterState):
self._printer.report_print_job_status() self._printer.report_print_job_status()
time.sleep(3) time.sleep(3)
if self._printer.current_print_job is None: self.update_print_job_info()
if (
self._log.warn("Printing state was triggered with empty print job") self._printer.current_print_job is not None
return and self._printer.current_print_job.progress >= 100
):
if self._printer.current_print_job.progress >= 100: self._printer.finalize_print_job()
self._finish_print()
def update_print_job_info(self): def update_print_job_info(self):
print_job_info = self._printer.bambu_client.get_device().print_job print_job_info = self._printer.bambu_client.get_device().print_job
task_name: str = print_job_info.subtask_name task_name: str = print_job_info.subtask_name
project_file_info = self._printer.project_files.get_file_by_suffix( project_file_info = self._printer.project_files.get_file_by_stem(
task_name, [".3mf", ".gcode.3mf"] task_name, [".gcode", ".3mf"]
) )
if project_file_info is None: if project_file_info is None:
self._log.debug(f"No 3mf file found for {print_job_info}") self._log.debug(f"No 3mf file found for {print_job_info}")
self._current_print_job = None self._current_print_job = None
self._printer.change_state(self._printer._state_idle)
return return
progress = print_job_info.print_percentage progress = print_job_info.print_percentage
self._printer.current_print_job = PrintJob(project_file_info, progress) self._printer.current_print_job = PrintJob(project_file_info, progress)
self._printer.select_project_file(project_file_info.file_name) self._printer.select_project_file(project_file_info.path.as_posix())
def pause_print(self): def pause_print(self):
if self._printer.bambu_client.connected: if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.PAUSE): if self._printer.bambu_client.publish(pybambu.commands.PAUSE):
self._log.info("print paused") self._log.info("print paused")
self._printer.change_state(self._printer._state_paused)
else: else:
self._log.info("print pause failed") self._log.info("print pause failed")
@ -88,17 +87,6 @@ class PrintingState(APrinterState):
if self._printer.bambu_client.connected: if self._printer.bambu_client.connected:
if self._printer.bambu_client.publish(pybambu.commands.STOP): if self._printer.bambu_client.publish(pybambu.commands.STOP):
self._log.info("print cancelled") self._log.info("print cancelled")
self._finish_print() self._printer.finalize_print_job()
self._printer.change_state(self._printer._state_idle)
else: else:
self._log.info("print cancel failed") self._log.info("print cancel failed")
def _finish_print(self):
if self._printer.current_print_job is not None:
self._log.debug(
f"SD File Print finishing: {self._printer.current_print_job.file_info.file_name}"
)
self._printer.sendIO("Done printing file")
self._printer.current_print_job = None
self._printer.change_state(self._printer._state_idle)

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.3"
# 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

View File

@ -2,8 +2,9 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
from pathlib import Path from pathlib import Path
import sys
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView from octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
import pybambu import pybambu
@ -29,7 +30,9 @@ def output_test_folder(output_folder: Path):
@fixture @fixture
def log_test(): def log_test():
return logging.getLogger("gcode_unittest") log = logging.getLogger("gcode_unittest")
log.setLevel(logging.DEBUG)
return log
class DictGetter: class DictGetter:
@ -89,7 +92,11 @@ def project_files_info_ftp():
def cache_files_info_ftp(): def cache_files_info_ftp():
return { return {
"cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))), "cache/print.3mf": (1200, _ftp_date_format(datetime(2024, 5, 7))),
"cache/print2.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)),
),
} }
@ -187,8 +194,11 @@ def test_list_sd_card(printer: BambuVirtualPrinter):
assert result[0] == b"Begin file list" assert result[0] == b"Begin file list"
assert result[1].endswith(b'"print.3mf"') assert result[1].endswith(b'"print.3mf"')
assert result[2].endswith(b'"print2.3mf"') assert result[2].endswith(b'"print2.3mf"')
assert result[3] == b"End file list" assert result[3].endswith(b'"print.3mf"')
assert result[4] == b"ok" 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): def test_list_ftp_paths_p1s(settings, ftps_session_mock):
@ -239,6 +249,67 @@ def test_list_ftp_paths_x1(settings, ftps_session_mock):
) )
def test_delete_sd_file_gcode(printer: BambuVirtualPrinter):
with patch(
"octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
) as delete_function:
printer.write(b"M30 print.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
delete_function.assert_called_with("print.3mf")
printer.write(b"M30 cache/print.3mf\n")
printer.flush()
result = printer.readlines()
assert result[-1] == b"ok"
delete_function.assert_called_with("cache/print.3mf")
def test_delete_sd_file_by_dosname(printer: BambuVirtualPrinter):
with patch(
"octoprint_bambu_printer.printer.file_system.ftps_client.IoTFTPSConnection.delete_file"
) as delete_function:
file_info = printer.project_files.get_file_data("cache/print.3mf")
assert file_info is not None
printer.write(b"M30 " + file_info.dosname.encode() + b"\n")
printer.flush()
assert printer.readlines()[-1] == b"ok"
assert delete_function.call_count == 1
delete_function.assert_called_with("cache/print.3mf")
printer.write(b"M30 cache/print.3mf\n")
printer.flush()
assert printer.readlines()[-1] == b"ok"
assert delete_function.call_count == 2
delete_function.assert_called_with("cache/print.3mf")
def test_select_project_file_by_stem(printer: BambuVirtualPrinter):
printer.write(b"M23 print3\n")
printer.flush()
result = printer.readlines()
assert printer.selected_file is not None
assert printer.selected_file.path == Path("cache/print3.gcode.3mf")
assert result[-2] == b"File selected"
assert result[-1] == b"ok"
def test_select_project_long_name_file_with_multiple_extensions(
printer: BambuVirtualPrinter,
):
printer.write(b"M23 long file path with spaces.gcode.3mf\n")
printer.flush()
result = printer.readlines()
assert printer.selected_file is not None
assert printer.selected_file.path == Path(
"cache/long file path with spaces.gcode.3mf"
)
assert result[-2] == b"File selected"
assert result[-1] == b"ok"
def test_cannot_start_print_without_file(printer: BambuVirtualPrinter): def test_cannot_start_print_without_file(printer: BambuVirtualPrinter):
printer.write(b"M24\n") printer.write(b"M24\n")
printer.flush() printer.flush()
@ -278,9 +349,13 @@ def test_print_started_with_selected_file(printer: BambuVirtualPrinter, print_jo
printer.write(b"M24\n") printer.write(b"M24\n")
printer.flush() printer.flush()
result = printer.readlines() result = printer.readlines()
assert result[0] == b"ok" 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) assert isinstance(printer.current_state, PrintingState)
@ -291,18 +366,26 @@ def test_pause_print(printer: BambuVirtualPrinter, bambu_client_mock, print_job_
printer.write(b"M23 print.3mf\n") printer.write(b"M23 print.3mf\n")
printer.write(b"M24\n") printer.write(b"M24\n")
printer.flush() printer.flush()
printer.readlines()
print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState) assert isinstance(printer.current_state, PrintingState)
bambu_client_mock.publish.return_value = True printer.write(b"M25\n") # pausing the print
printer.write(b"M25\n") # GCode for pausing the print
printer.flush() printer.flush()
result = printer.readlines() result = printer.readlines()
assert result[0] == b"ok" 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) 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): def test_events_update_printer_state(printer: BambuVirtualPrinter, print_job_mock):
print_job_mock.subtask_name = "print.3mf"
print_job_mock.gcode_state = "RUNNING" print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update") printer.new_update("event_printer_data_update")
printer.flush() printer.flush()
@ -338,10 +421,45 @@ def test_printer_info_check(printer: BambuVirtualPrinter):
assert isinstance(printer.current_state, IdleState) assert isinstance(printer.current_state, IdleState)
def test_abort_print(printer: BambuVirtualPrinter): def test_abort_print_during_printing(printer: BambuVirtualPrinter, print_job_mock):
printer.write(b"M26\n") # GCode for aborting the print 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.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() result = printer.readlines()
assert result[-1] == b"ok" assert result[-1] == b"ok"
assert isinstance(printer.current_state, IdleState) assert isinstance(printer.current_state, IdleState)
@ -369,7 +487,9 @@ def test_file_selection_does_not_affect_current_print(
printer.write(b"M23 print.3mf\nM24\n") printer.write(b"M23 print.3mf\nM24\n")
printer.flush() printer.flush()
printer.readlines() print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState) assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job is not None assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf" assert printer.current_print_job.file_info.file_name == "print.3mf"
@ -389,7 +509,9 @@ def test_finished_print_job_reset_after_new_file_selected(
printer.write(b"M23 print.3mf\nM24\n") printer.write(b"M23 print.3mf\nM24\n")
printer.flush() printer.flush()
printer.readlines() print_job_mock.gcode_state = "RUNNING"
printer.new_update("event_printer_data_update")
printer.flush()
assert isinstance(printer.current_state, PrintingState) assert isinstance(printer.current_state, PrintingState)
assert printer.current_print_job is not None assert printer.current_print_job is not None
assert printer.current_print_job.file_info.file_name == "print.3mf" assert printer.current_print_job.file_info.file_name == "print.3mf"
@ -413,3 +535,28 @@ def test_finished_print_job_reset_after_new_file_selected(
assert printer.current_print_job is None assert printer.current_print_job is None
assert printer.selected_file is not None assert printer.selected_file is not None
assert printer.selected_file.file_name == "print2.3mf" 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"