Compare commits

...

53 Commits

Author SHA1 Message Date
dw-0
b8640f45a6 refactor(Klipper): refactor example printer.cfg creation
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 23:30:38 +01:00
dw-0
5fb4444f03 refactor(Moonraker): refactor example moonraker.conf creation
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 23:30:15 +01:00
dw-0
926ba1acb4 feat(ConfigManager): implement own ConfigParser write() method
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 21:49:09 +01:00
dw-0
c2e7ee98df feat(Mainsail): implement Mainsail installer
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 18:08:18 +01:00
dw-0
3865266da1 refactor(RepoManager): default to master branch if none is provided
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 18:07:18 +01:00
dw-0
b83f642a13 refactor(ConfigManager): logging can be silenced
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 18:03:42 +01:00
dw-0
30b4414469 feat(Klipper): create example printer.cfg if wanted
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 18:00:16 +01:00
dw-0
1178d3c730 refactor(Moonraker): skip selection dialog if there is only 1 klipper instance
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 16:18:44 +01:00
dw-0
59d8867c8c fix(kiauh): copy&paste issue in repo url for Moonraker
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 16:12:43 +01:00
dw-0
80a953a587 fix(Moonraker): typo in python version check
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 16:10:20 +01:00
dw-0
a80f0bb0e8 feat(utils): add several util methods
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 14:42:53 +01:00
dw-0
78cefddb2e feat(InstanceManager): add restart service method
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 13:26:22 +01:00
dw-0
b20613819e feat(Logger): add "start" parameter
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 13:00:46 +01:00
dw-0
1836beab42 feat(klipper): add getter for specific properties
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-17 00:01:25 +01:00
dw-0
545397f598 feat(kiauh): fix typo in check_package_install
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-16 17:38:01 +01:00
dw-0
f709cf84e7 feat(kiauh): add helper methods for downloading files
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-16 15:41:48 +01:00
dw-0
f62c10dc8b feat(kiauh): add helper methods to check for installed packages
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-16 15:38:23 +01:00
dw-0
e121ba8a62 feat(Moonraker): add python version check
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-04 22:35:36 +01:00
dw-0
9a1a66aa64 docs(utils): add docstrings
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-04 21:35:28 +01:00
dw-0
420b193f4b feat(Moonraker): implement Moonraker
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-03 23:13:28 +01:00
dw-0
de20f0c412 refactor(ConfigManager): allow to take in any config file
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-03 23:10:43 +01:00
dw-0
57f34b07c6 refactor(utils): add more util functions
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-03 23:09:43 +01:00
dw-0
e35e44a76a refactor(kiauh): move create_folders to BaseInstance
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-03 23:08:41 +01:00
dw-0
bfb10c742b refactor(kiauh): reword print_info to print_status and implement new print_info method
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-03 23:06:30 +01:00
dw-0
458c89a78a fix(InstanceManager): print service name instead of suffix only
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-12-02 16:52:30 +01:00
dw-0
6128e35d45 refactor(kiauh): rework menu formatting logic
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-15 22:58:54 +01:00
dw-0
279d000bb0 refactor(kiauh): specify python3 in shebang
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-14 21:31:25 +01:00
dw-0
a4a3d5eecb feat(BackupManager): implement simple backup manager
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-14 21:28:13 +01:00
dw-0
1392ca9f82 refactor(klipper): pass the script path as a Path to the parse function
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-13 20:07:21 +01:00
dw-0
47121f6875 refactor(utils): clean up, add comments
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-13 20:06:48 +01:00
dw-0
d0d2404132 refactor(kiauh): move core modules to core package
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 23:28:05 +01:00
dw-0
6ed5395f17 feat(klipper): check for brltty-udev too
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 01:26:43 +01:00
dw-0
be805c169b feat(klipper): allow keeping klipper and klipper-env dir during uninstall
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 01:13:39 +01:00
dw-0
eaf12db27e fix(klipper): allow go back when asked for new instances
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 00:39:10 +01:00
dw-0
fe8767113b refactor(klipper): rework dialogs
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 00:37:25 +01:00
dw-0
2148d95cf4 fix(InstanceManager): return None for suffix if there is none
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 00:10:52 +01:00
dw-0
682be48e8d fix(InstanceManager): instance_service should be service file name
remove debug prints

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-12 00:01:21 +01:00
dw-0
68369753fd refactor(InstanceManager): rework
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-11 23:56:27 +01:00
dw-0
44ed3b6ddf feat(kiauh): add .iml to gitignore
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-11 23:53:42 +01:00
dw-0
e12e578098 refactor(klipper): rewrite dialogs
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-05 23:13:16 +01:00
dw-0
515a42f098 feat(klipper): implement update function
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-05 16:15:19 +01:00
dw-0
f9ecad0eca refactor(klipper): use name "klipper" for single instance setup
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-05 16:12:17 +01:00
dw-0
fb09acf660 refactor(utils): reduce complexity
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-02 22:52:34 +01:00
dw-0
093da73dd1 refactor(klipper): use constants for commonly used strings
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-11-02 22:37:17 +01:00
dw-0
c9e8c4807e feat(klipper): convert single to multi instance
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-31 20:54:44 +01:00
dw-0
09e874214b feat(ConfigManager): implement ConfigManager
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-29 17:19:26 +01:00
dw-0
623bd7553b feat(RepoManager): implement RepoManager
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-29 13:25:21 +01:00
dw-0
1e0c74b549 style: rename input functions
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-29 01:29:27 +02:00
dw-0
358c666da9 feat(style): use black code style / formatter
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-29 00:31:34 +02:00
dw-0
84a530be7d fix(klipper): handle disruptive system packages/services
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-28 23:52:51 +02:00
dw-0
bfff3019cb fix(InstanceManager): fix TypeError if instance name is None
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-28 22:10:07 +02:00
dw-0
2a100c2934 feat(klipper): check for required user-groups
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-28 21:31:31 +02:00
dw-0
ce0daa52ae feat(klipper): implement instance manager and klipper installer in python
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2023-10-26 13:58:31 +02:00
57 changed files with 4381 additions and 88 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,7 @@
.vscode .vscode
.idea
.pytest_cache
.kiauh-env
*.code-workspace *.code-workspace
klipper_repos.txt *.iml
kiauh.cfg

16
kiauh.cfg.example Normal file
View File

@@ -0,0 +1,16 @@
[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]
default_port: 80
unstable_releases: False

15
kiauh.py Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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()

158
kiauh.sh
View File

