Move all ftp operations to printer file system.

This commit is contained in:
Anton Skrypnyk
2024-07-25 16:51:15 +03:00
parent 55b78cea05
commit 1f7eed6b23
16 changed files with 439 additions and 428 deletions

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,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

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_log_info(self) -> str:
return f'{self.dosname} {self.size} {self.timestamp_m20} "{self.file_name}"'
def to_dict(self):
return asdict(self)

View File

@ -0,0 +1,233 @@
"""
Based on: <https://github.com/dgonzo27/py-iot-utils>
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
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 Generator, Union
from contextlib import redirect_stdout
import io
import re
class ImplicitTLS(ftplib.FTP_TLS):
"""ftplib.FTP_TLS sub-class to support implicit SSL FTPS"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._sock = None
@property
def sock(self):
"""return socket"""
return self._sock
@sock.setter
def sock(self, value):
"""wrap and set SSL socket"""
if value is not None and not isinstance(value, ssl.SSLSocket):
value = self.context.wrap_socket(value)
self._sock = value
def ntransfercmd(self, cmd, rest=None):
conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
if self._prot_p:
conn = self.context.wrap_socket(
conn, server_hostname=self.host, session=self.sock.session
) # this is the fix
return conn, size
@dataclass
class IoTFTPSConnection:
"""iot ftps ftpsclient"""
ftps_session: ftplib.FTP | ImplicitTLS
def close(self) -> None:
"""close the current session from the ftps server"""
self.ftps_session.close()
def download_file(self, source: str, dest: str):
"""download a file to a path on the local filesystem"""
with open(dest, "wb") as file:
self.ftps_session.retrbinary(f"RETR {source}", file.write)
def upload_file(self, source: str, dest: str, callback=None) -> bool:
"""upload a file to a path inside the FTPS server"""
file_size = os.path.getsize(source)
block_size = max(file_size // 100, 8192)
rest = None
try:
# Taken from ftplib.storbinary but with custom ssl handling
# due to the shitty bambu p1p ftps server TODO fix properly.
with open(source, "rb") as fp:
self.ftps_session.voidcmd("TYPE I")
with self.ftps_session.transfercmd(f"STOR {dest}", rest) as conn:
while 1:
buf = fp.read(block_size)
if not buf:
break
conn.sendall(buf)
if callback:
callback(buf)
# shutdown ssl layer
if ftplib._SSLSocket is not None and isinstance(
conn, ftplib._SSLSocket
):
# Yeah this is suposed to be conn.unwrap
# But since we operate in prot p mode
# we can close the connection always.
# This is cursed but it works.
if "vsFTPd" in self.welcome:
conn.unwrap()
else:
conn.shutdown(socket.SHUT_RDWR)
return True
except Exception as ex:
print(f"unexpected exception occurred: {ex}")
pass
return False
def delete_file(self, path: str) -> bool:
"""delete a file from under a path inside the FTPS server"""
try:
self.ftps_session.delete(path)
return True
except Exception as ex:
print(f"unexpected exception occurred: {ex}")
pass
return False
def move_file(self, source: str, dest: str):
"""move a file inside the FTPS server to another path inside the FTPS server"""
self.ftps_session.rename(source, dest)
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
) -> Generator[Path]:
"""list files under a path inside the FTPS server"""
if extensions is None:
_extension_acceptable = lambda p: True
else:
if isinstance(extensions, str):
extensions = [extensions]
_extension_acceptable = lambda p: any(s in p.suffixes for s in extensions)
try:
list_result = self.ftps_session.nlst(list_path) or []
for file_name in list_result:
path = Path(list_path) / file_name
if _extension_acceptable(path):
yield path
except Exception as ex:
print(f"unexpected exception occurred: {ex}")
def list_files_ex(self, path: str) -> Union[list[str], None]:
"""list files under a path inside the FTPS server"""
try:
f = io.StringIO()
with redirect_stdout(f):
self.ftps_session.dir(path)
s = f.getvalue()
files = []
for row in s.split("\n"):
if len(row) <= 0:
continue
attribs = row.split(" ")
match = re.search(r".*\ (\d\d\:\d\d|\d\d\d\d)\ (.*)", row)
name = ""
if match:
name = match.groups(1)[1]
else:
name = attribs[len(attribs) - 1]
file = (attribs[0], name)
files.append(file)
return files
except Exception as ex:
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

View File

@ -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
)