diff --git a/.gitignore b/.gitignore index ce8ca81..efabb67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .idea .vscode +.idea +.pytest_cache +.kiauh-env *.code-workspace -klipper_repos.txt +*.iml +kiauh.cfg diff --git a/kiauh.cfg.example b/kiauh.cfg.example new file mode 100644 index 0000000..0d61335 --- /dev/null +++ b/kiauh.cfg.example @@ -0,0 +1,20 @@ +[kiauh] +backup_before_update: False + +[klipper] +repository_url: https://github.com/Klipper3d/klipper +branch: master +method: https + +[moonraker] +repository_url: https://github.com/Arksine/moonraker +branch: master +method: https + +[mainsail] +port: 80 +unstable_releases: False + +[fluidd] +port: 80 +unstable_releases: False diff --git a/kiauh.py b/kiauh.py new file mode 100644 index 0000000..ff930a4 --- /dev/null +++ b/kiauh.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 kiauh.main import main + +if __name__ == "__main__": + main() diff --git a/kiauh.sh b/kiauh.sh index ad54475..19175d1 100755 --- a/kiauh.sh +++ b/kiauh.sh @@ -12,77 +12,97 @@ set -e clear -### sourcing all additional scripts -KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" -for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done -for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done +function main() { + local python_command + local entrypoint -#===================================================# -#=================== UPDATE KIAUH ==================# -#===================================================# - -function update_kiauh() { - status_msg "Updating KIAUH ..." - - cd "${KIAUH_SRCDIR}" - git reset --hard && git pull - - ok_msg "Update complete! Please restart KIAUH." - exit 0 -} - -#===================================================# -#=================== KIAUH STATUS ==================# -#===================================================# - -function kiauh_update_avail() { - [[ ! -d "${KIAUH_SRCDIR}/.git" ]] && return - local origin head - - cd "${KIAUH_SRCDIR}" - - ### abort if not on master branch - ! git branch -a | grep -q "\* master" && return - - ### compare commit hash - git fetch -q - origin=$(git rev-parse --short=8 origin/master) - head=$(git rev-parse --short=8 HEAD) - - if [[ ${origin} != "${head}" ]]; then - echo "true" + if command -v python3 &>/dev/null; then + python_command="python3" + elif command -v python &>/dev/null; then + python_command="python" + else + echo "Python is not installed. Please install Python and try again." + exit 1 fi + + entrypoint=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") + + ${python_command} "${entrypoint}/kiauh.py" } -function kiauh_update_dialog() { - [[ ! $(kiauh_update_avail) == "true" ]] && return - top_border - echo -e "|${green} New KIAUH update available! ${white}|" - hr - echo -e "|${green} View Changelog: https://git.io/JnmlX ${white}|" - blank_line - echo -e "|${yellow} It is recommended to keep KIAUH up to date. Updates ${white}|" - echo -e "|${yellow} usually contain bugfixes, important changes or new ${white}|" - echo -e "|${yellow} features. Please consider updating! ${white}|" - bottom_border +main - local yn - read -p "${cyan}###### Do you want to update now? (Y/n):${white} " yn - while true; do - case "${yn}" in - Y|y|Yes|yes|"") - do_action "update_kiauh" - break;; - N|n|No|no) - break;; - *) - deny_action "kiauh_update_dialog";; - esac - done -} - -check_euid -init_logfile -set_globals -kiauh_update_dialog -main_menu +#### sourcing all additional scripts +#KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" +#for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done +#for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done +# +##===================================================# +##=================== UPDATE KIAUH ==================# +##===================================================# +# +#function update_kiauh() { +# status_msg "Updating KIAUH ..." +# +# cd "${KIAUH_SRCDIR}" +# git reset --hard && git pull +# +# ok_msg "Update complete! Please restart KIAUH." +# exit 0 +#} +# +##===================================================# +##=================== KIAUH STATUS ==================# +##===================================================# +# +#function kiauh_update_avail() { +# [[ ! -d "${KIAUH_SRCDIR}/.git" ]] && return +# local origin head +# +# cd "${KIAUH_SRCDIR}" +# +# ### abort if not on master branch +# ! git branch -a | grep -q "\* master" && return +# +# ### compare commit hash +# git fetch -q +# origin=$(git rev-parse --short=8 origin/master) +# head=$(git rev-parse --short=8 HEAD) +# +# if [[ ${origin} != "${head}" ]]; then +# echo "true" +# fi +#} +# +#function kiauh_update_dialog() { +# [[ ! $(kiauh_update_avail) == "true" ]] && return +# top_border +# echo -e "|${green} New KIAUH update available! ${white}|" +# hr +# echo -e "|${green} View Changelog: https://git.io/JnmlX ${white}|" +# blank_line +# echo -e "|${yellow} It is recommended to keep KIAUH up to date. Updates ${white}|" +# echo -e "|${yellow} usually contain bugfixes, important changes or new ${white}|" +# echo -e "|${yellow} features. Please consider updating! ${white}|" +# bottom_border +# +# local yn +# read -p "${cyan}###### Do you want to update now? (Y/n):${white} " yn +# while true; do +# case "${yn}" in +# Y|y|Yes|yes|"") +# do_action "update_kiauh" +# break;; +# N|n|No|no) +# break;; +# *) +# deny_action "kiauh_update_dialog";; +# esac +# done +#} +# +#check_euid +#init_logfile +#set_globals +#kiauh_update_dialog +#main_menu diff --git a/kiauh/__init__.py b/kiauh/__init__.py new file mode 100644 index 0000000..f93f7de --- /dev/null +++ b/kiauh/__init__.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +KIAUH_CFG = PROJECT_ROOT.joinpath("kiauh.cfg") + +APPLICATION_ROOT = Path(__file__).resolve().parent +sys.path.append(str(APPLICATION_ROOT)) diff --git a/kiauh/components/__init__.py b/kiauh/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/components/klipper/__init__.py b/kiauh/components/klipper/__init__.py new file mode 100644 index 0000000..9f46783 --- /dev/null +++ b/kiauh/components/klipper/__init__.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +from core.backup_manager import BACKUP_ROOT_DIR + +MODULE_PATH = Path(__file__).resolve().parent + +KLIPPER_DIR = Path.home().joinpath("klipper") +KLIPPER_ENV_DIR = Path.home().joinpath("klippy-env") +KLIPPER_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("klipper-backups") +KLIPPER_REQUIREMENTS_TXT = KLIPPER_DIR.joinpath("scripts/klippy-requirements.txt") +DEFAULT_KLIPPER_REPO_URL = "https://github.com/Klipper3D/klipper" + +EXIT_KLIPPER_SETUP = "Exiting Klipper setup ..." diff --git a/kiauh/components/klipper/assets/klipper.env b/kiauh/components/klipper/assets/klipper.env new file mode 100644 index 0000000..b56553e --- /dev/null +++ b/kiauh/components/klipper/assets/klipper.env @@ -0,0 +1 @@ +KLIPPER_ARGS="%KLIPPER_DIR%/klippy/klippy.py %CFG% -I %SERIAL% -l %LOG% -a %UDS%" diff --git a/kiauh/components/klipper/assets/klipper.service b/kiauh/components/klipper/assets/klipper.service new file mode 100644 index 0000000..b41788f --- /dev/null +++ b/kiauh/components/klipper/assets/klipper.service @@ -0,0 +1,18 @@ +[Unit] +Description=Klipper 3D Printer Firmware SV1 +Documentation=https://www.klipper3d.org/ +After=network-online.target +Wants=udev.target + +[Install] +WantedBy=multi-user.target + +[Service] +Type=simple +User=%USER% +RemainAfterExit=yes +WorkingDirectory=%KLIPPER_DIR% +EnvironmentFile=%ENV_FILE% +ExecStart=%ENV%/bin/python $KLIPPER_ARGS +Restart=always +RestartSec=10 diff --git a/kiauh/components/klipper/assets/printer.cfg b/kiauh/components/klipper/assets/printer.cfg new file mode 100644 index 0000000..88fe7df --- /dev/null +++ b/kiauh/components/klipper/assets/printer.cfg @@ -0,0 +1,11 @@ +[mcu] +serial: /dev/serial/by-id/ + +[virtual_sdcard] +path: %GCODES_DIR% +on_error_gcode: CANCEL_PRINT + +[printer] +kinematics: none +max_velocity: 1000 +max_accel: 1000 diff --git a/kiauh/components/klipper/klipper.py b/kiauh/components/klipper/klipper.py new file mode 100644 index 0000000..05eef56 --- /dev/null +++ b/kiauh/components/klipper/klipper.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 subprocess +from pathlib import Path +from typing import List + +from components.klipper import KLIPPER_DIR, KLIPPER_ENV_DIR, MODULE_PATH +from core.instance_manager.base_instance import BaseInstance +from utils.constants import SYSTEMD +from utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class Klipper(BaseInstance): + @classmethod + def blacklist(cls) -> List[str]: + return ["None", "mcu"] + + def __init__(self, suffix: str = ""): + super().__init__(instance_type=self, suffix=suffix) + self.klipper_dir: Path = KLIPPER_DIR + self.env_dir: Path = KLIPPER_ENV_DIR + self._cfg_file = self.cfg_dir.joinpath("printer.cfg") + self._log = self.log_dir.joinpath("klippy.log") + self._serial = self.comms_dir.joinpath("klippy.serial") + self._uds = self.comms_dir.joinpath("klippy.sock") + + @property + def cfg_file(self) -> Path: + return self._cfg_file + + @property + def log(self) -> Path: + return self._log + + @property + def serial(self) -> Path: + return self._serial + + @property + def uds(self) -> Path: + return self._uds + + def create(self) -> None: + Logger.print_status("Creating new Klipper Instance ...") + service_template_path = MODULE_PATH.joinpath("assets/klipper.service") + service_file_name = self.get_service_file_name(extension=True) + service_file_target = SYSTEMD.joinpath(service_file_name) + env_template_file_path = MODULE_PATH.joinpath("assets/klipper.env") + env_file_target = self.sysd_dir.joinpath("klipper.env") + + try: + self.create_folders() + self.write_service_file( + service_template_path, service_file_target, env_file_target + ) + self.write_env_file(env_template_file_path, env_file_target) + + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error creating service file {service_file_target}: {e}" + ) + raise + except OSError as e: + Logger.print_error(f"Error creating env file {env_file_target}: {e}") + raise + + def delete(self) -> None: + service_file = self.get_service_file_name(extension=True) + service_file_path = self.get_service_file_path() + + Logger.print_status(f"Deleting Klipper Instance: {service_file}") + + try: + command = ["sudo", "rm", "-f", service_file_path] + subprocess.run(command, check=True) + Logger.print_ok(f"Service file deleted: {service_file_path}") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error deleting service file: {e}") + raise + + def write_service_file( + self, + service_template_path: Path, + service_file_target: Path, + env_file_target: Path, + ) -> None: + service_content = self._prep_service_file( + service_template_path, env_file_target + ) + command = ["sudo", "tee", service_file_target] + subprocess.run( + command, + input=service_content.encode(), + stdout=subprocess.DEVNULL, + check=True, + ) + Logger.print_ok(f"Service file created: {service_file_target}") + + def write_env_file( + self, env_template_file_path: Path, env_file_target: Path + ) -> None: + env_file_content = self._prep_env_file(env_template_file_path) + with open(env_file_target, "w") as env_file: + env_file.write(env_file_content) + Logger.print_ok(f"Env file created: {env_file_target}") + + def _prep_service_file( + self, service_template_path: Path, env_file_path: Path + ) -> str: + try: + with open(service_template_path, "r") as template_file: + template_content = template_file.read() + except FileNotFoundError: + Logger.print_error( + f"Unable to open {service_template_path} - File not found" + ) + raise + service_content = template_content.replace("%USER%", self.user) + service_content = service_content.replace( + "%KLIPPER_DIR%", str(self.klipper_dir) + ) + service_content = service_content.replace("%ENV%", str(self.env_dir)) + service_content = service_content.replace("%ENV_FILE%", str(env_file_path)) + return service_content + + def _prep_env_file(self, env_template_file_path: Path) -> str: + try: + with open(env_template_file_path, "r") as env_file: + env_template_file_content = env_file.read() + except FileNotFoundError: + Logger.print_error( + f"Unable to open {env_template_file_path} - File not found" + ) + raise + env_file_content = env_template_file_content.replace( + "%KLIPPER_DIR%", str(self.klipper_dir) + ) + env_file_content = env_file_content.replace( + "%CFG%", f"{self.cfg_dir}/printer.cfg" + ) + env_file_content = env_file_content.replace("%SERIAL%", str(self.serial)) + env_file_content = env_file_content.replace("%LOG%", str(self.log)) + env_file_content = env_file_content.replace("%UDS%", str(self.uds)) + return env_file_content diff --git a/kiauh/components/klipper/klipper_dialogs.py b/kiauh/components/klipper/klipper_dialogs.py new file mode 100644 index 0000000..30892c5 --- /dev/null +++ b/kiauh/components/klipper/klipper_dialogs.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap +from typing import List + +from core.instance_manager.base_instance import BaseInstance +from core.menus.base_menu import print_back_footer +from utils.constants import COLOR_GREEN, RESET_FORMAT, COLOR_YELLOW, COLOR_CYAN + + +def print_instance_overview( + instances: List[BaseInstance], show_index=False, show_select_all=False +): + headline = f"{COLOR_GREEN}The following Klipper instances were found:{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + |{headline:^64}| + |-------------------------------------------------------| + """ + )[1:] + + if show_select_all: + select_all = f"{COLOR_YELLOW}a) Select all{RESET_FORMAT}" + dialog += f"| {select_all:<63}|\n" + dialog += "| |\n" + + for i, s in enumerate(instances): + line = f"{COLOR_CYAN}{f'{i})' if show_index else '●'} {s.get_service_file_name()}{RESET_FORMAT}" + dialog += f"| {line:<63}|\n" + + print(dialog, end="") + print_back_footer() + + +def print_select_instance_count_dialog(): + line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}" + line2 = f"{COLOR_YELLOW}Setting up too many instances may crash your system.{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | Please select the number of Klipper instances to set | + | up. The number of Klipper instances will determine | + | the amount of printers you can run from this host. | + | | + | {line1:<63}| + | {line2:<63}| + """ + )[1:] + + print(dialog, end="") + print_back_footer() + + +def print_select_custom_name_dialog(): + line1 = f"{COLOR_YELLOW}INFO:{RESET_FORMAT}" + line2 = f"{COLOR_YELLOW}Only alphanumeric characters are allowed!{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | You can now assign a custom name to each instance. | + | If skipped, each instance will get an index assigned | + | in ascending order, starting at index '1'. | + | | + | {line1:<63}| + | {line2:<63}| + """ + )[1:] + + print(dialog, end="") + print_back_footer() + + +def print_missing_usergroup_dialog(missing_groups) -> None: + line1 = f"{COLOR_YELLOW}WARNING: Your current user is not in group:{RESET_FORMAT}" + line2 = f"{COLOR_CYAN}● tty{RESET_FORMAT}" + line3 = f"{COLOR_CYAN}● dialout{RESET_FORMAT}" + line4 = f"{COLOR_YELLOW}INFO:{RESET_FORMAT}" + line5 = f"{COLOR_YELLOW}Relog required for group assignments to take effect!{RESET_FORMAT}" + + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | {line1:<63}| + """ + )[1:] + + if "tty" in missing_groups: + dialog += f"| {line2:<63}|\n" + if "dialout" in missing_groups: + dialog += f"| {line3:<63}|\n" + + dialog += textwrap.dedent( + f""" + | | + | It is possible that you won't be able to successfully | + | connect and/or flash the controller board without | + | your user being a member of that group. | + | If you want to add the current user to the group(s) | + | listed above, answer with 'Y'. Else skip with 'n'. | + | | + | {line4:<63}| + | {line5:<63}| + \\=======================================================/ + """ + )[1:] + + print(dialog, end="") + + +def print_update_warn_dialog() -> None: + line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}" + line2 = f"{COLOR_YELLOW}Do NOT continue if there are ongoing prints running!{RESET_FORMAT}" + line3 = f"{COLOR_YELLOW}All Klipper instances will be restarted during the {RESET_FORMAT}" + line4 = f"{COLOR_YELLOW}update process and ongoing prints WILL FAIL.{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | {line1:<63}| + | {line2:<63}| + | {line3:<63}| + | {line4:<63}| + \\=======================================================/ + """ + )[1:] + + print(dialog, end="") diff --git a/kiauh/components/klipper/klipper_remove.py b/kiauh/components/klipper/klipper_remove.py new file mode 100644 index 0000000..d802d4b --- /dev/null +++ b/kiauh/components/klipper/klipper_remove.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +from typing import List, Union + +from components.klipper import KLIPPER_DIR, KLIPPER_ENV_DIR +from components.klipper.klipper import Klipper +from components.klipper.klipper_dialogs import print_instance_overview +from core.instance_manager.instance_manager import InstanceManager +from utils.filesystem_utils import remove_file +from utils.input_utils import get_selection_input +from utils.logger import Logger + + +def run_klipper_removal( + remove_service: bool, + remove_dir: bool, + remove_env: bool, + delete_logs: bool, +) -> None: + im = InstanceManager(Klipper) + + if remove_service: + Logger.print_status("Removing Klipper instances ...") + if im.instances: + instances_to_remove = select_instances_to_remove(im.instances) + remove_instances(im, instances_to_remove) + else: + Logger.print_info("No Klipper Services installed! Skipped ...") + + if (remove_dir or remove_env) and im.instances: + Logger.print_warn("There are still other Klipper services installed!") + Logger.print_warn("Therefor the following parts cannot be removed:") + Logger.print_warn( + """ + ● Klipper local repository + ● Klipper Python environment + """, + False, + ) + else: + if remove_dir: + Logger.print_status("Removing Klipper local repository ...") + remove_klipper_dir() + if remove_env: + Logger.print_status("Removing Klipper Python environment ...") + remove_klipper_env() + + # delete klipper logs of all instances + if delete_logs: + Logger.print_status("Removing all Klipper logs ...") + delete_klipper_logs(im.instances) + + +def select_instances_to_remove( + instances: List[Klipper], +) -> Union[List[Klipper], None]: + print_instance_overview(instances, True, True) + + options = [str(i) for i in range(len(instances))] + options.extend(["a", "A", "b", "B"]) + + selection = get_selection_input("Select Klipper instance to remove", options) + + instances_to_remove = [] + if selection == "b".lower(): + return None + elif selection == "a".lower(): + instances_to_remove.extend(instances) + else: + instance = instances[int(selection)] + instances_to_remove.append(instance) + + return instances_to_remove + + +def remove_instances( + instance_manager: InstanceManager, + instance_list: List[Klipper], +) -> None: + for instance in instance_list: + Logger.print_status(f"Removing instance {instance.get_service_file_name()} ...") + instance_manager.current_instance = instance + instance_manager.stop_instance() + instance_manager.disable_instance() + instance_manager.delete_instance() + + instance_manager.reload_daemon() + + +def remove_klipper_dir() -> None: + if not KLIPPER_DIR.exists(): + Logger.print_info(f"'{KLIPPER_DIR}' does not exist. Skipped ...") + return + + try: + shutil.rmtree(KLIPPER_DIR) + except OSError as e: + Logger.print_error(f"Unable to delete '{KLIPPER_DIR}':\n{e}") + + +def remove_klipper_env() -> None: + if not KLIPPER_ENV_DIR.exists(): + Logger.print_info(f"'{KLIPPER_ENV_DIR}' does not exist. Skipped ...") + return + + try: + shutil.rmtree(KLIPPER_ENV_DIR) + except OSError as e: + Logger.print_error(f"Unable to delete '{KLIPPER_ENV_DIR}':\n{e}") + + +def delete_klipper_logs(instances: List[Klipper]) -> None: + all_logfiles = [] + for instance in instances: + all_logfiles = list(instance.log_dir.glob("klippy.log*")) + if not all_logfiles: + Logger.print_info("No Klipper logs found. Skipped ...") + return + + for log in all_logfiles: + Logger.print_status(f"Remove '{log}'") + remove_file(log) diff --git a/kiauh/components/klipper/klipper_setup.py b/kiauh/components/klipper/klipper_setup.py new file mode 100644 index 0000000..724d16a --- /dev/null +++ b/kiauh/components/klipper/klipper_setup.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +from components.webui_client.client_utils import get_existing_client_config +from kiauh import KIAUH_CFG +from components.klipper import ( + EXIT_KLIPPER_SETUP, + DEFAULT_KLIPPER_REPO_URL, + KLIPPER_DIR, + KLIPPER_ENV_DIR, + KLIPPER_REQUIREMENTS_TXT, +) +from components.klipper.klipper import Klipper +from components.klipper.klipper_dialogs import print_update_warn_dialog +from components.klipper.klipper_utils import ( + handle_disruptive_system_packages, + check_user_groups, + handle_to_multi_instance_conversion, + create_example_printer_cfg, + add_to_existing, + get_install_count, + init_name_scheme, + check_is_single_to_multi_conversion, + update_name_scheme, + handle_instance_naming, + backup_klipper_dir, +) +from components.moonraker.moonraker import Moonraker +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.instance_manager import InstanceManager +from core.repo_manager.repo_manager import RepoManager +from utils.input_utils import get_confirm +from utils.logger import Logger +from utils.system_utils import ( + parse_packages_from_file, + create_python_venv, + install_python_requirements, + update_system_package_lists, + install_system_packages, +) + + +def install_klipper() -> None: + kl_im = InstanceManager(Klipper) + + # ask to add new instances, if there are existing ones + if kl_im.instances and not add_to_existing(): + Logger.print_status(EXIT_KLIPPER_SETUP) + return + + install_count = get_install_count() + if install_count is None: + Logger.print_status(EXIT_KLIPPER_SETUP) + return + + # create a dict of the size of the existing instances + install count + name_dict = {c: "" for c in range(len(kl_im.instances) + install_count)} + name_scheme = init_name_scheme(kl_im.instances, install_count) + mr_im = InstanceManager(Moonraker) + name_scheme = update_name_scheme( + name_scheme, name_dict, kl_im.instances, mr_im.instances + ) + + handle_instance_naming(name_dict, name_scheme) + + create_example_cfg = get_confirm("Create example printer.cfg?") + + try: + if not kl_im.instances: + setup_klipper_prerequesites() + + count = 0 + for name in name_dict: + if name_dict[name] in [n.suffix for n in kl_im.instances]: + continue + + if check_is_single_to_multi_conversion(kl_im.instances): + handle_to_multi_instance_conversion(name_dict[name]) + continue + + count += 1 + create_klipper_instance(name_dict[name], create_example_cfg) + + if count == install_count: + break + + kl_im.reload_daemon() + + except Exception: + Logger.print_error("Klipper installation failed!") + return + + # step 4: check/handle conflicting packages/services + handle_disruptive_system_packages() + + # step 5: check for required group membership + check_user_groups() + + +def setup_klipper_prerequesites() -> None: + cm = ConfigManager(cfg_file=KIAUH_CFG) + repo = str(cm.get_value("klipper", "repository_url") or DEFAULT_KLIPPER_REPO_URL) + branch = str(cm.get_value("klipper", "branch") or "master") + + repo_manager = RepoManager( + repo=repo, + branch=branch, + target_dir=KLIPPER_DIR, + ) + repo_manager.clone_repo() + + # install klipper dependencies and create python virtualenv + try: + install_klipper_packages(KLIPPER_DIR) + create_python_venv(KLIPPER_ENV_DIR) + install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQUIREMENTS_TXT) + except Exception: + Logger.print_error("Error during installation of Klipper requirements!") + raise + + +def install_klipper_packages(klipper_dir: Path) -> None: + script = klipper_dir.joinpath("scripts/install-debian.sh") + packages = parse_packages_from_file(script) + packages = [pkg.replace("python-dev", "python3-dev") for pkg in packages] + packages.append("python3-venv") + # Add dfu-util for octopi-images + packages.append("dfu-util") + # Add dbus requirement for DietPi distro + if Path("/boot/dietpi/.version").exists(): + packages.append("dbus") + + update_system_package_lists(silent=False) + install_system_packages(packages) + + +def update_klipper() -> None: + print_update_warn_dialog() + if not get_confirm("Update Klipper now?"): + return + + cm = ConfigManager(cfg_file=KIAUH_CFG) + if cm.get_value("kiauh", "backup_before_update"): + backup_klipper_dir() + + instance_manager = InstanceManager(Klipper) + instance_manager.stop_all_instance() + + repo = str(cm.get_value("klipper", "repository_url") or DEFAULT_KLIPPER_REPO_URL) + branch = str(cm.get_value("klipper", "branch") or "master") + + repo_manager = RepoManager( + repo=repo, + branch=branch, + target_dir=KLIPPER_DIR, + ) + repo_manager.pull_repo() + + # install possible new system packages + install_klipper_packages(KLIPPER_DIR) + # install possible new python dependencies + install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQUIREMENTS_TXT) + + instance_manager.start_all_instance() + + +def create_klipper_instance(name: str, create_example_cfg: bool) -> None: + kl_im = InstanceManager(Klipper) + new_instance = Klipper(suffix=name) + kl_im.current_instance = new_instance + kl_im.create_instance() + kl_im.enable_instance() + if create_example_cfg: + # if a client-config is installed, include it in the new example cfg + client_configs = get_existing_client_config() + create_example_printer_cfg(new_instance, client_configs) + kl_im.start_instance() diff --git a/kiauh/components/klipper/klipper_utils.py b/kiauh/components/klipper/klipper_utils.py new file mode 100644 index 0000000..dfc749b --- /dev/null +++ b/kiauh/components/klipper/klipper_utils.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 grp +import os +import re +import shutil +import subprocess +import textwrap +from pathlib import Path +from typing import List, Union, Literal, Dict, Optional + +from components.klipper import ( + MODULE_PATH, + KLIPPER_DIR, + KLIPPER_ENV_DIR, + KLIPPER_BACKUP_DIR, +) +from components.klipper.klipper import Klipper +from components.klipper.klipper_dialogs import ( + print_missing_usergroup_dialog, + print_instance_overview, + print_select_instance_count_dialog, + print_select_custom_name_dialog, +) +from components.moonraker.moonraker import Moonraker +from components.moonraker.moonraker_utils import moonraker_to_multi_conversion +from components.webui_client import ClientData +from core.backup_manager.backup_manager import BackupManager +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.base_instance import BaseInstance +from core.instance_manager.instance_manager import InstanceManager +from core.instance_manager.name_scheme import NameScheme +from core.repo_manager.repo_manager import RepoManager +from utils.common import get_install_status_common +from utils.constants import CURRENT_USER +from utils.input_utils import get_confirm, get_string_input, get_number_input +from utils.logger import Logger +from utils.system_utils import mask_system_service + + +def get_klipper_status() -> Dict[ + Literal["status", "status_code", "instances", "repo", "local", "remote"], + Union[str, int], +]: + status = get_install_status_common(Klipper, KLIPPER_DIR, KLIPPER_ENV_DIR) + return { + "status": status.get("status"), + "status_code": status.get("status_code"), + "instances": status.get("instances"), + "repo": RepoManager.get_repo_name(KLIPPER_DIR), + "local": RepoManager.get_local_commit(KLIPPER_DIR), + "remote": RepoManager.get_remote_commit(KLIPPER_DIR), + } + + +def check_is_multi_install( + existing_instances: List[Klipper], install_count: int +) -> bool: + return not existing_instances and install_count > 1 + + +def check_is_single_to_multi_conversion(existing_instances: List[Klipper]) -> bool: + return len(existing_instances) == 1 and existing_instances[0].suffix == "" + + +def init_name_scheme( + existing_instances: List[Klipper], install_count: int +) -> NameScheme: + if check_is_multi_install( + existing_instances, install_count + ) or check_is_single_to_multi_conversion(existing_instances): + print_select_custom_name_dialog() + if get_confirm("Assign custom names?", False, allow_go_back=True): + return NameScheme.CUSTOM + else: + return NameScheme.INDEX + else: + return NameScheme.SINGLE + + +def update_name_scheme( + name_scheme: NameScheme, + name_dict: Dict[int, str], + klipper_instances: List[Klipper], + moonraker_instances: List[Moonraker], +) -> NameScheme: + # if there are more moonraker instances installed than klipper, we + # load their names into the name_dict, as we will detect and enforce that naming scheme + if len(moonraker_instances) > len(klipper_instances): + update_name_dict(name_dict, moonraker_instances) + return detect_name_scheme(moonraker_instances) + elif len(klipper_instances) > 1: + update_name_dict(name_dict, klipper_instances) + return detect_name_scheme(klipper_instances) + else: + return name_scheme + + +def update_name_dict(name_dict: Dict[int, str], instances: List[BaseInstance]) -> None: + for k, v in enumerate(instances): + name_dict[k] = v.suffix + + +def handle_instance_naming(name_dict: Dict[int, str], name_scheme: NameScheme) -> None: + if name_scheme == NameScheme.SINGLE: + return + + for k in name_dict: + if name_dict[k] == "" and name_scheme == NameScheme.INDEX: + name_dict[k] = str(k + 1) + elif name_dict[k] == "" and name_scheme == NameScheme.CUSTOM: + assign_custom_name(k, name_dict) + + +def add_to_existing() -> bool: + kl_instances = InstanceManager(Klipper).instances + print_instance_overview(kl_instances) + return get_confirm("Add new instances?", allow_go_back=True) + + +def get_install_count() -> Union[int, None]: + """ + Print a dialog for selecting the amount of Klipper instances + to set up with an option to navigate back. Returns None if the + user selected to go back, otherwise an integer greater or equal than 1 | + :return: Integer >= 1 or None + """ + kl_instances = InstanceManager(Klipper).instances + print_select_instance_count_dialog() + question = f"Number of{' additional' if len(kl_instances) > 0 else ''} Klipper instances to set up" + return get_number_input(question, 1, default=1, allow_go_back=True) + + +def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None: + existing_names = [] + existing_names.extend(Klipper.blacklist()) + existing_names.extend(name_dict[n] for n in name_dict) + question = f"Enter name for instance {key + 1}" + name_dict[key] = get_string_input(question, exclude=existing_names) + + +def handle_to_multi_instance_conversion(new_name: str) -> None: + Logger.print_status("Converting single instance to multi instances ...") + klipper_to_multi_conversion(new_name) + moonraker_to_multi_conversion(new_name) + + +def klipper_to_multi_conversion(new_name: str) -> None: + Logger.print_status("Convert Klipper single to multi instance ...") + im = InstanceManager(Klipper) + im.current_instance = im.instances[0] + # temporarily store the data dir path + old_data_dir = im.instances[0].data_dir + # remove the old single instance + im.stop_instance() + im.disable_instance() + im.delete_instance() + # create a new klipper instance with the new name + im.current_instance = Klipper(suffix=new_name) + new_data_dir: Path = im.current_instance.data_dir + + # rename the old data dir and use it for the new instance + Logger.print_status(f"Rename '{old_data_dir}' to '{new_data_dir}' ...") + if not new_data_dir.is_dir(): + old_data_dir.rename(new_data_dir) + else: + Logger.print_info(f"'{new_data_dir}' already exist. Skipped ...") + + im.create_instance() + im.enable_instance() + im.start_instance() + + +def check_user_groups(): + current_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()] + + missing_groups = [] + if "tty" not in current_groups: + missing_groups.append("tty") + if "dialout" not in current_groups: + missing_groups.append("dialout") + + if not missing_groups: + return + + print_missing_usergroup_dialog(missing_groups) + if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"): + log = "Skipped adding user to required groups. You might encounter issues." + Logger.warn(log) + return + + try: + for group in missing_groups: + Logger.print_status(f"Adding user '{CURRENT_USER}' to group {group} ...") + command = ["sudo", "usermod", "-a", "-G", group, CURRENT_USER] + subprocess.run(command, check=True) + Logger.print_ok(f"Group {group} assigned to user '{CURRENT_USER}'.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Unable to add user to usergroups: {e}") + raise + + log = "Remember to relog/restart this machine for the group(s) to be applied!" + Logger.print_warn(log) + + +def handle_disruptive_system_packages() -> None: + services = [] + + command = ["systemctl", "is-enabled", "brltty"] + brltty_status = subprocess.run(command, capture_output=True, text=True) + + command = ["systemctl", "is-enabled", "brltty-udev"] + brltty_udev_status = subprocess.run(command, capture_output=True, text=True) + + command = ["systemctl", "is-enabled", "ModemManager"] + modem_manager_status = subprocess.run(command, capture_output=True, text=True) + + if "enabled" in brltty_status.stdout: + services.append("brltty") + if "enabled" in brltty_udev_status.stdout: + services.append("brltty-udev") + if "enabled" in modem_manager_status.stdout: + services.append("ModemManager") + + for service in services if services else []: + try: + log = f"{service} service detected! Masking {service} service ..." + Logger.print_status(log) + mask_system_service(service) + Logger.print_ok(f"{service} service masked!") + except subprocess.CalledProcessError: + warn_msg = textwrap.dedent( + f""" + KIAUH was unable to mask the {service} system service. + Please fix the problem manually. Otherwise, this may have + undesirable effects on the operation of Klipper. + """ + )[1:] + Logger.print_warn(warn_msg) + + +def detect_name_scheme(instance_list: List[BaseInstance]) -> NameScheme: + pattern = re.compile("^\d+$") + for instance in instance_list: + if not pattern.match(instance.suffix): + return NameScheme.CUSTOM + + return NameScheme.INDEX + + +def get_highest_index(instance_list: List[Klipper]) -> int: + indices = [int(instance.suffix.split("-")[-1]) for instance in instance_list] + return max(indices) + + +def create_example_printer_cfg( + instance: Klipper, client_configs: Optional[List[ClientData]] = None +) -> None: + Logger.print_status(f"Creating example printer.cfg in '{instance.cfg_dir}'") + if instance.cfg_file.is_file(): + Logger.print_info(f"'{instance.cfg_file}' already exists.") + return + + source = MODULE_PATH.joinpath("assets/printer.cfg") + target = instance.cfg_file + try: + shutil.copy(source, target) + except OSError as e: + Logger.print_error(f"Unable to create example printer.cfg:\n{e}") + return + + cm = ConfigManager(target) + cm.set_value("virtual_sdcard", "path", str(instance.gcodes_dir)) + + # include existing client configs in the example config + if client_configs is not None and len(client_configs) > 0: + for c in client_configs: + section = c.get("client_config").get("printer_cfg_section") + cm.config.add_section(section=section) + + cm.write_config() + + Logger.print_ok(f"Example printer.cfg created in '{instance.cfg_dir}'") + + +def backup_klipper_dir() -> None: + bm = BackupManager() + bm.backup_directory("klipper", source=KLIPPER_DIR, target=KLIPPER_BACKUP_DIR) + bm.backup_directory("klippy-env", source=KLIPPER_ENV_DIR, target=KLIPPER_BACKUP_DIR) diff --git a/kiauh/components/klipper/menus/__init__.py b/kiauh/components/klipper/menus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/components/klipper/menus/klipper_remove_menu.py b/kiauh/components/klipper/menus/klipper_remove_menu.py new file mode 100644 index 0000000..dc92b33 --- /dev/null +++ b/kiauh/components/klipper/menus/klipper_remove_menu.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper import klipper_remove +from core.menus import BACK_HELP_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN + + +# noinspection PyUnusedLocal +class KlipperRemoveMenu(BaseMenu): + def __init__(self): + super().__init__( + header=False, + options={ + "0": self.toggle_all, + "1": self.toggle_remove_klipper_service, + "2": self.toggle_remove_klipper_dir, + "3": self.toggle_remove_klipper_env, + "4": self.toggle_delete_klipper_logs, + "c": self.run_removal_process, + }, + footer_type=BACK_HELP_FOOTER, + ) + self.remove_klipper_service = False + self.remove_klipper_dir = False + self.remove_klipper_env = False + self.delete_klipper_logs = False + + def print_menu(self) -> None: + header = " [ Remove Klipper ] " + color = COLOR_RED + count = 62 - len(color) - len(RESET_FORMAT) + checked = f"[{COLOR_CYAN}x{RESET_FORMAT}]" + unchecked = "[ ]" + o1 = checked if self.remove_klipper_service else unchecked + o2 = checked if self.remove_klipper_dir else unchecked + o3 = checked if self.remove_klipper_env else unchecked + o4 = checked if self.delete_klipper_logs else unchecked + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | Enter a number and hit enter to select / deselect | + | the specific option for removal. | + |-------------------------------------------------------| + | 0) Select everything | + |-------------------------------------------------------| + | 1) {o1} Remove Service | + | 2) {o2} Remove Local Repository | + | 3) {o3} Remove Python Environment | + | 4) {o4} Delete all Log-Files | + |-------------------------------------------------------| + | C) Continue | + """ + )[1:] + print(menu, end="") + + def toggle_all(self, **kwargs) -> None: + self.remove_klipper_service = True + self.remove_klipper_dir = True + self.remove_klipper_env = True + self.delete_klipper_logs = True + + def toggle_remove_klipper_service(self, **kwargs) -> None: + self.remove_klipper_service = not self.remove_klipper_service + + def toggle_remove_klipper_dir(self, **kwargs) -> None: + self.remove_klipper_dir = not self.remove_klipper_dir + + def toggle_remove_klipper_env(self, **kwargs) -> None: + self.remove_klipper_env = not self.remove_klipper_env + + def toggle_delete_klipper_logs(self, **kwargs) -> None: + self.delete_klipper_logs = not self.delete_klipper_logs + + def run_removal_process(self, **kwargs) -> None: + if ( + not self.remove_klipper_service + and not self.remove_klipper_dir + and not self.remove_klipper_env + and not self.delete_klipper_logs + ): + error = f"{COLOR_RED}Nothing selected! Select options to remove first.{RESET_FORMAT}" + print(error) + return + + klipper_remove.run_klipper_removal( + self.remove_klipper_service, + self.remove_klipper_dir, + self.remove_klipper_env, + self.delete_klipper_logs, + ) + + self.remove_klipper_service = False + self.remove_klipper_dir = False + self.remove_klipper_env = False + self.delete_klipper_logs = False diff --git a/kiauh/components/log_uploads/__init__.py b/kiauh/components/log_uploads/__init__.py new file mode 100644 index 0000000..7672138 --- /dev/null +++ b/kiauh/components/log_uploads/__init__.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 +from typing import Dict, Union, Literal + +FileKey = Literal["filepath", "display_name"] +LogFile = Dict[FileKey, Union[str, Path]] diff --git a/kiauh/components/log_uploads/log_upload_utils.py b/kiauh/components/log_uploads/log_upload_utils.py new file mode 100644 index 0000000..daa67e4 --- /dev/null +++ b/kiauh/components/log_uploads/log_upload_utils.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 urllib.request +from pathlib import Path +from typing import List + +from components.klipper.klipper import Klipper +from components.log_uploads import LogFile +from core.instance_manager.instance_manager import InstanceManager +from utils.logger import Logger + + +def get_logfile_list() -> List[LogFile]: + cm = InstanceManager(Klipper) + log_dirs: List[Path] = [instance.log_dir for instance in cm.instances] + + logfiles: List[LogFile] = [] + for _dir in log_dirs: + for f in _dir.iterdir(): + logfiles.append({"filepath": f, "display_name": get_display_name(f)}) + + return logfiles + + +def get_display_name(filepath: Path) -> str: + printer = " ".join(filepath.parts[-3].split("_")[:-1]) + name = filepath.name + + return f"{printer}: {name}" + + +def upload_logfile(logfile: LogFile) -> None: + file = logfile.get("filepath") + name = logfile.get("display_name") + Logger.print_status(f"Uploading the following logfile from {name} ...") + + with open(file, "rb") as f: + headers = {"x-random": ""} + req = urllib.request.Request("http://paste.c-net.org/", headers=headers, data=f) + try: + response = urllib.request.urlopen(req) + link = response.read().decode("utf-8") + Logger.print_ok("Upload successfull! Access it via the following link:") + Logger.print_ok(f">>>> {link}", False) + except Exception as e: + Logger.print_error(f"Uploading logfile failed!") + Logger.print_error(str(e)) diff --git a/kiauh/components/log_uploads/menus/log_upload_menu.py b/kiauh/components/log_uploads/menus/log_upload_menu.py new file mode 100644 index 0000000..baccce5 --- /dev/null +++ b/kiauh/components/log_uploads/menus/log_upload_menu.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.log_uploads.log_upload_utils import get_logfile_list +from components.log_uploads.log_upload_utils import upload_logfile +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import RESET_FORMAT, COLOR_YELLOW + + +# noinspection PyMethodMayBeStatic +class LogUploadMenu(BaseMenu): + def __init__(self): + self.logfile_list = get_logfile_list() + options = {index: self.upload for index in range(len(self.logfile_list))} + super().__init__( + header=True, + options=options, + footer_type=BACK_FOOTER, + ) + + def print_menu(self): + header = " [ Log Upload ] " + color = COLOR_YELLOW + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | You can select the following logfiles for uploading: | + | | + """ + )[1:] + + logfile_list = get_logfile_list() + for logfile in enumerate(logfile_list): + line = f"{logfile[0]}) {logfile[1].get('display_name')}" + menu += f"| {line:<54}|\n" + + print(menu, end="") + + def upload(self, **kwargs): + upload_logfile(self.logfile_list[kwargs.get("opt_index")]) diff --git a/kiauh/components/moonraker/__init__.py b/kiauh/components/moonraker/__init__.py new file mode 100644 index 0000000..9341992 --- /dev/null +++ b/kiauh/components/moonraker/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +from core.backup_manager import BACKUP_ROOT_DIR + +MODULE_PATH = Path(__file__).resolve().parent + +MOONRAKER_DIR = Path.home().joinpath("moonraker") +MOONRAKER_ENV_DIR = Path.home().joinpath("moonraker-env") +MOONRAKER_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("moonraker-backups") +MOONRAKER_DB_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("moonraker-db-backups") +MOONRAKER_REQUIREMENTS_TXT = MOONRAKER_DIR.joinpath( + "scripts/moonraker-requirements.txt" +) +DEFAULT_MOONRAKER_REPO_URL = "https://github.com/Arksine/moonraker" +DEFAULT_MOONRAKER_PORT = 7125 + +# introduced due to +# https://github.com/Arksine/moonraker/issues/349 +# https://github.com/Arksine/moonraker/pull/346 +POLKIT_LEGACY_FILE = Path("/etc/polkit-1/localauthority/50-local.d/10-moonraker.pkla") +POLKIT_FILE = Path("/etc/polkit-1/rules.d/moonraker.rules") +POLKIT_USR_FILE = Path("/usr/share/polkit-1/rules.d/moonraker.rules") +POLKIT_SCRIPT = Path.home().joinpath("moonraker/scripts/set-policykit-rules.sh") + +EXIT_MOONRAKER_SETUP = "Exiting Moonraker setup ..." diff --git a/kiauh/components/moonraker/assets/moonraker.conf b/kiauh/components/moonraker/assets/moonraker.conf new file mode 100644 index 0000000..d985233 --- /dev/null +++ b/kiauh/components/moonraker/assets/moonraker.conf @@ -0,0 +1,29 @@ +[server] +host: 0.0.0.0 +port: %PORT% +klippy_uds_address: %UDS% + +[authorization] +trusted_clients: + 10.0.0.0/8 + 127.0.0.0/8 + 169.254.0.0/16 + 172.16.0.0/12 + 192.168.0.0/16 + FE80::/10 + ::1/128 +cors_domains: + *.lan + *.local + *://localhost + *://localhost:* + *://my.mainsail.xyz + *://app.fluidd.xyz + +[octoprint_compat] + +[history] + +[update_manager] +channel: dev +refresh_interval: 168 diff --git a/kiauh/components/moonraker/assets/moonraker.env b/kiauh/components/moonraker/assets/moonraker.env new file mode 100644 index 0000000..bca6af5 --- /dev/null +++ b/kiauh/components/moonraker/assets/moonraker.env @@ -0,0 +1 @@ +MOONRAKER_ARGS="%MOONRAKER_DIR%/moonraker/moonraker.py -d %PRINTER_DATA%" \ No newline at end of file diff --git a/kiauh/components/moonraker/assets/moonraker.service b/kiauh/components/moonraker/assets/moonraker.service new file mode 100644 index 0000000..696d7ba --- /dev/null +++ b/kiauh/components/moonraker/assets/moonraker.service @@ -0,0 +1,19 @@ +[Unit] +Description=API Server for Klipper SV1 +Documentation=https://moonraker.readthedocs.io/ +Requires=network-online.target +After=network-online.target + +[Install] +WantedBy=multi-user.target + +[Service] +Type=simple +User=%USER% +SupplementaryGroups=moonraker-admin +RemainAfterExit=yes +WorkingDirectory=%MOONRAKER_DIR% +EnvironmentFile=%ENV_FILE% +ExecStart=%ENV%/bin/python $MOONRAKER_ARGS +Restart=always +RestartSec=10 diff --git a/kiauh/components/moonraker/menus/__init__.py b/kiauh/components/moonraker/menus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/components/moonraker/menus/moonraker_remove_menu.py b/kiauh/components/moonraker/menus/moonraker_remove_menu.py new file mode 100644 index 0000000..f740b45 --- /dev/null +++ b/kiauh/components/moonraker/menus/moonraker_remove_menu.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.moonraker import moonraker_remove +from core.menus import BACK_HELP_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN + + +# noinspection PyUnusedLocal +class MoonrakerRemoveMenu(BaseMenu): + def __init__(self): + super().__init__( + header=False, + options={ + "0": self.toggle_all, + "1": self.toggle_remove_moonraker_service, + "2": self.toggle_remove_moonraker_dir, + "3": self.toggle_remove_moonraker_env, + "4": self.toggle_remove_moonraker_polkit, + "5": self.toggle_delete_moonraker_logs, + "c": self.run_removal_process, + }, + footer_type=BACK_HELP_FOOTER, + ) + self.remove_moonraker_service = False + self.remove_moonraker_dir = False + self.remove_moonraker_env = False + self.remove_moonraker_polkit = False + self.delete_moonraker_logs = False + + def print_menu(self) -> None: + header = " [ Remove Moonraker ] " + color = COLOR_RED + count = 62 - len(color) - len(RESET_FORMAT) + checked = f"[{COLOR_CYAN}x{RESET_FORMAT}]" + unchecked = "[ ]" + o1 = checked if self.remove_moonraker_service else unchecked + o2 = checked if self.remove_moonraker_dir else unchecked + o3 = checked if self.remove_moonraker_env else unchecked + o4 = checked if self.remove_moonraker_polkit else unchecked + o5 = checked if self.delete_moonraker_logs else unchecked + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | Enter a number and hit enter to select / deselect | + | the specific option for removal. | + |-------------------------------------------------------| + | 0) Select everything | + |-------------------------------------------------------| + | 1) {o1} Remove Service | + | 2) {o2} Remove Local Repository | + | 3) {o3} Remove Python Environment | + | 4) {o4} Remove Policy Kit Rules | + | 5) {o5} Delete all Log-Files | + |-------------------------------------------------------| + | C) Continue | + """ + )[1:] + print(menu, end="") + + def toggle_all(self, **kwargs) -> None: + self.remove_moonraker_service = True + self.remove_moonraker_dir = True + self.remove_moonraker_env = True + self.remove_moonraker_polkit = True + self.delete_moonraker_logs = True + + def toggle_remove_moonraker_service(self, **kwargs) -> None: + self.remove_moonraker_service = not self.remove_moonraker_service + + def toggle_remove_moonraker_dir(self, **kwargs) -> None: + self.remove_moonraker_dir = not self.remove_moonraker_dir + + def toggle_remove_moonraker_env(self, **kwargs) -> None: + self.remove_moonraker_env = not self.remove_moonraker_env + + def toggle_remove_moonraker_polkit(self, **kwargs) -> None: + self.remove_moonraker_polkit = not self.remove_moonraker_polkit + + def toggle_delete_moonraker_logs(self, **kwargs) -> None: + self.delete_moonraker_logs = not self.delete_moonraker_logs + + def run_removal_process(self, **kwargs) -> None: + if ( + not self.remove_moonraker_service + and not self.remove_moonraker_dir + and not self.remove_moonraker_env + and not self.remove_moonraker_polkit + and not self.delete_moonraker_logs + ): + error = f"{COLOR_RED}Nothing selected! Select options to remove first.{RESET_FORMAT}" + print(error) + return + + moonraker_remove.run_moonraker_removal( + self.remove_moonraker_service, + self.remove_moonraker_dir, + self.remove_moonraker_env, + self.remove_moonraker_polkit, + self.delete_moonraker_logs, + ) + + self.remove_moonraker_service = False + self.remove_moonraker_dir = False + self.remove_moonraker_env = False + self.remove_moonraker_polkit = False + self.delete_moonraker_logs = False diff --git a/kiauh/components/moonraker/moonraker.py b/kiauh/components/moonraker/moonraker.py new file mode 100644 index 0000000..aa0e03b --- /dev/null +++ b/kiauh/components/moonraker/moonraker.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 subprocess +from pathlib import Path +from typing import List, Union + +from components.moonraker import MOONRAKER_DIR, MOONRAKER_ENV_DIR, MODULE_PATH +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.base_instance import BaseInstance +from utils.constants import SYSTEMD +from utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class Moonraker(BaseInstance): + @classmethod + def blacklist(cls) -> List[str]: + return ["None", "mcu"] + + def __init__(self, suffix: str = ""): + super().__init__(instance_type=self, suffix=suffix) + self.moonraker_dir: Path = MOONRAKER_DIR + self.env_dir: Path = MOONRAKER_ENV_DIR + self.cfg_file = self.cfg_dir.joinpath("moonraker.conf") + self.port = self._get_port() + self.backup_dir = self.data_dir.joinpath("backup") + self.certs_dir = self.data_dir.joinpath("certs") + self._db_dir = self.data_dir.joinpath("database") + self.log = self.log_dir.joinpath("moonraker.log") + + @property + def db_dir(self) -> Path: + return self._db_dir + + def create(self, create_example_cfg: bool = False) -> None: + Logger.print_status("Creating new Moonraker Instance ...") + service_template_path = MODULE_PATH.joinpath("assets/moonraker.service") + env_template_file_path = MODULE_PATH.joinpath("assets/moonraker.env") + service_file_name = self.get_service_file_name(extension=True) + service_file_target = SYSTEMD.joinpath(service_file_name) + env_file_target = self.sysd_dir.joinpath("moonraker.env") + + try: + self.create_folders([self.backup_dir, self.certs_dir, self._db_dir]) + self.write_service_file( + service_template_path, service_file_target, env_file_target + ) + self.write_env_file(env_template_file_path, env_file_target) + + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error creating service file {service_file_target}: {e}" + ) + raise + except OSError as e: + Logger.print_error(f"Error writing file: {e}") + raise + + def delete(self) -> None: + service_file = self.get_service_file_name(extension=True) + service_file_path = self.get_service_file_path() + + Logger.print_status(f"Deleting Moonraker Instance: {service_file}") + + try: + command = ["sudo", "rm", "-f", service_file_path] + subprocess.run(command, check=True) + Logger.print_ok(f"Service file deleted: {service_file_path}") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error deleting service file: {e}") + raise + + def write_service_file( + self, + service_template_path: Path, + service_file_target: Path, + env_file_target: Path, + ) -> None: + service_content = self._prep_service_file( + service_template_path, env_file_target + ) + command = ["sudo", "tee", service_file_target] + subprocess.run( + command, + input=service_content.encode(), + stdout=subprocess.DEVNULL, + check=True, + ) + Logger.print_ok(f"Service file created: {service_file_target}") + + def write_env_file( + self, env_template_file_path: Path, env_file_target: Path + ) -> None: + env_file_content = self._prep_env_file(env_template_file_path) + with open(env_file_target, "w") as env_file: + env_file.write(env_file_content) + Logger.print_ok(f"Env file created: {env_file_target}") + + def _prep_service_file( + self, service_template_path: Path, env_file_path: Path + ) -> str: + try: + with open(service_template_path, "r") as template_file: + template_content = template_file.read() + except FileNotFoundError: + Logger.print_error( + f"Unable to open {service_template_path} - File not found" + ) + raise + service_content = template_content.replace("%USER%", self.user) + service_content = service_content.replace( + "%MOONRAKER_DIR%", str(self.moonraker_dir) + ) + service_content = service_content.replace("%ENV%", str(self.env_dir)) + service_content = service_content.replace("%ENV_FILE%", str(env_file_path)) + return service_content + + def _prep_env_file(self, env_template_file_path: Path) -> str: + try: + with open(env_template_file_path, "r") as env_file: + env_template_file_content = env_file.read() + except FileNotFoundError: + Logger.print_error( + f"Unable to open {env_template_file_path} - File not found" + ) + raise + env_file_content = env_template_file_content.replace( + "%MOONRAKER_DIR%", str(self.moonraker_dir) + ) + env_file_content = env_file_content.replace( + "%PRINTER_DATA%", str(self.data_dir) + ) + return env_file_content + + def _get_port(self) -> Union[int, None]: + if not self.cfg_file.is_file(): + return None + + cm = ConfigManager(cfg_file=self.cfg_file) + port = cm.get_value("server", "port") + + return int(port) if port is not None else port diff --git a/kiauh/components/moonraker/moonraker_dialogs.py b/kiauh/components/moonraker/moonraker_dialogs.py new file mode 100644 index 0000000..4f13d10 --- /dev/null +++ b/kiauh/components/moonraker/moonraker_dialogs.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap +from typing import List + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from core.menus.base_menu import print_back_footer +from utils.constants import COLOR_GREEN, RESET_FORMAT, COLOR_YELLOW, COLOR_CYAN + + +def print_moonraker_overview( + klipper_instances: List[Klipper], + moonraker_instances: List[Moonraker], + show_index=False, + show_select_all=False, +): + headline = f"{COLOR_GREEN}The following instances were found:{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + |{headline:^64}| + |-------------------------------------------------------| + """ + )[1:] + + if show_select_all: + select_all = f"{COLOR_YELLOW}a) Select all{RESET_FORMAT}" + dialog += f"| {select_all:<63}|\n" + dialog += "| |\n" + + instance_map = { + k.get_service_file_name(): ( + k.get_service_file_name().replace("klipper", "moonraker") + if k.suffix in [m.suffix for m in moonraker_instances] + else "" + ) + for k in klipper_instances + } + + for i, k in enumerate(instance_map): + mr_name = instance_map.get(k) + m = f"<-> {mr_name}" if mr_name != "" else "" + line = f"{COLOR_CYAN}{f'{i})' if show_index else '●'} {k} {m} {RESET_FORMAT}" + dialog += f"| {line:<63}|\n" + + warn_l1 = f"{COLOR_YELLOW}PLEASE NOTE: {RESET_FORMAT}" + warn_l2 = f"{COLOR_YELLOW}If you select an instance with an existing Moonraker{RESET_FORMAT}" + warn_l3 = f"{COLOR_YELLOW}instance, that Moonraker instance will be re-created!{RESET_FORMAT}" + warning = textwrap.dedent( + f""" + | | + |-------------------------------------------------------| + | {warn_l1:<63}| + | {warn_l2:<63}| + | {warn_l3:<63}| + """ + )[1:] + + dialog += warning + + print(dialog, end="") + print_back_footer() diff --git a/kiauh/components/moonraker/moonraker_remove.py b/kiauh/components/moonraker/moonraker_remove.py new file mode 100644 index 0000000..db10bf4 --- /dev/null +++ b/kiauh/components/moonraker/moonraker_remove.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +import subprocess +from typing import List, Union + +from components.klipper.klipper_dialogs import print_instance_overview +from components.moonraker import MOONRAKER_DIR, MOONRAKER_ENV_DIR +from components.moonraker.moonraker import Moonraker +from core.instance_manager.instance_manager import InstanceManager +from utils.filesystem_utils import remove_file +from utils.input_utils import get_selection_input +from utils.logger import Logger + + +def run_moonraker_removal( + remove_service: bool, + remove_dir: bool, + remove_env: bool, + remove_polkit: bool, + delete_logs: bool, +) -> None: + im = InstanceManager(Moonraker) + + if remove_service: + Logger.print_status("Removing Moonraker instances ...") + if im.instances: + instances_to_remove = select_instances_to_remove(im.instances) + remove_instances(im, instances_to_remove) + else: + Logger.print_info("No Moonraker Services installed! Skipped ...") + + if (remove_polkit or remove_dir or remove_env) and im.instances: + Logger.print_warn("There are still other Moonraker services installed!") + Logger.print_warn("Therefor the following parts cannot be removed:") + Logger.print_warn( + """ + ● Moonraker PolicyKit rules + ● Moonraker local repository + ● Moonraker Python environment + """, + False, + ) + else: + if remove_polkit: + Logger.print_status("Removing all Moonraker policykit rules ...") + remove_polkit_rules() + if remove_dir: + Logger.print_status("Removing Moonraker local repository ...") + remove_moonraker_dir() + if remove_env: + Logger.print_status("Removing Moonraker Python environment ...") + remove_moonraker_env() + + # delete moonraker logs of all instances + if delete_logs: + Logger.print_status("Removing all Moonraker logs ...") + delete_moonraker_logs(im.instances) + + +def select_instances_to_remove( + instances: List[Moonraker], +) -> Union[List[Moonraker], None]: + print_instance_overview(instances, True, True) + + options = [str(i) for i in range(len(instances))] + options.extend(["a", "A", "b", "B"]) + + selection = get_selection_input("Select Moonraker instance to remove", options) + + instances_to_remove = [] + if selection == "b".lower(): + return None + elif selection == "a".lower(): + instances_to_remove.extend(instances) + else: + instance = instances[int(selection)] + instances_to_remove.append(instance) + + return instances_to_remove + + +def remove_instances( + instance_manager: InstanceManager, + instance_list: List[Moonraker], +) -> None: + for instance in instance_list: + Logger.print_status(f"Removing instance {instance.get_service_file_name()} ...") + instance_manager.current_instance = instance + instance_manager.stop_instance() + instance_manager.disable_instance() + instance_manager.delete_instance() + + instance_manager.reload_daemon() + + +def remove_moonraker_dir() -> None: + if not MOONRAKER_DIR.exists(): + Logger.print_info(f"'{MOONRAKER_DIR}' does not exist. Skipped ...") + return + + try: + shutil.rmtree(MOONRAKER_DIR) + except OSError as e: + Logger.print_error(f"Unable to delete '{MOONRAKER_DIR}':\n{e}") + + +def remove_moonraker_env() -> None: + if not MOONRAKER_ENV_DIR.exists(): + Logger.print_info(f"'{MOONRAKER_ENV_DIR}' does not exist. Skipped ...") + return + + try: + shutil.rmtree(MOONRAKER_ENV_DIR) + except OSError as e: + Logger.print_error(f"Unable to delete '{MOONRAKER_ENV_DIR}':\n{e}") + + +def remove_polkit_rules() -> None: + if not MOONRAKER_DIR.exists(): + log = "Cannot remove policykit rules. Moonraker directory not found." + Logger.print_warn(log) + return + + try: + command = [f"{MOONRAKER_DIR}/scripts/set-policykit-rules.sh", "--clear"] + subprocess.run( + command, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, check=True + ) + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error while removing policykit rules: {e}") + + Logger.print_ok("Policykit rules successfully removed!") + + +def delete_moonraker_logs(instances: List[Moonraker]) -> None: + all_logfiles = [] + for instance in instances: + all_logfiles = list(instance.log_dir.glob("moonraker.log*")) + if not all_logfiles: + Logger.print_info("No Moonraker logs found. Skipped ...") + return + + for log in all_logfiles: + Logger.print_status(f"Remove '{log}'") + remove_file(log) diff --git a/kiauh/components/moonraker/moonraker_setup.py b/kiauh/components/moonraker/moonraker_setup.py new file mode 100644 index 0000000..c7b80ac --- /dev/null +++ b/kiauh/components/moonraker/moonraker_setup.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 subprocess +import sys +from pathlib import Path +from typing import List + +from components.webui_client import MAINSAIL_DIR +from components.webui_client.client_utils import enable_mainsail_remotemode, get_existing_clients +from kiauh import KIAUH_CFG +from components.klipper.klipper import Klipper +from components.klipper.klipper_dialogs import print_instance_overview +from components.moonraker import ( + EXIT_MOONRAKER_SETUP, + DEFAULT_MOONRAKER_REPO_URL, + MOONRAKER_DIR, + MOONRAKER_ENV_DIR, + MOONRAKER_REQUIREMENTS_TXT, + POLKIT_LEGACY_FILE, + POLKIT_FILE, + POLKIT_USR_FILE, + POLKIT_SCRIPT, +) +from components.moonraker.moonraker import Moonraker +from components.moonraker.moonraker_dialogs import print_moonraker_overview +from components.moonraker.moonraker_utils import ( + create_example_moonraker_conf, + backup_moonraker_dir, +) +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.instance_manager import InstanceManager +from core.repo_manager.repo_manager import RepoManager +from utils.filesystem_utils import check_file_exist +from utils.input_utils import ( + get_confirm, + get_selection_input, +) +from utils.logger import Logger +from utils.system_utils import ( + parse_packages_from_file, + create_python_venv, + install_python_requirements, + update_system_package_lists, + install_system_packages, +) + + +def install_moonraker() -> None: + if not check_moonraker_install_requirements(): + return + + kl_im = InstanceManager(Klipper) + klipper_instances = kl_im.instances + mr_im = InstanceManager(Moonraker) + moonraker_instances = mr_im.instances + + selected_klipper_instance = 0 + if len(klipper_instances) > 1: + print_moonraker_overview( + klipper_instances, + moonraker_instances, + show_index=True, + show_select_all=True, + ) + options = [str(i) for i in range(len(klipper_instances))] + options.extend(["a", "A", "b", "B"]) + question = "Select Klipper instance to setup Moonraker for" + selected_klipper_instance = get_selection_input(question, options).lower() + + instance_names = [] + if selected_klipper_instance == "b": + Logger.print_status(EXIT_MOONRAKER_SETUP) + return + + elif selected_klipper_instance == "a": + for instance in klipper_instances: + instance_names.append(instance.suffix) + + else: + index = int(selected_klipper_instance) + instance_names.append(klipper_instances[index].suffix) + + create_example_cfg = get_confirm("Create example moonraker.conf?") + setup_moonraker_prerequesites() + install_moonraker_polkit() + + used_ports_map = { + instance.suffix: instance.port for instance in moonraker_instances + } + for name in instance_names: + current_instance = Moonraker(suffix=name) + + mr_im.current_instance = current_instance + mr_im.create_instance() + mr_im.enable_instance() + + if create_example_cfg: + # if a webclient and/or it's config is installed, patch its update section to the config + clients = get_existing_clients() + create_example_moonraker_conf(current_instance, used_ports_map, clients) + + mr_im.start_instance() + + mr_im.reload_daemon() + + # if mainsail is installed, and we installed + # multiple moonraker instances, we enable mainsails remote mode + if MAINSAIL_DIR.exists() and len(mr_im.instances) > 1: + enable_mainsail_remotemode() + + +def check_moonraker_install_requirements() -> bool: + if not (sys.version_info.major >= 3 and sys.version_info.minor >= 7): + Logger.print_error("Versioncheck failed!") + Logger.print_error("Python 3.7 or newer required to run Moonraker.") + return False + + kl_instance_count = len(InstanceManager(Klipper).instances) + if kl_instance_count < 1: + Logger.print_warn("Klipper not installed!") + Logger.print_warn("Moonraker cannot be installed! Install Klipper first.") + return False + + mr_instance_count = len(InstanceManager(Moonraker).instances) + if mr_instance_count >= kl_instance_count: + Logger.print_warn("Unable to install more Moonraker instances!") + Logger.print_warn("More Klipper instances required.") + return False + + return True + + +def setup_moonraker_prerequesites() -> None: + cm = ConfigManager(cfg_file=KIAUH_CFG) + repo = str( + cm.get_value("moonraker", "repository_url") or DEFAULT_MOONRAKER_REPO_URL + ) + branch = str(cm.get_value("moonraker", "branch") or "master") + + repo_manager = RepoManager( + repo=repo, + branch=branch, + target_dir=MOONRAKER_DIR, + ) + repo_manager.clone_repo() + + # install moonraker dependencies and create python virtualenv + install_moonraker_packages(MOONRAKER_DIR) + create_python_venv(MOONRAKER_ENV_DIR) + install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQUIREMENTS_TXT) + + +def install_moonraker_packages(moonraker_dir: Path) -> None: + script = moonraker_dir.joinpath("scripts/install-moonraker.sh") + packages = parse_packages_from_file(script) + update_system_package_lists(silent=False) + install_system_packages(packages) + + +def install_moonraker_polkit() -> None: + Logger.print_status("Installing Moonraker policykit rules ...") + + legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True) + polkit_file_exists = check_file_exist(POLKIT_FILE, True) + usr_file_exists = check_file_exist(POLKIT_USR_FILE, True) + + if legacy_file_exists or (polkit_file_exists and usr_file_exists): + Logger.print_info("Moonraker policykit rules are already installed.") + return + + try: + command = [POLKIT_SCRIPT, "--disable-systemctl"] + result = subprocess.run( + command, stderr=subprocess.PIPE, stdout=subprocess.DEVNULL, text=True + ) + if result.returncode != 0 or result.stderr: + Logger.print_error(f"{result.stderr}", False) + Logger.print_error("Installing Moonraker policykit rules failed!") + return + + Logger.print_ok("Moonraker policykit rules successfully installed!") + except subprocess.CalledProcessError as e: + log = f"Error while installing Moonraker policykit rules: {e.stderr.decode()}" + Logger.print_error(log) + + +def handle_existing_instances(instance_list: List[Klipper]) -> bool: + instance_count = len(instance_list) + + if instance_count > 0: + print_instance_overview(instance_list) + if not get_confirm("Add new instances?", allow_go_back=True): + return False + + return True + + +def update_moonraker() -> None: + if not get_confirm("Update Moonraker now?"): + return + + cm = ConfigManager(cfg_file=KIAUH_CFG) + if cm.get_value("kiauh", "backup_before_update"): + backup_moonraker_dir() + + instance_manager = InstanceManager(Moonraker) + instance_manager.stop_all_instance() + + repo = str( + cm.get_value("moonraker", "repository_url") or DEFAULT_MOONRAKER_REPO_URL + ) + branch = str(cm.get_value("moonraker", "branch") or "master") + + repo_manager = RepoManager( + repo=repo, + branch=branch, + target_dir=MOONRAKER_DIR, + ) + repo_manager.pull_repo() + + # install possible new system packages + install_moonraker_packages(MOONRAKER_DIR) + # install possible new python dependencies + install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQUIREMENTS_TXT) + + instance_manager.start_all_instance() diff --git a/kiauh/components/moonraker/moonraker_utils.py b/kiauh/components/moonraker/moonraker_utils.py new file mode 100644 index 0000000..e7ea965 --- /dev/null +++ b/kiauh/components/moonraker/moonraker_utils.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +from typing import Dict, Literal, List, Union, Optional + +from components.moonraker import ( + DEFAULT_MOONRAKER_PORT, + MODULE_PATH, + MOONRAKER_DIR, + MOONRAKER_ENV_DIR, + MOONRAKER_BACKUP_DIR, + MOONRAKER_DB_BACKUP_DIR, +) +from components.moonraker.moonraker import Moonraker +from components.webui_client import MAINSAIL_DIR, ClientData +from components.webui_client.client_utils import enable_mainsail_remotemode +from core.backup_manager.backup_manager import BackupManager +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.instance_manager import InstanceManager +from core.repo_manager.repo_manager import RepoManager +from utils.common import get_install_status_common +from utils.logger import Logger +from utils.system_utils import ( + get_ipv4_addr, +) + + +def get_moonraker_status() -> Dict[ + Literal["status", "status_code", "instances", "repo", "local", "remote"], + Union[str, int], +]: + status = get_install_status_common(Moonraker, MOONRAKER_DIR, MOONRAKER_ENV_DIR) + return { + "status": status.get("status"), + "status_code": status.get("status_code"), + "instances": status.get("instances"), + "repo": RepoManager.get_repo_name(MOONRAKER_DIR), + "local": RepoManager.get_local_commit(MOONRAKER_DIR), + "remote": RepoManager.get_remote_commit(MOONRAKER_DIR), + } + + +def create_example_moonraker_conf( + instance: Moonraker, + ports_map: Dict[str, int], + clients: Optional[List[ClientData]] = None, +) -> None: + Logger.print_status(f"Creating example moonraker.conf in '{instance.cfg_dir}'") + if instance.cfg_file.is_file(): + Logger.print_info(f"'{instance.cfg_file}' already exists.") + return + + source = MODULE_PATH.joinpath("assets/moonraker.conf") + target = instance.cfg_file + try: + shutil.copy(source, target) + except OSError as e: + Logger.print_error(f"Unable to create example moonraker.conf:\n{e}") + return + + ports = [ + ports_map.get(instance) + for instance in ports_map + if ports_map.get(instance) is not None + ] + if ports_map.get(instance.suffix) is None: + # this could be improved to not increment the max value of the ports list and assign it as the port + # as it can lead to situation where the port for e.g. instance moonraker-2 becomes 7128 if the port + # of moonraker-1 is 7125 and moonraker-3 is 7127 and there are moonraker.conf files for moonraker-1 + # and moonraker-3 already. though, there does not seem to be a very reliable way of always assigning + # the correct port to each instance and the user will likely be required to correct the value manually. + port = max(ports) + 1 if ports else DEFAULT_MOONRAKER_PORT + else: + port = ports_map.get(instance.suffix) + + ports_map[instance.suffix] = port + + ip = get_ipv4_addr().split(".")[:2] + ip.extend(["0", "0/16"]) + uds = instance.comms_dir.joinpath("klippy.sock") + + cm = ConfigManager(target) + trusted_clients = f"\n{'.'.join(ip)}" + trusted_clients += cm.get_value("authorization", "trusted_clients") + + cm.set_value("server", "port", str(port)) + cm.set_value("server", "klippy_uds_address", str(uds)) + cm.set_value("authorization", "trusted_clients", trusted_clients) + + # add existing client and client configs in the update section + if clients is not None and len(clients) > 0: + for c in clients: + # client part + c_section = f"update_manager {c.get('name')}" + c_options = [ + ("type", "web"), + ("channel", "stable"), + ("repo", c.get("mr_conf_repo")), + ("path", c.get("mr_conf_path")), + ] + cm.config.add_section(section=c_section) + for option in c_options: + cm.config.set(c_section, option[0], option[1]) + + # client config part + c_config = c.get("client_config") + if c_config.get("dir").exists(): + c_config_section = f"update_manager {c_config.get('name')}" + c_config_options = [ + ("type", "git_repo"), + ("primary_branch", "master"), + ("path", c_config.get("mr_conf_path")), + ("origin", c_config.get("mr_conf_origin")), + ("managed_services", "klipper"), + ] + cm.config.add_section(section=c_config_section) + for option in c_config_options: + cm.config.set(c_config_section, option[0], option[1]) + + cm.write_config() + Logger.print_ok(f"Example moonraker.conf created in '{instance.cfg_dir}'") + + +def moonraker_to_multi_conversion(new_name: str) -> None: + """ + Converts the first instance in the List of Moonraker instances to an instance + with a new name. This method will be called when converting from a single Klipper + instance install to a multi instance install when Moonraker is also already + installed with a single instance. + :param new_name: new name the previous single instance is renamed to + :return: None + """ + im = InstanceManager(Moonraker) + instances: List[Moonraker] = im.instances + if not instances: + return + + # in case there are multiple Moonraker instances, we don't want to do anything + if len(instances) > 1: + Logger.print_info("More than a single Moonraker instance found. Skipped ...") + return + + Logger.print_status("Convert Moonraker single to multi instance ...") + # remove the old single instance + im.current_instance = im.instances[0] + im.stop_instance() + im.disable_instance() + im.delete_instance() + # create a new klipper instance with the new name + im.current_instance = Moonraker(suffix=new_name) + # create, enable and start the new moonraker instance + im.create_instance() + im.enable_instance() + im.start_instance() + + # if mainsail is installed, we enable mainsails remote mode + if MAINSAIL_DIR.exists() and len(im.instances) > 1: + enable_mainsail_remotemode() + + +def backup_moonraker_dir(): + bm = BackupManager() + bm.backup_directory("moonraker", source=MOONRAKER_DIR, target=MOONRAKER_BACKUP_DIR) + bm.backup_directory( + "moonraker-env", source=MOONRAKER_ENV_DIR, target=MOONRAKER_BACKUP_DIR + ) + + +def backup_moonraker_db_dir() -> None: + im = InstanceManager(Moonraker) + instances: List[Moonraker] = im.instances + bm = BackupManager() + + for instance in instances: + name = f"database-{instance.data_dir_name}" + bm.backup_directory( + name, source=instance.db_dir, target=MOONRAKER_DB_BACKUP_DIR + ) diff --git a/kiauh/components/webui_client/__init__.py b/kiauh/components/webui_client/__init__.py new file mode 100644 index 0000000..b0d44f3 --- /dev/null +++ b/kiauh/components/webui_client/__init__.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 +from typing import Literal, TypedDict, Set + +from core.backup_manager import BACKUP_ROOT_DIR + +MODULE_PATH = Path(__file__).resolve().parent + +########### +# MAINSAIL +########### +MAINSAIL_DIR = Path.home().joinpath("mainsail") +MAINSAIL_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("mainsail-backups") +MAINSAIL_CONFIG_DIR = Path.home().joinpath("mainsail-config") +MAINSAIL_CONFIG_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("mainsail-config-backups") +MAINSAIL_CONFIG_REPO_URL = "https://github.com/mainsail-crew/mainsail-config.git" +MAINSAIL_CONFIG_JSON = MAINSAIL_DIR.joinpath("config.json") +MAINSAIL_URL = ( + "https://github.com/mainsail-crew/mainsail/releases/latest/download/mainsail.zip" +) +MAINSAIL_PRE_RLS_URL = ( + "https://github.com/mainsail-crew/mainsail/releases/download/%TAG%/mainsail.zip" +) +MAINSAIL_TAGS_URL = "https://api.github.com/repos/mainsail-crew/mainsail/tags" + +######### +# FLUIDD +######### +FLUIDD_DIR = Path.home().joinpath("fluidd") +FLUIDD_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("fluidd-backups") +FLUIDD_CONFIG_DIR = Path.home().joinpath("fluidd-config") +FLUIDD_CONFIG_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("fluidd-config-backups") +FLUIDD_CONFIG_REPO_URL = "https://github.com/fluidd-core/fluidd-config.git" +FLUIDD_URL = "https://github.com/fluidd-core/fluidd/releases/latest/download/fluidd.zip" +FLUIDD_PRE_RLS_URL = ( + "https://github.com/fluidd-core/fluidd/releases/download/%TAG%/fluidd.zip" +) +FLUIDD_TAGS_URL = "https://api.github.com/repos/fluidd-core/fluidd/tags" + +ClientName = Literal["mainsail", "fluidd"] +ClientConfigName = Literal["mainsail-config", "fluidd-config"] + + +class ClientData(TypedDict): + name: ClientName + display_name: str + dir: Path + backup_dir: Path + url: str + pre_release_url: str + tags_url: str + remote_mode: bool # required only for Mainsail + mr_conf_repo: str + mr_conf_path: str + client_config: "ClientConfigData" + + +class ClientConfigData(TypedDict): + name: ClientConfigName + display_name: str + cfg_filename: str + dir: Path + backup_dir: Path + url: str + printer_cfg_section: str + mr_conf_path: str + mr_conf_origin: str diff --git a/kiauh/components/webui_client/client_config/__init__.py b/kiauh/components/webui_client/client_config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/components/webui_client/client_config/client_config_remove.py b/kiauh/components/webui_client/client_config/client_config_remove.py new file mode 100644 index 0000000..af5fe27 --- /dev/null +++ b/kiauh/components/webui_client/client_config/client_config_remove.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +import subprocess +from typing import List + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from components.webui_client import ClientConfigData +from core.instance_manager.instance_manager import InstanceManager +from utils.filesystem_utils import remove_file, remove_config_section +from utils.logger import Logger + + +def run_client_config_removal( + client_config: ClientConfigData, + remove_moonraker_conf_section: bool, + remove_printer_cfg_include: bool, + kl_instances: List[Klipper], + mr_instances: List[Moonraker], +) -> None: + remove_client_config_dir(client_config) + remove_client_config_symlink(client_config) + if remove_moonraker_conf_section: + remove_config_section( + f"update_manager {client_config.get('name')}", mr_instances + ) + if remove_printer_cfg_include: + remove_config_section(client_config.get("printer_cfg_section"), kl_instances) + + +def remove_client_config_dir(client_config: ClientConfigData) -> None: + Logger.print_status(f"Removing {client_config.get('name')} ...") + client_config_dir = client_config.get("dir") + if not client_config_dir.exists(): + Logger.print_info(f"'{client_config_dir}' does not exist. Skipping ...") + return + + try: + shutil.rmtree(client_config_dir) + except OSError as e: + Logger.print_error(f"Unable to delete '{client_config_dir}':\n{e}") + + +def remove_client_config_symlink(client_config: ClientConfigData) -> None: + im = InstanceManager(Klipper) + instances: List[Klipper] = im.instances + for instance in instances: + Logger.print_status(f"Removing symlink from '{instance.cfg_file}' ...") + symlink = instance.cfg_dir.joinpath(client_config.get("cfg_filename")) + if not symlink.exists(): + Logger.print_info(f"'{symlink}' does not exist. Skipping ...") + continue + + try: + remove_file(symlink) + except subprocess.CalledProcessError: + Logger.print_error("Failed to remove symlink!") diff --git a/kiauh/components/webui_client/client_config/client_config_setup.py b/kiauh/components/webui_client/client_config/client_config_setup.py new file mode 100644 index 0000000..13f1062 --- /dev/null +++ b/kiauh/components/webui_client/client_config/client_config_setup.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +import subprocess +from pathlib import Path +from typing import List + +from kiauh import KIAUH_CFG +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from components.webui_client import ClientConfigData, ClientName, ClientData +from components.webui_client.client_dialogs import print_client_already_installed_dialog +from components.webui_client.client_utils import ( + load_client_data, + backup_client_config_data, config_for_other_client_exist, + ) +from core.config_manager.config_manager import ConfigManager + +from core.instance_manager.instance_manager import InstanceManager +from core.repo_manager.repo_manager import RepoManager +from utils.filesystem_utils import ( + create_symlink, + add_config_section, +) +from utils.input_utils import get_confirm +from utils.logger import Logger + + +def install_client_config(client_name: ClientName) -> None: + client: ClientData = load_client_data(client_name) + client_config: ClientConfigData = client.get("client_config") + d_name = client_config.get("display_name") + + if config_for_other_client_exist(client_name): + Logger.print_info("Another Client-Config is already installed! Skipped ...") + return + + if client_config.get("dir").exists(): + print_client_already_installed_dialog(d_name) + if get_confirm(f"Re-install {d_name}?", allow_go_back=True): + shutil.rmtree(client_config.get("dir")) + else: + return + + mr_im = InstanceManager(Moonraker) + mr_instances: List[Moonraker] = mr_im.instances + kl_im = InstanceManager(Klipper) + kl_instances = kl_im.instances + + try: + download_client_config(client_config) + create_client_config_symlink(client_config, kl_instances) + add_config_section( + section=f"update_manager {client_config.get('name')}", + instances=mr_instances, + options=[ + ("type", "git_repo"), + ("primary_branch", "master"), + ("path", client_config.get("mr_conf_path")), + ("origin", client_config.get("mr_conf_origin")), + ("managed_services", "klipper"), + ], + ) + add_config_section(client_config.get("printer_cfg_section"), kl_instances) + kl_im.restart_all_instance() + + except Exception as e: + Logger.print_error(f"{d_name} installation failed!\n{e}") + return + + Logger.print_ok(f"{d_name} installation complete!", start="\n") + + +def download_client_config(client_config: ClientConfigData) -> None: + try: + Logger.print_status(f"Downloading {client_config.get('display_name')} ...") + rm = RepoManager( + client_config.get("url"), target_dir=str(client_config.get("dir")) + ) + rm.clone_repo() + except Exception: + Logger.print_error(f"Downloading {client_config.get('display_name')} failed!") + raise + + +def update_client_config(client: ClientData) -> None: + client_config: ClientConfigData = client.get("client_config") + + Logger.print_status(f"Updating {client_config.get('display_name')} ...") + + cm = ConfigManager(cfg_file=KIAUH_CFG) + if cm.get_value("kiauh", "backup_before_update"): + backup_client_config_data(client) + + repo_manager = RepoManager( + repo=client_config.get("url"), + branch="master", + target_dir=str(client_config.get("dir")), + ) + repo_manager.pull_repo() + + Logger.print_ok(f"Successfully updated {client_config.get('display_name')}.") + Logger.print_warn("Remember to restart Klipper to reload the configurations!") + + +def create_client_config_symlink( + client_config: ClientConfigData, klipper_instances: List[Klipper] = None +) -> None: + if klipper_instances is None: + kl_im = InstanceManager(Klipper) + klipper_instances = kl_im.instances + + Logger.print_status(f"Create symlink for {client_config.get('cfg_filename')} ...") + source = Path(client_config.get("dir"), client_config.get("cfg_filename")) + for instance in klipper_instances: + target = instance.cfg_dir + Logger.print_status(f"Linking {source} to {target}") + try: + create_symlink(source, target) + except subprocess.CalledProcessError: + Logger.print_error("Creating symlink failed!") diff --git a/kiauh/components/webui_client/client_dialogs.py b/kiauh/components/webui_client/client_dialogs.py new file mode 100644 index 0000000..1053a58 --- /dev/null +++ b/kiauh/components/webui_client/client_dialogs.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap +from typing import List + +from components.webui_client import ClientData +from core.menus.base_menu import print_back_footer +from utils.constants import RESET_FORMAT, COLOR_YELLOW, COLOR_CYAN + + +def print_moonraker_not_found_dialog(): + line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}" + line2 = f"{COLOR_YELLOW}No local Moonraker installation was found!{RESET_FORMAT}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | {line1:<63}| + | {line2:<63}| + |-------------------------------------------------------| + | It is possible to install Mainsail without a local | + | Moonraker installation. If you continue, you need to | + | make sure, that Moonraker is installed on another | + | machine in your network. Otherwise Mainsail will NOT | + | work correctly. | + """ + )[1:] + + print(dialog, end="") + print_back_footer() + + +def print_client_already_installed_dialog(name: str): + line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}" + line2 = f"{COLOR_YELLOW}{name} seems to be already installed!{RESET_FORMAT}" + line3 = f"If you continue, your current {name}" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | {line1:<63}| + | {line2:<63}| + |-------------------------------------------------------| + | {line3:<54}| + | installation will be overwritten. | + """ + )[1:] + + print(dialog, end="") + print_back_footer() + + +def print_client_port_select_dialog(name: str, port: str, ports_in_use: List[str]): + port = f"{COLOR_CYAN}{port}{RESET_FORMAT}" + line1 = f"Please select the port, {name} should be served on." + line2 = f"In case you need {name} to be served on a specific" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | {line1:<54}| + | If you are unsure what to select, hit Enter to apply | + | the suggested value of: {port:38} | + | | + | {line2:<54}| + | port, you can set it now. Make sure the port is not | + | used by any other application on your system! | + """ + )[1:] + + if len(ports_in_use) > 0: + dialog += "|-------------------------------------------------------|\n" + dialog += "| The following ports were found to be in use already: |\n" + for port in ports_in_use: + port = f"{COLOR_CYAN}● {port}{RESET_FORMAT}" + dialog += f"| {port:60} |\n" + + dialog += "\\=======================================================/\n" + + print(dialog, end="") + + +def print_install_client_config_dialog(client: ClientData): + name = client.get("display_name") + url = client.get("client_config").get("url").replace(".git", "") + line1 = f"have {name} fully functional and working." + line2 = f"The recommended macros for {name} can be seen here:" + dialog = textwrap.dedent( + f""" + /=======================================================\\ + | It is recommended to use special macros in order to | + | {line1:<54}| + | | + | {line2:<54}| + | {url:<54}| + | | + | If you already use these macros skip this step. | + | Otherwise you should consider to answer with 'Y' to | + | download the recommended macros. | + \\=======================================================/ + """ + )[1:] + + print(dialog, end="") diff --git a/kiauh/components/webui_client/client_remove.py b/kiauh/components/webui_client/client_remove.py new file mode 100644 index 0000000..49bc694 --- /dev/null +++ b/kiauh/components/webui_client/client_remove.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +from typing import List + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from components.webui_client import ClientData +from components.webui_client.client_config.client_config_remove import ( + run_client_config_removal, +) +from components.webui_client.client_utils import backup_mainsail_config_json + +from core.instance_manager.instance_manager import InstanceManager +from utils.filesystem_utils import ( + remove_nginx_config, + remove_nginx_logs, + remove_config_section, +) +from utils.logger import Logger + + +def run_client_removal( + client: ClientData, + rm_client: bool, + rm_client_config: bool, + backup_ms_config_json: bool, + rm_moonraker_conf_section: bool, + rm_printer_cfg_section: bool, +) -> None: + mr_im = InstanceManager(Moonraker) + mr_instances: List[Moonraker] = mr_im.instances + kl_im = InstanceManager(Klipper) + kl_instances: List[Klipper] = kl_im.instances + if backup_ms_config_json and client.get("name") == "mainsail": + backup_mainsail_config_json() + if rm_client: + client_name = client.get("name") + remove_client_dir(client) + remove_nginx_config(client_name) + remove_nginx_logs(client_name) + if rm_moonraker_conf_section: + section = f"update_manager {client_name}" + remove_config_section(section, mr_instances) + if rm_client_config: + run_client_config_removal( + client.get("client_config"), + rm_moonraker_conf_section, + rm_printer_cfg_section, + kl_instances, + mr_instances, + ) + + +def remove_client_dir(client: ClientData) -> None: + Logger.print_status(f"Removing {client.get('display_name')} ...") + client_dir = client.get("dir") + if not client.get("dir").exists(): + Logger.print_info(f"'{client_dir}' does not exist. Skipping ...") + return + + try: + shutil.rmtree(client_dir) + except OSError as e: + Logger.print_error(f"Unable to delete '{client_dir}':\n{e}") diff --git a/kiauh/components/webui_client/client_setup.py b/kiauh/components/webui_client/client_setup.py new file mode 100644 index 0000000..2744cef --- /dev/null +++ b/kiauh/components/webui_client/client_setup.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 +from typing import List + +from components.klipper.klipper import Klipper +from components.webui_client import ( + ClientName, + ClientData, +) + +from components.moonraker.moonraker import Moonraker +from components.webui_client.client_config.client_config_setup import ( + install_client_config, +) +from components.webui_client.client_dialogs import ( + print_moonraker_not_found_dialog, + print_client_port_select_dialog, + print_install_client_config_dialog, +) +from components.webui_client.client_utils import ( + backup_mainsail_config_json, + restore_mainsail_config_json, + enable_mainsail_remotemode, + symlink_webui_nginx_log, + load_client_data, config_for_other_client_exist, + ) +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.instance_manager import InstanceManager +from kiauh import KIAUH_CFG +from utils import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED +from utils.common import check_install_dependencies +from utils.filesystem_utils import ( + unzip, + copy_upstream_nginx_cfg, + copy_common_vars_nginx_cfg, + create_nginx_cfg, + create_symlink, + remove_file, + add_config_section, + read_ports_from_nginx_configs, + is_valid_port, + get_next_free_port, +) +from utils.input_utils import get_confirm, get_number_input +from utils.logger import Logger +from utils.system_utils import ( + download_file, + set_nginx_permissions, + get_ipv4_addr, + control_systemd_service, +) + + +def install_client(client_name: ClientName) -> None: + client: ClientData = load_client_data(client_name) + d_name = client.get("display_name") + + if client is None: + Logger.print_error("Missing parameter client_name!") + return + + if client.get("dir").exists(): + Logger.print_info( + f"{client.get('display_name')} seems to be already installed! Skipped ..." + ) + return + + mr_im = InstanceManager(Moonraker) + mr_instances: List[Moonraker] = mr_im.instances + + enable_remotemode = False + if not mr_instances: + print_moonraker_not_found_dialog() + if not get_confirm( + f"Continue {d_name} installation?", + allow_go_back=True, + ): + return + + # if moonraker is not installed or multiple instances + # are installed we enable mainsails remote mode + if client.get("remote_mode") and not mr_instances or len(mr_instances) > 1: + enable_remotemode = True + + kl_im = InstanceManager(Klipper) + kl_instances = kl_im.instances + install_client_cfg = False + client_config = client.get("client_config") + if ( + kl_instances + and not client_config.get("dir").exists() + and not config_for_other_client_exist(client_to_ignore=client.get("name")) + ): + print_install_client_config_dialog(client) + question = f"Download the recommended {client_config.get('display_name')}?" + install_client_cfg = get_confirm(question, allow_go_back=False) + + cm = ConfigManager(cfg_file=KIAUH_CFG) + default_port = cm.get_value(client.get("name"), "port") + client_port = default_port if default_port and default_port.isdigit() else "80" + ports_in_use = read_ports_from_nginx_configs() + + # check if configured port is a valid number and not in use already + valid_port = is_valid_port(client_port, ports_in_use) + while not valid_port: + next_port = get_next_free_port(ports_in_use) + print_client_port_select_dialog(d_name, next_port, ports_in_use) + client_port = str( + get_number_input( + f"Configure {d_name} for port", + min_count=int(next_port), + default=next_port, + ) + ) + valid_port = is_valid_port(client_port, ports_in_use) + + check_install_dependencies(["nginx"]) + + try: + download_client(client) + if enable_remotemode and client.get("name") == "mainsail": + enable_mainsail_remotemode() + if mr_instances: + add_config_section( + section=f"update_manager {client.get('name')}", + instances=mr_instances, + options=[ + ("type", "web"), + ("channel", "stable"), + ("repo", client.get("mr_conf_repo")), + ("path", client.get("mr_conf_path")), + ], + ) + mr_im.restart_all_instance() + if install_client_cfg and kl_instances: + install_client_config(client.get("name")) + + copy_upstream_nginx_cfg() + copy_common_vars_nginx_cfg() + create_client_nginx_cfg(client, client_port) + if kl_instances: + symlink_webui_nginx_log(kl_instances) + control_systemd_service("nginx", "restart") + + except Exception as e: + Logger.print_error(f"{d_name} installation failed!\n{e}") + return + + log = f"Open {d_name} now on: http://{get_ipv4_addr()}:{client_port}" + Logger.print_ok(f"{d_name} installation complete!", start="\n") + Logger.print_ok(log, prefix=False, end="\n\n") + + +def download_client(client: ClientData) -> None: + zipfile = f"{client.get('name').lower()}.zip" + target = Path().home().joinpath(zipfile) + try: + Logger.print_status(f"Downloading {zipfile} ...") + download_file(client.get("url"), target, True) + Logger.print_ok("Download complete!") + + Logger.print_status(f"Extracting {zipfile} ...") + unzip(target, client.get("dir")) + target.unlink(missing_ok=True) + Logger.print_ok("OK!") + + except Exception: + Logger.print_error(f"Downloading {zipfile} failed!") + raise + + +def update_client(client: ClientData) -> None: + Logger.print_status(f"Updating {client.get('display_name')} ...") + if client.get("name") == "mainsail": + backup_mainsail_config_json(is_temp=True) + + download_client(client) + + if client.get("name") == "mainsail": + restore_mainsail_config_json() + + +def create_client_nginx_cfg(client: ClientData, port: int) -> None: + d_name = client.get("display_name") + root_dir = client.get("dir") + source = NGINX_SITES_AVAILABLE.joinpath(client.get("name")) + target = NGINX_SITES_ENABLED.joinpath(client.get("name")) + try: + Logger.print_status(f"Creating NGINX config for {d_name} ...") + remove_file(Path("/etc/nginx/sites-enabled/default"), True) + create_nginx_cfg(client.get("name"), port, root_dir) + create_symlink(source, target, True) + set_nginx_permissions() + Logger.print_ok(f"NGINX config for {d_name} successfully created.") + except Exception: + Logger.print_error(f"Creating NGINX config for {d_name} failed!") + raise diff --git a/kiauh/components/webui_client/client_utils.py b/kiauh/components/webui_client/client_utils.py new file mode 100644 index 0000000..4dd53d0 --- /dev/null +++ b/kiauh/components/webui_client/client_utils.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 json +import shutil +from json import JSONDecodeError +from pathlib import Path +from typing import List, Optional, Dict, Literal, Union, get_args + +import urllib.request + +from components.klipper.klipper import Klipper +from components.webui_client import ( + MAINSAIL_CONFIG_JSON, + MAINSAIL_DIR, + MAINSAIL_BACKUP_DIR, + FLUIDD_PRE_RLS_URL, + FLUIDD_BACKUP_DIR, + FLUIDD_URL, + FLUIDD_DIR, + ClientData, + FLUIDD_CONFIG_REPO_URL, + FLUIDD_CONFIG_DIR, + ClientConfigData, + MAINSAIL_PRE_RLS_URL, + MAINSAIL_URL, + MAINSAIL_CONFIG_REPO_URL, + MAINSAIL_CONFIG_DIR, + ClientName, + MAINSAIL_TAGS_URL, + FLUIDD_TAGS_URL, + FLUIDD_CONFIG_BACKUP_DIR, + MAINSAIL_CONFIG_BACKUP_DIR, +) +from core.backup_manager.backup_manager import BackupManager +from core.repo_manager.repo_manager import RepoManager +from utils import NGINX_SITES_AVAILABLE, NGINX_CONFD +from utils.common import get_install_status_webui +from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW +from utils.logger import Logger + + +def load_client_data(client_name: ClientName) -> Optional[ClientData]: + client_data = None + + if client_name == "mainsail": + client_config_data = ClientConfigData( + name="mainsail-config", + display_name="Mainsail-Config", + cfg_filename="mainsail.cfg", + dir=MAINSAIL_CONFIG_DIR, + backup_dir=MAINSAIL_CONFIG_BACKUP_DIR, + url=MAINSAIL_CONFIG_REPO_URL, + printer_cfg_section="include mainsail.cfg", + mr_conf_path="~/mainsail-config", + mr_conf_origin=MAINSAIL_CONFIG_REPO_URL, + ) + client_data = ClientData( + name=client_name, + display_name=client_name.capitalize(), + dir=MAINSAIL_DIR, + backup_dir=MAINSAIL_BACKUP_DIR, + url=MAINSAIL_URL, + pre_release_url=MAINSAIL_PRE_RLS_URL, + tags_url=MAINSAIL_TAGS_URL, + remote_mode=True, + mr_conf_repo="mainsail-crew/mainsail", + mr_conf_path="~/mainsail", + client_config=client_config_data, + ) + elif client_name == "fluidd": + client_config_data = ClientConfigData( + name="fluidd-config", + display_name="Fluidd-Config", + cfg_filename="fluidd.cfg", + dir=FLUIDD_CONFIG_DIR, + backup_dir=FLUIDD_CONFIG_BACKUP_DIR, + url=FLUIDD_CONFIG_REPO_URL, + printer_cfg_section="include fluidd.cfg", + mr_conf_path="~/fluidd-config", + mr_conf_origin=FLUIDD_CONFIG_REPO_URL, + ) + client_data = ClientData( + name=client_name, + display_name=client_name.capitalize(), + dir=FLUIDD_DIR, + backup_dir=FLUIDD_BACKUP_DIR, + url=FLUIDD_URL, + pre_release_url=FLUIDD_PRE_RLS_URL, + tags_url=FLUIDD_TAGS_URL, + remote_mode=False, + mr_conf_repo="fluidd-core/fluidd", + mr_conf_path="~/fluidd", + client_config=client_config_data, + ) + + return client_data + + +def get_client_status(client: ClientData) -> str: + return get_install_status_webui( + client.get("dir"), + NGINX_SITES_AVAILABLE.joinpath(client.get("name")), + NGINX_CONFD.joinpath("upstreams.conf"), + NGINX_CONFD.joinpath("common_vars.conf"), + ) + + +def get_client_config_status(client: ClientData) -> Dict[ + Literal["repo", "local", "remote"], + Union[str, int], +]: + client_config = client.get("client_config") + client_config = client_config.get("dir") + + return { + "repo": RepoManager.get_repo_name(client_config), + "local": RepoManager.get_local_commit(client_config), + "remote": RepoManager.get_remote_commit(client_config), + } + + +def get_current_client_config(clients: List[ClientData]) -> str: + installed = [] + for client in clients: + client_config = client.get("client_config") + if client_config.get("dir").exists(): + installed.append(client) + + if len(installed) > 1: + return f"{COLOR_YELLOW}Conflict!{RESET_FORMAT}" + elif len(installed) == 1: + cfg = installed[0].get("client_config") + return f"{COLOR_CYAN}{cfg.get('display_name')}{RESET_FORMAT}" + + return f"{COLOR_CYAN}-{RESET_FORMAT}" + + +def backup_mainsail_config_json(is_temp=False) -> None: + Logger.print_status(f"Backup '{MAINSAIL_CONFIG_JSON}' ...") + bm = BackupManager() + if is_temp: + fn = Path.home().joinpath("config.json.kiauh.bak") + bm.backup_file(MAINSAIL_CONFIG_JSON, custom_filename=fn) + else: + bm.backup_file(MAINSAIL_CONFIG_JSON) + + +def restore_mainsail_config_json() -> None: + try: + Logger.print_status(f"Restore '{MAINSAIL_CONFIG_JSON}' ...") + source = Path.home().joinpath("config.json.kiauh.bak") + shutil.copy(source, MAINSAIL_CONFIG_JSON) + except OSError: + Logger.print_info("Unable to restore config.json. Skipped ...") + + +def enable_mainsail_remotemode() -> None: + Logger.print_status("Enable Mainsails remote mode ...") + with open(MAINSAIL_CONFIG_JSON, "r") as f: + config_data = json.load(f) + + if config_data["instancesDB"] == "browser": + Logger.print_info("Remote mode already configured. Skipped ...") + return + + Logger.print_status("Setting instance storage location to 'browser' ...") + config_data["instancesDB"] = "browser" + + with open(MAINSAIL_CONFIG_JSON, "w") as f: + json.dump(config_data, f, indent=4) + Logger.print_ok("Mainsails remote mode enabled!") + + +def symlink_webui_nginx_log(klipper_instances: List[Klipper]) -> None: + Logger.print_status("Link NGINX logs into log directory ...") + access_log = Path("/var/log/nginx/mainsail-access.log") + error_log = Path("/var/log/nginx/mainsail-error.log") + + for instance in klipper_instances: + desti_access = instance.log_dir.joinpath("mainsail-access.log") + if not desti_access.exists(): + desti_access.symlink_to(access_log) + + desti_error = instance.log_dir.joinpath("mainsail-error.log") + if not desti_error.exists(): + desti_error.symlink_to(error_log) + + +def get_local_client_version(client: ClientData) -> str: + relinfo_file = client.get("dir").joinpath("release_info.json") + if not relinfo_file.is_file(): + return "-" + + with open(relinfo_file, "r") as f: + return json.load(f)["version"] + + +def get_remote_client_version(client: ClientData) -> str: + try: + with urllib.request.urlopen(client.get("tags_url")) as response: + data = json.loads(response.read()) + return data[0]["name"] + except (JSONDecodeError, TypeError): + return "ERROR" + + +def backup_client_data(client: ClientData) -> None: + name = client.get("name") + src = client.get("dir") + dest = client.get("backup_dir") + + with open(src.joinpath(".version"), "r") as v: + version = v.readlines()[0] + + bm = BackupManager() + bm.backup_directory(f"{name}-{version}", src, dest) + if name == "mainsail": + bm.backup_file(MAINSAIL_CONFIG_JSON, dest) + bm.backup_file(NGINX_SITES_AVAILABLE.joinpath(name), dest) + + +def backup_client_config_data(client: ClientData) -> None: + client_config = client.get("client_config") + name = client_config.get("name") + source = client_config.get("dir") + target = client_config.get("backup_dir") + bm = BackupManager() + bm.backup_directory(name, source, target) + + +def get_existing_clients() -> List[ClientData]: + clients = list(get_args(ClientName)) + installed_clients: List[ClientData] = [] + for c in clients: + c_data: ClientData = load_client_data(c) + if c_data.get("dir").exists(): + installed_clients.append(c_data) + + return installed_clients + + +def get_existing_client_config() -> List[ClientData]: + clients = list(get_args(ClientName)) + installed_client_configs: List[ClientData] = [] + for c in clients: + c_data: ClientData = load_client_data(c) + c_config_data: ClientConfigData = c_data.get("client_config") + if c_config_data.get("dir").exists(): + installed_client_configs.append(c_data) + + return installed_client_configs + + +def config_for_other_client_exist(client_to_ignore: ClientName) -> bool: + """ + Check if any other client configs are present on the system. + It is usually not harmful, but chances are they can conflict each other. + Multiple client configs are, at least, redundant to have them installed + :param client_to_ignore: The client name to ignore for the check + :return: True, if other client configs were found, else False + """ + + clients = set([c["name"] for c in get_existing_client_config()]) + clients = clients - {client_to_ignore} + + return True if len(clients) > 0 else False diff --git a/kiauh/components/webui_client/menus/__init__.py b/kiauh/components/webui_client/menus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/components/webui_client/menus/client_remove_menu.py b/kiauh/components/webui_client/menus/client_remove_menu.py new file mode 100644 index 0000000..b4648d6 --- /dev/null +++ b/kiauh/components/webui_client/menus/client_remove_menu.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap +from typing import Callable, Dict + +from components.webui_client import client_remove, ClientData +from core.menus import BACK_HELP_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN + + +# noinspection PyUnusedLocal +class ClientRemoveMenu(BaseMenu): + def __init__(self, client: ClientData): + self.client = client + self.rm_client = False + self.rm_client_config = False + self.backup_mainsail_config_json = False + self.rm_moonraker_conf_section = False + self.rm_printer_cfg_section = False + + super().__init__( + header=False, + options=self.get_options(), + footer_type=BACK_HELP_FOOTER, + ) + + def get_options(self) -> Dict[str, Callable]: + options = { + "0": self.toggle_all, + "1": self.toggle_rm_client, + "2": self.toggle_rm_client_config, + "3": self.toggle_rm_printer_cfg_section, + "4": self.toggle_rm_moonraker_conf_section, + "c": self.run_removal_process, + } + if self.client.get("name") == "mainsail": + options["5"] = self.toggle_backup_mainsail_config_json + + return options + + def print_menu(self) -> None: + client_name = self.client.get("display_name") + client_config = self.client.get("client_config") + client_config_name = client_config.get("display_name") + + header = f" [ Remove {client_name} ] " + color = COLOR_RED + count = 62 - len(color) - len(RESET_FORMAT) + checked = f"[{COLOR_CYAN}x{RESET_FORMAT}]" + unchecked = "[ ]" + o1 = checked if self.rm_client else unchecked + o2 = checked if self.rm_client_config else unchecked + o3 = checked if self.rm_printer_cfg_section else unchecked + o4 = checked if self.rm_moonraker_conf_section else unchecked + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | Enter a number and hit enter to select / deselect | + | the specific option for removal. | + |-------------------------------------------------------| + | 0) Select everything | + |-------------------------------------------------------| + | 1) {o1} Remove {client_name:16} | + | 2) {o2} Remove {client_config_name:24} | + | | + | printer.cfg & moonraker.conf | + | 3) {o3} Remove printer.cfg include | + | 4) {o4} Remove Moonraker update section | + """ + )[1:] + + if self.client.get("name") == "mainsail": + o5 = checked if self.backup_mainsail_config_json else unchecked + menu += textwrap.dedent( + f""" + | | + | Mainsail config.json | + | 5) {o5} Backup config.json | + """ + )[1:] + + menu += textwrap.dedent( + """ + |-------------------------------------------------------| + | C) Continue | + """ + )[1:] + print(menu, end="") + + def toggle_all(self, **kwargs) -> None: + self.rm_client = True + self.rm_client_config = True + self.backup_mainsail_config_json = True + self.rm_moonraker_conf_section = True + self.rm_printer_cfg_section = True + + def toggle_rm_client(self, **kwargs) -> None: + self.rm_client = not self.rm_client + + def toggle_rm_client_config(self, **kwargs) -> None: + self.rm_client_config = not self.rm_client_config + + def toggle_backup_mainsail_config_json(self, **kwargs) -> None: + self.backup_mainsail_config_json = not self.backup_mainsail_config_json + + def toggle_rm_moonraker_conf_section(self, **kwargs) -> None: + self.rm_moonraker_conf_section = not self.rm_moonraker_conf_section + + def toggle_rm_printer_cfg_section(self, **kwargs) -> None: + self.rm_printer_cfg_section = not self.rm_printer_cfg_section + + def run_removal_process(self, **kwargs) -> None: + if ( + not self.rm_client + and not self.rm_client_config + and not self.backup_mainsail_config_json + and not self.rm_moonraker_conf_section + and not self.rm_printer_cfg_section + ): + error = f"{COLOR_RED}Nothing selected ...{RESET_FORMAT}" + print(error) + return + + client_remove.run_client_removal( + client=self.client, + rm_client=self.rm_client, + rm_client_config=self.rm_client_config, + backup_ms_config_json=self.backup_mainsail_config_json, + rm_moonraker_conf_section=self.rm_moonraker_conf_section, + rm_printer_cfg_section=self.rm_printer_cfg_section, + ) + + self.rm_client = False + self.rm_client_config = False + self.backup_mainsail_config_json = False + self.rm_moonraker_conf_section = False + self.rm_printer_cfg_section = False diff --git a/kiauh/core/__init__.py b/kiauh/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/core/backup_manager/__init__.py b/kiauh/core/backup_manager/__init__.py new file mode 100644 index 0000000..7bbbe1e --- /dev/null +++ b/kiauh/core/backup_manager/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +BACKUP_ROOT_DIR = Path.home().joinpath("kiauh-backups") diff --git a/kiauh/core/backup_manager/backup_manager.py b/kiauh/core/backup_manager/backup_manager.py new file mode 100644 index 0000000..1c27266 --- /dev/null +++ b/kiauh/core/backup_manager/backup_manager.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +from pathlib import Path +from typing import List + +from core.backup_manager import BACKUP_ROOT_DIR +from utils.common import get_current_date +from utils.logger import Logger + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class BackupManager: + def __init__(self, backup_root_dir: Path = BACKUP_ROOT_DIR): + self._backup_root_dir = backup_root_dir + self._ignore_folders = None + + @property + def backup_root_dir(self) -> Path: + return self._backup_root_dir + + @backup_root_dir.setter + def backup_root_dir(self, value: Path): + self._backup_root_dir = value + + @property + def ignore_folders(self) -> List[str]: + return self._ignore_folders + + @ignore_folders.setter + def ignore_folders(self, value: List[str]): + self._ignore_folders = value + + def backup_file(self, file: Path = None, target: Path = None, custom_filename=None): + if not file: + raise ValueError("Parameter 'file' cannot be None!") + + target = self.backup_root_dir if target is None else target + + Logger.print_status(f"Creating backup of {file} ...") + if Path(file).is_file(): + date = get_current_date().get("date") + time = get_current_date().get("time") + filename = f"{file.stem}-{date}-{time}{file.suffix}" + filename = custom_filename if custom_filename is not None else filename + try: + Path(target).mkdir(exist_ok=True) + shutil.copyfile(file, target.joinpath(filename)) + Logger.print_ok("Backup successfull!") + except OSError as e: + Logger.print_error(f"Unable to backup '{file}':\n{e}") + else: + Logger.print_info(f"File '{file}' not found ...") + + def backup_directory(self, name: str, source: Path, target: Path = None) -> None: + if source is None or not Path(source).exists(): + raise OSError("Parameter 'source' is None or Path does not exist!") + + target = self.backup_root_dir if target is None else target + try: + log = f"Creating backup of {name} in {target} ..." + Logger.print_status(log) + date = get_current_date().get("date") + time = get_current_date().get("time") + shutil.copytree( + source, + target.joinpath(f"{name.lower()}-{date}-{time}"), + ignore=self.ignore_folders_func, + ) + Logger.print_ok("Backup successfull!") + except OSError as e: + Logger.print_error(f"Unable to backup directory '{source}':\n{e}") + return + + def ignore_folders_func(self, dirpath, filenames): + return ( + [f for f in filenames if f in self._ignore_folders] + if self._ignore_folders is not None + else [] + ) diff --git a/kiauh/core/base_extension.py b/kiauh/core/base_extension.py new file mode 100644 index 0000000..09245ee --- /dev/null +++ b/kiauh/core/base_extension.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 abc import abstractmethod, ABC +from typing import Dict + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class BaseExtension(ABC): + def __init__(self, metadata: Dict[str, str]): + self.metadata = metadata + + @abstractmethod + def install_extension(self, **kwargs) -> None: + raise NotImplementedError( + "Subclasses must implement the install_extension method" + ) + + @abstractmethod + def remove_extension(self, **kwargs) -> None: + raise NotImplementedError( + "Subclasses must implement the remove_extension method" + ) diff --git a/kiauh/core/config_manager/__init__.py b/kiauh/core/config_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/core/config_manager/config_manager.py b/kiauh/core/config_manager/config_manager.py new file mode 100644 index 0000000..12d4eb0 --- /dev/null +++ b/kiauh/core/config_manager/config_manager.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 configparser +from pathlib import Path +from typing import Union + +from utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class ConfigManager: + def __init__(self, cfg_file: Path): + self.config_file = cfg_file + self.config = CustomConfigParser() + + if cfg_file.is_file(): + self.read_config() + + def read_config(self) -> None: + if not self.config_file: + Logger.print_error("Unable to read config file. File not found.") + return + + self.config.read_file(open(self.config_file, "r")) + + def write_config(self) -> None: + with open(self.config_file, "w") as cfg: + self.config.write(cfg) + + def get_value(self, section: str, key: str, silent=True) -> Union[str, bool, None]: + if not self.config.has_section(section): + if not silent: + log = f"Section not defined. Unable to read section: [{section}]." + Logger.print_error(log) + return None + + if not self.config.has_option(section, key): + if not silent: + log = f"Option not defined in section [{section}]. Unable to read option: '{key}'." + Logger.print_error(log) + return None + + value = self.config.get(section, key) + if value == "True" or value == "true": + return True + elif value == "False" or value == "false": + return False + else: + return value + + def set_value(self, section: str, key: str, value: str): + self.config.set(section, key, value) + + +class CustomConfigParser(configparser.ConfigParser): + """ + A custom ConfigParser class overwriting the write() method of configparser.Configparser. + Key and value will be delimited by a ": ". + Note the whitespace AFTER the colon, which is the whole reason for that overwrite. + """ + + def write(self, fp, space_around_delimiters=False): + if self._defaults: + fp.write("[%s]\n" % configparser.DEFAULTSECT) + for key, value in self._defaults.items(): + fp.write("%s: %s\n" % (key, str(value).replace("\n", "\n\t"))) + fp.write("\n") + for section in self._sections: + fp.write("[%s]\n" % section) + for key, value in self._sections[section].items(): + if key == "__name__": + continue + if (value is not None) or (self._optcre == self.OPTCRE): + key = ": ".join((key, str(value).replace("\n", "\n\t"))) + fp.write("%s\n" % key) + fp.write("\n") diff --git a/kiauh/core/instance_manager/__init__.py b/kiauh/core/instance_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/core/instance_manager/base_instance.py b/kiauh/core/instance_manager/base_instance.py new file mode 100644 index 0000000..95e24dc --- /dev/null +++ b/kiauh/core/instance_manager/base_instance.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 abc import abstractmethod, ABC +from pathlib import Path +from typing import List, Type, TypeVar + +from utils.constants import SYSTEMD, CURRENT_USER + +B = TypeVar(name="B", bound="BaseInstance", covariant=True) + + +class BaseInstance(ABC): + @classmethod + def blacklist(cls) -> List[str]: + return [] + + def __init__( + self, + suffix: str, + instance_type: B = B, + ): + self._instance_type = instance_type + self._suffix = suffix + self._user = CURRENT_USER + self._data_dir_name = self.get_data_dir_name_from_suffix() + self._data_dir = Path.home().joinpath(f"{self._data_dir_name}_data") + self._cfg_dir = self.data_dir.joinpath("config") + self._log_dir = self.data_dir.joinpath("logs") + self._comms_dir = self.data_dir.joinpath("comms") + self._sysd_dir = self.data_dir.joinpath("systemd") + self._gcodes_dir = self.data_dir.joinpath("gcodes") + + @property + def instance_type(self) -> Type["BaseInstance"]: + return self._instance_type + + @instance_type.setter + def instance_type(self, value: Type["BaseInstance"]) -> None: + self._instance_type = value + + @property + def suffix(self) -> str: + return self._suffix + + @suffix.setter + def suffix(self, value: str) -> None: + self._suffix = value + + @property + def user(self) -> str: + return self._user + + @user.setter + def user(self, value: str) -> None: + self._user = value + + @property + def data_dir_name(self) -> str: + return self._data_dir_name + + @data_dir_name.setter + def data_dir_name(self, value: str) -> None: + self._data_dir_name = value + + @property + def data_dir(self) -> Path: + return self._data_dir + + @data_dir.setter + def data_dir(self, value: str) -> None: + self._data_dir = value + + @property + def cfg_dir(self) -> Path: + return self._cfg_dir + + @cfg_dir.setter + def cfg_dir(self, value: str) -> None: + self._cfg_dir = value + + @property + def log_dir(self) -> Path: + return self._log_dir + + @log_dir.setter + def log_dir(self, value: str) -> None: + self._log_dir = value + + @property + def comms_dir(self) -> Path: + return self._comms_dir + + @comms_dir.setter + def comms_dir(self, value: str) -> None: + self._comms_dir = value + + @property + def sysd_dir(self) -> Path: + return self._sysd_dir + + @sysd_dir.setter + def sysd_dir(self, value: str) -> None: + self._sysd_dir = value + + @property + def gcodes_dir(self) -> Path: + return self._gcodes_dir + + @gcodes_dir.setter + def gcodes_dir(self, value: str) -> None: + self._gcodes_dir = value + + @abstractmethod + def create(self) -> None: + raise NotImplementedError("Subclasses must implement the create method") + + @abstractmethod + def delete(self) -> None: + raise NotImplementedError("Subclasses must implement the delete method") + + def create_folders(self, add_dirs: List[Path] = None) -> None: + dirs = [ + self.data_dir, + self.cfg_dir, + self.log_dir, + self.comms_dir, + self.sysd_dir, + ] + + if add_dirs: + dirs.extend(add_dirs) + + for _dir in dirs: + _dir.mkdir(exist_ok=True) + + def get_service_file_name(self, extension: bool = False) -> str: + name = f"{self.__class__.__name__.lower()}" + if self.suffix != "": + name += f"-{self.suffix}" + + return name if not extension else f"{name}.service" + + def get_service_file_path(self) -> Path: + return SYSTEMD.joinpath(self.get_service_file_name(extension=True)) + + def get_data_dir_name_from_suffix(self) -> str: + if self._suffix == "": + return "printer" + elif self._suffix.isdigit(): + return f"printer_{self._suffix}" + else: + return self._suffix diff --git a/kiauh/core/instance_manager/instance_manager.py b/kiauh/core/instance_manager/instance_manager.py new file mode 100644 index 0000000..9750086 --- /dev/null +++ b/kiauh/core/instance_manager/instance_manager.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 +import subprocess +from pathlib import Path +from typing import List, Optional, Union, TypeVar + +from core.instance_manager.base_instance import BaseInstance +from utils.constants import SYSTEMD +from utils.logger import Logger + +I = TypeVar(name="I", bound=BaseInstance, covariant=True) + + +# noinspection PyMethodMayBeStatic +class InstanceManager: + def __init__(self, instance_type: I) -> None: + self._instance_type = instance_type + self._current_instance: Optional[I] = None + self._instance_suffix: Optional[str] = None + self._instance_service: Optional[str] = None + self._instance_service_full: Optional[str] = None + self._instance_service_path: Optional[str] = None + self._instances: List[I] = [] + + @property + def instance_type(self) -> I: + return self._instance_type + + @instance_type.setter + def instance_type(self, value: I): + self._instance_type = value + + @property + def current_instance(self) -> I: + return self._current_instance + + @current_instance.setter + def current_instance(self, value: I) -> None: + self._current_instance = value + self.instance_suffix = value.suffix + self.instance_service = value.get_service_file_name() + self.instance_service_path = value.get_service_file_path() + + @property + def instance_suffix(self) -> str: + return self._instance_suffix + + @instance_suffix.setter + def instance_suffix(self, value: str): + self._instance_suffix = value + + @property + def instance_service(self) -> str: + return self._instance_service + + @instance_service.setter + def instance_service(self, value: str): + self._instance_service = value + + @property + def instance_service_full(self) -> str: + return f"{self._instance_service}.service" + + @property + def instance_service_path(self) -> str: + return self._instance_service_path + + @instance_service_path.setter + def instance_service_path(self, value: str): + self._instance_service_path = value + + @property + def instances(self) -> List[I]: + return self.find_instances() + + @instances.setter + def instances(self, value: List[I]): + self._instances = value + + def create_instance(self) -> None: + if self.current_instance is not None: + try: + self.current_instance.create() + except (OSError, subprocess.CalledProcessError) as e: + Logger.print_error(f"Creating instance failed: {e}") + raise + else: + raise ValueError("current_instance cannot be None") + + def delete_instance(self) -> None: + if self.current_instance is not None: + try: + self.current_instance.delete() + except (OSError, subprocess.CalledProcessError) as e: + Logger.print_error(f"Removing instance failed: {e}") + raise + else: + raise ValueError("current_instance cannot be None") + + def enable_instance(self) -> None: + Logger.print_status(f"Enabling {self.instance_service_full} ...") + try: + command = ["sudo", "systemctl", "enable", self.instance_service_full] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_service_full} enabled.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error enabling service {self.instance_service_full}:") + Logger.print_error(f"{e}") + + def disable_instance(self) -> None: + Logger.print_status(f"Disabling {self.instance_service_full} ...") + try: + command = ["sudo", "systemctl", "disable", self.instance_service_full] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_service_full} disabled.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error disabling {self.instance_service_full}:") + Logger.print_error(f"{e}") + + def start_instance(self) -> None: + Logger.print_status(f"Starting {self.instance_service_full} ...") + try: + command = ["sudo", "systemctl", "start", self.instance_service_full] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_service_full} started.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error starting {self.instance_service_full}:") + Logger.print_error(f"{e}") + + def restart_instance(self) -> None: + Logger.print_status(f"Restarting {self.instance_service_full} ...") + try: + command = ["sudo", "systemctl", "restart", self.instance_service_full] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_service_full} restarted.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error restarting {self.instance_service_full}:") + Logger.print_error(f"{e}") + + def start_all_instance(self) -> None: + for instance in self.instances: + self.current_instance = instance + self.start_instance() + + def restart_all_instance(self) -> None: + for instance in self.instances: + self.current_instance = instance + self.restart_instance() + + def stop_instance(self) -> None: + Logger.print_status(f"Stopping {self.instance_service_full} ...") + try: + command = ["sudo", "systemctl", "stop", self.instance_service_full] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_service_full} stopped.") + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error stopping {self.instance_service_full}:") + Logger.print_error(f"{e}") + raise + + def stop_all_instance(self) -> None: + for instance in self.instances: + self.current_instance = instance + self.stop_instance() + + def reload_daemon(self) -> None: + Logger.print_status("Reloading systemd manager configuration ...") + try: + command = ["sudo", "systemctl", "daemon-reload"] + if subprocess.run(command, check=True): + Logger.print_ok("Systemd manager configuration reloaded") + except subprocess.CalledProcessError as e: + Logger.print_error("Error reloading systemd manager configuration:") + Logger.print_error(f"{e}") + raise + + def find_instances(self) -> List[I]: + name = self.instance_type.__name__.lower() + pattern = re.compile(f"^{name}(-[0-9a-zA-Z]+)?.service$") + excluded = self.instance_type.blacklist() + + service_list = [ + Path(SYSTEMD, service) + for service in SYSTEMD.iterdir() + if pattern.search(service.name) + and not any(s in service.name for s in excluded) + ] + + instance_list = [ + self.instance_type(suffix=self._get_instance_suffix(service)) + for service in service_list + ] + + return sorted(instance_list, key=lambda x: self._sort_instance_list(x.suffix)) + + def _get_instance_suffix(self, file_path: Path) -> str: + return file_path.stem.split("-")[-1] if "-" in file_path.stem else "" + + def _sort_instance_list(self, s: Union[int, str, None]): + if s is None: + return + + return int(s) if s.isdigit() else s diff --git a/kiauh/core/instance_manager/name_scheme.py b/kiauh/core/instance_manager/name_scheme.py new file mode 100644 index 0000000..bfd9e2c --- /dev/null +++ b/kiauh/core/instance_manager/name_scheme.py @@ -0,0 +1,8 @@ +from enum import unique, Enum + + +@unique +class NameScheme(Enum): + SINGLE = "SINGLE" + INDEX = "INDEX" + CUSTOM = "CUSTOM" diff --git a/kiauh/core/menus/__init__.py b/kiauh/core/menus/__init__.py new file mode 100644 index 0000000..afca077 --- /dev/null +++ b/kiauh/core/menus/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 # +# ======================================================================= # + +QUIT_FOOTER = "quit" +BACK_FOOTER = "back" +BACK_HELP_FOOTER = "back_help" diff --git a/kiauh/core/menus/advanced_menu.py b/kiauh/core/menus/advanced_menu.py new file mode 100644 index 0000000..27eaacd --- /dev/null +++ b/kiauh/core/menus/advanced_menu.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import COLOR_YELLOW, RESET_FORMAT + + +class AdvancedMenu(BaseMenu): + def __init__(self): + super().__init__(header=True, options={}, footer_type=BACK_FOOTER) + + def print_menu(self): + header = " [ Advanced Menu ] " + color = COLOR_YELLOW + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | Klipper & API: | Mainsail: | + | 0) [Rollback] | 5) [Theme installer] | + | | | + | Firmware: | System: | + | 1) [Build only] | 6) [Change hostname] | + | 2) [Flash only] | | + | 3) [Build + Flash] | Extras: | + | 4) [Get MCU ID] | 7) [G-Code Shell Command] | + """ + )[1:] + print(menu, end="") diff --git a/kiauh/core/menus/backup_menu.py b/kiauh/core/menus/backup_menu.py new file mode 100644 index 0000000..459461a --- /dev/null +++ b/kiauh/core/menus/backup_menu.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper.klipper_utils import backup_klipper_dir +from components.moonraker.moonraker_utils import ( + backup_moonraker_dir, + backup_moonraker_db_dir, +) +from components.webui_client.client_utils import ( + backup_client_data, + load_client_data, + backup_client_config_data, +) +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.common import backup_printer_config_dir +from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class BackupMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + "1": self.backup_klipper, + "2": self.backup_moonraker, + "3": self.backup_printer_config, + "4": self.backup_moonraker_db, + "5": self.backup_mainsail, + "6": self.backup_fluidd, + "7": self.backup_mainsail_config, + "8": self.backup_fluidd_config, + "9": self.backup_klipperscreen, + }, + footer_type=BACK_FOOTER, + ) + + def print_menu(self): + header = " [ Backup Menu ] " + line1 = f"{COLOR_YELLOW}INFO: Backups are located in '~/kiauh-backups'{RESET_FORMAT}" + color = COLOR_CYAN + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | {line1:^62} | + |-------------------------------------------------------| + | Klipper & Moonraker API: | Client-Config: | + | 1) [Klipper] | 7) [Mainsail-Config] | + | 2) [Moonraker] | 8) [Fluidd-Config] | + | 3) [Config Folder] | | + | 4) [Moonraker Database] | Touchscreen GUI: | + | | 9) [KlipperScreen] | + | Webinterface: | | + | 5) [Mainsail] | | + | 6) [Fluidd] | | + """ + )[1:] + print(menu, end="") + + def backup_klipper(self, **kwargs): + backup_klipper_dir() + + def backup_moonraker(self, **kwargs): + backup_moonraker_dir() + + def backup_printer_config(self, **kwargs): + backup_printer_config_dir() + + def backup_moonraker_db(self, **kwargs): + backup_moonraker_db_dir() + + def backup_mainsail(self, **kwargs): + backup_client_data(load_client_data("mainsail")) + + def backup_fluidd(self, **kwargs): + backup_client_data(load_client_data("fluidd")) + + def backup_mainsail_config(self, **kwargs): + backup_client_config_data(load_client_data("mainsail")) + + def backup_fluidd_config(self, **kwargs): + backup_client_config_data(load_client_data("fluidd")) + + def backup_klipperscreen(self, **kwargs): + pass diff --git a/kiauh/core/menus/base_menu.py b/kiauh/core/menus/base_menu.py new file mode 100644 index 0000000..06ad576 --- /dev/null +++ b/kiauh/core/menus/base_menu.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 subprocess +import sys +import textwrap +from abc import abstractmethod, ABC +from typing import Dict, Any, Literal, Union, Callable + +from core.menus import QUIT_FOOTER, BACK_FOOTER, BACK_HELP_FOOTER +from utils.constants import ( + COLOR_GREEN, + COLOR_YELLOW, + COLOR_RED, + COLOR_CYAN, + RESET_FORMAT, +) +from utils.logger import Logger + + +def clear(): + subprocess.call("clear", shell=True) + + +def print_header(): + line1 = " [ KIAUH ] " + line2 = "Klipper Installation And Update Helper" + line3 = "" + color = COLOR_CYAN + count = 62 - len(color) - len(RESET_FORMAT) + header = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{line1:~^{count}}{RESET_FORMAT} | + | {color}{line2:^{count}}{RESET_FORMAT} | + | {color}{line3:~^{count}}{RESET_FORMAT} | + \=======================================================/ + """ + )[1:] + print(header, end="") + + +def print_quit_footer(): + text = "Q) Quit" + color = COLOR_RED + count = 62 - len(color) - len(RESET_FORMAT) + footer = textwrap.dedent( + f""" + |-------------------------------------------------------| + | {color}{text:^{count}}{RESET_FORMAT} | + \=======================================================/ + """ + )[1:] + print(footer, end="") + + +def print_back_footer(): + text = "B) « Back" + color = COLOR_GREEN + count = 62 - len(color) - len(RESET_FORMAT) + footer = textwrap.dedent( + f""" + |-------------------------------------------------------| + | {color}{text:^{count}}{RESET_FORMAT} | + \=======================================================/ + """ + )[1:] + print(footer, end="") + + +def print_back_help_footer(): + text1 = "B) « Back" + text2 = "H) Help [?]" + color1 = COLOR_GREEN + color2 = COLOR_YELLOW + count = 34 - len(color1) - len(RESET_FORMAT) + footer = textwrap.dedent( + f""" + |-------------------------------------------------------| + | {color1}{text1:^{count}}{RESET_FORMAT} | {color2}{text2:^{count}}{RESET_FORMAT} | + \=======================================================/ + """ + )[1:] + print(footer, end="") + + +class BaseMenu(ABC): + NAVI_OPTIONS = {"quit": ["q"], "back": ["b"], "back_help": ["b", "h"]} + + def __init__( + self, + options: Dict[str, Union[Callable, Any]], + options_offset: int = 0, + header: bool = True, + footer_type: Literal[ + "QUIT_FOOTER", "BACK_FOOTER", "BACK_HELP_FOOTER" + ] = QUIT_FOOTER, + ): + self.previous_menu = None + self.options = options + self.options_offset = options_offset + self.header = header + self.footer_type = footer_type + + @abstractmethod + def print_menu(self) -> None: + raise NotImplementedError("Subclasses must implement the print_menu method") + + def print_footer(self) -> None: + footer_type_map = { + QUIT_FOOTER: print_quit_footer, + BACK_FOOTER: print_back_footer, + BACK_HELP_FOOTER: print_back_help_footer, + } + footer_function = footer_type_map.get(self.footer_type, print_quit_footer) + footer_function() + + def display(self) -> None: + # clear() + if self.header: + print_header() + self.print_menu() + self.print_footer() + + def handle_user_input(self) -> str: + while True: + choice = input(f"{COLOR_CYAN}###### Perform action: {RESET_FORMAT}").lower() + option = self.options.get(choice, None) + + has_navi_option = self.footer_type in self.NAVI_OPTIONS + user_navigated = choice in self.NAVI_OPTIONS[self.footer_type] + if has_navi_option and user_navigated: + return choice + + if option is not None: + return choice + else: + Logger.print_error("Invalid input!", False) + + def start(self) -> None: + while True: + self.display() + choice = self.handle_user_input() + + if choice == "q": + Logger.print_ok("###### Happy printing!", False) + sys.exit(0) + elif choice == "b": + return + elif choice == "h": + print("help!") + else: + self.execute_option(choice) + + def execute_option(self, choice: str) -> None: + option = self.options.get(choice, None) + + if isinstance(option, type) and issubclass(option, BaseMenu): + self.navigate_to_menu(option, True) + elif isinstance(option, BaseMenu): + self.navigate_to_menu(option, False) + elif callable(option): + option(opt_index=choice) + elif option is None: + raise NotImplementedError(f"No implementation for option {choice}") + else: + raise TypeError( + f"Type {type(option)} of option {choice} not of type BaseMenu or Method" + ) + + def navigate_to_menu(self, menu, instantiate: bool) -> None: + """ + Method for handling the actual menu switch. Can either take in a menu type or an already + instantiated menu class. Use instantiated menu classes only if the menu requires specific input parameters + :param menu: A menu type or menu instance + :param instantiate: Specify if the menu requires instantiation + :return: None + """ + menu = menu() if instantiate else menu + menu.previous_menu = self + menu.start() diff --git a/kiauh/core/menus/extensions_menu.py b/kiauh/core/menus/extensions_menu.py new file mode 100644 index 0000000..a46b00c --- /dev/null +++ b/kiauh/core/menus/extensions_menu.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 importlib +import inspect +import json +import textwrap +from pathlib import Path +from typing import List, Dict + +from core.base_extension import BaseExtension +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import RESET_FORMAT, COLOR_CYAN, COLOR_YELLOW + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class ExtensionsMenu(BaseMenu): + def __init__(self): + self.extensions = self.discover_extensions() + super().__init__( + header=True, + options=self.get_options(), + footer_type=BACK_FOOTER, + ) + + def discover_extensions(self) -> List[BaseExtension]: + extensions = [] + extensions_dir = Path(__file__).resolve().parents[2].joinpath("extensions") + + for extension in extensions_dir.iterdir(): + metadata_json = Path(extension).joinpath("metadata.json") + if not metadata_json.exists(): + continue + + try: + with open(metadata_json, "r") as m: + metadata = json.load(m).get("metadata") + module_name = ( + f"kiauh.extensions.{extension.name}.{metadata.get('module')}" + ) + name, extension = inspect.getmembers( + importlib.import_module(module_name), + predicate=lambda o: inspect.isclass(o) + and issubclass(o, BaseExtension) + and o != BaseExtension, + )[0] + extensions.append(extension(metadata)) + except (IOError, json.JSONDecodeError, ImportError) as e: + print(f"Failed loading extension {extension}: {e}") + + return sorted(extensions, key=lambda ex: ex.metadata.get("index")) + + def get_options(self) -> Dict[str, BaseMenu]: + options = {} + for extension in self.extensions: + index = extension.metadata.get("index") + options[f"{index}"] = ExtensionSubmenu(extension) + + return options + + def print_menu(self): + header = " [ Extensions Menu ] " + color = COLOR_CYAN + line1 = f"{COLOR_YELLOW}Available Extensions:{RESET_FORMAT}" + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | {line1:<62} | + | | + """ + )[1:] + print(menu, end="") + + for extension in self.extensions: + index = extension.metadata.get("index") + name = extension.metadata.get("display_name") + row = f"{index}) {name}" + print(f"| {row:<53} |") + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class ExtensionSubmenu(BaseMenu): + def __init__(self, extension: BaseExtension): + self.extension = extension + self.extension_name = extension.metadata.get("display_name") + self.extension_desc = extension.metadata.get("description") + super().__init__( + header=False, + options={ + "1": extension.install_extension, + "2": extension.remove_extension, + }, + footer_type=BACK_FOOTER, + ) + + def print_menu(self) -> None: + header = f" [ {self.extension_name} ] " + color = COLOR_YELLOW + count = 62 - len(color) - len(RESET_FORMAT) + + wrapper = textwrap.TextWrapper(55, initial_indent="| ", subsequent_indent="| ") + lines = wrapper.wrap(self.extension_desc) + formatted_lines = [f"{line:<55} |" for line in lines] + description_text = "\n".join(formatted_lines) + + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + """ + )[1:] + menu += f"{description_text}\n" + menu += textwrap.dedent( + """ + |-------------------------------------------------------| + | 1) Install | + | 2) Remove | + """ + )[1:] + print(menu, end="") diff --git a/kiauh/core/menus/install_menu.py b/kiauh/core/menus/install_menu.py new file mode 100644 index 0000000..a90e235 --- /dev/null +++ b/kiauh/core/menus/install_menu.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper import klipper_setup +from components.moonraker import moonraker_setup +from components.webui_client import client_setup +from components.webui_client.client_config import client_config_setup +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import COLOR_GREEN, RESET_FORMAT + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class InstallMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + "1": self.install_klipper, + "2": self.install_moonraker, + "3": self.install_mainsail, + "4": self.install_fluidd, + "5": self.install_mainsail_config, + "6": self.install_fluidd_config, + "7": None, + "8": None, + "9": None, + }, + footer_type=BACK_FOOTER, + ) + + def print_menu(self): + header = " [ Installation Menu ] " + color = COLOR_GREEN + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | Firmware & API: | Touchscreen GUI: | + | 1) [Klipper] | 7) [KlipperScreen] | + | 2) [Moonraker] | | + | | Android / iOS: | + | Webinterface: | 8) [Mobileraker] | + | 3) [Mainsail] | | + | 4) [Fluidd] | Webcam Streamer: | + | | 9) [Crowsnest] | + | Client-Config: | | + | 5) [Mainsail-Config] | | + | 6) [Fluidd-Config] | | + | | | + """ + )[1:] + print(menu, end="") + + def install_klipper(self, **kwargs): + klipper_setup.install_klipper() + + def install_moonraker(self, **kwargs): + moonraker_setup.install_moonraker() + + def install_mainsail(self, **kwargs): + client_setup.install_client(client_name="mainsail") + + def install_mainsail_config(self, **kwargs): + client_config_setup.install_client_config(client_name="mainsail") + + def install_fluidd(self, **kwargs): + client_setup.install_client(client_name="fluidd") + + def install_fluidd_config(self, **kwargs): + client_config_setup.install_client_config(client_name="fluidd") diff --git a/kiauh/core/menus/main_menu.py b/kiauh/core/menus/main_menu.py new file mode 100644 index 0000000..4ab3b31 --- /dev/null +++ b/kiauh/core/menus/main_menu.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper.klipper_utils import get_klipper_status +from components.log_uploads.menus.log_upload_menu import LogUploadMenu +from components.moonraker.moonraker_utils import get_moonraker_status +from components.webui_client.client_utils import ( + get_client_status, + load_client_data, + get_current_client_config, +) +from core.menus import QUIT_FOOTER +from core.menus.advanced_menu import AdvancedMenu +from core.menus.backup_menu import BackupMenu +from core.menus.base_menu import BaseMenu +from core.menus.extensions_menu import ExtensionsMenu +from core.menus.install_menu import InstallMenu +from core.menus.remove_menu import RemoveMenu +from core.menus.settings_menu import SettingsMenu +from core.menus.update_menu import UpdateMenu +from utils.constants import ( + COLOR_MAGENTA, + COLOR_CYAN, + RESET_FORMAT, + COLOR_RED, + COLOR_GREEN, + COLOR_YELLOW, +) + + +# noinspection PyMethodMayBeStatic +class MainMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + "0": LogUploadMenu, + "1": InstallMenu, + "2": UpdateMenu, + "3": RemoveMenu, + "4": AdvancedMenu, + "5": BackupMenu, + "e": ExtensionsMenu, + "s": SettingsMenu, + }, + footer_type=QUIT_FOOTER, + ) + self.kl_status = "" + self.kl_repo = "" + self.mr_status = "" + self.mr_repo = "" + self.ms_status = "" + self.fl_status = "" + self.ks_status = "" + self.mb_status = "" + self.cn_status = "" + self.cc_status = "" + self.init_status() + + def init_status(self) -> None: + status_vars = ["kl", "mr", "ms", "fl", "ks", "mb", "cn"] + for var in status_vars: + setattr(self, f"{var}_status", f"{COLOR_RED}Not installed!{RESET_FORMAT}") + + def fetch_status(self) -> None: + # klipper + klipper_status = get_klipper_status() + kl_status = klipper_status.get("status") + kl_code = klipper_status.get("status_code") + kl_instances = f" {klipper_status.get('instances')}" if kl_code == 1 else "" + self.kl_status = self.format_status_by_code(kl_code, kl_status, kl_instances) + self.kl_repo = f"{COLOR_CYAN}{klipper_status.get('repo')}{RESET_FORMAT}" + # moonraker + moonraker_status = get_moonraker_status() + mr_status = moonraker_status.get("status") + mr_code = moonraker_status.get("status_code") + mr_instances = f" {moonraker_status.get('instances')}" if mr_code == 1 else "" + self.mr_status = self.format_status_by_code(mr_code, mr_status, mr_instances) + self.mr_repo = f"{COLOR_CYAN}{moonraker_status.get('repo')}{RESET_FORMAT}" + # mainsail + mainsail_client_data = load_client_data("mainsail") + self.ms_status = get_client_status(mainsail_client_data) + # fluidd + fluidd_client_data = load_client_data("fluidd") + self.fl_status = get_client_status(fluidd_client_data) + # client-config + self.cc_status = get_current_client_config( + [mainsail_client_data, fluidd_client_data] + ) + + def format_status_by_code(self, code: int, status: str, count: str) -> str: + if code == 1: + return f"{COLOR_GREEN}{status}{count}{RESET_FORMAT}" + elif code == 2: + return f"{COLOR_RED}{status}{count}{RESET_FORMAT}" + + return f"{COLOR_YELLOW}{status}{count}{RESET_FORMAT}" + + def print_menu(self): + self.fetch_status() + + header = " [ Main Menu ] " + footer1 = "KIAUH v6.0.0" + footer2 = f"Changelog: {COLOR_MAGENTA}https://git.io/JnmlX{RESET_FORMAT}" + color = COLOR_CYAN + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | 0) [Log-Upload] | Klipper: {self.kl_status:<32} | + | | Repo: {self.kl_repo:<32} | + | 1) [Install] |------------------------------------| + | 2) [Update] | Moonraker: {self.mr_status:<32} | + | 3) [Remove] | Repo: {self.mr_repo:<32} | + | 4) [Advanced] |------------------------------------| + | 5) [Backup] | Mainsail: {self.ms_status:<26} | + | | Fluidd: {self.fl_status:<26} | + | S) [Settings] | Client-Config: {self.cc_status:<26} | + | | | + | Community: | KlipperScreen: {self.ks_status:<26} | + | E) [Extensions] | Mobileraker: {self.mb_status:<26} | + | | Crowsnest: {self.cn_status:<26} | + |-------------------------------------------------------| + | {COLOR_CYAN}{footer1:^16}{RESET_FORMAT} | {footer2:^43} | + """ + )[1:] + print(menu, end="") diff --git a/kiauh/core/menus/remove_menu.py b/kiauh/core/menus/remove_menu.py new file mode 100644 index 0000000..ad53c94 --- /dev/null +++ b/kiauh/core/menus/remove_menu.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper.menus.klipper_remove_menu import KlipperRemoveMenu +from components.moonraker.menus.moonraker_remove_menu import MoonrakerRemoveMenu +from components.webui_client.client_utils import load_client_data +from components.webui_client.menus.client_remove_menu import ClientRemoveMenu +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import COLOR_RED, RESET_FORMAT + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class RemoveMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + "1": KlipperRemoveMenu, + "2": MoonrakerRemoveMenu, + "3": ClientRemoveMenu(client=load_client_data("mainsail")), + "4": ClientRemoveMenu(client=load_client_data("fluidd")), + "5": None, + "6": None, + "7": None, + "8": None, + "9": None, + "10": None, + "11": None, + "12": None, + "13": None, + }, + footer_type=BACK_FOOTER, + ) + + def print_menu(self): + header = " [ Remove Menu ] " + color = COLOR_RED + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | INFO: Configurations and/or any backups will be kept! | + |-------------------------------------------------------| + | Firmware & API: | Webcam Streamer: | + | 1) [Klipper] | 6) [Crowsnest] | + | 2) [Moonraker] | 7) [MJPG-Streamer] | + | | | + | Klipper Webinterface: | Other: | + | 3) [Mainsail] | 8) [PrettyGCode] | + | 4) [Fluidd] | 9) [Telegram Bot] | + | | 10) [Obico for Klipper] | + | Touchscreen GUI: | 11) [OctoEverywhere] | + | 5) [KlipperScreen] | 12) [Mobileraker] | + | | 13) [NGINX] | + | | | + """ + )[1:] + print(menu, end="") diff --git a/kiauh/core/menus/settings_menu.py b/kiauh/core/menus/settings_menu.py new file mode 100644 index 0000000..66f352c --- /dev/null +++ b/kiauh/core/menus/settings_menu.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 core.menus.base_menu import BaseMenu + + +# noinspection PyMethodMayBeStatic +class SettingsMenu(BaseMenu): + def __init__(self): + super().__init__(header=True, options={}) + + def print_menu(self): + print("self") + + def execute_option_p(self): + # Implement the functionality for Option P + print("Executing Option P") + + def execute_option_q(self): + # Implement the functionality for Option Q + print("Executing Option Q") + + def execute_option_r(self): + # Implement the functionality for Option R + print("Executing Option R") diff --git a/kiauh/core/menus/update_menu.py b/kiauh/core/menus/update_menu.py new file mode 100644 index 0000000..ffa74ba --- /dev/null +++ b/kiauh/core/menus/update_menu.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 textwrap + +from components.klipper.klipper_setup import update_klipper +from components.klipper.klipper_utils import ( + get_klipper_status, +) +from components.moonraker.moonraker_setup import update_moonraker +from components.moonraker.moonraker_utils import get_moonraker_status +from components.webui_client.client_config.client_config_setup import ( + update_client_config, +) +from components.webui_client.client_setup import update_client +from components.webui_client.client_utils import ( + get_local_client_version, + get_remote_client_version, + load_client_data, + get_client_config_status, +) +from core.menus import BACK_FOOTER +from core.menus.base_menu import BaseMenu +from utils.constants import ( + COLOR_GREEN, + RESET_FORMAT, + COLOR_YELLOW, + COLOR_WHITE, + COLOR_RED, +) + + +# noinspection PyUnusedLocal +# noinspection PyMethodMayBeStatic +class UpdateMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + "0": self.update_all, + "1": self.update_klipper, + "2": self.update_moonraker, + "3": self.update_mainsail, + "4": self.update_fluidd, + "5": self.update_mainsail_config, + "6": self.update_fluidd_config, + "7": self.update_klipperscreen, + "8": self.update_mobileraker, + "9": self.update_crowsnest, + "10": self.upgrade_system_packages, + }, + footer_type=BACK_FOOTER, + ) + self.kl_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.kl_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.mr_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.mr_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.ms_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.ms_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.fl_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.fl_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.mc_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.mc_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + self.fc_local = f"{COLOR_WHITE}{RESET_FORMAT}" + self.fc_remote = f"{COLOR_WHITE}{RESET_FORMAT}" + + def print_menu(self): + self.fetch_update_status() + + header = " [ Update Menu ] " + color = COLOR_GREEN + count = 62 - len(color) - len(RESET_FORMAT) + menu = textwrap.dedent( + f""" + /=======================================================\\ + | {color}{header:~^{count}}{RESET_FORMAT} | + |-------------------------------------------------------| + | 0) Update all | | | + | | Current: | Latest: | + | Klipper & API: |---------------|---------------| + | 1) Klipper | {self.kl_local:<22} | {self.kl_remote:<22} | + | 2) Moonraker | {self.mr_local:<22} | {self.mr_remote:<22} | + | | | | + | Webinterface: |---------------|---------------| + | 3) Mainsail | {self.ms_local:<22} | {self.ms_remote:<22} | + | 4) Fluidd | {self.fl_local:<22} | {self.fl_remote:<22} | + | | | | + | Client-Config: |---------------|---------------| + | 5) Mainsail-Config | {self.mc_local:<22} | {self.mc_remote:<22} | + | 6) Fluidd-Config | {self.fc_local:<22} | {self.fc_remote:<22} | + | | | | + | Other: |---------------|---------------| + | 7) KlipperScreen | | | + | 8) Mobileraker | | | + | 9) Crowsnest | | | + | |-------------------------------| + | 10) System | | + """ + )[1:] + print(menu, end="") + + def update_all(self, **kwargs): + print("update_all") + + def update_klipper(self, **kwargs): + update_klipper() + + def update_moonraker(self, **kwargs): + update_moonraker() + + def update_mainsail(self, **kwargs): + update_client(load_client_data("mainsail")) + + def update_mainsail_config(self, **kwargs): + update_client_config(load_client_data("mainsail")) + + def update_fluidd(self, **kwargs): + update_client(load_client_data("fluidd")) + + def update_fluidd_config(self, **kwargs): + update_client_config(load_client_data("fluidd")) + + def update_klipperscreen(self, **kwargs): ... + + def update_mobileraker(self, **kwargs): ... + + def update_crowsnest(self, **kwargs): ... + + def upgrade_system_packages(self, **kwargs): ... + + def fetch_update_status(self): + # klipper + kl_status = get_klipper_status() + self.kl_local = kl_status.get("local") + self.kl_remote = kl_status.get("remote") + if self.kl_local == self.kl_remote: + self.kl_local = f"{COLOR_GREEN}{self.kl_local}{RESET_FORMAT}" + else: + self.kl_local = f"{COLOR_YELLOW}{self.kl_local}{RESET_FORMAT}" + self.kl_remote = f"{COLOR_GREEN}{self.kl_remote}{RESET_FORMAT}" + # moonraker + mr_status = get_moonraker_status() + self.mr_local = mr_status.get("local") + self.mr_remote = mr_status.get("remote") + if self.mr_local == self.mr_remote: + self.mr_local = f"{COLOR_GREEN}{self.mr_local}{RESET_FORMAT}" + else: + self.mr_local = f"{COLOR_YELLOW}{self.mr_local}{RESET_FORMAT}" + self.mr_remote = f"{COLOR_GREEN}{self.mr_remote}{RESET_FORMAT}" + # mainsail + mainsail_client_data = load_client_data("mainsail") + self.ms_local = get_local_client_version(mainsail_client_data) + self.ms_remote = get_remote_client_version(mainsail_client_data) + if self.ms_local == self.ms_remote: + self.ms_local = f"{COLOR_GREEN}{self.ms_local}{RESET_FORMAT}" + else: + self.ms_local = f"{COLOR_YELLOW}{self.ms_local}{RESET_FORMAT}" + self.ms_remote = f"{COLOR_GREEN if self.ms_remote != 'ERROR' else COLOR_RED}{self.ms_remote}{RESET_FORMAT}" + # fluidd + fluidd_client_data = load_client_data("fluidd") + self.fl_local = get_local_client_version(fluidd_client_data) + self.fl_remote = get_remote_client_version(fluidd_client_data) + if self.fl_local == self.fl_remote: + self.fl_local = f"{COLOR_GREEN}{self.fl_local}{RESET_FORMAT}" + else: + self.fl_local = f"{COLOR_YELLOW}{self.fl_local}{RESET_FORMAT}" + self.fl_remote = f"{COLOR_GREEN if self.fl_remote != 'ERROR' else COLOR_RED}{self.fl_remote}{RESET_FORMAT}" + # mainsail-config + mc_status = get_client_config_status(load_client_data("mainsail")) + self.mc_local = mc_status.get("local") + self.mc_remote = mc_status.get("remote") + if self.mc_local == self.mc_remote: + self.mc_local = f"{COLOR_GREEN}{self.mc_local}{RESET_FORMAT}" + else: + self.mc_local = f"{COLOR_YELLOW}{self.mc_local}{RESET_FORMAT}" + self.mc_remote = f"{COLOR_GREEN}{self.mc_remote}{RESET_FORMAT}" + # fluidd-config + fc_status = get_client_config_status(load_client_data("fluidd")) + self.fc_local = fc_status.get("local") + self.fc_remote = fc_status.get("remote") + if self.fc_local == self.mc_remote: + self.fc_local = f"{COLOR_GREEN}{self.fc_local}{RESET_FORMAT}" + else: + self.fc_local = f"{COLOR_YELLOW}{self.fc_local}{RESET_FORMAT}" + self.fc_remote = f"{COLOR_GREEN}{self.fc_remote}{RESET_FORMAT}" diff --git a/kiauh/core/repo_manager/__init__.py b/kiauh/core/repo_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/core/repo_manager/repo_manager.py b/kiauh/core/repo_manager/repo_manager.py new file mode 100644 index 0000000..a8f9eec --- /dev/null +++ b/kiauh/core/repo_manager/repo_manager.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 shutil +import subprocess +from pathlib import Path + +from utils.input_utils import get_confirm +from utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class RepoManager: + def __init__( + self, + repo: str, + target_dir: str, + branch: str = None, + ): + self._repo = repo + self._branch = branch if branch is not None else "master" + self._method = self._get_method() + self._target_dir = target_dir + + @property + def repo(self) -> str: + return self._repo + + @repo.setter + def repo(self, value) -> None: + self._repo = value + + @property + def branch(self) -> str: + return self._branch + + @branch.setter + def branch(self, value) -> None: + self._branch = value + + @property + def method(self) -> str: + return self._method + + @method.setter + def method(self, value) -> None: + self._method = value + + @property + def target_dir(self) -> str: + return self._target_dir + + @target_dir.setter + def target_dir(self, value) -> None: + self._target_dir = value + + @staticmethod + def get_repo_name(repo: Path) -> str: + """ + Helper method to extract the organisation and name of a repository | + :param repo: repository to extract the values from + :return: String in form of "/" + """ + if not repo.exists() and not repo.joinpath(".git").exists(): + return "-" + + try: + cmd = ["git", "-C", repo, "config", "--get", "remote.origin.url"] + result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return "/".join(result.decode().strip().split("/")[-2:]) + except subprocess.CalledProcessError: + return "-" + + @staticmethod + def get_local_commit(repo: Path) -> str: + if not repo.exists() and not repo.joinpath(".git").exists(): + return "-" + + try: + cmd = f"cd {repo} && git describe HEAD --always --tags | cut -d '-' -f 1,2" + return subprocess.check_output(cmd, shell=True, text=True).strip() + except subprocess.CalledProcessError: + return "-" + + @staticmethod + def get_remote_commit(repo: Path) -> str: + if not repo.exists() and not repo.joinpath(".git").exists(): + return "-" + + try: + # get locally checked out branch + branch_cmd = f"cd {repo} && git branch | grep -E '\*'" + branch = subprocess.check_output(branch_cmd, shell=True, text=True) + branch = branch.split("*")[-1].strip() + cmd = f"cd {repo} && git describe 'origin/{branch}' --always --tags | cut -d '-' -f 1,2" + return subprocess.check_output(cmd, shell=True, text=True).strip() + except subprocess.CalledProcessError: + return "-" + + def clone_repo(self): + log = f"Cloning repository from '{self.repo}' with method '{self.method}'" + Logger.print_status(log) + try: + if Path(self.target_dir).exists(): + question = f"'{self.target_dir}' already exists. Overwrite?" + if not get_confirm(question, default_choice=False): + Logger.print_info("Skipping re-clone of repository.") + return + shutil.rmtree(self.target_dir) + + self._clone() + self._checkout() + except subprocess.CalledProcessError: + log = "An unexpected error occured during cloning of the repository." + Logger.print_error(log) + return + except OSError as e: + Logger.print_error(f"Error removing existing repository: {e.strerror}") + return + + def pull_repo(self) -> None: + Logger.print_status(f"Updating repository '{self.repo}' ...") + try: + self._pull() + except subprocess.CalledProcessError: + log = "An unexpected error occured during updating the repository." + Logger.print_error(log) + return + + def _clone(self): + try: + command = ["git", "clone", self.repo, self.target_dir] + subprocess.run(command, check=True) + + Logger.print_ok("Clone successfull!") + except subprocess.CalledProcessError as e: + log = f"Error cloning repository {self.repo}: {e.stderr.decode()}" + Logger.print_error(log) + raise + + def _checkout(self): + try: + command = ["git", "checkout", f"{self.branch}"] + subprocess.run(command, cwd=self.target_dir, check=True) + + Logger.print_ok("Checkout successfull!") + except subprocess.CalledProcessError as e: + log = f"Error checking out branch {self.branch}: {e.stderr.decode()}" + Logger.print_error(log) + raise + + def _pull(self) -> None: + try: + command = ["git", "pull"] + subprocess.run(command, cwd=self.target_dir, check=True) + except subprocess.CalledProcessError as e: + log = f"Error on git pull: {e.stderr.decode()}" + Logger.print_error(log) + raise + + def _get_method(self) -> str: + return "ssh" if self.repo.startswith("git") else "https" diff --git a/kiauh/extensions/__init__.py b/kiauh/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/extensions/gcode_shell_cmd/__init__.py b/kiauh/extensions/gcode_shell_cmd/__init__.py new file mode 100644 index 0000000..27ffecb --- /dev/null +++ b/kiauh/extensions/gcode_shell_cmd/__init__.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +EXT_MODULE_NAME = "gcode_shell_command.py" +MODULE_PATH = Path(__file__).resolve().parent +MODULE_ASSETS = MODULE_PATH.joinpath("assets") +KLIPPER_DIR = Path.home().joinpath("klipper") +KLIPPER_EXTRAS = KLIPPER_DIR.joinpath("klippy/extras") +EXTENSION_SRC = MODULE_ASSETS.joinpath(EXT_MODULE_NAME) +EXTENSION_TARGET_PATH = KLIPPER_EXTRAS.joinpath(EXT_MODULE_NAME) +EXAMPLE_CFG_SRC = MODULE_ASSETS.joinpath("shell_command.cfg") diff --git a/kiauh/extensions/gcode_shell_cmd/assets/gcode_shell_command.py b/kiauh/extensions/gcode_shell_cmd/assets/gcode_shell_command.py new file mode 100644 index 0000000..85b664b --- /dev/null +++ b/kiauh/extensions/gcode_shell_cmd/assets/gcode_shell_command.py @@ -0,0 +1,94 @@ +# Run a shell command via gcode +# +# Copyright (C) 2019 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import logging +import os +import shlex +import subprocess + + +class ShellCommand: + def __init__(self, config): + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object("gcode") + cmd = config.get("command") + cmd = os.path.expanduser(cmd) + self.command = shlex.split(cmd) + self.timeout = config.getfloat("timeout", 2.0, above=0.0) + self.verbose = config.getboolean("verbose", True) + self.proc_fd = None + self.partial_output = "" + self.gcode.register_mux_command( + "RUN_SHELL_COMMAND", + "CMD", + self.name, + self.cmd_RUN_SHELL_COMMAND, + desc=self.cmd_RUN_SHELL_COMMAND_help, + ) + + def _process_output(self, eventime): + if self.proc_fd is None: + return + try: + data = os.read(self.proc_fd, 4096) + except Exception: + pass + data = self.partial_output + data.decode() + if "\n" not in data: + self.partial_output = data + return + elif data[-1] != "\n": + split = data.rfind("\n") + 1 + self.partial_output = data[split:] + data = data[:split] + else: + self.partial_output = "" + self.gcode.respond_info(data) + + cmd_RUN_SHELL_COMMAND_help = "Run a linux shell command" + + def cmd_RUN_SHELL_COMMAND(self, params): + gcode_params = params.get("PARAMS", "") + gcode_params = shlex.split(gcode_params) + reactor = self.printer.get_reactor() + try: + proc = subprocess.Popen( + self.command + gcode_params, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except Exception: + logging.exception("shell_command: Command {%s} failed" % (self.name)) + raise self.gcode.error("Error running command {%s}" % (self.name)) + if self.verbose: + self.proc_fd = proc.stdout.fileno() + self.gcode.respond_info("Running Command {%s}...:" % (self.name)) + hdl = reactor.register_fd(self.proc_fd, self._process_output) + eventtime = reactor.monotonic() + endtime = eventtime + self.timeout + complete = False + while eventtime < endtime: + eventtime = reactor.pause(eventtime + 0.05) + if proc.poll() is not None: + complete = True + break + if not complete: + proc.terminate() + if self.verbose: + if self.partial_output: + self.gcode.respond_info(self.partial_output) + self.partial_output = "" + if complete: + msg = "Command {%s} finished\n" % (self.name) + else: + msg = "Command {%s} timed out" % (self.name) + self.gcode.respond_info(msg) + reactor.unregister_fd(hdl) + self.proc_fd = None + + +def load_config_prefix(config): + return ShellCommand(config) diff --git a/kiauh/extensions/gcode_shell_cmd/assets/shell_command.cfg b/kiauh/extensions/gcode_shell_cmd/assets/shell_command.cfg new file mode 100644 index 0000000..34e7581 --- /dev/null +++ b/kiauh/extensions/gcode_shell_cmd/assets/shell_command.cfg @@ -0,0 +1,7 @@ +[gcode_shell_command hello_world] +command: echo hello world +timeout: 2. +verbose: True +[gcode_macro HELLO_WORLD] +gcode: + RUN_SHELL_COMMAND CMD=hello_world \ No newline at end of file diff --git a/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py b/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py new file mode 100644 index 0000000..bf0a977 --- /dev/null +++ b/kiauh/extensions/gcode_shell_cmd/gcode_shell_cmd_extension.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 os +import shutil +from typing import List + +from components.klipper.klipper import Klipper +from core.backup_manager.backup_manager import BackupManager +from core.base_extension import BaseExtension +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.instance_manager import InstanceManager +from extensions.gcode_shell_cmd import ( + EXTENSION_TARGET_PATH, + EXTENSION_SRC, + KLIPPER_DIR, + EXAMPLE_CFG_SRC, + KLIPPER_EXTRAS, +) +from utils.filesystem_utils import check_file_exist +from utils.input_utils import get_confirm +from utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class GcodeShellCmdExtension(BaseExtension): + def install_extension(self, **kwargs) -> None: + install_example = get_confirm("Create an example shell command?", False, False) + + klipper_dir_exists = check_file_exist(KLIPPER_DIR) + if not klipper_dir_exists: + Logger.print_warn( + "No Klipper directory found! Unable to install extension." + ) + return + + extension_installed = check_file_exist(EXTENSION_TARGET_PATH) + overwrite = True + if extension_installed: + overwrite = get_confirm( + "Extension seems to be installed already. Overwrite?", True, False + ) + + if not overwrite: + Logger.print_warn("Installation aborted due to user request.") + return + + im = InstanceManager(Klipper) + im.stop_all_instance() + + try: + Logger.print_status(f"Copy extension to '{KLIPPER_EXTRAS}' ...") + shutil.copy(EXTENSION_SRC, EXTENSION_TARGET_PATH) + except OSError as e: + Logger.print_error(f"Unable to install extension: {e}") + return + + if install_example: + self.install_example_cfg(im.instances) + + im.start_all_instance() + + Logger.print_ok("Installing G-Code Shell Command extension successfull!") + + def remove_extension(self, **kwargs) -> None: + extension_installed = check_file_exist(EXTENSION_TARGET_PATH) + if not extension_installed: + Logger.print_info("Extension does not seem to be installed! Skipping ...") + return + + question = "Do you really want to remove the extension?" + if get_confirm(question, True, False): + try: + Logger.print_status(f"Removing '{EXTENSION_TARGET_PATH}' ...") + os.remove(EXTENSION_TARGET_PATH) + Logger.print_ok("Extension successfully removed!") + except OSError as e: + Logger.print_error(f"Unable to remove extension: {e}") + + Logger.print_warn("PLEASE NOTE:") + Logger.print_warn( + "Remaining gcode shell command will cause Klipper to throw an error." + ) + Logger.print_warn("Make sure to remove them from the printer.cfg!") + + def install_example_cfg(self, instances: List[Klipper]): + cfg_dirs = [instance.cfg_dir for instance in instances] + # copy extension to klippy/extras + for cfg_dir in cfg_dirs: + Logger.print_status(f"Create shell_command.cfg in '{cfg_dir}' ...") + if check_file_exist(cfg_dir.joinpath("shell_command.cfg")): + Logger.print_info("File already exists! Skipping ...") + continue + try: + shutil.copy(EXAMPLE_CFG_SRC, cfg_dir) + Logger.print_ok("Done!") + except OSError as e: + Logger.warn(f"Unable to create example config: {e}") + + # backup each printer.cfg before modification + bm = BackupManager() + for instance in instances: + bm.backup_file( + instance.cfg_file, + custom_filename=f"{instance.suffix}.printer.cfg", + ) + + # add section to printer.cfg if not already defined + section = "include shell_command.cfg" + cfg_files = [instance.cfg_file for instance in instances] + for cfg_file in cfg_files: + Logger.print_status(f"Include shell_command.cfg in '{cfg_file}' ...") + cm = ConfigManager(cfg_file) + if cm.config.has_section(section): + Logger.print_info("Section already defined! Skipping ...") + continue + cm.config.add_section(section) + cm.write_config() + Logger.print_ok("Done!") diff --git a/kiauh/extensions/gcode_shell_cmd/metadata.json b/kiauh/extensions/gcode_shell_cmd/metadata.json new file mode 100644 index 0000000..cfb38b4 --- /dev/null +++ b/kiauh/extensions/gcode_shell_cmd/metadata.json @@ -0,0 +1,9 @@ +{ + "metadata": { + "index": 1, + "module": "gcode_shell_cmd_extension", + "maintained_by": "dw-0", + "display_name": "G-Code Shell Command", + "description": "Allows to run a shell command from gcode." + } +} diff --git a/kiauh/main.py b/kiauh/main.py new file mode 100644 index 0000000..0ca361c --- /dev/null +++ b/kiauh/main.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 core.menus.main_menu import MainMenu +from utils.logger import Logger + + +def main(): + try: + MainMenu().start() + except KeyboardInterrupt: + Logger.print_ok("\nHappy printing!\n", prefix=False) diff --git a/kiauh/utils/__init__.py b/kiauh/utils/__init__.py new file mode 100644 index 0000000..afc69aa --- /dev/null +++ b/kiauh/utils/__init__.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 + +from core.backup_manager import BACKUP_ROOT_DIR + +MODULE_PATH = Path(__file__).resolve().parent +INVALID_CHOICE = "Invalid choice. Please select a valid value." +PRINTER_CFG_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("printer-cfg-backups") + +# ================== NGINX =====================# +NGINX_SITES_AVAILABLE = Path("/etc/nginx/sites-available") +NGINX_SITES_ENABLED = Path("/etc/nginx/sites-enabled") +NGINX_CONFD = Path("/etc/nginx/conf.d") diff --git a/kiauh/utils/assets/common_vars.conf b/kiauh/utils/assets/common_vars.conf new file mode 100644 index 0000000..9c3f85e --- /dev/null +++ b/kiauh/utils/assets/common_vars.conf @@ -0,0 +1,6 @@ +# /etc/nginx/conf.d/common_vars.conf + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} \ No newline at end of file diff --git a/kiauh/utils/assets/nginx_cfg b/kiauh/utils/assets/nginx_cfg new file mode 100644 index 0000000..d7aabf4 --- /dev/null +++ b/kiauh/utils/assets/nginx_cfg @@ -0,0 +1,95 @@ +server { + listen %PORT%; + # uncomment the next line to activate IPv6 + # listen [::]:%PORT%; + + access_log /var/log/nginx/%NAME%-access.log; + error_log /var/log/nginx/%NAME%-error.log; + + # disable this section on smaller hardware like a pi zero + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_proxied expired no-cache no-store private auth; + gzip_comp_level 4; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml; + + # web_path from %NAME% static files + root %ROOT_DIR%; + + index index.html; + server_name _; + + # disable max upload size checks + client_max_body_size 0; + + # disable proxy request buffering + proxy_request_buffering off; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location /websocket { + proxy_pass http://apiserver/websocket; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + } + + location ~ ^/(printer|api|access|machine|server)/ { + proxy_pass http://apiserver$request_uri; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + } + + location /webcam/ { + postpone_output 0; + proxy_buffering off; + proxy_ignore_headers X-Accel-Buffering; + access_log off; + error_log off; + proxy_pass http://mjpgstreamer1/; + } + + location /webcam2/ { + postpone_output 0; + proxy_buffering off; + proxy_ignore_headers X-Accel-Buffering; + access_log off; + error_log off; + proxy_pass http://mjpgstreamer2/; + } + + location /webcam3/ { + postpone_output 0; + proxy_buffering off; + proxy_ignore_headers X-Accel-Buffering; + access_log off; + error_log off; + proxy_pass http://mjpgstreamer3/; + } + + location /webcam4/ { + postpone_output 0; + proxy_buffering off; + proxy_ignore_headers X-Accel-Buffering; + access_log off; + error_log off; + proxy_pass http://mjpgstreamer4/; + } +} diff --git a/kiauh/utils/assets/upstreams.conf b/kiauh/utils/assets/upstreams.conf new file mode 100644 index 0000000..d04e04a --- /dev/null +++ b/kiauh/utils/assets/upstreams.conf @@ -0,0 +1,25 @@ +# /etc/nginx/conf.d/upstreams.conf +upstream apiserver { + ip_hash; + server 127.0.0.1:7125; +} + +upstream mjpgstreamer1 { + ip_hash; + server 127.0.0.1:8080; +} + +upstream mjpgstreamer2 { + ip_hash; + server 127.0.0.1:8081; +} + +upstream mjpgstreamer3 { + ip_hash; + server 127.0.0.1:8082; +} + +upstream mjpgstreamer4 { + ip_hash; + server 127.0.0.1:8083; +} \ No newline at end of file diff --git a/kiauh/utils/common.py b/kiauh/utils/common.py new file mode 100644 index 0000000..4e5bbee --- /dev/null +++ b/kiauh/utils/common.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 datetime import datetime +from pathlib import Path +from typing import Dict, Literal, List, Type, Union + +from components.klipper.klipper import Klipper +from core.instance_manager.base_instance import BaseInstance +from core.instance_manager.instance_manager import InstanceManager +from utils import PRINTER_CFG_BACKUP_DIR +from utils.constants import ( + COLOR_CYAN, + RESET_FORMAT, + COLOR_YELLOW, + COLOR_GREEN, + COLOR_RED, +) +from utils.filesystem_utils import check_file_exist +from utils.logger import Logger +from utils.system_utils import check_package_install, install_system_packages + + +def get_current_date() -> Dict[Literal["date", "time"], str]: + """ + Get the current date | + :return: Dict holding a date and time key:value pair + """ + now: datetime = datetime.today() + date: str = now.strftime("%Y%m%d") + time: str = now.strftime("%H%M%S") + + return {"date": date, "time": time} + + +def check_install_dependencies(deps: List[str]) -> None: + """ + Common helper method to check if dependencies are installed + and if not, install them automatically | + :param deps: List of strings of package names to check if installed + :return: None + """ + requirements = check_package_install(deps) + if requirements: + Logger.print_status("Installing dependencies ...") + Logger.print_info("The following packages need installation:") + for _ in requirements: + print(f"{COLOR_CYAN}● {_}{RESET_FORMAT}") + install_system_packages(requirements) + + +def get_install_status_common( + instance_type: Type[BaseInstance], repo_dir: Path, env_dir: Path +) -> Dict[Literal["status", "status_code", "instances"], Union[str, int]]: + """ + Helper method to get the installation status of software components, + which only consist of 3 major parts and if those parts exist, the + component can be considered as "installed". Typically, Klipper or + Moonraker match that criteria. + :param instance_type: The component type + :param repo_dir: the repository directory + :param env_dir: the python environment directory + :return: Dictionary with status string, statuscode and instance count + """ + im = InstanceManager(instance_type) + instances_exist = len(im.instances) > 0 + status = [repo_dir.exists(), env_dir.exists(), instances_exist] + if all(status): + return { + "status": "Installed:", + "status_code": 1, + "instances": len(im.instances), + } + elif not any(status): + return { + "status": "Not installed!", + "status_code": 2, + "instances": len(im.instances), + } + else: + return { + "status": "Incomplete!", + "status_code": 3, + "instances": len(im.instances), + } + + +def get_install_status_webui( + install_dir: Path, nginx_cfg: Path, upstreams_cfg: Path, common_cfg: Path +) -> str: + """ + Helper method to get the installation status of webuis + like Mainsail or Fluidd | + :param install_dir: folder of the static webui files + :param nginx_cfg: the webuis NGINX config + :param upstreams_cfg: the required upstreams.conf + :param common_cfg: the required common_vars.conf + :return: formatted string, containing the status + """ + dir_exist = install_dir.exists() + nginx_cfg_exist = check_file_exist(nginx_cfg) + upstreams_cfg_exist = check_file_exist(upstreams_cfg) + common_cfg_exist = check_file_exist(common_cfg) + status = [dir_exist, nginx_cfg_exist] + general_nginx_status = [upstreams_cfg_exist, common_cfg_exist] + + if all(status) and all(general_nginx_status): + return f"{COLOR_GREEN}Installed!{RESET_FORMAT}" + elif not all(status): + return f"{COLOR_RED}Not installed!{RESET_FORMAT}" + else: + return f"{COLOR_YELLOW}Incomplete!{RESET_FORMAT}" + + +def backup_printer_config_dir(): + # local import to prevent circular import + from core.backup_manager.backup_manager import BackupManager + + im = InstanceManager(Klipper) + instances: List[Klipper] = im.instances + bm = BackupManager() + + for instance in instances: + name = f"config-{instance.data_dir_name}" + bm.backup_directory( + name, + source=instance.cfg_dir, + target=PRINTER_CFG_BACKUP_DIR, + ) diff --git a/kiauh/utils/constants.py b/kiauh/utils/constants.py new file mode 100644 index 0000000..a2dd612 --- /dev/null +++ b/kiauh/utils/constants.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 os +import pwd +from pathlib import Path + +# text colors and formats +COLOR_WHITE = "\033[37m" # white +COLOR_MAGENTA = "\033[35m" # magenta +COLOR_GREEN = "\033[92m" # bright green +COLOR_YELLOW = "\033[93m" # bright yellow +COLOR_RED = "\033[91m" # bright red +COLOR_CYAN = "\033[96m" # bright cyan +RESET_FORMAT = "\033[0m" # reset format +# current user +CURRENT_USER = pwd.getpwuid(os.getuid())[0] +SYSTEMD = Path("/etc/systemd/system") diff --git a/kiauh/utils/filesystem_utils.py b/kiauh/utils/filesystem_utils.py new file mode 100644 index 0000000..b7fed2f --- /dev/null +++ b/kiauh/utils/filesystem_utils.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 +import shutil +import subprocess +from pathlib import Path +from zipfile import ZipFile + +from typing import List, Type, TypeVar, Union, Tuple + +from components.klipper.klipper import Klipper +from components.moonraker.moonraker import Moonraker +from core.config_manager.config_manager import ConfigManager +from core.instance_manager.base_instance import BaseInstance +from core.instance_manager.instance_manager import InstanceManager +from utils import ( + NGINX_SITES_AVAILABLE, + MODULE_PATH, + NGINX_CONFD, + NGINX_SITES_ENABLED, +) +from utils.logger import Logger + + +B = TypeVar('B', bound='BaseInstance') +ConfigOption = Tuple[str, str] + + +def check_file_exist(file_path: Path, sudo=False) -> bool: + """ + Helper function for checking the existence of a file | + :param file_path: the absolute path of the file to check + :param sudo: use sudo if required + :return: True, if file exists, otherwise False + """ + if sudo: + try: + command = ["sudo", "find", file_path] + subprocess.check_output(command, stderr=subprocess.DEVNULL) + return True + except subprocess.CalledProcessError: + return False + else: + if file_path.exists(): + return True + else: + return False + + +def create_symlink(source: Path, target: Path, sudo=False) -> None: + try: + cmd = ["ln", "-sf", source, target] + if sudo: + cmd.insert(0, "sudo") + subprocess.run(cmd, stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + Logger.print_error(f"Failed to create symlink: {e}") + raise + + +def remove_file(file_path: Path, sudo=False) -> None: + try: + cmd = f"{'sudo ' if sudo else ''}rm -f {file_path}" + subprocess.run(cmd, stderr=subprocess.PIPE, check=True, shell=True) + except subprocess.CalledProcessError as e: + log = f"Cannot remove file {file_path}: {e.stderr.decode()}" + Logger.print_error(log) + raise + + +def unzip(filepath: Path, target_dir: Path) -> None: + """ + Helper function to unzip a zip-archive into a target directory | + :param filepath: the path to the zip-file to unzip + :param target_dir: the target directory to extract the files into + :return: None + """ + with ZipFile(filepath, "r") as _zip: + _zip.extractall(target_dir) + + +def copy_upstream_nginx_cfg() -> None: + """ + Creates an upstream.conf in /etc/nginx/conf.d + :return: None + """ + source = MODULE_PATH.joinpath("assets/upstreams.conf") + target = NGINX_CONFD.joinpath("upstreams.conf") + try: + command = ["sudo", "cp", source, target] + subprocess.run(command, stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + log = f"Unable to create upstreams.conf: {e.stderr.decode()}" + Logger.print_error(log) + raise + + +def copy_common_vars_nginx_cfg() -> None: + """ + Creates a common_vars.conf in /etc/nginx/conf.d + :return: None + """ + source = MODULE_PATH.joinpath("assets/common_vars.conf") + target = NGINX_CONFD.joinpath("common_vars.conf") + try: + command = ["sudo", "cp", source, target] + subprocess.run(command, stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + log = f"Unable to create upstreams.conf: {e.stderr.decode()}" + Logger.print_error(log) + raise + + +def create_nginx_cfg(name: str, port: int, root_dir: Path) -> None: + """ + Creates an NGINX config from a template file and replaces all placeholders + :param name: name of the config to create + :param port: listen port + :param root_dir: directory of the static files + :return: None + """ + tmp = Path.home().joinpath(f"{name}.tmp") + shutil.copy(MODULE_PATH.joinpath("assets/nginx_cfg"), tmp) + with open(tmp, "r+") as f: + content = f.read() + content = content.replace("%NAME%", name) + content = content.replace("%PORT%", str(port)) + content = content.replace("%ROOT_DIR%", str(root_dir)) + f.seek(0) + f.write(content) + f.truncate() + + target = NGINX_SITES_AVAILABLE.joinpath(name) + try: + command = ["sudo", "mv", tmp, target] + subprocess.run(command, stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + log = f"Unable to create '{target}': {e.stderr.decode()}" + Logger.print_error(log) + raise + + +def read_ports_from_nginx_configs() -> List[str]: + """ + Helper function to iterate over all NGINX configs and read all ports defined for listen + :return: A sorted list of listen ports + """ + if not NGINX_SITES_ENABLED.exists(): + return [] + + port_list = [] + for config in NGINX_SITES_ENABLED.iterdir(): + with open(config, "r") as cfg: + lines = cfg.readlines() + + for line in lines: + line = line.replace("default_server", "") + line = re.sub(r"[;:\[\]]", "", line.strip()) + if line.startswith("listen") and line.split()[-1] not in port_list: + port_list.append(line.split()[-1]) + + return sorted(port_list, key=lambda x: int(x)) + + +def is_valid_port(port: str, ports_in_use: List[str]) -> bool: + return port.isdigit() and port not in ports_in_use + + +def get_next_free_port(ports_in_use: List[str]) -> str: + valid_ports = set(range(80, 7125)) + used_ports = set(map(int, ports_in_use)) + + return str(min(valid_ports - used_ports)) + + +def add_config_section(section: str, instances: List[B], options: List[ConfigOption] = None) -> None: + for instance in instances: + cfg_file = instance.cfg_file + Logger.print_status(f"Add section '[{section}]' to '{cfg_file}' ...") + + if not Path(cfg_file).exists(): + Logger.print_warn(f"'{cfg_file}' not found!") + continue + + cm = ConfigManager(cfg_file) + if cm.config.has_section(section): + Logger.print_info("Section already exist. Skipped ...") + continue + + cm.config.add_section(section) + + if options is not None: + for option in options: + cm.config.set(section, option[0], option[1]) + + cm.write_config() + + +def remove_config_section(section: str, instances: List[B]) -> None: + for instance in instances: + cfg_file = instance.cfg_file + Logger.print_status(f"Remove section '[{section}]' from '{cfg_file}' ...") + + if not Path(cfg_file).exists(): + Logger.print_warn(f"'{cfg_file}' not found!") + continue + + cm = ConfigManager(cfg_file) + if not cm.config.has_section(section): + Logger.print_info("Section does not exist. Skipped ...") + continue + + cm.config.remove_section(section) + cm.write_config() + + +def patch_moonraker_conf( + moonraker_instances: List[Moonraker], + name: str, + section_name: str, + template_file: str, +) -> None: + for instance in moonraker_instances: + cfg_file = instance.cfg_file + Logger.print_status(f"Add {name} update section to '{cfg_file}' ...") + + if not Path(cfg_file).exists(): + Logger.print_warn(f"'{cfg_file}' not found!") + return + + cm = ConfigManager(cfg_file) + if cm.config.has_section(section_name): + Logger.print_info("Section already exist. Skipped ...") + return + + template = MODULE_PATH.joinpath("assets", template_file) + with open(template, "r") as t: + template_content = "\n" + template_content += t.read() + + with open(cfg_file, "a") as f: + f.write(template_content) + + +def remove_nginx_config(name: str) -> None: + Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...") + try: + remove_file(NGINX_SITES_AVAILABLE.joinpath(name), True) + remove_file(NGINX_SITES_ENABLED.joinpath(name), True) + + except subprocess.CalledProcessError as e: + log = f"Unable to remove NGINX config '{name}':\n{e.stderr.decode()}" + Logger.print_error(log) + + +def remove_nginx_logs(name: str) -> None: + Logger.print_status(f"Removing NGINX logs for {name.capitalize()} ...") + try: + remove_file(Path(f"/var/log/nginx/{name}-access.log"), True) + remove_file(Path(f"/var/log/nginx/{name}-error.log"), True) + + im = InstanceManager(Klipper) + instances: List[Klipper] = im.instances + if not instances: + return + + for instance in instances: + remove_file(instance.log_dir.joinpath(f"{name}-access.log")) + remove_file(instance.log_dir.joinpath(f"{name}-error.log")) + + except (OSError, subprocess.CalledProcessError) as e: + Logger.print_error(f"Unable to remove NGINX logs:\n{e}") diff --git a/kiauh/utils/input_utils.py b/kiauh/utils/input_utils.py new file mode 100644 index 0000000..d0a3b48 --- /dev/null +++ b/kiauh/utils/input_utils.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 typing import Optional, List, Union + +from utils import INVALID_CHOICE +from utils.constants import COLOR_CYAN, RESET_FORMAT +from utils.logger import Logger + + +def get_confirm( + question: str, default_choice=True, allow_go_back=False +) -> Union[bool, None]: + """ + Helper method for validating confirmation (yes/no) user input. | + :param question: The question to display + :param default_choice: A default if input was submitted without input + :param allow_go_back: Navigate back to a previous dialog + :return: Either True or False, or None on go_back + """ + options_confirm = ["y", "yes"] + options_decline = ["n", "no"] + options_go_back = ["b", "B"] + + if default_choice: + def_choice = "(Y/n)" + options_confirm.append("") + else: + def_choice = "(y/N)" + options_decline.append("") + + while True: + choice = ( + input(format_question(question + f" {def_choice}", None)).strip().lower() + ) + + if choice in options_confirm: + return True + elif choice in options_decline: + return False + elif allow_go_back and choice in options_go_back: + return None + else: + Logger.print_error(INVALID_CHOICE) + + +def get_number_input( + question: str, min_count: int, max_count=None, default=None, allow_go_back=False +) -> Union[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 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 + """ + options_go_back = ["b", "B"] + _question = format_question(question, default) + while True: + _input = input(_question) + if allow_go_back and _input in options_go_back: + return None + + if _input == "": + return default + + try: + return validate_number_input(_input, min_count, max_count) + except ValueError: + Logger.print_error(INVALID_CHOICE) + + +def get_string_input(question: str, exclude=Optional[List], default=None) -> str: + """ + Helper method to get a string input from the user + :param question: The question to display + :param exclude: List of strings which are not allowed + :param default: Optional default value + :return: The validated string value + """ + while True: + _input = input(format_question(question, default)).strip() + + if _input.isalnum() and _input.lower() not in exclude: + return _input + + Logger.print_error(INVALID_CHOICE) + if _input in exclude: + Logger.print_error("This value is already in use/reserved.") + + +def get_selection_input(question: str, option_list: List, default=None) -> str: + """ + Helper method to get a selection from a list of options from the user + :param question: The question to display + :param option_list: The list of options the user can select from + :param default: Optional default value + :return: The option that was selected by the user + """ + while True: + _input = input(format_question(question, default)).strip() + + if _input in option_list: + return _input + + Logger.print_error(INVALID_CHOICE) + + +def format_question(question: str, default=None) -> str: + """ + Helper method to have a standardized formatting of questions | + :param question: The question to display + :param default: If defined, the default option will be displayed to the user + :return: The formatted question string + """ + formatted_q = question + if default is not None: + formatted_q += f" (default={default})" + + return f"{COLOR_CYAN}###### {formatted_q}: {RESET_FORMAT}" + + +def validate_number_input(value: str, min_count: int, max_count: int) -> int: + """ + Helper method for a simple number input validation. | + :param value: The value to validate + :param min_count: The lowest allowed value + :param max_count: The highest allowed value (or None) + :return: The validated value as Integer + :raises: ValueError if value is invalid + """ + if max_count is not None: + if min_count <= int(value) <= max_count: + return int(value) + elif int(value) >= min_count: + return int(value) + + raise ValueError diff --git a/kiauh/utils/logger.py b/kiauh/utils/logger.py new file mode 100644 index 0000000..66f9326 --- /dev/null +++ b/kiauh/utils/logger.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 utils.constants import ( + COLOR_WHITE, + COLOR_GREEN, + COLOR_YELLOW, + COLOR_RED, + COLOR_MAGENTA, + RESET_FORMAT, +) + + +class Logger: + @staticmethod + def info(msg): + # log to kiauh.log + pass + + @staticmethod + def warn(msg): + # log to kiauh.log + pass + + @staticmethod + def error(msg): + # log to kiauh.log + pass + + @staticmethod + def print_info(msg, prefix=True, start="", end="\n") -> None: + message = f"[INFO] {msg}" if prefix else msg + print(f"{COLOR_WHITE}{start}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_ok(msg, prefix=True, start="", end="\n") -> None: + message = f"[OK] {msg}" if prefix else msg + print(f"{COLOR_GREEN}{start}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_warn(msg, prefix=True, start="", end="\n") -> None: + message = f"[WARN] {msg}" if prefix else msg + print(f"{COLOR_YELLOW}{start}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_error(msg, prefix=True, start="", end="\n") -> None: + message = f"[ERROR] {msg}" if prefix else msg + print(f"{COLOR_RED}{start}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_status(msg, prefix=True, start="", end="\n") -> None: + message = f"\n###### {msg}" if prefix else msg + print(f"{COLOR_MAGENTA}{start}{message}{RESET_FORMAT}", end=end) diff --git a/kiauh/utils/system_utils.py b/kiauh/utils/system_utils.py new file mode 100644 index 0000000..718607a --- /dev/null +++ b/kiauh/utils/system_utils.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 + +# ======================================================================= # +# Copyright (C) 2020 - 2024 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 os +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +import venv +from pathlib import Path +from typing import List, Literal + +from utils.input_utils import get_confirm +from utils.logger import Logger +from utils.filesystem_utils import check_file_exist + + +def kill(opt_err_msg: str = "") -> None: + """ + Kills the application | + :param opt_err_msg: an optional, additional error message + :return: None + """ + + if opt_err_msg: + Logger.print_error(opt_err_msg) + Logger.print_error("A critical error has occured. KIAUH was terminated.") + sys.exit(1) + + +def parse_packages_from_file(source_file: Path) -> List[str]: + """ + Read the package names from bash scripts, when defined like: + PKGLIST="package1 package2 package3" | + :param source_file: path of the sourcefile to read from + :return: A list of package names + """ + + packages = [] + print("Reading dependencies...") + with open(source_file, "r") as file: + for line in file: + line = line.strip() + if line.startswith("PKGLIST="): + line = line.replace('"', "") + line = line.replace("PKGLIST=", "") + line = line.replace("${PKGLIST}", "") + packages.extend(line.split()) + + return packages + + +def create_python_venv(target: Path) -> None: + """ + Create a python 3 virtualenv at the provided target destination | + :param target: Path where to create the virtualenv at + :return: None + """ + Logger.print_status("Set up Python virtual environment ...") + if not target.exists(): + try: + venv.create(target, with_pip=True) + Logger.print_ok("Setup of virtualenv successfull!") + except OSError as e: + Logger.print_error(f"Error setting up virtualenv:\n{e}") + raise + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error setting up virtualenv:\n{e.output.decode()}") + raise + else: + if get_confirm("Virtualenv already exists. Re-create?", default_choice=False): + try: + shutil.rmtree(target) + create_python_venv(target) + except OSError as e: + log = f"Error removing existing virtualenv: {e.strerror}" + Logger.print_error(log, False) + raise + else: + Logger.print_info("Skipping re-creation of virtualenv ...") + + +def update_python_pip(target: Path) -> None: + """ + Updates pip in the provided target destination | + :param target: Path of the virtualenv + :return: None + """ + Logger.print_status("Updating pip ...") + try: + pip_location = target.joinpath("bin/pip") + pip_exists = check_file_exist(pip_location) + if not pip_exists: + raise FileNotFoundError("Error updating pip! Not found.") + + command = [pip_location, "install", "-U", "pip"] + result = subprocess.run(command, stderr=subprocess.PIPE, text=True) + if result.returncode != 0 or result.stderr: + Logger.print_error(f"{result.stderr}", False) + Logger.print_error("Updating pip failed!") + return + + Logger.print_ok("Updating pip successfull!") + except FileNotFoundError as e: + Logger.print_error(e) + raise + except subprocess.CalledProcessError as e: + Logger.print_error(f"Error updating pip:\n{e.output.decode()}") + raise + + +def install_python_requirements(target: Path, requirements: Path) -> None: + """ + Installs the python packages based on a provided requirements.txt | + :param target: Path of the virtualenv + :param requirements: Path to the requirements.txt file + :return: None + """ + Logger.print_status("Installing Python requirements ...") + try: + update_python_pip(target) + command = [target.joinpath("bin/pip"), "install", "-r", f"{requirements}"] + result = subprocess.run(command, stderr=subprocess.PIPE, text=True) + if result.returncode != 0 or result.stderr: + Logger.print_error(f"{result.stderr}", False) + Logger.print_error("Installing Python requirements failed!") + return + + Logger.print_ok("Installing Python requirements successfull!") + except subprocess.CalledProcessError as e: + log = f"Error installing Python requirements:\n{e.output.decode()}" + Logger.print_error(log) + raise + + +def update_system_package_lists(silent: bool, rls_info_change=False) -> None: + """ + Updates the systems package list | + :param silent: Log info to the console or not + :param rls_info_change: Flag for "--allow-releaseinfo-change" + :return: None + """ + cache_mtime = 0 + cache_files = [ + Path("/var/lib/apt/periodic/update-success-stamp"), + Path("/var/lib/apt/lists"), + ] + for cache_file in cache_files: + if cache_file.exists(): + cache_mtime = max(cache_mtime, os.path.getmtime(cache_file)) + + update_age = int(time.time() - cache_mtime) + update_interval = 6 * 3600 # 48hrs + + if update_age <= update_interval: + return + + if not silent: + Logger.print_status("Updating package list...") + + try: + command = ["sudo", "apt-get", "update"] + if rls_info_change: + command.append("--allow-releaseinfo-change") + + result = subprocess.run(command, stderr=subprocess.PIPE, text=True) + if result.returncode != 0 or result.stderr: + Logger.print_error(f"{result.stderr}", False) + Logger.print_error("Updating system package list failed!") + return + + Logger.print_ok("System package list updated successfully!") + except subprocess.CalledProcessError as e: + kill(f"Error updating system package list:\n{e.stderr.decode()}") + + +def check_package_install(packages: List[str]) -> List[str]: + """ + Checks the system for installed packages | + :param packages: List of strings of package names + :return: A list containing the names of packages that are not installed + """ + not_installed = [] + for package in packages: + command = ["dpkg-query", "-f'${Status}'", "--show", package] + result = subprocess.run( + command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True + ) + if "installed" not in result.stdout.strip("'").split(): + not_installed.append(package) + else: + Logger.print_ok(f"{package} already installed.") + + return not_installed + + +def install_system_packages(packages: List[str]) -> None: + """ + Installs a list of system packages | + :param packages: List of system package names + :return: None + """ + try: + command = ["sudo", "apt-get", "install", "-y"] + for pkg in packages: + command.append(pkg) + subprocess.run(command, stderr=subprocess.PIPE, check=True) + + Logger.print_ok("Packages installed successfully.") + except subprocess.CalledProcessError as e: + kill(f"Error installing packages:\n{e.stderr.decode()}") + + +def mask_system_service(service_name: str) -> None: + """ + Mask a system service to prevent it from starting | + :param service_name: name of the service to mask + :return: None + """ + try: + command = ["sudo", "systemctl", "mask", service_name] + subprocess.run(command, stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + log = f"Unable to mask system service {service_name}: {e.stderr.decode()}" + Logger.print_error(log) + raise + + +# this feels hacky and not quite right, but for now it works +# see: https://stackoverflow.com/questions/166506/finding-local-ip-addresses-using-pythons-stdlib +def get_ipv4_addr() -> str: + """ + Helper function that returns the IPv4 of the current machine + by opening a socket and sending a package to an arbitrary IP. | + :return: Local IPv4 of the current machine + """ + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0) + try: + # doesn't even have to be reachable + s.connect(("192.255.255.255", 1)) + return s.getsockname()[0] + except Exception: + return "127.0.0.1" + finally: + s.close() + + +def download_file(url: str, target: Path, show_progress=True) -> None: + """ + Helper method for downloading files from a provided URL | + :param url: the url to the file + :param target: the target path incl filename + :param show_progress: show download progress or not + :return: None + """ + try: + if show_progress: + urllib.request.urlretrieve(url, target, download_progress) + sys.stdout.write("\n") + else: + urllib.request.urlretrieve(url, target) + except urllib.error.HTTPError as e: + Logger.print_error(f"Download failed! HTTP error occured: {e}") + raise + except urllib.error.URLError as e: + Logger.print_error(f"Download failed! URL error occured: {e}") + raise + except Exception as e: + Logger.print_error(f"Download failed! An error occured: {e}") + raise + + +def download_progress(block_num, block_size, total_size) -> None: + """ + Reporthook method for urllib.request.urlretrieve() method call in download_file() | + :param block_num: + :param block_size: + :param total_size: total filesize in bytes + :return: None + """ + downloaded = block_num * block_size + percent = 100 if downloaded >= total_size else downloaded / total_size * 100 + mb = 1024 * 1024 + progress = int(percent / 5) + remaining = "-" * (20 - progress) + dl = f"\rDownloading: [{'#' * progress}{remaining}]{percent:.2f}% ({downloaded / mb:.2f}/{total_size / mb:.2f}MB)" + sys.stdout.write(dl) + sys.stdout.flush() + + +def set_nginx_permissions() -> None: + """ + Check if permissions of the users home directory + grant execution rights to group and other and set them if not set. + Required permissions for NGINX to be able to serve Mainsail/Fluidd. + This seems to have become necessary with Ubuntu 21+. | + :return: None + """ + cmd = f"ls -ld {Path.home()} | cut -d' ' -f1" + homedir_perm = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, text=True) + homedir_perm = homedir_perm.stdout + + if homedir_perm.count("x") < 3: + Logger.print_status("Granting NGINX the required permissions ...") + subprocess.run(["chmod", "og+x", Path.home()]) + Logger.print_ok("Permissions granted.") + + +def control_systemd_service( + name: str, action: Literal["start", "stop", "restart", "disable"] +) -> None: + """ + Helper method to execute several actions for a specific systemd service. | + :param name: the service name + :param action: Either "start", "stop", "restart" or "disable" + :return: None + """ + try: + Logger.print_status(f"{action.capitalize()} {name}.service ...") + command = ["sudo", "systemctl", action, f"{name}.service"] + subprocess.run(command, stderr=subprocess.PIPE, check=True) + Logger.print_ok("OK!") + except subprocess.CalledProcessError as e: + log = f"Failed to {action} {name}.service: {e.stderr.decode()}" + Logger.print_error(log) + raise diff --git a/klipper_repos.txt.example b/klipper_repos.txt.example deleted file mode 100644 index 6cc3393..0000000 --- a/klipper_repos.txt.example +++ /dev/null @@ -1,18 +0,0 @@ -# This file acts as an example file. -# -# 1) Make a copy of this file and rename it to 'klipper_repos.txt' -# 2) Add your custom Klipper repository to the bottom of that copy -# 3) Save the file -# -# Back in KIAUH you can now go into -> [Settings] and use action '2' to set a different Klipper repository -# -# Make sure to always separate the repository and the branch with a ','. -# , -> https://github.com/Klipper3d/klipper,master -# If you omit a branch, it will always default to 'master' -# -# You are allowed to omit the 'https://github.com/' part of the repository URL -# Down below are now a few examples of what is considered as valid: -https://github.com/Klipper3d/klipper,master -https://github.com/Klipper3d/klipper -Klipper3d/klipper,master -Klipper3d/klipper diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..be1fddf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +exclude = ''' +( + \.git/ + | \.github/ + | docs/ + | resources/ + | scripts/ +) +'''