@@ -12,77 +12,97 @@
set -e set -e
clear clear
### sourcing all additional scripts function main() {
KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" local python_command
for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done local entrypoint
for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done
#===================================================# if command -v python3 &>/dev/null; then
#=================== UPDATE KIAUH ==================# python_command="python3"
#===================================================# elif command -v python &>/dev/null; then
python_command="python"
function update_kiauh() { else
status_msg "Updating KIAUH ..." echo "Python is not installed. Please install Python and try again."
exit 1
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 fi
entrypoint=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
${python_command} "${entrypoint}/kiauh.py"
} }
function kiauh_update_dialog() { main
[[ ! $(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 #### sourcing all additional scripts
read -p "${cyan}###### Do you want to update now? (Y/n):${white} " yn #KIAUH_SRCDIR="$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")"
while true; do #for script in "${KIAUH_SRCDIR}/scripts/"*.sh; do . "${script}"; done
case "${yn}" in #for script in "${KIAUH_SRCDIR}/scripts/ui/"*.sh; do . "${script}"; done
Y|y|Yes|yes|"") #
do_action "update_kiauh" ##===================================================#
break;; ##=================== UPDATE KIAUH ==================#
N|n|No|no) ##===================================================#
break;; #
*) #function update_kiauh() {
deny_action "kiauh_update_dialog";; # status_msg "Updating KIAUH ..."
esac #
done # cd "${KIAUH_SRCDIR}"
} # git reset --hard && git pull
#
check_euid # ok_msg "Update complete! Please restart KIAUH."
init_logfile # exit 0
set_globals #}
kiauh_update_dialog #
main_menu ##===================================================#
##=================== 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

17
kiauh/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 os.path import join, dirname, abspath
from pathlib import Path
APPLICATION_ROOT = dirname(dirname(abspath(__file__)))
KIAUH_CFG = join(APPLICATION_ROOT, "kiauh.cfg")
KIAUH_BACKUP_DIR = f"{Path.home()}/kiauh-backups"

0
kiauh/core/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 kiauh import KIAUH_BACKUP_DIR
from kiauh.utils.common import get_current_date
from kiauh.utils.logger import Logger
# noinspection PyMethodMayBeStatic
class BackupManager:
def __init__(
self, backup_name: str, source: Path, backup_dir: Path = KIAUH_BACKUP_DIR
):
self._backup_name = backup_name
self._source = source
self._backup_dir = backup_dir
@property
def backup_name(self) -> str:
return self._backup_name
@backup_name.setter
def backup_name(self, value: str):
self._backup_name = value
@property
def source(self) -> Path:
return self._source
@source.setter
def source(self, value: Path):
self._source = value
@property
def backup_dir(self) -> Path:
return self._backup_dir
@backup_dir.setter
def backup_dir(self, value: Path):
self._backup_dir = value
def backup(self) -> None:
if self._source is None or not Path(self._source).exists():
raise OSError
try:
log = f"Creating backup of {self.backup_name} in {self.backup_dir} ..."
Logger.print_status(log)
date = get_current_date()
dest = Path(
f"{self.backup_dir}/{self.backup_name}/{date.get('date')}-{date.get('time')}"
)
shutil.copytree(src=self.source, dst=dest)
except OSError as e:
Logger.print_error(f"Unable to backup source directory. Not exist.\n{e}")
return
Logger.print_ok("Backup successfull!")

View File

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 typing import Union
from kiauh.utils.logger import Logger
# noinspection PyMethodMayBeStatic
class ConfigManager:
def __init__(self, cfg_file: str):
self.config_file = cfg_file
self.config = CustomConfigParser()
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=False) -> 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")

