diff --git a/.gitignore b/.gitignore index 560b8c5..83a9457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .vscode +.idea +.pytest_cache +.kiauh-env *.code-workspace klipper_repos.txt +klipper_repos.json + diff --git a/kiauh.py b/kiauh.py new file mode 100644 index 0000000..ec12ca5 --- /dev/null +++ b/kiauh.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 aac5a8e..9b2d782 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..e69de29 diff --git a/kiauh/instance_manager/__init__.py b/kiauh/instance_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/instance_manager/base_instance.py b/kiauh/instance_manager/base_instance.py new file mode 100644 index 0000000..2f749b3 --- /dev/null +++ b/kiauh/instance_manager/base_instance.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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, Optional + + +class BaseInstance(ABC): + @classmethod + def blacklist(cls) -> List[str]: + return [] + + def __init__(self, prefix: Optional[str], name: Optional[str], + user: Optional[str], data_dir_name: Optional[str]): + self._prefix = prefix + self._name = name + self._user = user + self._data_dir_name = data_dir_name + self.data_dir = f"{Path.home()}/{self._data_dir_name}_data" + self.cfg_dir = f"{self.data_dir}/config" + self.log_dir = f"{self.data_dir}/logs" + self.comms_dir = f"{self.data_dir}/comms" + self.sysd_dir = f"{self.data_dir}/systemd" + + @property + def prefix(self) -> str: + return self._prefix + + @prefix.setter + def prefix(self, value) -> None: + self._prefix = value + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value) -> None: + self._name = value + + @property + def user(self) -> str: + return self._user + + @user.setter + def user(self, value) -> 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) -> None: + self._data_dir_name = value + + @abstractmethod + def create(self) -> None: + raise NotImplementedError("Subclasses must implement the create method") + + @abstractmethod + def read(self) -> None: + raise NotImplementedError("Subclasses must implement the read method") + + @abstractmethod + def update(self) -> None: + raise NotImplementedError("Subclasses must implement the update method") + + @abstractmethod + def delete(self, del_remnants: bool) -> None: + raise NotImplementedError("Subclasses must implement the delete method") + + @abstractmethod + def get_service_file_name(self) -> str: + raise NotImplementedError( + "Subclasses must implement the get_service_file_name method") diff --git a/kiauh/instance_manager/instance_manager.py b/kiauh/instance_manager/instance_manager.py new file mode 100644 index 0000000..0bbdf14 --- /dev/null +++ b/kiauh/instance_manager/instance_manager.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 re +import subprocess +from pathlib import Path +from typing import Optional, List, Type, Union + +from kiauh.instance_manager.base_instance import BaseInstance +from kiauh.utils.constants import SYSTEMD +from kiauh.utils.logger import Logger + + +# noinspection PyMethodMayBeStatic +class InstanceManager: + def __init__(self, instance_type: Type[BaseInstance], + current_instance: Optional[BaseInstance] = None) -> None: + self.instance_type = instance_type + self.current_instance = current_instance + self.instance_name = current_instance.name if current_instance is not None else None + self.instances = [] + + def get_current_instance(self) -> BaseInstance: + return self.current_instance + + def set_current_instance(self, instance: BaseInstance) -> None: + self.current_instance = instance + self.instance_name = f"{instance.prefix}-{instance.name}" if instance.name else instance.prefix + + 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, del_remnants=False) -> None: + if self.current_instance is not None: + try: + self.current_instance.delete(del_remnants) + 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_info(f"Enabling {self.instance_name}.service ...") + try: + command = ["sudo", "systemctl", "enable", + f"{self.instance_name}.service"] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_name}.service enabled.") + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error enabling service {self.instance_name}.service:") + Logger.print_error(f"{e}") + + def disable_instance(self) -> None: + Logger.print_info(f"Disabling {self.instance_name}.service ...") + try: + command = ["sudo", "systemctl", "disable", + f"{self.instance_name}.service"] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_name}.service disabled.") + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error disabling service {self.instance_name}.service:") + Logger.print_error(f"{e}") + + def start_instance(self) -> None: + Logger.print_info(f"Starting {self.instance_name}.service ...") + try: + command = ["sudo", "systemctl", "start", + f"{self.instance_name}.service"] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_name}.service started.") + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error starting service {self.instance_name}.service:") + Logger.print_error(f"{e}") + + def stop_instance(self) -> None: + Logger.print_info(f"Stopping {self.instance_name}.service ...") + try: + command = ["sudo", "systemctl", "stop", + f"{self.instance_name}.service"] + if subprocess.run(command, check=True): + Logger.print_ok(f"{self.instance_name}.service stopped.") + except subprocess.CalledProcessError as e: + Logger.print_error( + f"Error stopping service {self.instance_name}.service:") + Logger.print_error(f"{e}") + raise + + def reload_daemon(self) -> None: + Logger.print_info("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 get_instances(self) -> List[BaseInstance]: + if not self.instances: + self._find_instances() + + return sorted(self.instances, + key=lambda x: self._sort_instance_list(x.name)) + + def _find_instances(self) -> None: + prefix = self.instance_type.__name__.lower() + pattern = re.compile(f"{prefix}(-[0-9a-zA-Z]+)?.service") + + excluded = self.instance_type.blacklist() + service_list = [ + os.path.join(SYSTEMD, service) + for service in os.listdir(SYSTEMD) + if pattern.search(service) + and not any(s in service for s in excluded)] + + instance_list = [ + self.instance_type(name=self._get_instance_name(Path(service))) + for service in service_list] + + self.instances = instance_list + + def _get_instance_name(self, file_path: Path) -> Union[str, None]: + full_name = str(file_path).split("/")[-1].split(".")[0] + if full_name.isalnum(): + return None + + return full_name.split("-")[-1] + + def _sort_instance_list(self, s): + return int(s) if s.isdigit() else s diff --git a/kiauh/main.py b/kiauh/main.py new file mode 100644 index 0000000..738b036 --- /dev/null +++ b/kiauh/main.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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.menus.main_menu import MainMenu +from kiauh.utils.logger import Logger + + +def main(): + try: + MainMenu().start() + except KeyboardInterrupt: + Logger.print_ok("\nHappy printing!\n", prefix=False) diff --git a/kiauh/menus/__init__.py b/kiauh/menus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/menus/advanced_menu.py b/kiauh/menus/advanced_menu.py new file mode 100644 index 0000000..ddc3315 --- /dev/null +++ b/kiauh/menus/advanced_menu.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 kiauh.menus.base_menu import BaseMenu +from kiauh.utils.constants import COLOR_YELLOW, RESET_FORMAT + + +class AdvancedMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={}, + footer_type="back" + ) + + def print_menu(self): + menu = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_YELLOW}~~~~~~~~~~~~~ [ Advanced Menu ] ~~~~~~~~~~~~~{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/menus/base_menu.py b/kiauh/menus/base_menu.py new file mode 100644 index 0000000..6cb5be6 --- /dev/null +++ b/kiauh/menus/base_menu.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 + +from kiauh.utils.constants import COLOR_GREEN, COLOR_YELLOW, COLOR_RED, \ + COLOR_CYAN, RESET_FORMAT + + +def clear(): + subprocess.call("clear", shell=True) + + +def print_header(): + header = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_CYAN}~~~~~~~~~~~~~~~~~ [ KIAUH ] ~~~~~~~~~~~~~~~~~{RESET_FORMAT} | + | {COLOR_CYAN} Klipper Installation And Update Helper {RESET_FORMAT} | + | {COLOR_CYAN}~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(header, end="") + + +def print_quit_footer(): + footer = textwrap.dedent(f""" + |-------------------------------------------------------| + | {COLOR_RED}Q) Quit{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(footer, end="") + + +def print_back_footer(): + footer = textwrap.dedent(f""" + |-------------------------------------------------------| + | {COLOR_GREEN}B) « Back{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(footer, end="") + + +def print_back_help_footer(): + footer = textwrap.dedent(f""" + |-------------------------------------------------------| + | {COLOR_GREEN}B) « Back{RESET_FORMAT} | {COLOR_RED}Q) Quit{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(footer, end="") + + +def print_back_quit_footer(): + footer = textwrap.dedent(f""" + |-------------------------------------------------------| + | {COLOR_GREEN}B) « Back{RESET_FORMAT} | {COLOR_YELLOW}H) Help [?]{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(footer, end="") + + +def print_back_quit_help_footer(): + footer = textwrap.dedent(f""" + |-------------------------------------------------------| + | {COLOR_GREEN}B) « Back{RESET_FORMAT} | {COLOR_RED}Q) Quit{RESET_FORMAT} | {COLOR_YELLOW}H) Help [?]{RESET_FORMAT} | + \=======================================================/ + """)[1:] + print(footer, end="") + + +class BaseMenu(ABC): + QUIT_FOOTER = "quit" + BACK_FOOTER = "back" + BACK_HELP_FOOTER = "back_help" + BACK_QUIT_FOOTER = "back_quit" + BACK_QUIT_HELP_FOOTER = "back_quit_help" + + def __init__(self, options: Dict[int, Any], options_offset=0, header=True, + footer_type="quit"): + self.options = options + self.options_offset = options_offset + self.header = header + self.footer_type = footer_type + + @abstractmethod + def print_menu(self): + raise NotImplementedError( + "Subclasses must implement the print_menu method") + + def print_footer(self): + footer_type_map = { + self.QUIT_FOOTER: print_quit_footer, + self.BACK_FOOTER: print_back_footer, + self.BACK_HELP_FOOTER: print_back_help_footer, + self.BACK_QUIT_FOOTER: print_back_quit_footer, + self.BACK_QUIT_HELP_FOOTER: print_back_quit_help_footer + } + footer_function = footer_type_map.get(self.footer_type, + print_quit_footer) + footer_function() + + def display(self): + # clear() + if self.header: + print_header() + self.print_menu() + self.print_footer() + + def handle_user_input(self): + while True: + choice = input(f"{COLOR_CYAN}###### Perform action: {RESET_FORMAT}") + + error_msg = f"{COLOR_RED}Invalid input.{RESET_FORMAT}" \ + if choice.isalpha() \ + else f"{COLOR_RED}Invalid input. Select a number between {min(self.options)} and {max(self.options)}.{RESET_FORMAT}" + + if choice.isdigit() and 0 <= int(choice) < len(self.options): + return choice + elif choice.isalpha(): + allowed_input = { + "quit": ["q"], + "back": ["b"], + "back_help": ["b", "h"], + "back_quit": ["b", "q"], + "back_quit_help": ["b", "q", "h"] + } + if self.footer_type in allowed_input and choice.lower() in \ + allowed_input[self.footer_type]: + return choice + else: + print(error_msg) + else: + print(error_msg) + + def start(self): + while True: + self.display() + choice = self.handle_user_input() + + if choice == "q": + print(f"{COLOR_GREEN}###### Happy printing!{RESET_FORMAT}") + sys.exit(0) + elif choice == "b": + return + elif choice == "p": + print("help!") + else: + self.execute_option(int(choice)) + + def execute_option(self, choice): + option = self.options.get(choice, None) + + if isinstance(option, type) and issubclass(option, BaseMenu): + self.navigate_to_submenu(option) + elif callable(option): + option() + 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_submenu(self, submenu_class): + submenu = submenu_class() + submenu.previous_menu = self + submenu.start() diff --git a/kiauh/menus/install_menu.py b/kiauh/menus/install_menu.py new file mode 100644 index 0000000..91ae994 --- /dev/null +++ b/kiauh/menus/install_menu.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 kiauh.menus.base_menu import BaseMenu +from kiauh.modules.klipper import klipper_setup +from kiauh.utils.constants import COLOR_GREEN, RESET_FORMAT + + +# 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_klipperscreen, + 6: self.install_pretty_gcode, + 7: self.install_telegram_bot, + 8: self.install_obico, + 9: self.install_octoeverywhere, + 10: self.install_mobileraker, + 11: self.install_crowsnest + }, + footer_type="back" + ) + + def print_menu(self): + menu = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_GREEN}~~~~~~~~~~~ [ Installation Menu ] ~~~~~~~~~~~{RESET_FORMAT} | + |-------------------------------------------------------| + | You need this menu usually only for installing | + | all necessary dependencies for the various | + | functions on a completely fresh system. | + |-------------------------------------------------------| + | Firmware & API: | Other: | + | 1) [Klipper] | 6) [PrettyGCode] | + | 2) [Moonraker] | 7) [Telegram Bot] | + | | 8) $(obico_install_title) | + | Klipper Webinterface: | 9) [OctoEverywhere] | + | 3) [Mainsail] | 10) [Mobileraker] | + | 4) [Fluidd] | | + | | Webcam Streamer: | + | Touchscreen GUI: | 11) [Crowsnest] | + | 5) [KlipperScreen] | | + """)[1:] + print(menu, end="") + + def install_klipper(self): + klipper_setup.run_klipper_setup(install=True) + + def install_moonraker(self): + print("install_moonraker") + + def install_mainsail(self): + print("install_mainsail") + + def install_fluidd(self): + print("install_fluidd") + + def install_klipperscreen(self): + print("install_klipperscreen") + + def install_pretty_gcode(self): + print("install_pretty_gcode") + + def install_telegram_bot(self): + print("install_telegram_bot") + + def install_obico(self): + print("install_obico") + + def install_octoeverywhere(self): + print("install_octoeverywhere") + + def install_mobileraker(self): + print("install_mobileraker") + + def install_crowsnest(self): + print("install_crowsnest") diff --git a/kiauh/menus/main_menu.py b/kiauh/menus/main_menu.py new file mode 100644 index 0000000..97a029a --- /dev/null +++ b/kiauh/menus/main_menu.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 kiauh.menus.advanced_menu import AdvancedMenu +from kiauh.menus.base_menu import BaseMenu +from kiauh.menus.install_menu import InstallMenu +from kiauh.menus.remove_menu import RemoveMenu +from kiauh.menus.settings_menu import SettingsMenu +from kiauh.menus.update_menu import UpdateMenu +from kiauh.utils.constants import COLOR_MAGENTA, COLOR_CYAN, RESET_FORMAT + + +class MainMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + 0: self.test, + 1: InstallMenu, + 2: UpdateMenu, + 3: RemoveMenu, + 4: AdvancedMenu, + 5: None, + 6: SettingsMenu + }, + footer_type="quit" + ) + + def print_menu(self): + menu = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_CYAN}~~~~~~~~~~~~~~~ [ Main Menu ] ~~~~~~~~~~~~~~~{RESET_FORMAT} | + |-------------------------------------------------------| + | 0) [Log-Upload] | Klipper: | + | | Repo: | + | 1) [Install] | | + | 2) [Update] | Moonraker: | + | 3) [Remove] | Repo: | + | 4) [Advanced] | | + | 5) [Backup] | Mainsail: | + | | Fluidd: | + | 6) [Settings] | KlipperScreen: | + | | Mobileraker: | + | | | + | | Crowsnest: | + | | Telegram Bot: | + | | Obico: | + | | OctoEverywhere: | + |-------------------------------------------------------| + | {COLOR_CYAN}KIAUH v6.0.0{RESET_FORMAT} | Changelog: {COLOR_MAGENTA}https://git.io/JnmlX{RESET_FORMAT} | + """)[1:] + print(menu, end="") + + def test(self): + print("blub") diff --git a/kiauh/menus/remove_menu.py b/kiauh/menus/remove_menu.py new file mode 100644 index 0000000..9b414fa --- /dev/null +++ b/kiauh/menus/remove_menu.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 kiauh.menus.base_menu import BaseMenu +from kiauh.modules.klipper import klipper_setup +from kiauh.utils.constants import COLOR_RED, RESET_FORMAT + + +# noinspection PyMethodMayBeStatic +class RemoveMenu(BaseMenu): + def __init__(self): + super().__init__( + header=True, + options={ + 1: self.remove_klipper, + 2: self.remove_moonraker, + 3: self.remove_mainsail, + 4: self.remove_mainsail_config, + 5: self.remove_fluidd, + 6: self.remove_fluidd_config, + 7: self.remove_klipperscreen, + 8: self.remove_crowsnest, + 9: self.remove_mjpgstreamer, + 10: self.remove_pretty_gcode, + 11: self.remove_telegram_bot, + 12: self.remove_obico, + 13: self.remove_octoeverywhere, + 14: self.remove_mobileraker, + 15: self.remove_nginx, + }, + footer_type="back" + ) + + def print_menu(self): + menu = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_RED}~~~~~~~~~~~~~~ [ Remove Menu ] ~~~~~~~~~~~~~~{RESET_FORMAT} | + |-------------------------------------------------------| + | INFO: Configurations and/or any backups will be kept! | + |-------------------------------------------------------| + | Firmware & API: | Webcam Streamer: | + | 1) [Klipper] | 8) [Crowsnest] | + | 2) [Moonraker] | 9) [MJPG-Streamer] | + | | | + | Klipper Webinterface: | Other: | + | 3) [Mainsail] | 10) [PrettyGCode] | + | 4) [Mainsail-Config] | 11) [Telegram Bot] | + | 5) [Fluidd] | 12) [Obico for Klipper] | + | 6) [Fluidd-Config] | 13) [OctoEverywhere] | + | | 14) [Mobileraker] | + | Touchscreen GUI: | 15) [NGINX] | + | 7) [KlipperScreen] | | + """)[1:] + print(menu, end="") + + def remove_klipper(self): + klipper_setup.run_klipper_setup(install=False) + + def remove_moonraker(self): + print("remove_moonraker") + + def remove_mainsail(self): + print("remove_mainsail") + + def remove_mainsail_config(self): + print("remove_mainsail_config") + + def remove_fluidd(self): + print("remove_fluidd") + + def remove_fluidd_config(self): + print("remove_fluidd_config") + + def remove_klipperscreen(self): + print("remove_klipperscreen") + + def remove_crowsnest(self): + print("remove_crowsnest") + + def remove_mjpgstreamer(self): + print("remove_mjpgstreamer") + + def remove_pretty_gcode(self): + print("remove_pretty_gcode") + + def remove_telegram_bot(self): + print("remove_telegram_bot") + + def remove_obico(self): + print("remove_obico") + + def remove_octoeverywhere(self): + print("remove_octoeverywhere") + + def remove_mobileraker(self): + print("remove_mobileraker") + + def remove_nginx(self): + print("remove_nginx") diff --git a/kiauh/menus/settings_menu.py b/kiauh/menus/settings_menu.py new file mode 100644 index 0000000..df2f6c4 --- /dev/null +++ b/kiauh/menus/settings_menu.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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.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/menus/update_menu.py b/kiauh/menus/update_menu.py new file mode 100644 index 0000000..f95865a --- /dev/null +++ b/kiauh/menus/update_menu.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 kiauh.menus.base_menu import BaseMenu +from kiauh.utils.constants import COLOR_GREEN, RESET_FORMAT + + +# 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_klipperscreen, + 6: self.update_pgc_for_klipper, + 7: self.update_telegram_bot, + 8: self.update_moonraker_obico, + 9: self.update_octoeverywhere, + 10: self.update_mobileraker, + 11: self.update_crowsnest, + 12: self.upgrade_system_packages, + }, + footer_type="back" + ) + + def print_menu(self): + menu = textwrap.dedent(f""" + /=======================================================\\ + | {COLOR_GREEN}~~~~~~~~~~~~~~ [ Update Menu ] ~~~~~~~~~~~~~~{RESET_FORMAT} | + |-------------------------------------------------------| + | 0) [Update all] | | | + | | Current: | Latest: | + | Klipper & API: |--------------|--------------| + | 1) [Klipper] | | | + | 2) [Moonraker] | | | + | | | | + | Klipper Webinterface: |--------------|--------------| + | 3) [Mainsail] | | | + | 4) [Fluidd] | | | + | | | | + | Touchscreen GUI: |--------------|--------------| + | 5) [KlipperScreen] | | | + | | | | + | Other: |--------------|--------------| + | 6) [PrettyGCode] | | | + | 7) [Telegram Bot] | | | + | 8) [Obico for Klipper] | | | + | 9) [OctoEverywhere] | | | + | 10) [Mobileraker] | | | + | 11) [Crowsnest] | | | + | |-----------------------------| + | 12) [System] | | | + """)[1:] + print(menu, end="") + + def update_all(self): + print("update_all") + + def update_klipper(self): + print("update_klipper") + + def update_moonraker(self): + print("update_moonraker") + + def update_mainsail(self): + print("update_mainsail") + + def update_fluidd(self): + print("update_fluidd") + + def update_klipperscreen(self): + print("update_klipperscreen") + + def update_pgc_for_klipper(self): + print("update_pgc_for_klipper") + + def update_telegram_bot(self): + print("update_telegram_bot") + + def update_moonraker_obico(self): + print("update_moonraker_obico") + + def update_octoeverywhere(self): + print("update_octoeverywhere") + + def update_mobileraker(self): + print("update_mobileraker") + + def update_crowsnest(self): + print("update_crowsnest") + + def upgrade_system_packages(self): + print("upgrade_system_packages") diff --git a/kiauh/modules/__init__.py b/kiauh/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/modules/klipper/__init__.py b/kiauh/modules/klipper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/modules/klipper/klipper.py b/kiauh/modules/klipper/klipper.py new file mode 100644 index 0000000..e7d2826 --- /dev/null +++ b/kiauh/modules/klipper/klipper.py @@ -0,0 +1,164 @@ +# !/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 +import shutil +import subprocess +from pathlib import Path +from typing import List + +from kiauh.instance_manager.base_instance import BaseInstance +from kiauh.utils.constants import SYSTEMD, KLIPPER_DIR, KLIPPER_ENV_DIR +from kiauh.utils.logger import Logger +from kiauh.utils.system_utils import create_directory + + +# noinspection PyMethodMayBeStatic +class Klipper(BaseInstance): + @classmethod + def blacklist(cls) -> List[str]: + return ["None", "mcu"] + + def __init__(self, name: str): + super().__init__(name=name, + prefix="klipper", + user=pwd.getpwuid(os.getuid())[0], + data_dir_name=self._get_data_dir_from_name(name)) + self.klipper_dir = KLIPPER_DIR + self.env_dir = KLIPPER_ENV_DIR + self.cfg_file = f"{self.cfg_dir}/printer.cfg" + self.log = f"{self.log_dir}/klippy.log" + self.serial = f"{self.comms_dir}/klippy.serial" + self.uds = f"{self.comms_dir}/klippy.sock" + + def create(self) -> None: + Logger.print_info("Creating Klipper Instance") + module_path = os.path.dirname(os.path.abspath(__file__)) + service_template_path = os.path.join(module_path, "res", + "klipper.service") + env_template_file_path = os.path.join(module_path, "res", "klipper.env") + service_file_name = self.get_service_file_name(extension=True) + service_file_target = f"{SYSTEMD}/{service_file_name}" + env_file_target = os.path.abspath(f"{self.sysd_dir}/klipper.env") + + # create folder structure + dirs = [self.data_dir, self.cfg_dir, self.log_dir, + self.comms_dir, self.sysd_dir] + for _dir in dirs: + create_directory(Path(_dir)) + + try: + # writing the klipper service file (requires sudo!) + 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}") + + # writing the klipper.env file + 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}") + + 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 read(self) -> None: + print("Reading Klipper Instance") + + def update(self) -> None: + print("Updating Klipper Instance") + + def delete(self, del_remnants: bool) -> None: + service_file = self.get_service_file_name(extension=True) + service_file_path = self._get_service_file_path() + + Logger.print_info(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 + + if del_remnants: + self._delete_klipper_remnants() + + def _delete_klipper_remnants(self) -> None: + try: + Logger.print_info(f"Delete {self.klipper_dir} ...") + shutil.rmtree(Path(self.klipper_dir)) + Logger.print_info(f"Delete {self.env_dir} ...") + shutil.rmtree(Path(self.env_dir)) + except FileNotFoundError: + Logger.print_info("Cannot delete Klipper directories. Not found.") + except PermissionError as e: + Logger.print_error(f"Error deleting Klipper directories: {e}") + raise + + Logger.print_ok("Directories successfully deleted.") + + def get_service_file_name(self, extension=False) -> str: + name = self.prefix if self.name is None else self.prefix + '-' + self.name + return name if not extension else f"{name}.service" + + def _get_service_file_path(self): + return f"{SYSTEMD}/{self.get_service_file_name(extension=True)}" + + def _get_data_dir_from_name(self, name: str) -> str: + if name is None: + return "printer" + elif int(name.isdigit()): + return f"printer_{name}" + else: + return name + + def _prep_service_file(self, service_template_path, env_file_path): + 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%", + self.klipper_dir) + service_content = service_content.replace("%ENV%", self.env_dir) + service_content = service_content.replace("%ENV_FILE%", env_file_path) + return service_content + + def _prep_env_file(self, env_template_file_path): + 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%", + self.klipper_dir) + env_file_content = env_file_content.replace("%CFG%", self.cfg_file) + env_file_content = env_file_content.replace("%SERIAL%", self.serial) + env_file_content = env_file_content.replace("%LOG%", self.log) + env_file_content = env_file_content.replace("%UDS%", self.uds) + return env_file_content diff --git a/kiauh/modules/klipper/klipper_setup.py b/kiauh/modules/klipper/klipper_setup.py new file mode 100644 index 0000000..8deab8a --- /dev/null +++ b/kiauh/modules/klipper/klipper_setup.py @@ -0,0 +1,236 @@ +# !/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 re +import subprocess +from pathlib import Path +from typing import Optional, List, Union + +from kiauh.instance_manager.instance_manager import InstanceManager +from kiauh.modules.klipper.klipper import Klipper +from kiauh.modules.klipper.klipper_utils import print_instance_overview +from kiauh.utils.constants import KLIPPER_DIR, KLIPPER_ENV_DIR +from kiauh.utils.input_utils import get_user_confirm, get_user_number_input, \ + get_user_string_input, get_user_selection_input +from kiauh.utils.logger import Logger +from kiauh.utils.system_utils import parse_packages_from_file, \ + clone_repo, create_python_venv, \ + install_python_requirements, update_system_package_lists, \ + install_system_packages + + +def run_klipper_setup(install: bool) -> None: + instance_manager = InstanceManager(Klipper) + instance_list = instance_manager.get_instances() + instances_installed = len(instance_list) + + is_klipper_installed = check_klipper_installation(instance_manager) + if not install and not is_klipper_installed: + Logger.print_warn("Klipper not installed!") + return + + if install: + add_additional = handle_existing_instances(instance_manager) + if is_klipper_installed and not add_additional: + Logger.print_info("Exiting Klipper setup ...") + return + + install_klipper(instance_manager) + + if not install: + if instances_installed == 1: + remove_single_instance(instance_manager) + else: + remove_multi_instance(instance_manager) + + +def check_klipper_installation(instance_manager: InstanceManager) -> bool: + instance_list = instance_manager.get_instances() + instances_installed = len(instance_list) + + if instances_installed < 1: + return False + + return True + + +def handle_existing_instances(instance_manager: InstanceManager) -> bool: + instance_list = instance_manager.get_instances() + instance_count = len(instance_list) + + if instance_count > 0: + print_instance_overview(instance_list) + if not get_user_confirm("Add new instances?"): + return False + + return True + + +def install_klipper(instance_manager: InstanceManager) -> None: + instance_list = instance_manager.get_instances() + if_adding = " additional" if len(instance_list) > 0 else "" + install_count = get_user_number_input( + f"Number of{if_adding} Klipper instances to set up", + 1, default=1) + + instance_names = set_instance_names(instance_list, install_count) + + if len(instance_list) < 1: + setup_klipper_prerequesites() + + for name in instance_names: + current_instance = Klipper(name=name) + instance_manager.set_current_instance(current_instance) + instance_manager.create_instance() + instance_manager.enable_instance() + instance_manager.start_instance() + + instance_manager.reload_daemon() + + # step 4: check/handle conflicting packages/services + + # step 5: check for required group membership + + +def setup_klipper_prerequesites() -> None: + # clone klipper TODO: read branch and url from json to allow forks + url = "https://github.com/Klipper3D/klipper" + branch = "master" + clone_repo(Path(KLIPPER_DIR), url, branch) + + # install klipper dependencies and create python virtualenv + install_klipper_packages(Path(KLIPPER_DIR)) + create_python_venv(Path(KLIPPER_ENV_DIR)) + klipper_py_req = Path(f"{KLIPPER_DIR}/scripts/klippy-requirements.txt") + install_python_requirements(Path(KLIPPER_ENV_DIR), klipper_py_req) + + +def install_klipper_packages(klipper_dir: Path) -> None: + script = f"{klipper_dir}/scripts/install-debian.sh" + packages = parse_packages_from_file(script) + packages = [pkg.replace("python-dev", "python3-dev") for pkg in packages] + # Add dfu-util for octopi-images + packages.append("dfu-util") + # Add dbus requirement for DietPi distro + if os.path.exists("/boot/dietpi/.version"): + packages.append("dbus") + + update_system_package_lists(silent=False) + install_system_packages(packages) + + +def set_instance_names(instance_list, install_count: int) -> List[ + Union[str, None]]: + instance_count = len(instance_list) + + # default single instance install + if instance_count == 0 and install_count == 1: + return [None] + + # new multi instance install + elif ((instance_count == 0 and install_count > 1) + # or convert single instance install to multi instance install + or (instance_count == 1 and install_count >= 1)): + if get_user_confirm("Assign custom names?", False): + return assign_custom_names(instance_count, install_count, None) + else: + _range = range(1, install_count + 1) + return [str(i) for i in _range] + + # existing multi instance install + elif instance_count > 1: + if has_custom_names(instance_list): + return assign_custom_names(instance_count, install_count, + instance_list) + else: + start = get_highest_index(instance_list) + 1 + _range = range(start, start + install_count) + return [str(i) for i in _range] + + +def has_custom_names(instance_list: List[Klipper]) -> bool: + pattern = re.compile("^\d+$") + for instance in instance_list: + if not pattern.match(instance.name): + return True + + return False + + +def assign_custom_names(instance_count: int, install_count: int, + instance_list: Optional[List[Klipper]]) -> List[str]: + instance_names = [] + exclude = Klipper.blacklist() + + # if an instance_list is provided, exclude all existing instance names + if instance_list is not None: + for instance in instance_list: + exclude.append(instance.name) + + for i in range(instance_count + install_count): + question = f"Enter name for instance {i + 1}" + name = get_user_string_input(question, exclude=exclude) + instance_names.append(name) + exclude.append(name) + + return instance_names + + +def get_highest_index(instance_list: List[Klipper]) -> int: + indices = [int(instance.name.split('-')[-1]) for instance in instance_list] + return max(indices) + + +def remove_single_instance(instance_manager: InstanceManager) -> None: + instance_list = instance_manager.get_instances() + try: + instance_manager.set_current_instance(instance_list[0]) + instance_manager.stop_instance() + instance_manager.disable_instance() + instance_manager.delete_instance(del_remnants=True) + instance_manager.reload_daemon() + except (OSError, subprocess.CalledProcessError): + Logger.print_error("Removing instance failed!") + return + + +def remove_multi_instance(instance_manager: InstanceManager) -> None: + instance_list = instance_manager.get_instances() + print_instance_overview(instance_list, show_index=True, + show_select_all=True) + + options = [str(i) for i in range(len(instance_list))] + options.extend(["a", "A", "b", "B"]) + + selection = get_user_selection_input( + "Select Klipper instance to remove", options) + print(selection) + + if selection == "b".lower(): + return + elif selection == "a".lower(): + Logger.print_info("Removing all Klipper instances ...") + for instance in instance_list: + instance_manager.set_current_instance(instance) + instance_manager.stop_instance() + instance_manager.disable_instance() + instance_manager.delete_instance(del_remnants=True) + else: + instance = instance_list[int(selection)] + Logger.print_info( + f"Removing Klipper instance: {instance.get_service_file_name()}") + instance_manager.set_current_instance(instance) + instance_manager.stop_instance() + instance_manager.disable_instance() + instance_manager.delete_instance(del_remnants=False) + + instance_manager.reload_daemon() diff --git a/kiauh/modules/klipper/klipper_utils.py b/kiauh/modules/klipper/klipper_utils.py new file mode 100644 index 0000000..c67eb8a --- /dev/null +++ b/kiauh/modules/klipper/klipper_utils.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 List + +from kiauh.instance_manager.base_instance import BaseInstance +from kiauh.menus.base_menu import print_back_footer +from kiauh.utils.constants import COLOR_GREEN, COLOR_CYAN, COLOR_YELLOW, \ + RESET_FORMAT + + +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}" + + print("/=======================================================\\") + print(f"|{'{:^64}'.format(headline)}|") + print("|-------------------------------------------------------|") + + if show_select_all: + select_all = f" {COLOR_YELLOW}a) Select all{RESET_FORMAT}" + print(f"|{'{:64}'.format(select_all)}|") + print("| |") + + for i, s in enumerate(instances): + index = f"{i})" if show_index else "●" + instance = s.get_service_file_name() + line = f"{'{:53}'.format(f'{index} {instance}')}" + print(f"| {COLOR_CYAN}{line}{RESET_FORMAT}|") + + print_back_footer() diff --git a/kiauh/modules/klipper/res/klipper.env b/kiauh/modules/klipper/res/klipper.env new file mode 100644 index 0000000..b56553e --- /dev/null +++ b/kiauh/modules/klipper/res/klipper.env @@ -0,0 +1 @@ +KLIPPER_ARGS="%KLIPPER_DIR%/klippy/klippy.py %CFG% -I %SERIAL% -l %LOG% -a %UDS%" diff --git a/kiauh/modules/klipper/res/klipper.service b/kiauh/modules/klipper/res/klipper.service new file mode 100644 index 0000000..b41788f --- /dev/null +++ b/kiauh/modules/klipper/res/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/utils/__init__.py b/kiauh/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiauh/utils/constants.py b/kiauh/utils/constants.py new file mode 100644 index 0000000..3c96aab --- /dev/null +++ b/kiauh/utils/constants.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 + +# text colors and formats +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 + +SYSTEMD = "/etc/systemd/system" + +KLIPPER_DIR = f"{Path.home()}/klipper" +KLIPPER_ENV_DIR = f"{Path.home()}/klippy-env" +MOONRAKER_DIR = f"{Path.home()}/moonraker" +MOONRAKER_ENV_DIR = f"{Path.home()}/moonraker-env" +MAINSAIL_DIR = f"{Path.home()}/mainsail" +FLUIDD_DIR = f"{Path.home()}/fluidd" diff --git a/kiauh/utils/input_utils.py b/kiauh/utils/input_utils.py new file mode 100644 index 0000000..c6327d6 --- /dev/null +++ b/kiauh/utils/input_utils.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 + +from kiauh.utils.logger import Logger +from kiauh.utils.constants import COLOR_CYAN, RESET_FORMAT + + +def get_user_confirm(question: str, default_choice=True) -> bool: + options_confirm = ["y", "yes"] + options_decline = ["n", "no"] + + if default_choice: + def_choice = "(Y/n)" + options_confirm.append("") + else: + def_choice = "(y/N)" + options_decline.append("") + + while True: + choice = ( + input(f"{COLOR_CYAN}###### {question} {def_choice} {RESET_FORMAT}") + .strip() + .lower()) + + if choice in options_confirm: + return True + elif choice in options_decline: + return False + else: + Logger.print_error("Invalid choice. Please select 'y' or 'n'.") + + +def get_user_number_input(question: str, min_count: int, max_count=None, + default=None) -> int: + _question = question + f" (default={default})" if default else question + _question = f"{COLOR_CYAN}###### {_question}: {RESET_FORMAT}" + while True: + try: + num = input(_question) + if num == "": + return default + + if max_count is not None: + if min_count <= int(num) <= max_count: + return int(num) + else: + raise ValueError + elif int(num) >= min_count: + return int(num) + else: + raise ValueError + except ValueError: + Logger.print_error("Invalid choice. Please select a valid number.") + + +def get_user_string_input(question: str, exclude=Optional[List]) -> str: + while True: + _input = (input(f"{COLOR_CYAN}###### {question}: {RESET_FORMAT}") + .strip()) + + if _input.isalnum() and _input not in exclude: + return _input + + Logger.print_error("Invalid choice. Please enter a valid value.") + if _input in exclude: + Logger.print_error("This value is already in use/reserved.") + + +def get_user_selection_input(question: str, option_list: List) -> str: + while True: + _input = (input(f"{COLOR_CYAN}###### {question}: {RESET_FORMAT}") + .strip()) + + if _input in option_list: + return _input + + Logger.print_error("Invalid choice. Please enter a valid value.") diff --git a/kiauh/utils/logger.py b/kiauh/utils/logger.py new file mode 100644 index 0000000..f6adc0d --- /dev/null +++ b/kiauh/utils/logger.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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.utils.constants import 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_ok(msg, prefix=True, end="\n") -> None: + message = f"[OK] {msg}" if prefix else msg + print(f"{COLOR_GREEN}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_warn(msg, prefix=True, end="\n") -> None: + message = f"[WARN] {msg}" if prefix else msg + print(f"{COLOR_YELLOW}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_error(msg, prefix=True, end="\n") -> None: + message = f"[ERROR] {msg}" if prefix else msg + print(f"{COLOR_RED}{message}{RESET_FORMAT}", end=end) + + @staticmethod + def print_info(msg, prefix=True, end="\n") -> None: + message = f"###### {msg}" if prefix else msg + print(f"{COLOR_MAGENTA}{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..c089e90 --- /dev/null +++ b/kiauh/utils/system_utils.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python + +# ======================================================================= # +# Copyright (C) 2020 - 2023 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 subprocess +import sys +import time +from pathlib import Path +from typing import List + +from kiauh.utils.constants import COLOR_RED, RESET_FORMAT +from kiauh.utils.logger import Logger +from kiauh.utils.input_utils import get_user_confirm + + +def kill(opt_err_msg=None) -> None: + """ + Kill the application. + + Parameters + ---------- + opt_err_msg : str + optional, additional error message to display + + Returns + ---------- + 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 clone_repo(target_dir: Path, url: str, branch: str) -> None: + Logger.print_info(f"Cloning repository from {url}") + if not target_dir.exists(): + try: + command = ["git", "clone", f"{url}"] + subprocess.run(command, check=True) + + command = ["git", "checkout", f"{branch}"] + subprocess.run(command, cwd=target_dir, check=True) + + Logger.print_ok("Clone successfull!") + except subprocess.CalledProcessError as e: + print("Error cloning repository:", e.output.decode()) + else: + overwrite_target = get_user_confirm( + "Target directory already exists. Overwrite?") + if overwrite_target: + try: + shutil.rmtree(target_dir) + clone_repo(target_dir, url, branch) + except OSError as e: + print("Error removing existing repository:", e.strerror) + else: + print("Skipping re-clone of repository ...") + + +def parse_packages_from_file(source_file) -> List[str]: + 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: + Logger.print_info("Set up Python virtual environment ...") + if not target.exists(): + try: + command = ["python3", "-m", "venv", f"{target}"] + result = subprocess.run(command, stderr=subprocess.PIPE, text=True) + if result.returncode != 0 or result.stderr: + print(f"{COLOR_RED}{result.stderr}{RESET_FORMAT}") + Logger.print_error("Setup of virtualenv failed!") + return + + Logger.print_ok("Setup of virtualenv successfull!") + except subprocess.CalledProcessError as e: + print("Error setting up virtualenv:", e.output.decode()) + else: + overwrite_venv = get_user_confirm( + "Virtualenv already exists. Re-create?") + if overwrite_venv: + try: + shutil.rmtree(target) + create_python_venv(target) + except OSError as e: + Logger.print_error( + f"Error removing existing virtualenv: {e.strerror}", + False) + else: + print("Skipping re-creation of virtualenv ...") + + +def update_python_pip(target: Path) -> None: + Logger.print_info("Updating pip ...") + try: + command = [f"{target}/bin/pip", "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 subprocess.CalledProcessError as e: + print("Error updating pip:", e.output.decode()) + + +def install_python_requirements(target: Path, requirements: Path) -> None: + update_python_pip(target) + Logger.print_info("Installing Python requirements ...") + try: + command = [f"{target}/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: + print("Error installing Python requirements:", e.output.decode()) + + +def update_system_package_lists(silent: bool, rls_info_change=False) -> None: + cache_mtime = 0 + cache_files = [ + "/var/lib/apt/periodic/update-success-stamp", + "/var/lib/apt/lists" + ] + for cache_file in cache_files: + if Path(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: + print("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 install_system_packages(packages: List) -> 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 create_directory(_dir: Path) -> None: + try: + if not os.path.isdir(_dir): + Logger.print_info(f"Create directory: {_dir}") + os.makedirs(_dir, exist_ok=True) + Logger.print_ok("Directory created!") + else: + Logger.print_info( + f"Directory already exists: {_dir}\nSkip creation ...") + except OSError as e: + Logger.print_error(f"Error creating folder: {e}") + raise