diff --git a/.editorconfig b/.editorconfig index 5198504..b3230cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ end_of_line = lf [*.py] max_line_length = 88 -[*.{sh,yml,yaml}] +[*.{sh,yml,yaml,json}] indent_size = 2 \ No newline at end of file diff --git a/kiauh/components/klipper_firmware/menus/klipper_flash_menu.py b/kiauh/components/klipper_firmware/menus/klipper_flash_menu.py index 6029214..a9d1fe8 100644 --- a/kiauh/components/klipper_firmware/menus/klipper_flash_menu.py +++ b/kiauh/components/klipper_firmware/menus/klipper_flash_menu.py @@ -386,7 +386,7 @@ class KlipperSelectSDFlashBoardMenu(BaseMenu): self.flash_options.selected_baudrate = get_number_input( question="Please set the baud rate", default=250000, - min_count=0, + min_value=0, allow_go_back=True, ) KlipperFlashOverviewMenu(previous_menu=self.__class__).run() diff --git a/kiauh/components/webui_client/client_utils.py b/kiauh/components/webui_client/client_utils.py index d7c3596..6eab1ea 100644 --- a/kiauh/components/webui_client/client_utils.py +++ b/kiauh/components/webui_client/client_utils.py @@ -414,7 +414,7 @@ def get_client_port_selection( while True: _type = "Reconfigure" if reconfigure else "Configure" question = f"{_type} {client.display_name} for port" - port_input = get_number_input(question, min_count=80, default=port) + port_input = get_number_input(question, min_value=80, default=port) if port_input not in ports_in_use: client_settings: WebUiSettings = settings[client.name] diff --git a/kiauh/extensions/pretty_gcode/pretty_gcode_extension.py b/kiauh/extensions/pretty_gcode/pretty_gcode_extension.py index 732b013..0fe60ce 100644 --- a/kiauh/extensions/pretty_gcode/pretty_gcode_extension.py +++ b/kiauh/extensions/pretty_gcode/pretty_gcode_extension.py @@ -43,7 +43,7 @@ class PrettyGcodeExtension(BaseExtension): port = get_number_input( "On which port should PrettyGCode run", - min_count=0, + min_value=0, default=7136, allow_go_back=True, ) diff --git a/kiauh/extensions/spoolman/__init__.py b/kiauh/extensions/spoolman/__init__.py new file mode 100644 index 0000000..d1cff1b --- /dev/null +++ b/kiauh/extensions/spoolman/__init__.py @@ -0,0 +1,16 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2025 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from pathlib import Path + +MODULE_PATH = Path(__file__).resolve().parent +SPOOLMAN_DOCKER_IMAGE = "ghcr.io/donkie/spoolman:latest" +SPOOLMAN_DIR = Path.home().joinpath("spoolman") +SPOOLMAN_DATA_DIR = SPOOLMAN_DIR.joinpath("data") +SPOOLMAN_COMPOSE_FILE = SPOOLMAN_DIR.joinpath("docker-compose.yml") +SPOOLMAN_DEFAULT_PORT = 7912 diff --git a/kiauh/extensions/spoolman/assets/docker-compose.yml b/kiauh/extensions/spoolman/assets/docker-compose.yml new file mode 100644 index 0000000..686c6a7 --- /dev/null +++ b/kiauh/extensions/spoolman/assets/docker-compose.yml @@ -0,0 +1,14 @@ +services: + spoolman: + image: ghcr.io/donkie/spoolman:latest + restart: unless-stopped + volumes: + # Mount the host machine's ./data directory into the container's /home/app/.local/share/spoolman directory + - type: bind + source: ./data # This is where the data will be stored locally. Could also be set to for example `source: /home/pi/printer_data/spoolman`. + target: /home/app/.local/share/spoolman # Do NOT modify this line + ports: + # Map the host machine's port 7912 to the container's port 8000 + - "7912:8000" + environment: + - TZ=Europe/Stockholm # Optional, defaults to UTC diff --git a/kiauh/extensions/spoolman/metadata.json b/kiauh/extensions/spoolman/metadata.json new file mode 100644 index 0000000..6d6160f --- /dev/null +++ b/kiauh/extensions/spoolman/metadata.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "index": 11, + "module": "spoolman_extension", + "maintained_by": "dw-0", + "display_name": "Spoolman (Docker)", + "description": [ + "Filament manager for 3D printing", + "- Track your filament inventory", + "- Monitor filament usage", + "- Manage vendors, materials, and spools", + "- Integrates with Moonraker", + "\n\n", + "Note: This extension installs Spoolman using Docker. Docker must be installed on your system before installing Spoolman." + ], + "updates": true + } +} diff --git a/kiauh/extensions/spoolman/spoolman.py b/kiauh/extensions/spoolman/spoolman.py new file mode 100644 index 0000000..bedd0d6 --- /dev/null +++ b/kiauh/extensions/spoolman/spoolman.py @@ -0,0 +1,190 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2025 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # +from __future__ import annotations + +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from subprocess import CalledProcessError, run + +from components.moonraker.moonraker import Moonraker +from core.instance_manager.base_instance import BaseInstance +from core.logger import Logger +from extensions.spoolman import ( + MODULE_PATH, + SPOOLMAN_COMPOSE_FILE, + SPOOLMAN_DIR, + SPOOLMAN_DOCKER_IMAGE, +) +from utils.sys_utils import get_system_timezone + + +@dataclass +class Spoolman: + suffix: str + base: BaseInstance = field(init=False, repr=False) + dir: Path = SPOOLMAN_DIR + data_dir: Path = field(init=False) + + def __post_init__(self): + self.base: BaseInstance = BaseInstance(Moonraker, self.suffix) + self.data_dir = self.base.data_dir + + @staticmethod + def is_container_running() -> bool: + """Check if the Spoolman container is running""" + try: + result = run( + ["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "ps", "-q"], + capture_output=True, + text=True, + check=True, + ) + return bool(result.stdout.strip()) + except CalledProcessError: + return False + + @staticmethod + def is_docker_available() -> bool: + """Check if Docker is installed and available""" + try: + run(["docker", "--version"], capture_output=True, check=True) + return True + except (CalledProcessError, FileNotFoundError): + return False + + @staticmethod + def is_docker_compose_available() -> bool: + """Check if Docker Compose is installed and available""" + try: + # Try modern docker compose command + run(["docker", "compose", "version"], capture_output=True, check=True) + return True + except (CalledProcessError, FileNotFoundError): + # Try legacy docker-compose command + try: + run(["docker-compose", "--version"], capture_output=True, check=True) + return True + except (CalledProcessError, FileNotFoundError): + return False + + @staticmethod + def create_docker_compose() -> bool: + """Copy the docker-compose.yml file for Spoolman and set system timezone""" + try: + shutil.copy( + MODULE_PATH.joinpath("assets/docker-compose.yml"), + SPOOLMAN_COMPOSE_FILE, + ) + + # get system timezone + timezone = get_system_timezone() + + with open(SPOOLMAN_COMPOSE_FILE, "r") as f: + content = f.read() + + content = content.replace("TZ=Europe/Stockholm", f"TZ={timezone}") + + with open(SPOOLMAN_COMPOSE_FILE, "w") as f: + f.write(content) + + return True + except Exception as e: + Logger.print_error(f"Error creating Docker Compose file: {e}") + return False + + @staticmethod + def start_container() -> bool: + """Start the Spoolman container""" + try: + run( + ["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "up", "-d"], + check=True, + ) + return True + except CalledProcessError as e: + Logger.print_error(f"Failed to start Spoolman container: {e}") + return False + + @staticmethod + def update_container() -> bool: + """Update the Spoolman container""" + + def __get_image_id() -> str: + """Get the image ID of the Spoolman Docker image""" + try: + result = run( + ["docker", "images", "-q", SPOOLMAN_DOCKER_IMAGE], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except CalledProcessError: + raise Exception("Failed to get Spoolman Docker image ID") + + try: + old_image_id = __get_image_id() + Logger.print_status("Pulling latest Spoolman image...") + Spoolman.pull_image() + new_image_id = __get_image_id() + Logger.print_status("Tearing down old Spoolman container...") + Spoolman.tear_down_container() + Logger.print_status("Spinning up new Spoolman container...") + Spoolman.start_container() + if old_image_id != new_image_id: + Logger.print_status("Removing old Spoolman image...") + run(["docker", "rmi", old_image_id], check=True) + return True + + except CalledProcessError as e: + Logger.print_error(f"Failed to update Spoolman container: {e}") + return False + + @staticmethod + def tear_down_container() -> bool: + """Stop and remove the Spoolman container""" + try: + run( + ["docker", "compose", "-f", str(SPOOLMAN_COMPOSE_FILE), "down"], + check=True, + ) + return True + except CalledProcessError as e: + Logger.print_error(f"Failed to tear down Spoolman container: {e}") + return False + + @staticmethod + def pull_image() -> bool: + """Pull the Spoolman Docker image""" + try: + run(["docker", "pull", SPOOLMAN_DOCKER_IMAGE], check=True) + return True + except CalledProcessError as e: + Logger.print_error(f"Failed to pull Spoolman Docker image: {e}") + return False + + @staticmethod + def remove_image() -> bool: + """Remove the Spoolman Docker image""" + try: + image_exists = run( + ["docker", "images", "-q", SPOOLMAN_DOCKER_IMAGE], + capture_output=True, + text=True, + ).stdout.strip() + if not image_exists: + Logger.print_info("Spoolman Docker image not found. Nothing to remove.") + return False + + run(["docker", "rmi", SPOOLMAN_DOCKER_IMAGE], check=True) + return True + except CalledProcessError as e: + Logger.print_error(f"Failed to remove Spoolman Docker image: {e}") + return False diff --git a/kiauh/extensions/spoolman/spoolman_extension.py b/kiauh/extensions/spoolman/spoolman_extension.py new file mode 100644 index 0000000..8360928 --- /dev/null +++ b/kiauh/extensions/spoolman/spoolman_extension.py @@ -0,0 +1,344 @@ +# ======================================================================= # +# Copyright (C) 2020 - 2025 Dominik Willner # +# # +# This file is part of KIAUH - Klipper Installation And Update Helper # +# https://github.com/dw-0/kiauh # +# # +# This file may be distributed under the terms of the GNU GPLv3 license # +# ======================================================================= # + +import re +from subprocess import CalledProcessError, run +from typing import List, Tuple + +from components.moonraker.moonraker import Moonraker +from components.moonraker.services.moonraker_instance_service import ( + MoonrakerInstanceService, +) +from core.backup_manager.backup_manager import BackupManager +from core.instance_manager.instance_manager import InstanceManager +from core.logger import DialogType, Logger +from extensions.base_extension import BaseExtension +from extensions.spoolman import ( + SPOOLMAN_COMPOSE_FILE, + SPOOLMAN_DATA_DIR, + SPOOLMAN_DEFAULT_PORT, + SPOOLMAN_DIR, +) +from extensions.spoolman.spoolman import Spoolman +from utils.config_utils import ( + add_config_section, + remove_config_section, +) +from utils.fs_utils import run_remove_routines +from utils.input_utils import get_confirm, get_number_input +from utils.sys_utils import get_ipv4_addr + + +# noinspection PyMethodMayBeStatic +class SpoolmanExtension(BaseExtension): + ip: str = "" + port: int = SPOOLMAN_DEFAULT_PORT + + def install_extension(self, **kwargs) -> None: + Logger.print_status("Installing Spoolman using Docker...") + + docker_available, docker_compose_available = self.__check_docker_prereqs() + if not docker_available or not docker_compose_available: + return + + if not self.__handle_existing_installation(): + self.ip: str = get_ipv4_addr() + self.__run_setup() + + # noinspection HttpUrlsUsage + Logger.print_dialog( + DialogType.SUCCESS, + [ + "Spoolman successfully installed using Docker!", + "You can access Spoolman via the following URL:", + f"http://{self.ip}:{self.port}", + ], + center_content=True, + ) + + def update_extension(self, **kwargs) -> None: + Logger.print_status("Updating Spoolman Docker container...") + + if not SPOOLMAN_DIR.exists() or not SPOOLMAN_COMPOSE_FILE.exists(): + Logger.print_error("Spoolman installation not found or incomplete.") + return + + docker_available, docker_compose_available = self.__check_docker_prereqs() + if not docker_available or not docker_compose_available: + return + + Logger.print_status("Updating Spoolman container...") + if not Spoolman.update_container(): + return + + Logger.print_dialog( + DialogType.SUCCESS, + ["Spoolman Docker container successfully updated!"], + center_content=True, + ) + + def remove_extension(self, **kwargs) -> None: + Logger.print_status("Removing Spoolman Docker container...") + + if not SPOOLMAN_DIR.exists(): + Logger.print_info("Spoolman is not installed. Nothing to remove.") + return + + docker_available, docker_compose_available = self.__check_docker_prereqs() + if not docker_available or not docker_compose_available: + return + + # remove moonraker integration + mrsvc = MoonrakerInstanceService() + mrsvc.load_instances() + mr_instances: List[Moonraker] = mrsvc.get_all_instances() + + Logger.print_status("Removing Spoolman configuration from moonraker.conf...") + remove_config_section("spoolman", mr_instances) + + Logger.print_status("Removing Spoolman from moonraker.asvc...") + self.__remove_from_moonraker_asvc() + + # stop and remove the container if docker-compose exists + if SPOOLMAN_COMPOSE_FILE.exists(): + Logger.print_status("Stopping and removing Spoolman container...") + + if Spoolman.tear_down_container(): + Logger.print_ok("Spoolman container removed!") + else: + Logger.print_error( + "Failed to remove Spoolman container! Please remove it manually." + ) + + if Spoolman.remove_image(): + Logger.print_ok("Spoolman container and image removed!") + else: + Logger.print_error( + "Failed to remove Spoolman image! Please remove it manually." + ) + + # backup Spoolman directory to ~/spoolman_data- before removing it + try: + bm = BackupManager() + result = bm.backup_directory( + f"{SPOOLMAN_DIR.name}_data", + source=SPOOLMAN_DIR, + target=SPOOLMAN_DIR.parent, + ) + if result: + Logger.print_ok(f"Spoolman data backed up to {result}") + Logger.print_status("Removing Spoolman directory...") + if run_remove_routines(SPOOLMAN_DIR): + Logger.print_ok("Spoolman directory removed!") + else: + Logger.print_error( + "Failed to remove Spoolman directory! Please remove it manually." + ) + except Exception as e: + Logger.print_error(f"Failed to backup Spoolman directory: {e}") + Logger.print_info("Skipping Spoolman directory removal...") + + Logger.print_dialog( + DialogType.SUCCESS, + ["Spoolman successfully removed!"], + center_content=True, + ) + + def __run_setup(self) -> None: + # Create Spoolman directory and data directory + Logger.print_status("Setting up Spoolman directories...") + SPOOLMAN_DIR.mkdir(parents=True) + Logger.print_ok(f"Directory {SPOOLMAN_DIR} created!") + SPOOLMAN_DATA_DIR.mkdir(parents=True) + Logger.print_ok(f"Directory {SPOOLMAN_DATA_DIR} created!") + + # Set correct permissions for data directory + try: + Logger.print_status("Setting permissions for Spoolman data directory...") + run(["chown", "1000:1000", str(SPOOLMAN_DATA_DIR)], check=True) + Logger.print_ok("Permissions set!") + except CalledProcessError: + Logger.print_warn( + "Could not set permissions on data directory. This might cause issues." + ) + + Logger.print_status("Creating Docker Compose file...") + if Spoolman.create_docker_compose(): + Logger.print_ok("Docker Compose file created!") + else: + Logger.print_error("Failed to create Docker Compose file!") + + self.__port_config_prompt() + + Logger.print_status("Spinning up Spoolman container...") + if Spoolman.start_container(): + Logger.print_ok("Spoolman container started!") + else: + Logger.print_error("Failed to start Spoolman container!") + + if self.__add_moonraker_integration(): + Logger.print_ok("Spoolman integration added to Moonraker!") + else: + Logger.print_info("Moonraker integration skipped.") + + def __check_docker_prereqs(self) -> Tuple[bool, bool]: + # check if Docker is available + is_docker_available = Spoolman.is_docker_available() + if not is_docker_available: + Logger.print_error("Docker is not installed or not available.") + Logger.print_info( + "Please install Docker first: https://docs.docker.com/engine/install/" + ) + + # check if Docker Compose is available + is_docker_compose_available = Spoolman.is_docker_compose_available() + if not is_docker_compose_available: + Logger.print_error("Docker Compose is not installed or not available.") + + return is_docker_available, is_docker_compose_available + + def __port_config_prompt(self) -> None: + """Prompt for advanced configuration options""" + Logger.print_dialog( + DialogType.INFO, + [ + "You can configure Spoolman to run on a different port than the default. " + "Make sure you don't select a port which is already in use by " + "another application. Your input will not be validated! " + "The default port is 7912.", + ], + ) + if not get_confirm("Continue with default port 7912?", default_choice=True): + self.__set_port() + + def __set_port(self) -> None: + """Configure advanced options for Spoolman Docker container""" + port = get_number_input( + "Which port should Spoolman run on?", + default=SPOOLMAN_DEFAULT_PORT, + min_value=1024, + max_value=65535, + ) + + if port != SPOOLMAN_DEFAULT_PORT: + self.port = port + + with open(SPOOLMAN_COMPOSE_FILE, "r") as f: + content = f.read() + + port_mapping_pattern = r'"(\d+):8000"' + content = re.sub(port_mapping_pattern, f'"{port}:8000"', content) + + with open(SPOOLMAN_COMPOSE_FILE, "w") as f: + f.write(content) + + Logger.print_ok(f"Port set to {port}...") + + def __handle_existing_installation(self) -> bool: + if not (SPOOLMAN_DIR.exists() and SPOOLMAN_DIR.is_dir()): + return False + + compose_file_exists = SPOOLMAN_COMPOSE_FILE.exists() + container_running = Spoolman.is_container_running() + + if container_running and compose_file_exists: + Logger.print_info("Spoolman is already installed!") + return True + elif container_running and not compose_file_exists: + Logger.print_status( + "Spoolman container is running but Docker Compose file is missing..." + ) + if get_confirm( + "Do you want to recreate the Docker Compose file?", + default_choice=True, + ): + Spoolman.create_docker_compose() + self.__port_config_prompt() + return True + elif not container_running and compose_file_exists: + Logger.print_status( + "Docker Compose file exists but container is not running..." + ) + Spoolman.start_container() + return True + return False + + def __add_moonraker_integration(self) -> bool: + """Enable Moonraker integration for Spoolman Docker container""" + if not get_confirm("Add Moonraker integration?", default_choice=True): + return False + + Logger.print_status("Adding Spoolman integration to Moonraker...") + + # read port from the docker-compose file + port = SPOOLMAN_DEFAULT_PORT + if SPOOLMAN_COMPOSE_FILE.exists(): + with open(SPOOLMAN_COMPOSE_FILE, "r") as f: + content = f.read() + # Extract port from the port mapping + port_match = re.search(r'"(\d+):8000"', content) + if port_match: + port = port_match.group(1) + + mrsvc = MoonrakerInstanceService() + mrsvc.load_instances() + mr_instances = mrsvc.get_all_instances() + + # noinspection HttpUrlsUsage + add_config_section( + section="spoolman", + instances=mr_instances, + options=[("server", f"http://{self.ip}:{port}")], + ) + + Logger.print_status("Adding Spoolman to moonraker.asvc...") + self.__add_to_moonraker_asvc() + + InstanceManager.restart_all(mr_instances) + + return True + + def __add_to_moonraker_asvc(self) -> None: + """Add Spoolman to moonraker.asvc""" + mrsvc = MoonrakerInstanceService() + mrsvc.load_instances() + mr_instances = mrsvc.get_all_instances() + for instance in mr_instances: + asvc_path = instance.data_dir.joinpath("moonraker.asvc") + if asvc_path.exists(): + if "Spoolman" in open(asvc_path).read(): + Logger.print_info(f"Spoolman already in {asvc_path}. Skipping...") + continue + + with open(asvc_path, "a") as f: + f.write("Spoolman\n") + + Logger.print_ok(f"Spoolman added to {asvc_path}!") + + def __remove_from_moonraker_asvc(self) -> None: + """Remove Spoolman from moonraker.asvc""" + mrsvc = MoonrakerInstanceService() + mrsvc.load_instances() + mr_instances = mrsvc.get_all_instances() + for instance in mr_instances: + asvc_path = instance.data_dir.joinpath("moonraker.asvc") + if asvc_path.exists(): + if "Spoolman" not in open(asvc_path).read(): + Logger.print_info(f"Spoolman not in {asvc_path}. Skipping...") + continue + + with open(asvc_path, "r") as f: + lines = f.readlines() + + new_lines = [line for line in lines if "Spoolman" not in line] + + with open(asvc_path, "w") as f: + f.writelines(new_lines) + + Logger.print_ok(f"Spoolman removed from {asvc_path}!") diff --git a/kiauh/utils/input_utils.py b/kiauh/utils/input_utils.py index cf2a348..6b77e15 100644 --- a/kiauh/utils/input_utils.py +++ b/kiauh/utils/input_utils.py @@ -52,16 +52,16 @@ def get_confirm(question: str, default_choice=True, allow_go_back=False) -> bool def get_number_input( question: str, - min_count: int, - max_count: int | None = None, + min_value: int, + max_value: int | None = None, default: int | None = None, allow_go_back: bool = False, ) -> int | None: """ Helper method to get a number input from the user :param question: The question to display - :param min_count: The lowest allowed value - :param max_count: The highest allowed value (or None) + :param min_value: The lowest allowed value + :param max_value: The highest allowed value (or None) :param default: Optional default value :param allow_go_back: Navigate back to a previous dialog :return: Either the validated number input, or None on go_back @@ -77,7 +77,7 @@ def get_number_input( return default try: - return validate_number_input(_input, min_count, max_count) + return validate_number_input(_input, min_value, max_value) except ValueError: Logger.print_error(INVALID_CHOICE) diff --git a/kiauh/utils/sys_utils.py b/kiauh/utils/sys_utils.py index f2bcf70..a3600bd 100644 --- a/kiauh/utils/sys_utils.py +++ b/kiauh/utils/sys_utils.py @@ -359,11 +359,12 @@ def get_ipv4_addr() -> str: try: # doesn't even have to be reachable s.connect(("192.255.255.255", 1)) - return str(s.getsockname()[0]) - except Exception: - return "127.0.0.1" - finally: + ipv4: str = str(s.getsockname()[0]) s.close() + return ipv4 + except Exception: + s.close() + return "127.0.0.1" def download_file(url: str, target: Path, show_progress=True) -> None: @@ -600,3 +601,33 @@ def get_distro_info() -> Tuple[str, str]: raise ValueError("Error reading distro version!") return distro_id.lower(), distro_version + + +def get_system_timezone() -> str: + timezone = "UTC" + try: + with open("/etc/timezone", "r") as f: + timezone = f.read().strip() + except FileNotFoundError: + # fallback to reading timezone from timedatectl + try: + result = run( + ["timedatectl", "show", "--property=Timezone"], + capture_output=True, + text=True, + check=True, + ) + timezone = result.stdout.strip().split("=")[1] + except CalledProcessError: + # fallback if timedatectl fails, try reading from readlink + try: + result = run( + ["readlink", "-f", "/etc/localtime"], + capture_output=True, + text=True, + check=True, + ) + timezone = result.stdout.strip().split("zoneinfo/")[1] + except (CalledProcessError, IndexError): + Logger.print_warn("Could not determine system timezone, using UTC") + return timezone