View File

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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, Union, Optional, Type, TypeVar
from kiauh.utils.constants import SYSTEMD, CURRENT_USER
from kiauh.utils.system_utils import create_directory
B = TypeVar(name="B", bound="BaseInstance", covariant=True)
class BaseInstance(ABC):
@classmethod
def blacklist(cls) -> List[str]:
return []
def __init__(
self,
suffix: Optional[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_from_suffix()
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"
self._gcodes_dir = f"{self.data_dir}/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: Union[str, None]) -> 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):
return self._data_dir
@data_dir.setter
def data_dir(self, value: str):
self._data_dir = value
@property
def cfg_dir(self):
return self._cfg_dir
@cfg_dir.setter
def cfg_dir(self, value: str):
self._cfg_dir = value
@property
def log_dir(self):
return self._log_dir
@log_dir.setter
def log_dir(self, value: str):
self._log_dir = value
@property
def comms_dir(self):
return self._comms_dir
@comms_dir.setter
def comms_dir(self, value: str):
self._comms_dir = value
@property
def sysd_dir(self):
return self._sysd_dir
@sysd_dir.setter
def sysd_dir(self, value: str):
self._sysd_dir = value
@property
def gcodes_dir(self):
return self._gcodes_dir
@gcodes_dir.setter
def gcodes_dir(self, value: str):
self._gcodes_dir = value
@abstractmethod
def create(self) -> None:
raise NotImplementedError("Subclasses must implement the create method")
@abstractmethod
def delete(self, del_remnants: bool) -> None:
raise NotImplementedError("Subclasses must implement the delete method")
def create_folders(self, add_dirs: List[str] = 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:
create_directory(Path(_dir))
def get_service_file_name(self, extension: bool = False) -> str:
name = f"{self.__class__.__name__.lower()}"
if self.suffix is not None:
name += f"-{self.suffix}"
return name if not extension else f"{name}.service"
def get_service_file_path(self) -> str:
return f"{SYSTEMD}/{self.get_service_file_name(extension=True)}"
def get_data_dir_from_suffix(self) -> str:
if self._suffix is None:
return "printer"
elif self._suffix.isdigit():
return f"printer_{self._suffix}"
else:
return self._suffix

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 typing import List, Optional, Union, TypeVar
from kiauh.core.instance_manager.base_instance import BaseInstance
from kiauh.utils.constants import SYSTEMD
from kiauh.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]:
if not self._instances:
self._instances = self._find_instances()
return sorted(self._instances, key=lambda x: self._sort_instance_list(x.suffix))
@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, 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_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 = [
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(suffix=self._get_instance_suffix(service))
for service in service_list
]
return instance_list
def _get_instance_suffix(self, file_path: str) -> Union[str, None]:
full_name = file_path.split("/")[-1].split(".")[0]
return full_name.split("-")[-1] if "-" in full_name else None
def _sort_instance_list(self, s: Union[int, str, None]):
if s is None:
return
return int(s) if s.isdigit() else s

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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"

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus import BACK_FOOTER
from kiauh.core.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_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="")

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from kiauh.core.menus import QUIT_FOOTER, BACK_FOOTER, BACK_HELP_FOOTER
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():
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):
def __init__(
self,
options: Dict[int, Any],
options_offset: int = 0,
header: bool = True,
footer_type: Literal[
"QUIT_FOOTER", "BACK_FOOTER", "BACK_HELP_FOOTER"
] = QUIT_FOOTER,
):
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 = {
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):
# 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"],
}
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()

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus import BACK_FOOTER
from kiauh.core.menus.base_menu import BaseMenu
from kiauh.modules.klipper import klipper_setup
from kiauh.modules.mainsail import mainsail_setup
from kiauh.modules.moonraker import moonraker_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_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: | 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):
moonraker_setup.run_moonraker_setup(install=True)
def install_mainsail(self):
mainsail_setup.run_mainsail_setup(install=True)
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")

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus import QUIT_FOOTER
from kiauh.core.menus.advanced_menu import AdvancedMenu
from kiauh.core.menus.base_menu import BaseMenu
from kiauh.core.menus.install_menu import InstallMenu
from kiauh.core.menus.remove_menu import RemoveMenu
from kiauh.core.menus.settings_menu import SettingsMenu
from kiauh.core.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: None,
1: InstallMenu,
2: UpdateMenu,
3: RemoveMenu,
4: AdvancedMenu,
5: None,
6: SettingsMenu,
},
footer_type=QUIT_FOOTER,
)
def print_menu(self):
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: <TODO> |
| | Repo: <TODO> |
| 1) [Install] | |
| 2) [Update] | Moonraker: <TODO> |
| 3) [Remove] | Repo: <TODO> |
| 4) [Advanced] | |
| 5) [Backup] | Mainsail: <TODO> |
| | Fluidd: <TODO> |
| 6) [Settings] | KlipperScreen: <TODO> |
| | Mobileraker: <TODO> |
| | |
| | Crowsnest: <TODO> |
| | Telegram Bot: <TODO> |
| | Obico: <TODO> |
| | OctoEverywhere: <TODO> |
|-------------------------------------------------------|
| {COLOR_CYAN}{footer1:^16}{RESET_FORMAT} | {footer2:^43} |
"""
)[1:]
print(menu, end="")

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus import BACK_FOOTER
from kiauh.core.menus.base_menu import BaseMenu
from kiauh.modules.klipper import klipper_setup
from kiauh.modules.moonraker import moonraker_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_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] | 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):
moonraker_setup.run_moonraker_setup(install=False)
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")

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.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")

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus import BACK_FOOTER
from kiauh.core.menus.base_menu import BaseMenu
from kiauh.modules.klipper.klipper_setup import update_klipper
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_FOOTER,
)
def print_menu(self):
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] | | |
| 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):
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")

View File

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from kiauh.utils.input_utils import get_confirm
from kiauh.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
def clone_repo(self):
log = f"Cloning repository from '{self.repo}' with method '{self.method}'"
Logger.print_status(log)
try:
if os.path.exists(self.target_dir):
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"

20
kiauh/main.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.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)

View File

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from pathlib import Path
MODULE_PATH = os.path.dirname(os.path.abspath(__file__))
KLIPPER_DIR = f"{Path.home()}/klipper"
KLIPPER_ENV_DIR = f"{Path.home()}/klippy-env"
KLIPPER_REQUIREMENTS_TXT = f"{KLIPPER_DIR}/scripts/klippy-requirements.txt"
DEFAULT_KLIPPER_REPO_URL = "https://github.com/Klipper3D/klipper"
EXIT_KLIPPER_SETUP = "Exiting Klipper setup ..."

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from pathlib import Path
from typing import List
from kiauh.core.instance_manager.base_instance import BaseInstance
from kiauh.modules.klipper import KLIPPER_DIR, KLIPPER_ENV_DIR
from kiauh.utils.constants import SYSTEMD
from kiauh.utils.logger import Logger
# noinspection PyMethodMayBeStatic
class Klipper(BaseInstance):
@classmethod
def blacklist(cls) -> List[str]:
return ["None", "mcu"]
def __init__(self, suffix: str = None):
super().__init__(instance_type=self, suffix=suffix)
self.klipper_dir = KLIPPER_DIR
self.env_dir = KLIPPER_ENV_DIR
self._cfg_file = self._get_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"
@property
def cfg_file(self) -> str:
return self._cfg_file
@property
def log(self) -> str:
return self._log
@property
def serial(self) -> str:
return self._serial
@property
def uds(self) -> str:
return self._uds
def create(self) -> None:
Logger.print_status("Creating new 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")
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, del_remnants: bool) -> 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
if del_remnants:
self._delete_klipper_remnants()
def write_service_file(
self, service_template_path: str, service_file_target: str, env_file_target: str
):
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: str, env_file_target: str):
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 _delete_klipper_remnants(self) -> None:
try:
Logger.print_status(f"Delete {self.klipper_dir} ...")
shutil.rmtree(Path(self.klipper_dir))
Logger.print_status(f"Delete {self.env_dir} ...")
shutil.rmtree(Path(self.env_dir))
except FileNotFoundError:
Logger.print_status("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 _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%", f"{self.cfg_dir}/printer.cfg"
)
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
def _get_cfg(self):
cfg_file_loc = f"{self.cfg_dir}/printer.cfg"
if Path(cfg_file_loc).is_file():
return cfg_file_loc
return None

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 kiauh.core.instance_manager.base_instance import BaseInstance
from kiauh.core.menus.base_menu import print_back_footer
from kiauh.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="")

View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 subprocess
from pathlib import Path
from typing import List, Union
from kiauh import KIAUH_CFG
from kiauh.core.backup_manager.backup_manager import BackupManager
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.core.instance_manager.instance_manager import InstanceManager
from kiauh.modules.klipper import (
EXIT_KLIPPER_SETUP,
DEFAULT_KLIPPER_REPO_URL,
KLIPPER_DIR,
KLIPPER_ENV_DIR,
KLIPPER_REQUIREMENTS_TXT,
)
from kiauh.modules.klipper.klipper import Klipper
from kiauh.modules.klipper.klipper_dialogs import (
print_instance_overview,
print_select_instance_count_dialog,
print_update_warn_dialog,
)
from kiauh.modules.klipper.klipper_utils import (
handle_convert_single_to_multi_instance_names,
handle_new_multi_instance_names,
handle_existing_multi_instance_names,
handle_disruptive_system_packages,
check_user_groups,
handle_single_to_multi_conversion,
create_example_printer_cfg,
)
from kiauh.core.repo_manager.repo_manager import RepoManager
from kiauh.utils.input_utils import (
get_confirm,
get_number_input,
get_selection_input,
)
from kiauh.utils.logger import Logger
from kiauh.utils.system_utils import (
parse_packages_from_file,
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.instances
instances_installed = len(instance_list)
is_klipper_installed = instances_installed > 0
if not install and not is_klipper_installed:
Logger.print_warn("Klipper not installed!")
return
if install:
add_additional = handle_existing_instances(instance_list)
if is_klipper_installed and not add_additional:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
install_klipper(instance_manager, instance_list)
if not install:
if instances_installed == 1:
remove_single_instance(instance_manager, instance_list)
else:
remove_multi_instance(instance_manager, instance_list)
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 install_klipper(
instance_manager: InstanceManager, instance_list: List[Klipper]
) -> None:
print_select_instance_count_dialog()
question = f"Number of{' additional' if len(instance_list) > 0 else ''} Klipper instances to set up"
install_count = get_number_input(question, 1, default=1, allow_go_back=True)
if install_count is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
instance_names = set_instance_suffix(instance_list, install_count)
if instance_names is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
create_example_cfg = get_confirm("Create example printer.cfg?")
if len(instance_list) < 1:
setup_klipper_prerequesites()
convert_single_to_multi = (
len(instance_list) == 1
and instance_list[0].suffix is None
and install_count >= 1
)
for name in instance_names:
if convert_single_to_multi:
current_instance = handle_single_to_multi_conversion(instance_manager, name)
convert_single_to_multi = False
else:
current_instance = Klipper(suffix=name)
instance_manager.current_instance = current_instance
instance_manager.create_instance()
instance_manager.enable_instance()
if create_example_cfg:
create_example_printer_cfg(current_instance)
instance_manager.start_instance()
instance_manager.reload_daemon()
# 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)
cm.read_config()
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
install_klipper_packages(Path(KLIPPER_DIR))
create_python_venv(Path(KLIPPER_ENV_DIR))
klipper_py_req = Path(KLIPPER_REQUIREMENTS_TXT)
install_python_requirements(Path(KLIPPER_ENV_DIR), klipper_py_req)
def install_klipper_packages(klipper_dir: Path) -> None:
script = Path(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_suffix(
instance_list: List[Klipper], install_count: int
) -> List[Union[str, None]]:
instance_count = len(instance_list)
# new single instance install
if instance_count == 0 and install_count == 1:
return [None]
# convert single instance install to multi install
elif instance_count == 1 and install_count >= 1 and instance_list[0].suffix is None:
return handle_convert_single_to_multi_instance_names(install_count)
# new multi instance install
elif instance_count == 0 and install_count > 1:
return handle_new_multi_instance_names(instance_count, install_count)
# existing multi instance install
elif instance_count > 1:
return handle_existing_multi_instance_names(
instance_count, install_count, instance_list
)
def remove_single_instance(
instance_manager: InstanceManager, instance_list: List[Klipper]
) -> None:
question = f"Delete {KLIPPER_DIR} and {KLIPPER_ENV_DIR}?"
del_remnants = get_confirm(question, allow_go_back=True)
if del_remnants is None:
Logger.print_status("Exiting Klipper Uninstaller ...")
return
try:
instance_manager.current_instance = instance_list[0]
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance(del_remnants=del_remnants)
instance_manager.reload_daemon()
except (OSError, subprocess.CalledProcessError):
Logger.print_error("Removing instance failed!")
return
def remove_multi_instance(
instance_manager: InstanceManager, instance_list: List[Klipper]
) -> None:
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_selection_input("Select Klipper instance to remove", options)
if selection == "b".lower():
return
elif selection == "a".lower():
question = f"Delete {KLIPPER_DIR} and {KLIPPER_ENV_DIR}?"
del_remnants = get_confirm(question, allow_go_back=True)
if del_remnants is None:
Logger.print_status("Exiting Klipper Uninstaller ...")
return
Logger.print_status("Removing all Klipper instances ...")
for instance in instance_list:
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance(del_remnants=del_remnants)
else:
instance = instance_list[int(selection)]
log = f"Removing Klipper instance: {instance.get_service_file_name()}"
Logger.print_status(log)
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance(del_remnants=False)
instance_manager.reload_daemon()
def update_klipper() -> None:
print_update_warn_dialog()
if not get_confirm("Update Klipper now?"):
return
cm = ConfigManager(cfg_file=KIAUH_CFG)
cm.read_config()
if cm.get_value("kiauh", "backup_before_update"):
backup_manager = BackupManager(source=KLIPPER_DIR, backup_name="klipper")
backup_manager.backup()
backup_manager.backup_name = "klippy-env"
backup_manager.source = KLIPPER_ENV_DIR
backup_manager.backup()
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()
instance_manager.start_all_instance()

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 grp
import shutil
import subprocess
import textwrap
from typing import List, Union
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.core.instance_manager.instance_manager import InstanceManager
from kiauh.modules.klipper import MODULE_PATH
from kiauh.modules.klipper.klipper import Klipper
from kiauh.modules.klipper.klipper_dialogs import (
print_missing_usergroup_dialog,
print_select_custom_name_dialog,
)
from kiauh.utils.constants import CURRENT_USER
from kiauh.utils.input_utils import get_confirm, get_string_input
from kiauh.utils.logger import Logger
from kiauh.utils.system_utils import mask_system_service
def assign_custom_names(
instance_count: int, install_count: int, instance_list: List[Klipper] = None
) -> List[str]:
instance_names = []
exclude = Klipper.blacklist()
# if an instance_list is provided, exclude all existing instance suffixes
if instance_list is not None:
for instance in instance_list:
exclude.append(instance.suffix)
for i in range(instance_count + install_count):
question = f"Enter name for instance {i + 1}"
name = get_string_input(question, exclude=exclude)
instance_names.append(name)
exclude.append(name)
return instance_names
def handle_convert_single_to_multi_instance_names(
install_count: int,
) -> Union[List[str], None]:
print_select_custom_name_dialog()
choice = get_confirm("Assign custom names?", False, allow_go_back=True)
if choice is True:
# instance_count = 0 and install_count + 1 as we want to assign a new name to the existing single install
return assign_custom_names(0, install_count + 1)
elif choice is False:
# "install_count + 2" as we need to account for the existing single install
_range = range(1, install_count + 2)
return [str(i) for i in _range]
return None
def handle_new_multi_instance_names(
instance_count: int, install_count: int
) -> Union[List[str], None]:
print_select_custom_name_dialog()
choice = get_confirm("Assign custom names?", False, allow_go_back=True)
if choice is True:
return assign_custom_names(instance_count, install_count)
elif choice is False:
_range = range(1, install_count + 1)
return [str(i) for i in _range]
return None
def handle_existing_multi_instance_names(
instance_count: int, install_count: int, instance_list: List[Klipper]
) -> List[str]:
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 handle_single_to_multi_conversion(
instance_manager: InstanceManager, name: str
) -> Klipper:
instance_list = instance_manager.instances
instance_manager.current_instance = instance_list[0]
old_data_dir_name = instance_manager.instances[0].data_dir
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance(del_remnants=False)
instance_manager.current_instance = Klipper(suffix=name)
new_data_dir_name = instance_manager.current_instance.data_dir
try:
os.rename(old_data_dir_name, new_data_dir_name)
return instance_manager.current_instance
except OSError as e:
log = f"Cannot rename {old_data_dir_name} to {new_data_dir_name}:\n{e}"
Logger.print_error(log)
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 has_custom_names(instance_list: List[Klipper]) -> bool:
pattern = re.compile("^\d+$")
for instance in instance_list:
if not pattern.match(instance.suffix):
return True
return False
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) -> None:
Logger.print_status(f"Creating example printer.cfg in '{instance.cfg_dir}'")
if instance.cfg_file is not None:
Logger.print_info(f"printer.cfg in '{instance.cfg_dir}' already exists.")
return
source = os.path.join(MODULE_PATH, "res", "printer.cfg")
target = os.path.join(instance.cfg_dir, "printer.cfg")
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.read_config()
cm.set_value("virtual_sdcard", "path", instance.gcodes_dir)
cm.write_config()
Logger.print_ok(f"Example printer.cfg created in '{instance.cfg_dir}'")

View File

@@ -0,0 +1 @@
KLIPPER_ARGS="%KLIPPER_DIR%/klippy/klippy.py %CFG% -I %SERIAL% -l %LOG% -a %UDS%"

View File

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

View File

@@ -0,0 +1,11 @@
[mcu]
serial: /dev/serial/by-id/<your-mcu-id>
[virtual_sdcard]
path: %GCODES_DIR%
on_error_gcode: CANCEL_PRINT
[printer]
kinematics: none
max_velocity: 1000
max_accel: 1000

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
import os
MODULE_PATH = os.path.dirname(os.path.abspath(__file__))
MAINSAIL_DIR = os.path.join(Path.home(), "mainsail")
MAINSAIL_CONFIG_DIR = os.path.join(Path.home(), "mainsail-config")
MAINSAIL_CONFIG_JSON = os.path.join(MAINSAIL_DIR, "config.json")
MAINSAIL_URL = (
"https://github.com/mainsail-crew/mainsail/releases/latest/download/mainsail.zip"
)
MAINSAIL_UNSTABLE_URL = (
"https://github.com/mainsail-crew/mainsail/releases/download/%TAG%/mainsail.zip"
)
MAINSAIL_CONFIG_REPO_URL = "https://github.com/mainsail-crew/mainsail-config.git"

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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.core.menus.base_menu import print_back_footer
from kiauh.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_mainsail_already_installed_dialog():
line1 = f"{COLOR_YELLOW}WARNING:{RESET_FORMAT}"
line2 = f"{COLOR_YELLOW}Mainsail seems to be already installed!{RESET_FORMAT}"
dialog = textwrap.dedent(
f"""
/=======================================================\\
| {line1:<63}|
| {line2:<63}|
|-------------------------------------------------------|
| If you continue, your current Mainsail installation |
| will be overwritten. You will not loose any printer |
| configurations and the Moonraker database will remain |
| untouched. |
"""
)[1:]
print(dialog, end="")
print_back_footer()
def print_install_mainsail_config_dialog():
dialog = textwrap.dedent(
f"""
/=======================================================\\
| It is recommended to use special macros in order to |
| have Mainsail fully functional and working. |
| |
| The recommended macros for Mainsail can be seen here: |
| https://github.com/mainsail-crew/mainsail-config |
| |
| 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="")
def print_mainsail_port_select_dialog(port: str):
port = f"{COLOR_CYAN}{port}{RESET_FORMAT}"
dialog = textwrap.dedent(
f"""
/=======================================================\\
| Please select the port, Mainsail should be served on. |
| If you are unsure what to select, hit Enter to apply |
| the suggested value of: {port:38} |
| |
| In case you need Mainsail to be served on a specific |
| port, you can set it now. Make sure the port is not |
| used by any other application on your system! |
\\=======================================================/
"""
)[1:]
print(dialog, end="")

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 os.path
import shutil
from pathlib import Path
from typing import List
from kiauh import KIAUH_CFG
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.core.instance_manager.instance_manager import InstanceManager
from kiauh.core.repo_manager.repo_manager import RepoManager
from kiauh.modules.klipper.klipper import Klipper
from kiauh.modules.mainsail import (
MAINSAIL_URL,
MAINSAIL_DIR,
MAINSAIL_CONFIG_DIR,
MAINSAIL_CONFIG_REPO_URL,
MAINSAIL_CONFIG_JSON,
MODULE_PATH,
)
from kiauh.modules.mainsail.mainsail_dialogs import (
print_moonraker_not_found_dialog,
print_mainsail_already_installed_dialog,
print_install_mainsail_config_dialog,
print_mainsail_port_select_dialog,
)
from kiauh.modules.moonraker.moonraker import Moonraker
from kiauh.utils.common import check_install_dependencies
from kiauh.utils.input_utils import get_confirm, get_number_input
from kiauh.utils.logger import Logger
from kiauh.utils.system_utils import (
download_file,
unzip,
create_upstream_nginx_cfg,
create_nginx_cfg,
delete_default_nginx_cfg,
enable_nginx_cfg,
set_nginx_permissions,
get_ipv4_addr,
control_systemd_service,
create_common_vars_nginx_cfg,
)
def run_mainsail_setup(install: bool) -> None:
im_mr = InstanceManager(Moonraker)
is_moonraker_installed = len(im_mr.instances) > 0
enable_remotemode = False
if not is_moonraker_installed:
print_moonraker_not_found_dialog()
do_continue = get_confirm("Continue Mainsail installation?", allow_go_back=True)
if do_continue:
enable_remotemode = True
else:
return
is_mainsail_installed = Path(f"{Path.home()}/mainsail").exists()
do_reinstall = False
if is_mainsail_installed:
print_mainsail_already_installed_dialog()
do_reinstall = get_confirm("Re-install Mainsail?", allow_go_back=True)
if do_reinstall:
backup_config_json()
else:
return
im_kl = InstanceManager(Klipper)
is_klipper_installed = len(im_kl.instances) > 0
install_ms_config = False
if is_klipper_installed:
print_install_mainsail_config_dialog()
question = "Download the recommended macros?"
install_ms_config = get_confirm(question, allow_go_back=False)
cm = ConfigManager(cfg_file=KIAUH_CFG)
cm.read_config()
default_port = cm.get_value("mainsail", "default_port")
mainsail_port = default_port if default_port else 80
if not default_port:
print_mainsail_port_select_dialog(f"{mainsail_port}")
mainsail_port = get_number_input(
"Configure Mainsail for port",
min_count=mainsail_port,
default=mainsail_port,
)
check_install_dependencies(["nginx"])
try:
download_mainsail()
if do_reinstall:
restore_config_json()
if enable_remotemode:
enable_mainsail_remotemode()
if is_moonraker_installed:
patch_moonraker_conf(
im_mr.instances,
"Mainsail",
"update_manager mainsail",
"mainsail-updater.conf",
)
im_mr.restart_all_instance()
if is_klipper_installed and install_ms_config:
download_mainsail_config()
patch_moonraker_conf(
im_mr.instances,
"mainsail-config",
"update_manager mainsail-config",
"mainsail-config-updater.conf",
)
patch_printer_config(im_kl.instances)
im_kl.restart_all_instance()
create_upstream_nginx_cfg()
create_common_vars_nginx_cfg()
create_mainsail_nginx_cfg(mainsail_port)
if is_klipper_installed:
symlink_webui_nginx_log(im_kl.instances)
control_systemd_service("nginx", "restart")
except Exception as e:
Logger.print_error(f"Mainsail installation failed!\n{e}")
return
log = f"Open Mainsail now on: http://{get_ipv4_addr()}:{mainsail_port}"
Logger.print_ok("Mainsail installation complete!", start="\n")
Logger.print_ok(log, prefix=False, end="\n\n")
def download_mainsail() -> None:
try:
Logger.print_status("Downloading Mainsail ...")
download_file(MAINSAIL_URL, f"{Path.home()}", "mainsail.zip")
Logger.print_ok("Download complete!")
Logger.print_status("Extracting mainsail.zip ...")
unzip(f"{Path.home()}/mainsail.zip", MAINSAIL_DIR)
Logger.print_ok("OK!")
except Exception:
Logger.print_error("Downloading Mainsail failed!")
raise
def download_mainsail_config() -> None:
try:
Logger.print_status("Downloading mainsail-config ...")
rm = RepoManager(MAINSAIL_CONFIG_REPO_URL, target_dir=MAINSAIL_CONFIG_DIR)
rm.clone_repo()
except Exception:
Logger.print_error("Downloading mainsail-config failed!")
raise
def create_mainsail_nginx_cfg(port: int) -> None:
try:
Logger.print_status("Creating NGINX config for Mainsail ...")
root_dir = MAINSAIL_DIR
delete_default_nginx_cfg()
create_nginx_cfg("mainsail", port, root_dir)
enable_nginx_cfg("mainsail")
set_nginx_permissions()
Logger.print_ok("NGINX config for Mainsail successfully created.")
except Exception:
Logger.print_error("Creating NGINX config for Mainsail failed!")
raise
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 = Path(instance.log_dir).joinpath("mainsail-access.log")
if not desti_access.exists():
desti_access.symlink_to(access_log)
desti_error = Path(instance.log_dir).joinpath("mainsail-error.log")
if not desti_error.exists():
desti_error.symlink_to(error_log)
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)
cm.read_config()
if cm.config.has_section(section_name):
Logger.print_info("Section already exist. Skipped ...")
return
template = os.path.join(MODULE_PATH, "res", 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 patch_printer_config(klipper_instances: List[Klipper]) -> None:
for instance in klipper_instances:
cfg_file = instance.cfg_file
Logger.print_status(f"Including mainsail-config in '{cfg_file}' ...")
if not Path(cfg_file).exists():
Logger.print_warn(f"'{cfg_file}' not found!")
return
cm = ConfigManager(cfg_file)
cm.read_config()
if cm.config.has_section("include mainsail.cfg"):
Logger.print_info("Section already exist. Skipped ...")
return
with open(cfg_file, "a") as f:
f.write("\n[include mainsail.cfg]")
def backup_config_json() -> None:
try:
Logger.print_status(f"Backup '{MAINSAIL_CONFIG_JSON}' ...")
target = os.path.join(Path.home(), "config.json.kiauh.bak")
shutil.copy(MAINSAIL_CONFIG_JSON, target)
except OSError:
Logger.print_info(f"Unable to backup config.json. Skipped ...")
def restore_config_json() -> None:
try:
Logger.print_status(f"Restore '{MAINSAIL_CONFIG_JSON}' ...")
source = os.path.join(Path.home(), "config.json.kiauh.bak")
shutil.copy(source, MAINSAIL_CONFIG_JSON)
except OSError:
Logger.print_info(f"Unable to restore config.json. Skipped ...")
def enable_mainsail_remotemode() -> None:
with open(MAINSAIL_CONFIG_JSON, "r") as f:
config_data = json.load(f)
if config_data["instancesDB"] == "browser":
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)

