Move all ftp operations to printer file system.
This commit is contained in:
parent
55b78cea05
commit
1f7eed6b23
@ -1,10 +1,9 @@
|
||||
from __future__ import absolute_import, annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from time import perf_counter
|
||||
from contextlib import contextmanager
|
||||
import flask
|
||||
import datetime
|
||||
import logging.handlers
|
||||
from urllib.parse import quote as urlquote
|
||||
|
||||
@ -13,7 +12,7 @@ import octoprint.server
|
||||
import octoprint.plugin
|
||||
from octoprint.events import Events
|
||||
import octoprint.settings
|
||||
from octoprint.util import get_formatted_size, get_formatted_datetime, is_hidden_path
|
||||
from octoprint.util import is_hidden_path
|
||||
from octoprint.server.util.flask import no_firstrun_access
|
||||
from octoprint.server.util.tornado import (
|
||||
LargeResponseHandler,
|
||||
@ -24,10 +23,18 @@ from octoprint.logging.handlers import CleaningTimedRotatingFileHandler
|
||||
|
||||
from pybambu import BambuCloud
|
||||
|
||||
from .printer.ftpsclient.ftpsclient import IoTFTPSClient
|
||||
from .printer.file_system.bambu_timelapse_file_info import (
|
||||
BambuTimelapseFileInfo,
|
||||
)
|
||||
from .printer.bambu_virtual_printer import BambuVirtualPrinter
|
||||
|
||||
|
||||
@contextmanager
|
||||
def measure_elapsed():
|
||||
start = perf_counter()
|
||||
yield lambda: perf_counter() - start
|
||||
|
||||
|
||||
class BambuPrintPlugin(
|
||||
octoprint.plugin.SettingsPlugin,
|
||||
octoprint.plugin.TemplatePlugin,
|
||||
@ -36,6 +43,7 @@ class BambuPrintPlugin(
|
||||
octoprint.plugin.SimpleApiPlugin,
|
||||
octoprint.plugin.BlueprintPlugin,
|
||||
):
|
||||
_printer: BambuVirtualPrinter
|
||||
_logger: logging.Logger
|
||||
_plugin_manager: octoprint.plugin.PluginManager
|
||||
|
||||
@ -120,24 +128,16 @@ class BambuPrintPlugin(
|
||||
sd_upload_started(filename, filename)
|
||||
|
||||
def process():
|
||||
host = self._settings.get(["host"])
|
||||
access_code = self._settings.get(["access_code"])
|
||||
elapsed = time.monotonic()
|
||||
with measure_elapsed() as get_elapsed:
|
||||
try:
|
||||
ftp = IoTFTPSClient(
|
||||
f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True
|
||||
)
|
||||
with self._printer.file_system.get_ftps_client() as ftp:
|
||||
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)
|
||||
sd_upload_succeeded(filename, filename, get_elapsed())
|
||||
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}")
|
||||
sd_upload_failed(filename, filename, get_elapsed())
|
||||
self._logger.exception(e)
|
||||
|
||||
thread = threading.Thread(target=process)
|
||||
thread.daemon = True
|
||||
@ -188,49 +188,13 @@ class BambuPrintPlugin(
|
||||
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")
|
||||
else:
|
||||
timelapse_file_list = ftp.list_files("timelapse/", ".avi")
|
||||
for entry in timelapse_file_list:
|
||||
filename = entry.name
|
||||
filesize = ftp.ftps_session.size(entry.as_posix())
|
||||
date_str = ftp.ftps_session.sendcmd(
|
||||
f"MDTM {entry.as_posix()}"
|
||||
).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}",
|
||||
}
|
||||
)
|
||||
for file_info in self._printer.file_system.get_all_timelapse_files():
|
||||
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}
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Error getting timelapse files: {e}")
|
||||
|
||||
thread = threading.Thread(target=process)
|
||||
thread.daemon = True
|
||||
@ -239,22 +203,24 @@ class BambuPrintPlugin(
|
||||
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._printer.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):
|
||||
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,
|
||||
)
|
||||
self._download_file(filename, "timelapse/")
|
||||
return flask.redirect(
|
||||
"/plugin/bambu_printer/download/timelapse/" + urlquote(filename), code=302
|
||||
)
|
||||
@ -264,17 +230,7 @@ class BambuPrintPlugin(
|
||||
@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,
|
||||
)
|
||||
self._download_file(filename, "timelapse/thumbnail/")
|
||||
return flask.redirect(
|
||||
"/plugin/bambu_printer/download/thumbnail/" + urlquote(filename), code=302
|
||||
)
|
||||
|
@ -22,7 +22,7 @@ from .states.paused_state import PausedState
|
||||
from .states.printing_state import PrintingState
|
||||
|
||||
from .gcode_executor import GCodeExecutor
|
||||
from .remote_sd_card_file_list import RemoteSDCardFileList
|
||||
from .file_system.remote_sd_card_file_list import RemoteSDCardFileList
|
||||
|
||||
|
||||
AMBIENT_TEMPERATURE: float = 21.3
|
||||
@ -293,7 +293,7 @@ class BambuVirtualPrinter:
|
||||
def _select_sd_file(self, data: str) -> bool:
|
||||
filename = data.split(maxsplit=1)[1].strip()
|
||||
self._list_sd()
|
||||
if not self.file_system.select_file(filename):
|
||||
if not self.file_system.select_project_file(filename):
|
||||
return False
|
||||
|
||||
assert self.file_system.selected_file is not None
|
||||
@ -431,7 +431,9 @@ class BambuVirtualPrinter:
|
||||
@gcode_executor.register("M20")
|
||||
def _list_sd(self, data: str = ""):
|
||||
self.sendIO("Begin file list")
|
||||
for item in map(lambda f: f.get_log_info(), self.file_system.get_all_files()):
|
||||
for item in map(
|
||||
lambda f: f.get_log_info(), self.file_system.get_all_project_files()
|
||||
):
|
||||
self.sendIO(item)
|
||||
self.sendIO("End file list")
|
||||
return True
|
||||
|
@ -1,67 +0,0 @@
|
||||
import queue
|
||||
import time
|
||||
|
||||
|
||||
class CharCountingQueue(queue.Queue):
|
||||
def __init__(self, maxsize, name=None):
|
||||
queue.Queue.__init__(self, maxsize=maxsize)
|
||||
self._size = 0
|
||||
self._name = name
|
||||
|
||||
def clear(self):
|
||||
with self.mutex:
|
||||
self.queue.clear()
|
||||
|
||||
def put(self, item, block=True, timeout=None, partial=False) -> int:
|
||||
self.not_full.acquire()
|
||||
|
||||
try:
|
||||
if not self._will_it_fit(item) and partial:
|
||||
space_left = self.maxsize - self._qsize()
|
||||
if space_left:
|
||||
item = item[:space_left]
|
||||
|
||||
if not block:
|
||||
if not self._will_it_fit(item):
|
||||
raise queue.Full
|
||||
elif timeout is None:
|
||||
while not self._will_it_fit(item):
|
||||
self.not_full.wait()
|
||||
elif timeout < 0:
|
||||
raise ValueError("'timeout' must be a positive number")
|
||||
else:
|
||||
endtime = time.monotonic() + timeout
|
||||
while not self._will_it_fit(item):
|
||||
remaining = endtime - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise queue.Full
|
||||
self.not_full.wait(remaining)
|
||||
|
||||
self._put(item)
|
||||
self.unfinished_tasks += 1
|
||||
self.not_empty.notify()
|
||||
|
||||
return self._len(item)
|
||||
finally:
|
||||
self.not_full.release()
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
def _len(self, item):
|
||||
return len(item)
|
||||
|
||||
def _qsize(self, l=len): # noqa: E741
|
||||
return self._size
|
||||
|
||||
# Put a new item in the queue
|
||||
def _put(self, item):
|
||||
self.queue.append(item)
|
||||
self._size += self._len(item)
|
||||
|
||||
# Get an item from the queue
|
||||
def _get(self):
|
||||
item = self.queue.popleft()
|
||||
self._size -= self._len(item)
|
||||
return item
|
||||
|
||||
def _will_it_fit(self, item):
|
||||
return self.maxsize - self._qsize() >= self._len(item)
|
@ -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}",
|
||||
)
|
@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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: set[tuple[str, str | list[str] | None]] = field(default_factory=set)
|
||||
|
||||
def __post_init__(self):
|
||||
self._file_alias_cache = {}
|
||||
self._file_data_cache = {}
|
||||
|
||||
def with_filter(
|
||||
self, folder: str, extensions: str | list[str] | None = None
|
||||
) -> "CachedFileView":
|
||||
self.folder_view.add((folder, extensions))
|
||||
return self
|
||||
|
||||
def list_all_views(self):
|
||||
existing_files = []
|
||||
result = []
|
||||
|
||||
with self.file_system.get_ftps_client() as ftp:
|
||||
for filter in self.folder_view:
|
||||
result.extend(self.file_system.list_files(*filter, ftp, existing_files))
|
||||
return result
|
||||
|
||||
def update(self):
|
||||
file_info_list = self.list_all_views()
|
||||
self._file_alias_cache = {
|
||||
info.dosname: info.file_name for info in file_info_list
|
||||
}
|
||||
self._file_data_cache = {info.file_name: info for info in file_info_list}
|
||||
|
||||
def get_all_cached_info(self):
|
||||
return list(self._file_data_cache.values())
|
||||
|
||||
def get_file_by_suffix(self, file_stem: str, allowed_suffixes: list[str]):
|
||||
if file_stem == "":
|
||||
return None
|
||||
|
||||
file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes)
|
||||
if file_data is None:
|
||||
self.update()
|
||||
file_data = self._get_file_by_suffix_cached(file_stem, allowed_suffixes)
|
||||
return file_data
|
||||
|
||||
def get_cached_file_data(self, file_name: str) -> FileInfo | None:
|
||||
file_name = Path(file_name).name
|
||||
file_name = self._file_alias_cache.get(file_name, file_name)
|
||||
return self._file_data_cache.get(file_name, None)
|
||||
|
||||
def _get_file_by_suffix_cached(self, file_stem: str, allowed_suffixes: list[str]):
|
||||
for suffix in allowed_suffixes:
|
||||
file_data = self.get_cached_file_data(
|
||||
Path(file_stem).with_suffix(suffix).as_posix()
|
||||
)
|
||||
if file_data is not None:
|
||||
return file_data
|
||||
return None
|
33
octoprint_bambu_printer/printer/file_system/file_info.py
Normal file
33
octoprint_bambu_printer/printer/file_system/file_info.py
Normal 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_log_info(self) -> str:
|
||||
return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"'
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
@ -25,12 +25,13 @@ wrapper for FTPS server interactions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import ftplib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Optional, Union, List
|
||||
from typing import Generator, Union
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
import io
|
||||
@ -66,62 +67,14 @@ class ImplicitTLS(ftplib.FTP_TLS):
|
||||
return conn, size
|
||||
|
||||
|
||||
class IoTFTPSClient:
|
||||
@dataclass
|
||||
class IoTFTPSConnection:
|
||||
"""iot ftps ftpsclient"""
|
||||
|
||||
ftps_host: str
|
||||
ftps_port: int
|
||||
ftps_user: str
|
||||
ftps_pass: str
|
||||
ssl_implicit: bool
|
||||
ftps_session: Union[ftplib.FTP, ImplicitTLS]
|
||||
last_error: Optional[str] = None
|
||||
welcome: str
|
||||
ftps_session: ftplib.FTP | ImplicitTLS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ftps_host: str,
|
||||
ftps_port: Optional[int] = 21,
|
||||
ftps_user: Optional[str] = "",
|
||||
ftps_pass: Optional[str] = "",
|
||||
ssl_implicit: Optional[bool] = False,
|
||||
) -> None:
|
||||
self.ftps_host = ftps_host
|
||||
self.ftps_port = ftps_port
|
||||
self.ftps_user = ftps_user
|
||||
self.ftps_pass = ftps_pass
|
||||
self.ssl_implicit = ssl_implicit
|
||||
self.instantiate_ftps_session()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"IoT FTPS Client\n"
|
||||
"--------------------\n"
|
||||
f"host: {self.ftps_host}\n"
|
||||
f"port: {self.ftps_port}\n"
|
||||
f"user: {self.ftps_user}\n"
|
||||
f"ssl: {self.ssl_implicit}"
|
||||
)
|
||||
|
||||
def instantiate_ftps_session(self) -> None:
|
||||
"""init ftps_session based on input params"""
|
||||
self.ftps_session = ImplicitTLS() if self.ssl_implicit else ftplib.FTP()
|
||||
self.ftps_session.set_debuglevel(0)
|
||||
|
||||
self.welcome = self.ftps_session.connect(
|
||||
host=self.ftps_host, port=self.ftps_port
|
||||
)
|
||||
|
||||
if self.ftps_user and self.ftps_pass:
|
||||
self.ftps_session.login(user=self.ftps_user, passwd=self.ftps_pass)
|
||||
else:
|
||||
self.ftps_session.login()
|
||||
|
||||
if self.ssl_implicit:
|
||||
self.ftps_session.prot_p()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""disconnect the current session from the ftps server"""
|
||||
def close(self) -> None:
|
||||
"""close the current session from the ftps server"""
|
||||
self.ftps_session.close()
|
||||
|
||||
def download_file(self, source: str, dest: str):
|
||||
@ -191,7 +144,9 @@ class IoTFTPSClient:
|
||||
def mkdir(self, path: str) -> str:
|
||||
return self.ftps_session.mkd(path)
|
||||
|
||||
def list_files(self, list_path: str, extensions: str | list[str] | None = None):
|
||||
def list_files(
|
||||
self, list_path: str, extensions: str | list[str] | None = None
|
||||
) -> Generator[Path]:
|
||||
"""list files under a path inside the FTPS server"""
|
||||
|
||||
if extensions is None:
|
||||
@ -238,3 +193,41 @@ class IoTFTPSClient:
|
||||
print(f"unexpected exception occurred: [{ex}]")
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
@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
|
@ -0,0 +1,141 @@
|
||||
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 octoprint_bambu_printer.printer.file_system.cached_file_view import CachedFileView
|
||||
|
||||
from .ftps_client import IoTFTPSClient, IoTFTPSConnection
|
||||
from .file_info import FileInfo
|
||||
|
||||
|
||||
class RemoteSDCardFileList:
|
||||
|
||||
def __init__(self, settings) -> None:
|
||||
self._settings = settings
|
||||
self._file_alias_cache = {}
|
||||
self._file_data_cache = {}
|
||||
self._selected_project_file: FileInfo | None = None
|
||||
self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter")
|
||||
self._project_files_view = (
|
||||
CachedFileView(self).with_filter("", ".3mf").with_filter("cache/", ".3mf")
|
||||
)
|
||||
self._timelapse_files_view = CachedFileView(self)
|
||||
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")
|
||||
|
||||
@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 project_files(self):
|
||||
return self._project_files_view
|
||||
|
||||
def remove_file_selection(self):
|
||||
self._selected_project_file = None
|
||||
|
||||
def get_all_project_files(self):
|
||||
self._project_files_view.update()
|
||||
files = self._project_files_view.get_all_cached_info()
|
||||
self._logger.debug(f"get project files return: {files}")
|
||||
return files
|
||||
|
||||
def get_all_timelapse_files(self):
|
||||
self._timelapse_files_view.update()
|
||||
files = self._timelapse_files_view.get_all_cached_info()
|
||||
self._logger.debug(f"get timelapse files return: {files}")
|
||||
return files
|
||||
|
||||
def select_project_file(self, file_path: str) -> bool:
|
||||
self._logger.debug(f"_selectSdFile: {file_path}")
|
||||
file_name = Path(file_path).name
|
||||
file_info = self._project_files_view.get_cached_file_data(file_name)
|
||||
if file_info is None:
|
||||
self._logger.error(f"{file_name} open failed")
|
||||
return False
|
||||
|
||||
self._selected_project_file = file_info
|
||||
return True
|
||||
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
file_info = self._project_files_view.get_cached_file_data(file_path)
|
||||
if file_info is not None:
|
||||
try:
|
||||
with self.get_ftps_client() as ftp:
|
||||
if ftp.delete_file(str(file_info.path)):
|
||||
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.ftps_session.size(file_path.as_posix())
|
||||
date_str = ftp.ftps_session.sendcmd(f"MDTM {file_path.as_posix()}").replace(
|
||||
"213 ", ""
|
||||
)
|
||||
date = datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S").replace(
|
||||
tzinfo=datetime.timezone.utc
|
||||
)
|
||||
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:
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
@ -1 +0,0 @@
|
||||
from .ftpsclient import IoTFTPSClient
|
@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from octoprint_bambu_printer.printer.remote_sd_card_file_list import FileInfo
|
||||
from octoprint_bambu_printer.printer.file_system.remote_sd_card_file_list import (
|
||||
FileInfo,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -5,15 +5,13 @@ import logging
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from types import TracebackType
|
||||
from typing import Callable
|
||||
|
||||
from octoprint.util import to_bytes, to_unicode
|
||||
from serial import SerialTimeoutException
|
||||
|
||||
from .char_counting_queue import CharCountingQueue
|
||||
|
||||
|
||||
class PrinterSerialIO(threading.Thread, BufferedIOBase):
|
||||
command_regex = re.compile(r"^([GM])(\d+)")
|
||||
@ -80,7 +78,10 @@ class PrinterSerialIO(threading.Thread, BufferedIOBase):
|
||||
self._error_detected = e
|
||||
self.input_bytes.task_done()
|
||||
self._clearQueue(self.input_bytes)
|
||||
self._log.info("\n".join(traceback.format_exception(e)[-50:]))
|
||||
self._log.info(
|
||||
"\n".join(traceback.format_exception_only(type(e), e)[-50:])
|
||||
)
|
||||
self._running = False
|
||||
|
||||
self._log.debug("Closing IO read loop")
|
||||
|
||||
|
@ -1,169 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import datetime
|
||||
import itertools
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
import logging.handlers
|
||||
|
||||
from octoprint.util import get_dos_filename
|
||||
from octoprint.util.files import unix_timestamp_to_m20_timestamp
|
||||
|
||||
from .ftpsclient import IoTFTPSClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileInfo:
|
||||
dosname: str
|
||||
path: Path
|
||||
size: int
|
||||
timestamp: str
|
||||
|
||||
@property
|
||||
def file_name(self):
|
||||
return self.path.name
|
||||
|
||||
def get_log_info(self):
|
||||
return f'{self.dosname} {self.size} {self.timestamp} "{self.file_name}"'
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class RemoteSDCardFileList:
|
||||
|
||||
def __init__(self, settings) -> None:
|
||||
self._settings = settings
|
||||
self._file_alias_cache = {}
|
||||
self._file_data_cache = {}
|
||||
self._selected_file_info: FileInfo | None = None
|
||||
self._logger = logging.getLogger("octoprint.plugins.bambu_printer.BambuPrinter")
|
||||
|
||||
@property
|
||||
def selected_file(self):
|
||||
return self._selected_file_info
|
||||
|
||||
@property
|
||||
def has_selected_file(self):
|
||||
return self._selected_file_info is not None
|
||||
|
||||
def remove_file_selection(self):
|
||||
self._selected_file_info = None
|
||||
|
||||
def get_all_files(self):
|
||||
self._update_existing_files_info()
|
||||
self._logger.debug(f"get_all_files return: {self._file_data_cache}")
|
||||
return list(self._file_data_cache.values())
|
||||
|
||||
def get_data_by_suffix(self, file_stem: str, allowed_suffixes: list[str]):
|
||||
if file_stem == "":
|
||||
return None
|
||||
|
||||
file_data = self._get_cached_data_by_suffix(file_stem, allowed_suffixes)
|
||||
if file_data is None:
|
||||
self._update_existing_files_info()
|
||||
file_data = self._get_cached_data_by_suffix(file_stem, allowed_suffixes)
|
||||
return file_data
|
||||
|
||||
def select_file(self, file_path: str) -> bool:
|
||||
self._logger.debug(f"_selectSdFile: {file_path}")
|
||||
file_name = Path(file_path).name
|
||||
file_info = self._get_cached_file_data(file_name)
|
||||
if file_info is None:
|
||||
self._logger.error(f"{file_name} open failed")
|
||||
return False
|
||||
|
||||
self._selected_file_info = file_info
|
||||
return True
|
||||
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
host = self._settings.get(["host"])
|
||||
access_code = self._settings.get(["access_code"])
|
||||
|
||||
file_info = self._get_cached_file_data(file_path)
|
||||
if file_info is not None:
|
||||
ftp = IoTFTPSClient(
|
||||
f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True
|
||||
)
|
||||
try:
|
||||
if ftp.delete_file(str(file_info.path)):
|
||||
self._logger.debug(f"{file_path} deleted")
|
||||
else:
|
||||
raise Exception("delete failed")
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Error deleting file {file_path}")
|
||||
|
||||
def _get_ftp_file_info(
|
||||
self, ftp: IoTFTPSClient, file_path: Path, existing_files: list[str]
|
||||
):
|
||||
file_size = ftp.ftps_session.size(file_path.as_posix())
|
||||
date_str = ftp.ftps_session.sendcmd(f"MDTM {file_path.as_posix()}").replace(
|
||||
"213 ", ""
|
||||
)
|
||||
filedate = (
|
||||
datetime.datetime.strptime(date_str, "%Y%m%d%H%M%S")
|
||||
.replace(tzinfo=datetime.timezone.utc)
|
||||
.timestamp()
|
||||
)
|
||||
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,
|
||||
unix_timestamp_to_m20_timestamp(int(filedate)),
|
||||
)
|
||||
|
||||
def _scan_ftp_file_list(
|
||||
self, ftp, files: list[str], existing_files: list[str]
|
||||
) -> Iterator[FileInfo]:
|
||||
for entry in files:
|
||||
file_info = self._get_ftp_file_info(ftp, Path(entry), existing_files)
|
||||
|
||||
yield file_info
|
||||
existing_files.append(file_info.file_name)
|
||||
existing_files.append(file_info.dosname)
|
||||
|
||||
def _get_existing_files_info(self):
|
||||
ftp = self._connect_ftps_server()
|
||||
|
||||
file_list = []
|
||||
file_list.extend(ftp.list_files("", ".3mf"))
|
||||
file_list.extend(ftp.list_files("cache/", ".3mf"))
|
||||
|
||||
existing_files = []
|
||||
return list(self._scan_ftp_file_list(ftp, file_list, existing_files))
|
||||
|
||||
def _connect_ftps_server(self):
|
||||
host = self._settings.get(["host"])
|
||||
access_code = self._settings.get(["access_code"])
|
||||
ftp = IoTFTPSClient(f"{host}", 990, "bblp", f"{access_code}", ssl_implicit=True)
|
||||
return ftp
|
||||
|
||||
def _get_cached_file_data(self, file_name: str) -> FileInfo | None:
|
||||
self._logger.debug(f"get data for path: {file_name}")
|
||||
|
||||
# replace if name is an alias
|
||||
file_name = Path(file_name).name
|
||||
file_name = self._file_alias_cache.get(file_name, file_name)
|
||||
|
||||
data = self._file_data_cache.get(file_name, None)
|
||||
self._logger.debug(f"get file data: {data}")
|
||||
return data
|
||||
|
||||
def _update_existing_files_info(self):
|
||||
file_info_list = self._get_existing_files_info()
|
||||
self._file_alias_cache = {
|
||||
info.dosname: info.file_name for info in file_info_list
|
||||
}
|
||||
self._file_data_cache = {info.file_name: info for info in file_info_list}
|
||||
|
||||
def _get_cached_data_by_suffix(self, file_stem: str, allowed_suffixes: list[str]):
|
||||
for suffix in allowed_suffixes:
|
||||
file_data = self._get_cached_file_data(
|
||||
Path(file_stem).with_suffix(suffix).as_posix()
|
||||
)
|
||||
if file_data is not None:
|
||||
return file_data
|
||||
return None
|
@ -64,7 +64,7 @@ class PrintingState(APrinterState):
|
||||
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.file_system.get_data_by_suffix(
|
||||
project_file_info = self._printer.file_system.project_files.get_file_by_suffix(
|
||||
task_name, [".3mf", ".gcode.3mf"]
|
||||
)
|
||||
if project_file_info is None:
|
||||
|
30
test/test_data_conversions.py
Normal file
30
test/test_data_conversions.py
Normal 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}",
|
||||
}
|
@ -1,20 +1,14 @@
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock
|
||||
import unittest.mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pybambu
|
||||
import pybambu.commands
|
||||
from octoprint_bambu_printer.printer.bambu_virtual_printer import BambuVirtualPrinter
|
||||
from octoprint_bambu_printer.printer.remote_sd_card_file_list import (
|
||||
RemoteSDCardFileList,
|
||||
)
|
||||
from octoprint_bambu_printer.printer.file_system.ftps_client import IoTFTPSClient
|
||||
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
|
||||
@ -88,9 +82,6 @@ def files_info_ftp():
|
||||
|
||||
@fixture
|
||||
def ftps_session_mock(files_info_ftp):
|
||||
with unittest.mock.patch(
|
||||
"octoprint_bambu_printer.printer.ftpsclient.ftpsclient.IoTFTPSClient"
|
||||
) as ftps_client_mock:
|
||||
ftps_session = MagicMock()
|
||||
ftps_session.size.side_effect = DictGetter(
|
||||
{file: info[0] for file, info in files_info_ftp.items()}
|
||||
@ -101,26 +92,20 @@ def ftps_session_mock(files_info_ftp):
|
||||
)
|
||||
|
||||
all_files = list(files_info_ftp.keys())
|
||||
file_registry = DictGetter(
|
||||
ftps_session.nlst.side_effect = DictGetter(
|
||||
{
|
||||
("", ".3mf"): list(
|
||||
filter(lambda f: Path(f).parent == Path("."), all_files)
|
||||
),
|
||||
("cache/", ".3mf"): list(
|
||||
"": list(filter(lambda f: Path(f).parent == Path("."), all_files))
|
||||
+ ["Mock folder"],
|
||||
"cache/": list(
|
||||
map(
|
||||
lambda f: Path(f).name,
|
||||
filter(lambda f: Path(f).parent == Path("cache/"), all_files),
|
||||
)
|
||||
),
|
||||
)
|
||||
+ ["Mock folder"],
|
||||
}
|
||||
)
|
||||
ftps_client_mock.list_files.side_effect = lambda folder, ext: file_registry(
|
||||
(folder, ext)
|
||||
)
|
||||
ftps_client_mock.ftps_session = ftps_session
|
||||
RemoteSDCardFileList._connect_ftps_server = MagicMock(
|
||||
return_value=ftps_client_mock
|
||||
)
|
||||
IoTFTPSClient.open_ftps_session = MagicMock(return_value=ftps_session)
|
||||
yield
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user