Compare commits

..

5 Commits

Author SHA1 Message Date
dw-0 2f41e52189 fix(core): refresh system package list in update menu after update (#791) 2026-04-19 12:21:17 +02:00
Théo Gaillard 757344128a fix(extensions_menu): prevent extension index collisions during loading (#783)
* fix(extensions_menu): prevent extension index collisions during loading

* feat(extensions_menu): add GITHUB_ISSUES_URL for reporting extension loading issues
2026-03-22 13:43:20 +01:00
Théo Gaillard 7ca08f9b30 feat(instance_manager): add interactive stopping of Klipper instances (#784)
* feat(instance_manager): add interactive stopping of Klipper instances with user confirmation

* fix(instance_utils): remove unnecessary 'self' parameter from stop_klipper_instances_interactively function

* refactor(tmc_autotune): replace internal stop function with direct call to stop_klipper_instances_interactively
2026-03-22 12:01:15 +01:00
David Rios 308079a821 fix: incorrect Logger.warn (#778)
Fix method name
2026-02-27 22:45:58 +01:00
Théo Gaillard 09a5d96b63 feat(tmc_autotune): add initial implementation of TMC Autotune extens… (#771)
* feat(tmc_autotune): add initial implementation of TMC Autotune extension with installation and update functionalities

* fix: remove useless comments

* fix: added support for Kalico style plugins directory

* refactor: extract method for moonraker update manager section removal, automatically reloading moonraker upon call
2026-02-03 20:46:24 +01:00
9 changed files with 473 additions and 7 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ def check_user_groups() -> None:
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"): if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
log = "Skipped adding user to required groups. You might encounter issues." log = "Skipped adding user to required groups. You might encounter issues."
Logger.warn(log) Logger.print_warn(log)
return return
try: try:
+9 -1
View File
@@ -251,6 +251,9 @@ class UpdateMenu(BaseMenu):
self._set_status_data("klipperscreen", get_klipperscreen_status) self._set_status_data("klipperscreen", get_klipperscreen_status)
self._set_status_data("crowsnest", get_crowsnest_status) self._set_status_data("crowsnest", get_crowsnest_status)
self._fetch_system_package_update_status()
def _fetch_system_package_update_status(self) -> None:
update_system_package_lists(silent=True) update_system_package_lists(silent=True)
self.packages = get_upgradable_packages() self.packages = get_upgradable_packages()
self.package_count = len(self.packages) self.package_count = len(self.packages)
@@ -340,15 +343,20 @@ class UpdateMenu(BaseMenu):
try: try:
pkgs: str = ", ".join(self.packages) pkgs: str = ", ".join(self.packages)
Logger.print_dialog( Logger.print_dialog(
DialogType.CUSTOM, DialogType.CUSTOM,
["The following packages will be upgraded:", "\n\n", pkgs], ["The following packages will be upgraded:", "\n\n", pkgs],
custom_title="UPGRADABLE SYSTEM UPDATES", custom_title="UPGRADABLE SYSTEM UPDATES",
) )
if not get_confirm("Continue?"):
if not get_confirm("Upgrade packages?"):
return return
Logger.print_status("Upgrading system packages ...") Logger.print_status("Upgrading system packages ...")
upgrade_system_packages(self.packages) upgrade_system_packages(self.packages)
self._fetch_system_package_update_status()
except Exception as e: except Exception as e:
Logger.print_error(f"Error upgrading system packages:\n{e}") Logger.print_error(f"Error upgrading system packages:\n{e}")
raise raise
+1
View File
@@ -10,3 +10,4 @@
from pathlib import Path from pathlib import Path
EXTENSION_ROOT = Path(__file__).resolve().parents[1].joinpath("extensions") EXTENSION_ROOT = Path(__file__).resolve().parents[1].joinpath("extensions")
GITHUB_ISSUES_URL = "https://github.com/dw-0/kiauh/issues"
+30 -4
View File
@@ -19,7 +19,7 @@ from core.logger import Logger
from core.menus import Option from core.menus import Option
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from core.types.color import Color from core.types.color import Color
from extensions import EXTENSION_ROOT from extensions import EXTENSION_ROOT, GITHUB_ISSUES_URL
from extensions.base_extension import BaseExtension from extensions.base_extension import BaseExtension
@@ -56,6 +56,22 @@ class ExtensionsMenu(BaseMenu):
with open(metadata_json, "r") as m: with open(metadata_json, "r") as m:
# read extension metadata from json # read extension metadata from json
metadata = json.load(m).get("metadata") metadata = json.load(m).get("metadata")
index = str(metadata.get("index"))
# Prevent collisions where one extension silently overrides another.
if index in ext_dict:
existing_name = ext_dict[index].metadata.get("display_name")
duplicate_name = metadata.get("display_name")
Logger.print_warn(
"Failed loading extension"
f" {ext}: duplicate index '{index}'"
f" already used by '{existing_name}'."
f" Skipping '{duplicate_name}'."
f" Please report this at {GITHUB_ISSUES_URL}."
)
continue
int(index)
module_name = metadata.get("module") module_name = metadata.get("module")
module_path = f"kiauh.extensions.{ext.name}.{module_name}" module_path = f"kiauh.extensions.{ext.name}.{module_name}"
@@ -73,10 +89,20 @@ class ExtensionsMenu(BaseMenu):
# instantiate the extension with its metadata and add to dict # instantiate the extension with its metadata and add to dict
ext_instance: BaseExtension = ext_class(metadata) ext_instance: BaseExtension = ext_class(metadata)
ext_dict[f"{metadata.get('index')}"] = ext_instance ext_dict[index] = ext_instance
except (IOError, json.JSONDecodeError, ImportError) as e: except (
print(f"Failed loading extension {ext}: {e}") IOError,
json.JSONDecodeError,
ImportError,
TypeError,
ValueError,
AttributeError,
) as e:
Logger.print_warn(
f"Failed loading extension {ext}: {e}. "
f"Please report this at {GITHUB_ISSUES_URL}."
)
return dict(sorted(ext_dict.items(), key=lambda x: int(x[0]))) return dict(sorted(ext_dict.items(), key=lambda x: int(x[0])))
+28
View File
@@ -0,0 +1,28 @@
# ======================================================================= #
# Copyright (C) 2020 - 2026 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
# repo
TMCA_REPO = "https://github.com/andrewmcgr/klipper_tmc_autotune"
# directories
TMCA_DIR = Path.home().joinpath("klipper_tmc_autotune")
MODULE_PATH = Path(__file__).resolve().parent
KLIPPER_DIR = Path.home().joinpath("klipper")
KLIPPER_EXTRAS = KLIPPER_DIR.joinpath("klippy/extras")
KLIPPER_PLUGINS = KLIPPER_DIR.joinpath("klippy/plugins")
KLIPPER_EXTENSIONS_PATH = (
KLIPPER_PLUGINS if KLIPPER_PLUGINS.is_dir() else KLIPPER_EXTRAS
)
# files
TMCA_EXAMPLE_CONFIG = TMCA_DIR.joinpath("docs/example.cfg")
# names
TMCA_MOONRAKER_UPDATER_NAME = "update_manager klipper_tmc_autotune"
@@ -0,0 +1,13 @@
{
"metadata": {
"index": 13,
"module": "tmc_autotune_extension",
"maintained_by": "theogayar",
"display_name": "Klipper TMC Autotune",
"description": [
"Klipper extension for automatic configuration and tuning of TMC drivers."
],
"repo": "https://github.com/andrewmcgr/klipper_tmc_autotune",
"updates": true
}
}
@@ -0,0 +1,347 @@
# ======================================================================= #
# Copyright (C) 2020 - 2026 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 typing import List
from components.klipper.klipper import Klipper
from components.moonraker.moonraker import Moonraker
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.services.backup_service import BackupService
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
SimpleConfigParser,
)
from extensions.base_extension import BaseExtension
from extensions.tmc_autotune import (
KLIPPER_DIR,
KLIPPER_EXTENSIONS_PATH,
TMCA_DIR,
TMCA_EXAMPLE_CONFIG,
TMCA_MOONRAKER_UPDATER_NAME,
TMCA_REPO,
)
from utils.config_utils import add_config_section, remove_config_section
from utils.fs_utils import check_file_exist, create_symlink, run_remove_routines
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import get_confirm
from utils.instance_utils import get_instances, stop_klipper_instances_interactively
from utils.sys_utils import check_python_version
# noinspection PyMethodMayBeStatic
class TmcAutotuneExtension(BaseExtension):
def install_extension(self, **kwargs) -> None:
Logger.print_status("Installing Klipper TMC Autotune...")
# Check for Python 3.x, aligned with upstream install script
if not check_python_version(3, 0):
Logger.print_warn("Python 3.x is required. Aborting install.")
return
klipper_dir_exists = check_file_exist(KLIPPER_DIR)
if not klipper_dir_exists:
Logger.print_warn(
"No Klipper directory found! Unable to install extension."
)
return
tmca_exists = (
check_file_exist(TMCA_DIR)
and check_file_exist(KLIPPER_EXTENSIONS_PATH.joinpath("autotune_tmc.py"))
and check_file_exist(KLIPPER_EXTENSIONS_PATH.joinpath("motor_constants.py"))
and check_file_exist(KLIPPER_EXTENSIONS_PATH.joinpath("motor_database.cfg"))
)
overwrite = True
if tmca_exists:
overwrite = get_confirm(
question="Extension seems to be installed already. Overwrite?",
default_choice=True,
allow_go_back=False,
)
if not overwrite:
Logger.print_warn("Installation aborted due to user request.")
return
add_moonraker_update_section = get_confirm(
question="Add Klipper TMC Autotune to Moonraker update manager(s)?",
default_choice=True,
allow_go_back=False,
)
create_example_config = get_confirm(
question="Create an example autotune_tmc.cfg for each instance?",
default_choice=True,
allow_go_back=False,
)
kl_instances = get_instances(Klipper)
if not stop_klipper_instances_interactively(
kl_instances, "installation of TMC Autotune"
):
return
try:
git_clone_wrapper(TMCA_REPO, TMCA_DIR, force=True)
Logger.print_info("Creating symlinks in Klipper extras directory...")
create_symlink(
TMCA_DIR.joinpath("autotune_tmc.py"),
KLIPPER_EXTENSIONS_PATH.joinpath("autotune_tmc.py"),
)
create_symlink(
TMCA_DIR.joinpath("motor_constants.py"),
KLIPPER_EXTENSIONS_PATH.joinpath("motor_constants.py"),
)
create_symlink(
TMCA_DIR.joinpath("motor_database.cfg"),
KLIPPER_EXTENSIONS_PATH.joinpath("motor_database.cfg"),
)
Logger.print_ok(
"Symlinks created successfully for all instances.", end="\n\n"
)
if create_example_config:
self._install_example_cfg(kl_instances)
else:
Logger.print_info(
"Skipping example config creation as per user request."
)
Logger.print_warn(
"Make sure to create and include an autotune_tmc.cfg in your printer.cfg in order to use the extension!"
)
if add_moonraker_update_section:
mr_instances = get_instances(Moonraker)
self._add_moonraker_update_manager_section(mr_instances)
else:
Logger.print_info(
"Skipping update section creation as per user request."
)
Logger.print_warn(
"Make sure to create the corresponding section in your moonraker.conf in order to have it appear in your frontend update manager!"
)
except Exception as e:
Logger.print_error(f"Error during Klipper TMC Autotune installation:\n{e}")
if kl_instances:
InstanceManager.start_all(kl_instances)
return
if kl_instances:
InstanceManager.start_all(kl_instances)
if create_example_config:
Logger.print_dialog(
DialogType.ATTENTION,
[
"Basic configuration files were created per instance. You must edit them to enable the extension.",
"Documentation:",
f"{TMCA_REPO}",
"\n\n",
"IMPORTANT:",
"Define [autotune_tmc] sections ONLY in 'autotune_tmc.cfg'. ",
"Do NOT add them to 'printer.cfg', contrary to official docs. "
"While not fatal, mixing configs breaks file segmentation and is bad practice.",
],
margin_bottom=1,
)
Logger.print_ok("Klipper TMC Autotune installed successfully!")
def update_extension(self, **kwargs) -> None:
extension_installed = check_file_exist(TMCA_DIR)
if not extension_installed:
Logger.print_info("Extension does not seem to be installed! Skipping ...")
return
backup_before_update = get_confirm(
question="Backup Klipper TMC Autotune directory before update?",
default_choice=True,
allow_go_back=True,
)
kl_instances = get_instances(Klipper)
if not stop_klipper_instances_interactively(
kl_instances, "update of TMC Autotune"
):
return
Logger.print_status("Updating Klipper TMC Autotune...")
try:
if backup_before_update:
Logger.print_status("Backing up Klipper TMC Autotune directory...")
svc = BackupService()
svc.backup_directory(
source_path=TMCA_DIR,
backup_name="klipper_tmc_autotune",
)
Logger.print_ok("Backup completed successfully.")
git_pull_wrapper(TMCA_DIR)
except Exception as e:
Logger.print_error(f"Error during Klipper TMC Autotune update:\n{e}")
if kl_instances:
InstanceManager.start_all(kl_instances)
return
if kl_instances:
InstanceManager.start_all(kl_instances)
Logger.print_ok("Klipper TMC Autotune updated successfully.", end="\n\n")
def remove_extension(self, **kwargs) -> None:
extension_installed = check_file_exist(TMCA_DIR)
if not extension_installed:
Logger.print_info("Extension does not seem to be installed! Skipping ...")
return
kl_instances = get_instances(Klipper)
if not stop_klipper_instances_interactively(
kl_instances, "removal of TMC Autotune"
):
return
try:
Logger.print_info("Removing Klipper TMC Autotune extension ...")
run_remove_routines(TMCA_DIR)
Logger.print_info("Removing symlinks from Klipper extras directory ...")
run_remove_routines(KLIPPER_EXTENSIONS_PATH.joinpath("autotune_tmc.py"))
run_remove_routines(KLIPPER_EXTENSIONS_PATH.joinpath("motor_constants.py"))
run_remove_routines(KLIPPER_EXTENSIONS_PATH.joinpath("motor_database.cfg"))
mr_instances: List[Moonraker] = get_instances(Moonraker)
self._remove_moonraker_update_manager_section(mr_instances)
Logger.print_info("Removing include from printer.cfg files ...")
BackupService().backup_printer_cfg()
remove_config_section("include autotune_tmc.cfg", kl_instances)
Logger.print_dialog(
DialogType.ATTENTION,
[
"Manual edits to 'printer.cfg' may be required if using exotic stepper configurations.",
"\n\n",
"NOTE:",
"'autotune_tmc.cfg' is NOT removed automatically. ",
"Please delete it manually if no longer needed.",
],
margin_bottom=1,
)
except Exception as e:
Logger.print_error(f"Unable to remove extension:\n{e}")
if kl_instances:
InstanceManager.start_all(kl_instances)
return
if kl_instances:
InstanceManager.start_all(kl_instances)
Logger.print_ok("Klipper TMC Autotune removed successfully.")
def _install_example_cfg(self, kl_instances: List[Klipper]):
cfg_dirs = [instance.base.cfg_dir for instance in kl_instances]
for cfg_dir in cfg_dirs:
Logger.print_status(f"Create autotune_tmc.cfg in '{cfg_dir}' ...")
if check_file_exist(cfg_dir.joinpath("autotune_tmc.cfg")):
Logger.print_info("File already exists! Skipping ...")
continue
try:
shutil.copy(TMCA_EXAMPLE_CONFIG, cfg_dir.joinpath("autotune_tmc.cfg"))
Logger.print_ok("Done!")
except OSError as e:
Logger.print_error(f"Unable to create example config: {e}")
BackupService().backup_printer_cfg()
section = "include autotune_tmc.cfg"
cfg_files = [instance.cfg_file for instance in kl_instances]
for cfg_file in cfg_files:
Logger.print_status(f"Include autotune_tmc.cfg in '{cfg_file}' ...")
scp = SimpleConfigParser()
scp.read_file(cfg_file)
if scp.has_section(section):
Logger.print_info("Section already defined! Skipping ...")
continue
scp.add_section(section)
scp.write_file(cfg_file)
Logger.print_ok("Done!")
def _add_moonraker_update_manager_section(
self, mr_instances: List[Moonraker]
) -> None:
if not mr_instances:
Logger.print_dialog(
DialogType.WARNING,
[
"Moonraker not found! Klipper TMC Autotune update manager support "
"for Moonraker will not be added to moonraker.conf.",
],
)
if not get_confirm(
"Continue Klipper TMC Autotune installation?",
default_choice=False,
allow_go_back=True,
):
Logger.print_info("Installation aborted due to user request.")
return
BackupService().backup_moonraker_conf()
add_config_section(
section=TMCA_MOONRAKER_UPDATER_NAME,
instances=mr_instances,
options=[
("type", "git_repo"),
("channel", "dev"),
("path", TMCA_DIR.as_posix()),
("origin", TMCA_REPO),
("managed_services", "klipper"),
("primary_branch", "main"),
],
)
InstanceManager.restart_all(mr_instances)
Logger.print_ok(
"Klipper TMC Autotune successfully added to Moonraker update manager(s)!"
)
def _remove_moonraker_update_manager_section(
self, mr_instances: List[Moonraker]
) -> None:
if not mr_instances:
Logger.print_dialog(
DialogType.WARNING,
[
"Moonraker not found! Klipper TMC Autotune update manager support "
"for Moonraker will not be removed from moonraker.conf.",
],
)
return
BackupService().backup_moonraker_conf()
remove_config_section("update_manager klipper_tmc_autotune", mr_instances)
InstanceManager.restart_all(mr_instances)
Logger.print_ok(
"Klipper TMC Autotune successfully removed from Moonraker update manager(s)!"
)
+41
View File
@@ -12,8 +12,12 @@ import re
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from components.klipper.klipper import Klipper
from core.constants import SYSTEMD from core.constants import SYSTEMD
from core.instance_manager.base_instance import SUFFIX_BLACKLIST from core.instance_manager.base_instance import SUFFIX_BLACKLIST
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from utils.input_utils import get_confirm
from utils.instance_type import InstanceType from utils.instance_type import InstanceType
@@ -56,3 +60,40 @@ def get_instance_suffix(name: str, file_path: Path) -> str:
# otherwise there is and hyphen left, and we return the part after the hyphen # otherwise there is and hyphen left, and we return the part after the hyphen
suffix = file_path.stem[len(name) :] suffix = file_path.stem[len(name) :]
return suffix[1:] if suffix else "" return suffix[1:] if suffix else ""
def stop_klipper_instances_interactively(
kl_instances: List[Klipper], operation_name: str = "operation"
) -> bool:
"""
Interactively stops all active Klipper instances, warning the user that ongoing prints will be disrupted.
:param kl_instances: List of Klipper instances to stop.
:param operation_name: Optional name of the operation being performed (for user messaging). Do NOT capitalize.
:return: True if instances were stopped or no instances found, False if operation was aborted.
"""
if not kl_instances:
Logger.print_warn("No instances found, skipping instance stopping.")
return True
Logger.print_dialog(
DialogType.ATTENTION,
[
"Do NOT continue if there are ongoing prints running",
f"All Klipper instances will be restarted during the {operation_name} and "
"ongoing prints WILL FAIL.",
],
)
stop_klipper = get_confirm(
question=f"Stop Klipper now and proceed with {operation_name}?",
default_choice=False,
allow_go_back=True,
)
if stop_klipper:
InstanceManager.stop_all(kl_instances)
return True
else:
Logger.print_warn(f"{operation_name.capitalize()} aborted due to user request.")
return False
+3 -1
View File
@@ -284,12 +284,14 @@ def get_upgradable_packages() -> List[str]:
try: try:
command = ["apt", "list", "--upgradable"] command = ["apt", "list", "--upgradable"]
output: str = check_output(command, stderr=DEVNULL, text=True, encoding="utf-8") output: str = check_output(command, stderr=DEVNULL, text=True, encoding="utf-8")
pkglist = [] pkglist: List[str] = []
for line in output.split("\n"): for line in output.split("\n"):
if "/" not in line: if "/" not in line:
continue continue
pkg = line.split("/")[0] pkg = line.split("/")[0]
pkglist.append(pkg) pkglist.append(pkg)
return pkglist return pkglist
except CalledProcessError as e: except CalledProcessError as e:
raise Exception(f"Error reading upgradable packages: {e}") raise Exception(f"Error reading upgradable packages: {e}")