View File

@@ -0,0 +1,6 @@
[update_manager mainsail-config]
type: git_repo
primary_branch: master
path: ~/mainsail-config
origin: https://github.com/mainsail-crew/mainsail-config.git
managed_services: klipper

View File

@@ -0,0 +1,5 @@
[update_manager mainsail]
type: web
channel: stable
repo: mainsail-crew/mainsail
path: ~/mainsail

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from pathlib import Path
MODULE_PATH = os.path.dirname(os.path.abspath(__file__))
MOONRAKER_DIR = f"{Path.home()}/moonraker"
MOONRAKER_ENV_DIR = f"{Path.home()}/moonraker-env"
MOONRAKER_REQUIREMENTS_TXT = f"{MOONRAKER_DIR}/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 = "/etc/polkit-1/localauthority/50-local.d/10-moonraker.pkla"
POLKIT_FILE = "/etc/polkit-1/rules.d/moonraker.rules"
POLKIT_USR_FILE = "/usr/share/polkit-1/rules.d/moonraker.rules"
POLKIT_SCRIPT = f"{Path.home()}/moonraker/scripts/set-policykit-rules.sh"
EXIT_MOONRAKER_SETUP = "Exiting Moonraker setup ..."

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from pathlib import Path
from typing import List, Union
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.core.instance_manager.base_instance import BaseInstance
from kiauh.modules.moonraker import MOONRAKER_DIR, MOONRAKER_ENV_DIR, MODULE_PATH
from kiauh.utils.constants import SYSTEMD
from kiauh.utils.logger import Logger
# noinspection PyMethodMayBeStatic
class Moonraker(BaseInstance):
@classmethod
def blacklist(cls) -> List[str]:
return ["None", "mcu"]
def __init__(self, suffix: str = None):
super().__init__(instance_type=self, suffix=suffix)
self.moonraker_dir = MOONRAKER_DIR
self.env_dir = MOONRAKER_ENV_DIR
self.cfg_file = self._get_cfg()
self.port = self._get_port()
self.backup_dir = f"{self.data_dir}/backup"
self.certs_dir = f"{self.data_dir}/certs"
self.db_dir = f"{self.data_dir}/database"
self.log = f"{self.log_dir}/moonraker.log"
def create(self, create_example_cfg: bool = False) -> None:
Logger.print_status("Creating new Moonraker Instance ...")
service_template_path = os.path.join(MODULE_PATH, "res", "moonraker.service")
env_template_file_path = os.path.join(MODULE_PATH, "res", "moonraker.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}/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, del_remnants: bool) -> 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
if del_remnants:
self._delete_moonraker_remnants()
def write_service_file(
self, service_template_path: str, service_file_target: str, env_file_target: str
):
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: str, env_file_target: str):
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 _delete_moonraker_remnants(self) -> None:
try:
Logger.print_status(f"Delete {self.moonraker_dir} ...")
shutil.rmtree(Path(self.moonraker_dir))
Logger.print_status(f"Delete {self.env_dir} ...")
shutil.rmtree(Path(self.env_dir))
except FileNotFoundError:
Logger.print_status("Cannot delete Moonraker directories. Not found.")
except PermissionError as e:
Logger.print_error(f"Error deleting Moonraker directories: {e}")
raise
Logger.print_ok("Directories successfully deleted.")
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("%MOONRAKER_DIR%", self.moonraker_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(
"%MOONRAKER_DIR%", self.moonraker_dir
)
env_file_content = env_file_content.replace("%PRINTER_DATA%", self.data_dir)
return env_file_content
def _get_cfg(self):
cfg_file_loc = f"{self.cfg_dir}/moonraker.conf"
if Path(cfg_file_loc).is_file():
return cfg_file_loc
return None
def _get_port(self) -> Union[int, None]:
if self.cfg_file is None:
return None
cm = ConfigManager(cfg_file=self.cfg_file)
cm.read_config()
port = cm.get_value("server", "port")
return int(port) if port is not None else port

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 kiauh.core.menus.base_menu import print_back_footer
from kiauh.modules.klipper.klipper import Klipper
from kiauh.modules.moonraker.moonraker import Moonraker
from kiauh.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()

View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 kiauh import KIAUH_CFG
from kiauh.core.backup_manager.backup_manager import BackupManager
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.core.instance_manager.instance_manager import InstanceManager
from kiauh.modules.klipper.klipper import Klipper
from kiauh.modules.klipper.klipper_dialogs import (
print_instance_overview,
print_update_warn_dialog,
)
from kiauh.core.repo_manager.repo_manager import RepoManager
from kiauh.modules.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 kiauh.modules.moonraker.moonraker import Moonraker
from kiauh.modules.moonraker.moonraker_dialogs import print_moonraker_overview
from kiauh.modules.moonraker.moonraker_utils import create_example_moonraker_conf
from kiauh.utils.input_utils import (
get_confirm,
get_selection_input,
)
from kiauh.utils.logger import Logger
from kiauh.utils.system_utils import (
parse_packages_from_file,
create_python_venv,
install_python_requirements,
update_system_package_lists,
install_system_packages,
check_file_exists,
)
def run_moonraker_setup(install: bool) -> None:
kl_im = InstanceManager(Klipper)
kl_instance_list = kl_im.instances
kl_instance_count = len(kl_instance_list)
mr_im = InstanceManager(Moonraker)
mr_instance_list = mr_im.instances
mr_instance_count = len(mr_instance_list)
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
is_klipper_installed = kl_instance_count > 0
if install and not is_klipper_installed:
Logger.print_warn("Klipper not installed!")
Logger.print_warn("Moonraker cannot be installed! Install Klipper first.")
return
is_moonraker_installed = mr_instance_count > 0
if not install and not is_moonraker_installed:
Logger.print_warn("Moonraker not installed!")
return
if install:
install_moonraker(mr_im, mr_instance_list, kl_instance_list)
if not install:
remove_moonraker(mr_im, mr_instance_list)
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 install_moonraker(
instance_manager: InstanceManager,
moonraker_instances: List[Moonraker],
klipper_instances: List[Klipper],
) -> None:
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)
instance_manager.current_instance = current_instance
instance_manager.create_instance()
instance_manager.enable_instance()
if create_example_cfg:
create_example_moonraker_conf(current_instance, used_ports_map)
instance_manager.start_instance()
instance_manager.reload_daemon()
def setup_moonraker_prerequesites() -> None:
cm = ConfigManager(cfg_file=KIAUH_CFG)
cm.read_config()
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(Path(MOONRAKER_DIR))
create_python_venv(Path(MOONRAKER_ENV_DIR))
moonraker_py_req = Path(MOONRAKER_REQUIREMENTS_TXT)
install_python_requirements(Path(MOONRAKER_ENV_DIR), moonraker_py_req)
def install_moonraker_packages(moonraker_dir: Path) -> None:
script = Path(f"{moonraker_dir}/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_exists(Path(POLKIT_LEGACY_FILE))
polkit_file_exists = check_file_exists(Path(POLKIT_FILE))
usr_file_exists = check_file_exists(Path(POLKIT_USR_FILE))
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 remove_moonraker(
instance_manager: InstanceManager, instance_list: List[Moonraker]
) -> None:
print_instance_overview(instance_list, True, True)
options = [str(i) for i in range(len(instance_list))]
options.extend(["a", "A", "b", "B"])
selection = get_selection_input("Select Moonraker instance to remove", options)
del_remnants = False
remove_polkit = False
instances_to_remove = []
if selection == "b".lower():
return
elif selection == "a".lower():
question = f"Delete {MOONRAKER_DIR} and {MOONRAKER_ENV_DIR}?"
del_remnants = get_confirm(question, False, True)
instances_to_remove.extend(instance_list)
remove_polkit = True
Logger.print_status("Removing all Moonraker instances ...")
else:
instance = instance_list[int(selection)]
instance_name = instance.get_service_file_name()
instances_to_remove.append(instance)
is_last_instance = len(instance_list) == 1
if is_last_instance:
question = f"Delete {MOONRAKER_DIR} and {MOONRAKER_ENV_DIR}?"
del_remnants = get_confirm(question, False, True)
remove_polkit = True
Logger.print_status(f"Removing Moonraker instance {instance_name} ...")
if del_remnants is None:
Logger.print_status("Exiting Moonraker Uninstaller ...")
return
remove_instances(
instance_manager,
instances_to_remove,
remove_polkit,
del_remnants,
)
def remove_instances(
instance_manager: InstanceManager,
instance_list: List[Moonraker],
remove_polkit: bool,
del_remnants: bool,
) -> None:
for instance in instance_list:
instance_manager.current_instance = instance
instance_manager.stop_instance()
instance_manager.disable_instance()
instance_manager.delete_instance(del_remnants=del_remnants)
if remove_polkit:
remove_polkit_rules()
instance_manager.reload_daemon()
def remove_polkit_rules() -> None:
Logger.print_status("Removing all Moonraker policykit rules ...")
if not Path(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 update_moonraker() -> None:
print_update_warn_dialog()
if not get_confirm("Update Moonraker now?"):
return
cm = ConfigManager(cfg_file=KIAUH_CFG)
cm.read_config()
if cm.get_value("kiauh", "backup_before_update"):
backup_manager = BackupManager(source=MOONRAKER_DIR, backup_name="moonraker")
backup_manager.backup()
backup_manager.backup_name = "moonraker-env"
backup_manager.source = MOONRAKER_ENV_DIR
backup_manager.backup()
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()
instance_manager.start_all_instance()

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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, Dict
from kiauh.core.config_manager.config_manager import ConfigManager
from kiauh.modules.moonraker import (
DEFAULT_MOONRAKER_PORT,
MODULE_PATH,
)
from kiauh.modules.moonraker.moonraker import Moonraker
from kiauh.utils.logger import Logger
from kiauh.utils.system_utils import (
get_ipv4_addr,
)
def create_example_moonraker_conf(
instance: Moonraker, ports_map: Dict[str, int]
) -> None:
Logger.print_status(f"Creating example moonraker.conf in '{instance.cfg_dir}'")
if instance.cfg_file is not None:
Logger.print_info(f"moonraker.conf in '{instance.cfg_dir}' already exists.")
return
source = os.path.join(MODULE_PATH, "res", "moonraker.conf")
target = os.path.join(instance.cfg_dir, "moonraker.conf")
try:
shutil.copy(source, target)
except OSError as e:
Logger.print_error(f"Unable to create example moonraker.conf:\n{e}")
return
cm = ConfigManager(target)
cm.read_config()
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
uds = f"{instance.comms_dir}/klippy.sock"
ip = get_ipv4_addr().split(".")[:2]
ip.extend(["0", "0/16"])
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", uds)
cm.set_value("authorization", "trusted_clients", trusted_clients)
cm.write_config()
Logger.print_ok(f"Example moonraker.conf created in '{instance.cfg_dir}'")

View File

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

View File

@@ -0,0 +1 @@
MOONRAKER_ARGS="%MOONRAKER_DIR%/moonraker/moonraker.py -d %PRINTER_DATA%"

View File

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

19
kiauh/utils/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
MODULE_PATH = os.path.dirname(os.path.abspath(__file__))
INVALID_CHOICE = "Invalid choice. Please select a valid value."
# ================== NGINX =====================#
NGINX_SITES_AVAILABLE = "/etc/nginx/sites-available"
NGINX_SITES_ENABLED = "/etc/nginx/sites-enabled"
NGINX_CONFD = "/etc/nginx/conf.d"

45
kiauh/utils/common.py Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 typing import Dict, Literal, List
from kiauh.utils.constants import COLOR_CYAN, RESET_FORMAT
from kiauh.utils.logger import Logger
from kiauh.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)

25
kiauh/utils/constants.py Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
# 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 = "/etc/systemd/system"

148
kiauh/utils/input_utils.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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 kiauh.utils import INVALID_CHOICE
from kiauh.utils.constants import COLOR_CYAN, RESET_FORMAT
from kiauh.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

61
kiauh/utils/logger.py Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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_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)

