From 10af1d1c2ed900ab2336cb4729524f2adc9cf82e Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Mon, 5 Jan 2026 15:33:28 +0000 Subject: [PATCH 1/4] files.py: Added a credential section for users to add their username. Added a udiskie section for users to specify automount paths. Creates a symlink in ~/home/printer_data/USB that maps the username from the credential section to the corresponding udiskie automount path. Ensures the symlink is recreated when the user clicks the printer button. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moonrakerComm.py: Added JSON‑RPC API methods to support future implementations. moonrest.py: Added endpoints to interact with Moonraker over HTTP. Added the ability to send files via the _post request method. helper_methods.py: Added a method to check if a process is running. Added a method to calculate the SHA‑256 hash of a file. BlocksScreen.cfg: Added two new configuration sections. --- BlocksScreen.cfg | 8 +- BlocksScreen/helper_methods.py | 37 ++++++++- BlocksScreen/lib/files.py | 84 +++++++++++++++++++ BlocksScreen/lib/moonrakerComm.py | 27 ++++++- BlocksScreen/lib/moonrest.py | 121 ++++++++++++++++++++++++++-- BlocksScreen/lib/panels/printTab.py | 15 ++-- 6 files changed, 274 insertions(+), 18 deletions(-) diff --git a/BlocksScreen.cfg b/BlocksScreen.cfg index b5807fa0..3a03829d 100644 --- a/BlocksScreen.cfg +++ b/BlocksScreen.cfg @@ -3,4 +3,10 @@ host: localhost port: 7125 [screensaver] -timeout: 5000 \ No newline at end of file +timeout: 5000 + +[credentials] +username: + +[udiskie] +mount_path: /media/ \ No newline at end of file diff --git a/BlocksScreen/helper_methods.py b/BlocksScreen/helper_methods.py index 0dd2b2db..67968a23 100644 --- a/BlocksScreen/helper_methods.py +++ b/BlocksScreen/helper_methods.py @@ -7,14 +7,15 @@ import ctypes -import os import enum +import hashlib import logging +import os import pathlib import struct +import subprocess import typing - try: ctypes.cdll.LoadLibrary("libXext.so.6") libxext = ctypes.CDLL("libXext.so.6") @@ -324,3 +325,35 @@ def check_file_on_path( """Check if file exists on path. Returns true if file exists on that specified directory""" _filepath = os.path.join(path, filename) return os.path.exists(_filepath) + + +def is_process_running(process_name: str) -> bool: + """Verify if `process_name` is running on the local machine + + Args: + process_name (str): Process Name + + Returns: + bool: True if running, False otherwise + """ + try: + subprocess.check_output(["pgrep", process_name]) + return True + except subprocess.CalledProcessError: + return False + + +def sha256_checksum(filepath: str) -> str: + """Calculates the `SHA256` of a given file + + Args: + filepath (str): File location path + + Returns: + str: SHA256 for a given file + """ + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(8192): + h.update(chunk) + return h.hexdigest() diff --git a/BlocksScreen/lib/files.py b/BlocksScreen/lib/files.py index f080e6d7..a442a525 100644 --- a/BlocksScreen/lib/files.py +++ b/BlocksScreen/lib/files.py @@ -3,14 +3,23 @@ # from __future__ import annotations +import configparser +import getpass +import logging import os import typing +from pathlib import Path +from typing import Optional import events +from configfile import BlocksScreenConfig from events import ReceivedFileData +from helper_methods import is_process_running from lib.moonrakerComm import MoonWebSocket from PyQt6 import QtCore, QtGui, QtWidgets +_logger = logging.getLogger(name="logs/BlocksScreen.log") + class Files(QtCore.QObject): request_file_list = QtCore.pyqtSignal([], [str], name="api-get-files-list") @@ -42,6 +51,8 @@ def __init__( self.files: list = [] self.directories: list = [] self.files_metadata: dict = {} + self._current_username: Optional[str] = None + self._udiskie_mouting_path: Optional[str] = None self.request_file_list.connect(slot=self.ws.api.get_file_list) self.request_file_list[str].connect(slot=self.ws.api.get_file_list) self.request_dir_info.connect(slot=self.ws.api.get_dir_information) @@ -51,12 +62,35 @@ def __init__( self.request_files_thumbnails.connect(slot=self.ws.api.get_gcode_thumbnail) self.request_file_download.connect(slot=self.ws.api.download_file) QtWidgets.QApplication.instance().installEventFilter(self) # type: ignore + self.parse_config(parent.config) + self.check_usb_symlink_local() @property def file_list(self): """Get the current list of files""" return self.files + def parse_config(self, config: BlocksScreenConfig) -> None: + """Parses the credential configs to get the user username""" + try: + self.credentials_config = config.get_section("credentials", fallback=None) + if self.credentials_config: + self._current_username = self.credentials_config.get( + "username", parser=str, default="" + ) + except configparser.NoSectionError as e: + _logger.info("Error: %s", e) + self._current_username = "" + try: + self.udiskie = config.get_section("udiskie", fallback=None) + if self.udiskie: + self._udiskie_mouting_path = self.udiskie.get( + "mount_path", parser=str, default="" + ) + except configparser.NoSectionError as e: + _logger.info("Error: %s", e) + self._udiskie_mouting_path = "" + def handle_message_received(self, method: str, data, params: dict) -> None: """Handle file related messages received by moonraker""" if "server.files.list" in method: @@ -186,3 +220,53 @@ def event(self, a0: QtCore.QEvent) -> bool: self.handle_message_received(a0.method, a0.data, a0.params) return True return super().event(a0) + + def check_usb_symlink_local(self) -> None: + """Check if the symlink from /media/ to + ~/printer_data/gcodes/USB/ exists and create it if not""" + username = self._current_username + if self._current_username == "": + try: + username = getpass.getuser() + except Exception as e: + _logger.info("Error retrieving username: %s", e) + + home = Path.home() + if self._udiskie_mouting_path == "": + mouting_dir = Path("/media/") / username + else: + mouting_dir = Path(self._udiskie_mouting_path + username) + + dir_path = home / "printer_data/gcodes/USB/" + symlink_src = dir_path / username + if not dir_path.exists(): + try: + dir_path.mkdir(parents=True, exist_ok=True) + except PermissionError: + _logger.info( + "Permission denied — adjust directory permissions or run with elevated privileges" + ) + except Exception as e: + _logger.info("Error: %s", e.with_traceback) + + if not mouting_dir.exists(): + try: + mouting_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + _logger.info( + "Permission denied — adjust directory permissions or run with elevated privileges" + ) + except Exception as e: + _logger.info("Error: %s ", e.with_traceback) + + if not os.path.islink(symlink_src) and not is_process_running("udiskie"): + try: + os.symlink(mouting_dir, symlink_src, target_is_directory=True) + except PermissionError: + _logger.info( + "Permission denied — adjust directory permissions or run with elevated privileges" + ) + except FileNotFoundError: + _logger.info("Directory not found") + except Exception as e: + _logger.info("Error: %s", e.with_traceback) diff --git a/BlocksScreen/lib/moonrakerComm.py b/BlocksScreen/lib/moonrakerComm.py index 78fba08e..7f12cac3 100644 --- a/BlocksScreen/lib/moonrakerComm.py +++ b/BlocksScreen/lib/moonrakerComm.py @@ -69,6 +69,11 @@ def __init__(self, parent: QtCore.QObject) -> None: self.klippy_state_signal.connect(self.api.request_printer_info) _logger.info("Websocket object initialized") + @property + def moonRest(self) -> MoonRest: + """Returns the current moonrestAPI object""" + return self._moonRest + @QtCore.pyqtSlot(name="retry_wb_conn") def retry_wb_conn(self): """Retry websocket connection""" @@ -123,7 +128,7 @@ def connect(self) -> bool: ) # TODO Handle if i cannot connect to moonraker, request server.info and see if i get a result try: - _oneshot_token = self._moonRest.get_oneshot_token() + _oneshot_token = self.moonRest.get_oneshot_token() if _oneshot_token is None: raise OneShotTokenError("Unable to retrieve oneshot token") except Exception as e: @@ -362,6 +367,10 @@ def request_temperature_cached_data(self, include_monitors: bool = False): params={"include_monitors": include_monitors}, ) + def request_server_info(self): + """Requested printer information""" + return self._ws.send_request(method="server.config") + @QtCore.pyqtSlot(name="query_printer_info") def request_printer_info(self): """Requested printer information""" @@ -446,6 +455,10 @@ def restart_service(self, service): method="machine.services.restart", params={"service": service} ) + def system_info(self): + """Returns a top level System Info object containing various attributes that report info""" + return self._ws.send_request(method="machine.system_info") + @QtCore.pyqtSlot(name="firmware_restart") def firmware_restart(self): """Request Klipper firmware restart @@ -560,12 +573,12 @@ def download_file(self, root: str, filename: str): filename (str): file to download Returns: - _type_: _description_ + dict: The body of the response contains the contents of the requested file. """ if not isinstance(filename, str) or not isinstance(root, str): return False - return self._ws._moonRest.get_request(f"/server/files/{root}/{filename}") + return self._ws.moonRest.get_request(f"/server/files/{root}/{filename}") @QtCore.pyqtSlot(name="api-get-dir-info") @QtCore.pyqtSlot(str, name="api-get-dir-info") @@ -789,6 +802,14 @@ def rollback_update(self, name: str): method="machine,update.rollback", params={"name": name} ) + def get_user(self): + """Request current username""" + return self._ws.send_request(method="access.get_user") + + def get_user_list(self): + """Request users list""" + return self._ws.send_request(method="access.users.list") + def history_list(self, limit, start, since, before, order): """Request Job history list""" raise NotImplementedError diff --git a/BlocksScreen/lib/moonrest.py b/BlocksScreen/lib/moonrest.py index 1e43552a..c8891e00 100644 --- a/BlocksScreen/lib/moonrest.py +++ b/BlocksScreen/lib/moonrest.py @@ -27,8 +27,11 @@ import logging +import os +from typing import Optional import requests +from helper_methods import sha256_checksum from requests import Request, Response @@ -61,7 +64,7 @@ def build_endpoint(self): return f"http://{self._host}:{self._port}" def get_oneshot_token(self): - """Requests Moonraker API for a oneshot token to be used on + """`GET MoonrakerAPI` Requests Moonraker API for a oneshot token to be used on API key authentication Returns: @@ -77,24 +80,129 @@ def get_oneshot_token(self): else None ) + def get_download_file(self, root: str, filename: str): + """`GET MoonrakerAPI` /server/files + Retrieves file `filename` at root `root`. The `filename` must include the relative path if it is not in the root folder + + Returns: + dict: contents of the requested file from Moonraker + + """ + if root == "": + return self.get_request(method=f"server/files/{filename}") + return self.get_request(method=f"server/files/{root}/{filename}") + + def get_printer_info(self): + """`GET MoonrakerAPI` /printer/info + Get Klippy host information + + Returns: + dict: printer info from Moonraker + """ + return self.get_request(method="printer/info") + def get_server_info(self): - """GET MoonrakerAPI /server/info + """`GET MoonrakerAPI` /server/info + Query Server Info Returns: dict: server info from Moonraker """ return self.get_request(method="server/info") + def get_dir_information(self, directory: str = "", extended: bool = False) -> dict: + """`GET MoonrakerAPI` /server/files/directory?path=`directory`&extended=`extended` + Returns a list of files and subdirectories given a supplied path. Unlike `/server/files/list`, this command does not walk through subdirectories. + This request will return all files in a directory, including files in the gcodes root that do not have a valid gcode extension. + + Args: + directory (str): Path to the directory.The first part must be a registered root + extended (str): When set to true metadata will be included in the response for gcode file.Default is set to False + Returns: + dict: Returns a list of files and subdirectories given a supplied path. + Unlike /server/files/list,this command does not walk through subdirectories. + This request will return all files in a directory, + including files in the gcodes root that do not have a valid gcode extension + """ + if not isinstance(directory, str): + return False + return self.get_request( + method=f"/server/files/directory?path={directory}&extended={extended}" + ) + + def get_avaliable_files(self, root: str = "gcodes") -> dict: + """`GET MoonrakerAPI` /server/files/list?root={root} + Walks through a directory and fetches all detected files. File names include a path relative to the specified `root`. + `Note:` The gcodes root will only return files with valid gcode file extensions. + + Args: + root (str): The name of the root from which a file list should be returned + Returns: + dict: The result is an array of File Info objects: + """ + if not isinstance(root, str): + return False + return self.get_request(method=f"/server/files/directory?root={root}") + + def post_upload_file( + self, + full_path: str, + root: Optional[str] = "gcodes", + path: Optional[str] = "", + ) -> Response: + """`POST MoonrakerAPI` /server/files/upload + Upload a file with `full_path` to the moonraker server + + Args: + root (str): The root location in which to upload the file. Currently this may only be gcodes or config. Default is gcodes + filename (str): name of the file + path (str): An optional path, relative to the root, indicating a subfolder in which to save the file. If the subfolder does not exist it will be created + Returns: + str: Successful uploads will respond with a 201 response code and set the Location response header to the full path of the uploaded file + """ + if not isinstance(full_path, str): + return False + + with open(full_path, "rb") as f: + file = { + "file": (os.path.basename(full_path), f, "application/octet-stream") + } + data = { + "root": root, + "path": path, + "checksum": sha256_checksum(filepath=full_path), + } + return self.post_request( + method="server/files/upload", files=file, data=data + ) + + def post_create_directory(self, new_dir: str, root: Optional[str] = "gcodes"): + """`POST MoonrakerAPI` /server/files/directory + Creates a directory at the specified path + + Args: + new_dir (str): The path to the directory to create, including its root. Note that the parent directory must exist. Default is "gcodes" + root (Optional[str]): The root location in which to upload the file. Currently this may only be gcodes or config. Default is gcodes + Returns: + item (dict): An Item Details object describing the directory created. + action (str): A description of the action taken by the host. Will always be create_dir for this request. + + """ + data = {"path": f"{root}/{new_dir}"} + return self.post_request(method="server/files/directory", json=data) + def firmware_restart(self): - """firmware_restart - POST to /printer/firmware_restart to firmware restart Klipper + """`POST MoonrakerAPI` /printer/firmware_restart + Firmware restart to Klipper Returns: str: Returns an 'ok' from Moonraker """ return self.post_request(method="printer/firmware_restart") - def post_request(self, method, data=None, json=None, json_response=True): + def post_request( + self, method, data=None, json=None, json_response=True, files=None + ): """POST request""" return self._request( request_type="post", @@ -102,6 +210,7 @@ def post_request(self, method, data=None, json=None, json_response=True): data=data, json=json, json_response=json_response, + file=files, ) def get_request(self, method, json=True, timeout=timeout): @@ -121,6 +230,7 @@ def _request( json=None, json_response=True, timeout=timeout, + file=None, ): _url = f"{self.build_endpoint}/{method}" _headers = {"x-api-key": self._api_key} if self._api_key else {} @@ -139,6 +249,7 @@ def _request( data=data, headers=_headers, timeout=timeout, + files=file, ) if isinstance(response, Response): response.raise_for_status() diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index 65f0030d..01174962 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -2,21 +2,21 @@ import typing from functools import partial -from lib.panels.widgets.babystepPage import BabystepPage -from lib.panels.widgets.tunePage import TuneWidget +from configfile import BlocksScreenConfig, get_configparser from lib.files import Files from lib.moonrakerComm import MoonWebSocket +from lib.panels.widgets.babystepPage import BabystepPage from lib.panels.widgets.confirmPage import ConfirmWidget +from lib.panels.widgets.dialogPage import DialogPage from lib.panels.widgets.filesPage import FilesPage from lib.panels.widgets.jobStatusPage import JobStatusWidget +from lib.panels.widgets.loadPage import LoadScreen +from lib.panels.widgets.numpadPage import CustomNumpad from lib.panels.widgets.sensorsPanel import SensorsWindow -from lib.printer import Printer from lib.panels.widgets.slider_selector_page import SliderPage +from lib.panels.widgets.tunePage import TuneWidget +from lib.printer import Printer from lib.utils.blocks_button import BlocksCustomButton -from lib.panels.widgets.numpadPage import CustomNumpad -from lib.panels.widgets.loadPage import LoadScreen -from lib.panels.widgets.dialogPage import DialogPage -from configfile import BlocksScreenConfig, get_configparser from PyQt6 import QtCore, QtGui, QtWidgets @@ -237,6 +237,7 @@ def __init__( self.main_print_btn.clicked.connect( partial(self.change_page, self.indexOf(self.filesPage_widget)) ) + self.main_print_btn.clicked.connect(self.file_data.check_usb_symlink_local) self.babystepPage.run_gcode.connect(self.ws.api.run_gcode) self.run_gcode_signal.connect(self.ws.api.run_gcode) self.confirmPage_widget.on_delete.connect(self.delete_file) From cad8190c921f0b8a60715df0676bcc675134160e Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Mon, 5 Jan 2026 16:49:42 +0000 Subject: [PATCH 2/4] fix formatting --- BlocksScreen/helper_methods.py | 1 + 1 file changed, 1 insertion(+) diff --git a/BlocksScreen/helper_methods.py b/BlocksScreen/helper_methods.py index 1ca40074..50d860b8 100644 --- a/BlocksScreen/helper_methods.py +++ b/BlocksScreen/helper_methods.py @@ -348,6 +348,7 @@ def get_file_name(filename: typing.Optional[str]) -> str: # Split and return the last path component return parts[-1] if filename else "" + def is_process_running(process_name: str) -> bool: """Verify if `process_name` is running on the local machine From 019ce92a4adf31d8a59a0a7ef700a17b00ba4220 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Mon, 5 Jan 2026 17:08:06 +0000 Subject: [PATCH 3/4] logger.py: autocreation of logs dir in case it doesnt exist --- BlocksScreen/logger.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/BlocksScreen/logger.py b/BlocksScreen/logger.py index e2aa90ca..2cd218a3 100644 --- a/BlocksScreen/logger.py +++ b/BlocksScreen/logger.py @@ -4,6 +4,7 @@ import logging.handlers import queue import threading +from pathlib import Path class QueueHandler(logging.Handler): @@ -12,7 +13,7 @@ class QueueHandler(logging.Handler): def __init__( self, queue: queue.Queue, - format: str = "'[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", + format: str = "[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", level=logging.DEBUG, ): super(QueueHandler, self).__init__() @@ -73,16 +74,16 @@ def close(self): self._thread = None -global MainLoggingHandler - - def create_logger( name: str = "log", level=logging.INFO, - format: str = "'[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", + format: str = "[%(levelname)s] | %(asctime)s | %(name)s | %(relativeCreated)6d | %(threadName)s : %(message)s", ): """Create amd return logger""" - global MainLoggingHandler + log_file_path = Path(name) + log_file_path.parent.mkdir(parents=True, exist_ok=True) + if not log_file_path.exists(): + log_file_path.touch() logger = logging.getLogger(name) logger.setLevel(level) ql = QueueListener(filename=name) From 98da8aa8cc694413f15c10203575b910b7f29ac5 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Mon, 5 Jan 2026 17:42:03 +0000 Subject: [PATCH 4/4] imports fix --- BlocksScreen/lib/panels/printTab.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BlocksScreen/lib/panels/printTab.py b/BlocksScreen/lib/panels/printTab.py index d6dbe843..d11c5519 100644 --- a/BlocksScreen/lib/panels/printTab.py +++ b/BlocksScreen/lib/panels/printTab.py @@ -6,11 +6,11 @@ from lib.files import Files from lib.moonrakerComm import MoonWebSocket from lib.panels.widgets.babystepPage import BabystepPage +from lib.panels.widgets.basePopup import BasePopup from lib.panels.widgets.confirmPage import ConfirmWidget -from lib.panels.widgets.dialogPage import DialogPage from lib.panels.widgets.filesPage import FilesPage from lib.panels.widgets.jobStatusPage import JobStatusWidget -from lib.panels.widgets.loadPage import LoadScreen +from lib.panels.widgets.loadWidget import LoadingOverlayWidget from lib.panels.widgets.numpadPage import CustomNumpad from lib.panels.widgets.sensorsPanel import SensorsWindow from lib.panels.widgets.slider_selector_page import SliderPage