View File

@@ -0,0 +1,6 @@
# /etc/nginx/conf.d/common_vars.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

96
kiauh/utils/res/nginx_cfg Normal file
View File

@@ -0,0 +1,96 @@
# /etc/nginx/sites-available/%NAME%
server {
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;
proxy_read_timeout 600;
}
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/;
}
}

View File

@@ -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;
}

475
kiauh/utils/system_utils.py Normal file
View File

@@ -0,0 +1,475 @@
#!/usr/bin/env python3
# ======================================================================= #
# Copyright (C) 2020 - 2023 Dominik Willner <th33xitus@gmail.com> #
# #
# 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
from pathlib import Path
from typing import List, Literal
from zipfile import ZipFile
from kiauh.utils import (
NGINX_CONFD,
MODULE_PATH,
NGINX_SITES_AVAILABLE,
NGINX_SITES_ENABLED,
)
from kiauh.utils.input_utils import get_confirm
from kiauh.utils.logger import Logger
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:
command = ["python3", "-m", "venv", f"{target}"]
result = subprocess.run(command, stderr=subprocess.PIPE, text=True)
if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", prefix=False)
Logger.print_error("Setup of virtualenv failed!")
return
Logger.print_ok("Setup of virtualenv successfull!")
except subprocess.CalledProcessError as e:
Logger.print_error(f"Error setting up virtualenv:\n{e.output.decode()}")
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)
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:
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:
Logger.print_error(f"Error updating pip:\n{e.output.decode()}")
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
"""
update_python_pip(target)
Logger.print_status("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:
log = f"Error installing Python requirements:\n{e.output.decode()}"
Logger.print_error(log)
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 = ["/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:
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 create_directory(_dir: Path) -> None:
"""
Helper function for creating a directory or skipping if it already exists |
:param _dir: the directory to create
:return: None
"""
try:
if not os.path.isdir(_dir):
os.makedirs(_dir, exist_ok=True)
Logger.print_ok(f"Created directory: {_dir}")
except OSError as e:
Logger.print_error(f"Error creating folder: {e}")
raise
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
def check_file_exists(file_path: Path) -> bool:
"""
Helper function for checking the existence of a file where
elevated permissions are required |
:param file_path: the absolute path of the file to check
:return: True if file exists, otherwise False
"""
try:
command = ["sudo", "find", file_path]
subprocess.check_output(command, stderr=subprocess.DEVNULL)
return True
except subprocess.CalledProcessError:
return False
# 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_folder: str, target_name: str, show_progress=True
) -> None:
"""
Helper method for downloading files from a provided URL |
:param url: the url to the file
:param target_folder: the target folder to download the file into
:param target_name: the name of the downloaded file
:param show_progress: show download progress or not
:return: None
"""
target_path = os.path.join(target_folder, target_name)
try:
if show_progress:
urllib.request.urlretrieve(url, target_path, download_progress)
sys.stdout.write("\n")
else:
urllib.request.urlretrieve(url, target_path)
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 unzip(file: str, target_dir: str) -> None:
"""
Helper function to unzip a zip-archive into a target directory |
:param file: the zip-file to unzip
:param target_dir: the target directory to extract the files into
:return: None
"""
with ZipFile(file, "r") as _zip:
_zip.extractall(target_dir)
def create_upstream_nginx_cfg() -> None:
"""
Creates an upstream.conf in /etc/nginx/conf.d
:return: None
"""
source = os.path.join(MODULE_PATH, "res", "upstreams.conf")
target = os.path.join(NGINX_CONFD, "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 create_common_vars_nginx_cfg() -> None:
"""
Creates a common_vars.conf in /etc/nginx/conf.d
:return: None
"""
source = os.path.join(MODULE_PATH, "res", "common_vars.conf")
target = os.path.join(NGINX_CONFD, "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: str) -> 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 = f"{Path.home()}/{name}.tmp"
shutil.copy(os.path.join(MODULE_PATH, "res", "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%", root_dir)
f.seek(0)
f.write(content)
f.truncate()
target = os.path.join(NGINX_SITES_AVAILABLE, 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 delete_default_nginx_cfg() -> None:
"""
Deletes a default NGINX config
:return: None
"""
default_cfg = Path("/etc/nginx/sites-enabled/default")
if not check_file_exists(default_cfg):
return
try:
command = ["sudo", "rm", default_cfg]
subprocess.run(command, stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
log = f"Unable to delete '{default_cfg}': {e.stderr.decode()}"
Logger.print_error(log)
raise
def enable_nginx_cfg(name: str) -> None:
"""
Helper method to enable an NGINX config |
:param name: name of the config to enable
:return: None
"""
source = os.path.join(NGINX_SITES_AVAILABLE, name)
target = os.path.join(NGINX_SITES_ENABLED, name)
if check_file_exists(Path(target)):
return
try:
command = ["sudo", "ln", "-s", source, target]
subprocess.run(command, stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
log = f"Unable to create symlink: {e.stderr.decode()}"
Logger.print_error(log)
raise
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
"""
cmd1 = f"ls -ld {Path.home()} | cut -d' ' -f1"
homedir_perm = subprocess.run(cmd1, 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(f"OK!")
except subprocess.CalledProcessError as e:
log = f"Failed to {action} {name}.service: {e.stderr.decode()}"
Logger.print_error(log)
raise

View File

@@ -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 ','.
# <repository>,<branch> -> 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

13
pyproject.toml Normal file
View File

@@ -0,0 +1,13 @@
[tool.black]
line-length = 88
target-version = ['py38']
include = '\.pyi?$'
exclude = '''
(
\.git/
| \.github/
| docs/
| resources/
| scripts/
)
'''