Compare commits

...

56 Commits

Author SHA1 Message Date
Oleg Gurev
195b7fa926 fix: add moonraker-hmi and moonraker-telegram-bot to the blacklist (#720)
* Add moonraker-hmi and moonraker-telegram-bot to the blacklist of moonraker service detection function

* fix: add "hmi" to SUFFIX_BLACKLIST to prevent instance name conflicts

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2025-08-31 11:30:16 +02:00
dw-0
12919c7140 feat(extension): add website and repo metadata for extensions, update links formatting in menu
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-30 22:02:12 +02:00
dw-0
e590f668e6 fix(common): return default version if no tags exist in get_kiauh_version
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 18:28:40 +02:00
Tovi
075f2d384b docs(readme): add chinese readme instructions (#707)
* add chinese readme

* translated raspberry pi to chinese
2025-08-28 17:45:39 +02:00
dw-0
afdde34721 feat(core): add repository management to settings (#718)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 16:22:02 +02:00
dw-0
393dd1d5bf feat(extension): add OctoPrint installer (#716)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-28 16:21:42 +02:00
dw-0
8170057434 fix(moonraker): correctly patch trusted_clients options
fixes #711 #709

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-08-03 10:07:13 +02:00
Maksym Pyrozhok
985b66d41f chore: fix typos (#695)
Fix typo.
2025-07-12 19:36:38 +02:00
dw-0
f95d2586bf fix(webclient): add config.json to moonraker persistent files (#699)
fixes #694

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-06-28 10:12:28 +02:00
dw-0
f5141e7eff fix(mainsail): check for json configured as instanceDB (#698)
fix(mainsail): check for json configures as instanceDB

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-06-27 22:37:44 +02:00
dw-0
33113e72e9 fix: exception raised on pip warning (#688)
pip seems to write to stderr on warnings, caused by retries. even if the process exits with 0.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-05-31 17:44:02 +02:00
dw-0
6f59fd06aa fix: do not upgrade pip before installing packages (#680)
pip 25 seems to introduce some compatibility issues.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-05-02 20:08:32 +02:00
dw-0
56ea43ccb6 refactor: improve typesafety KiauhSettings
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:19:38 +02:00
dw-0
25e22c993f chore(scp): update SimpleConfigParser
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:15:12 +02:00
dw-0
ead521b377 refactor: replace mypy with pyright
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-14 21:07:56 +02:00
dw-0
3c952ccc12 refactor: use sane fallbacks on missing kiauh config options
for some options a warning is print if the fallback is used

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-12 15:12:11 +02:00
dw-0
c8f713c00e fix: no validation of optional_speedups option
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-04-12 00:36:34 +02:00
Pavel Sorejs
95cf809378 feat: add option to customize python binary for Klipper and Moonraker, add option to not install Moonraker speedups (#671)
Add option to cusomize python binary for klipper and moonraker. Add option to not install moonraker speedups.
2025-04-06 22:23:39 +02:00
dw-0
c91816d13f feat(extension): add Spoolman Docker installer (#669)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-30 17:57:46 +02:00
dw-0
1a6f06eaf2 refactor(moonraker): move setup functions into MoonrakerSetupService
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-29 23:00:06 +01:00
dw-0
ea8621af0c refactor(git_utils): remove unnecessary url parameter in git_pull_wrapper
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-29 16:49:08 +01:00
dw-0
88742ab496 feat: allow configuration of multiple repos in kiauh.cfg (#668)
* remove existing simple_config_parser directory

* Squashed 'kiauh/core/submodules/simple_config_parser/' content from commit da22e6a

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: da22e6ad9ca4bc121c39dc3bc6c63175a72e78a2

* Squashed 'kiauh/core/submodules/simple_config_parser/' changes from da22e6a..9ae5749

9ae5749 fix: comment out file writing in test
1ac4e3d refactor: improve section writing

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: 9ae574930dfe82107a3712c7c72b3aa777588996

* Squashed 'kiauh/core/submodules/simple_config_parser/' changes from 9ae5749..53e8408

53e8408 fix: do not add a blank line before writing a section header
dc77569 test: add test for removing option before writing

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: 53e840853f12318dcac68196fb74c1843cb75808

* Squashed 'kiauh/core/submodules/simple_config_parser/' changes from 53e8408..4a6e5f2

4a6e5f2 refactor: full rework of the internal storage of the parsed config

git-subtree-dir: kiauh/core/submodules/simple_config_parser
git-subtree-split: 4a6e5f23cb1f298f0a3efbf042186b16c91763c7

* refactor!: switching repos now offers list of repositories to choose from

this rework aligns more with the feature provided in kiauh v5.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-29 16:18:20 +01:00
dw-0
b99e6612e2 feat(ci): add automated release workflow for fast-forward and tagging
Adds a new GitHub Actions workflow that:
- Fast-forwards master branch from develop
- Creates and pushes a new release tag
- Requires manual trigger with tag name input

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-19 21:43:09 +01:00
dw-0
cf4e915430 cicd: restrict worflow runs to develop branch
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-13 18:26:23 +01:00
CODeRUS
c901cd1fdf feat(advanced): install input shaper dependencies (#662)
* feat(advanced): install input shaper dependencies

Signed-off-by: Andrey Kozhevnikov <coderusinbox@gmail.com>

* chore: fix formatting/wording

also add a quick check if the klipper env exists

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Andrey Kozhevnikov <coderusinbox@gmail.com>
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2025-03-13 18:26:23 +01:00
Aleksei Sviridkin
da3c37a872 feat(git_utils): Support for blolbless clone mode in git_cmd_clone (#640)
* feat(git_utils): enhance git_cmd_clone with depth and single-branch options

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(git_utils): add a newline for better readability in git_cmd_clone

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* feat(git_utils): enhance git_cmd_clone with optional depth and single-branch parameters

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* feat(git_utils): update git_cmd_clone to support blolbless cloning option

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* revert formatting changes

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix another formatting changes

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(git_utils): correct indentation for improved readability in get_local_tags function

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* fix(git_utils): rename blolbless parameter to blobless and update documentation for clarity

Signed-off-by: Aleksei Sviridkin <f@lex.la>

* refactor: enable the blobless clone feature for all regular clones

skip checkout step if brach is master or main

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2025-03-13 18:26:23 +01:00
dw-0
8f436646cd cicd: add action for fast-forward check and merge (#660)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-09 12:45:46 +01:00
dw-0
760f131d1c fix(klipper): handle file access exception for dietpi version file (#658)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-09 08:32:14 +01:00
dw-0
41804f0eaa style: ruff format
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-09 08:32:14 +01:00
dw-0
d3c9bcc38c refactor(klipper): move setup functions into KlipperSetupService
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-09 08:32:14 +01:00
dw-0
7fc36f3e68 feat(moonraker): display moonraker ip address after install
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-03-08 11:41:29 +01:00
CODeRUS
a4942b9404 fix: Add pkg-config to klipper packages (#655)
Signed-off-by: Andrey Kozhevnikov <coderusinbox@gmail.com>
2025-03-08 11:41:29 +01:00
dw-0
9e0a8a0081 Release v5.1.3
Release v5.1.3
2025-02-23 12:42:44 +01:00
dw-0
6082528628 Merge pull request #648 from Arksine/dev-v5-moonraker-deps-fix
fix: add support for Moonraker's dependency requirement specifiers to V5
2025-02-23 12:32:17 +01:00
Eric Callahan
9e92e4a36a fix: parse moonraker deps with requirement specifiers
Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
2025-02-22 16:50:28 -05:00
dw-0
7e8f1f3d81 Release v6.0.0-alpha.16
Merge develop into master (Release v6.0.0-alpha.16)
2025-02-22 16:25:49 +01:00
dw-0
234cf2c751 chore(copyright): update year (#645)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-02-22 16:21:34 +01:00
dw-0
3bc98eed13 fix(moonraker): adapt to new moonraker system_dependency.json syntax (#644)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-02-22 16:19:00 +01:00
dw-0
777f5e45e7 master -> develop
master -> develop
2025-02-20 21:00:21 +01:00
Paul Fertser
acf0faf158 fix: add ULA to trusted_clients in moonraker.conf (#637)
Signed-off-by: Paul Fertser <fercerpav@gmail.com>
2025-02-16 16:47:00 +01:00
dw-0
5c219ec544 master -> develop (#635) 2025-02-15 11:26:04 +01:00
dw-0
70055e891e Release v6.0.0-alpha.15 2025-02-15 11:17:54 +01:00
dw-0
e3a0a9dec0 Release v6.0.0-alpha.15
fixes #632
2025-02-15 11:15:45 +01:00
dw-0
1cf81377ee Release v6.0.0-alpha.14
fixes #632
2025-02-15 11:11:04 +01:00
dw-0
aa4ea99c5c fix(moonraker): use os-release file to get distro info (#633)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-02-15 11:09:32 +01:00
marbocub
20ffc82a04 feat: add .internal as a CORS domain in moonraker.conf (#631)
This adds .internal as a CORS domain in moonraker.conf, which is reserved by ICANN as a domain name for private top-level domains (TLDs).

Co-authored-by: dw-0 <th33xitus@gmail.com>
Co-authored-by: dw-0 <domwil1091+github@gmail.com>
2025-02-13 16:21:47 +01:00
dw-0
0becf9d574 Release v6.0.0-alpha.14
fixes #607
fixes #615
fixes #618
fixes #619
fixes #620
fixes #623
fixes #627
2025-02-09 21:15:42 +01:00
dw-0
ed1bfcdeb4 fix(moonraker): correctly install ubuntu 24.10 dependencies (#630)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-02-09 21:12:59 +01:00
dw-0
033916216c refactor: skip build firmware dependency screen if all are met (#629)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-02-09 17:04:46 +01:00
CODeRUS
d8f47c0960 feature: save and select kconfig (#621)
* feature: save and select kconfig

Signed-off-by: Andrey Kozhevnikov <coderusinbox@gmail.com>

* chore: clean up and sort imports

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

* refactor: replace os.path with Pathlib

- use config paths as type Paths instead of strings.
- tweak some menu visuals.

Signed-off-by: Dominik Willner <th33xitus@gmail.com>

---------

Signed-off-by: Andrey Kozhevnikov <coderusinbox@gmail.com>
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
Co-authored-by: dw-0 <th33xitus@gmail.com>
2025-02-09 15:45:05 +01:00
Mathijs Groothuis
4978f22101 fix(typo): Successfull > Successful
Co-authored-by: dw-0 <th33xitus@gmail.com>
Co-authored-by: dw-0 <domwil1091+github@gmail.com>
2025-02-08 13:30:37 +01:00
Mathijs Groothuis
8330f90b56 Fix typo: tyoing > typing
Fix typo: tyoing > typing

Co-authored-by: dw-0 <th33xitus@gmail.com>
Co-authored-by: dw-0 <domwil1091+github@gmail.com>
2025-02-05 19:07:19 +01:00
dw-0
2a08e3eb15 refactor: omit port 80 for IP in success message after webclient installation (#618)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-01-18 17:38:40 +01:00
dw-0
a2a3e92b50 refactor: remove BASE_USER argument from crowsnest install command (#617)
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-01-18 17:38:00 +01:00
dw-0
a58288e7e3 Release v6.0.0-alpha.13
Merge develop into master (Release v6.0.0-alpha.13)
2025-01-03 22:13:12 +01:00
dw-0
3852464ab7 fix: use raw strings for regex parameter in get_string_input (#612)
fixes #602

Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2025-01-03 22:10:39 +01:00
156 changed files with 4434 additions and 1552 deletions

View File

@@ -11,5 +11,5 @@ end_of_line = lf
[*.py] [*.py]
max_line_length = 88 max_line_length = 88
[*.sh] [*.{sh,yml,yaml,json}]
indent_size = 2 indent_size = 2

View File

@@ -0,0 +1,33 @@
name: Release - Fast-Forward and Tag
on:
workflow_dispatch:
inputs:
tag_name:
description: 'Provide a tag name (e.g. v1.0.0)'
required: true
type: string
jobs:
ff-and-tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'master'
- name: Merge Fast Forward
uses: MaximeHeckel/github-action-merge-fast-forward@v1.1.0
with:
branchtomerge: origin/develop
branch: master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create and Push Tag
run: |
git tag ${{ inputs.tag_name }}
git push origin ${{ inputs.tag_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

206
README_zh.md Normal file
View File

@@ -0,0 +1,206 @@
# KIAUH - Klipper 安装与更新助手
<p align="center">
<a>
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/kiauh.png" alt="KIAUH logo" height="181">
<h1 align="center">Klipper Installation And Update Helper</h1>
</a>
</p>
<p align="center">
一个方便的安装脚本让安装Klipper以及更多组件变得轻松
</p>
<p align="center">
<a><img src="https://img.shields.io/github/license/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/stars/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/forks/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/languages/top/dw-0/kiauh?logo=gnubash&logoColor=white"></a>
<a><img src="https://img.shields.io/github/v/tag/dw-0/kiauh"></a>
<br />
<a><img src="https://img.shields.io/github/last-commit/dw-0/kiauh"></a>
<a><img src="https://img.shields.io/github/contributors/dw-0/kiauh"></a>
</p>
## 📄 使用说明
### 📋 系统要求
KIAUH 是一个帮助您在 Linux 系统上安装 Klipper 的脚本工具,
它需要一个已经写入树莓派或其他单板计算机SD 卡的 Linux 系统。
如果您使用树莓派,推荐使用 `Raspberry Pi OS Lite (32位或64位)` 系统镜像。
[官方 Raspberry Pi Imager](https://www.raspberrypi.com/software/) 是将此类镜像写入 SD 卡的最简单方式。
* 下载、安装并启动 Raspberry Pi Imager 后,
选择 `Choose OS -> Raspberry Pi OS (other)`:
<p align="center">
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager1.png" alt="KIAUH logo" height="350">
</p>
* 然后选择 `Raspberry Pi OS Lite (32位)` (或如果您想使用64位版本):
<p align="center">
<img src="https://raw.githubusercontent.com/dw-0/kiauh/master/resources/screenshots/rpi_imager2.png" alt="KIAUH logo" height="350">
</p>
* 返回 Raspberry Pi Imager 主界面,选择对应的 SD 卡作为写入目标。
* 确保点击左下角的齿轮图标(在主菜单中)
启用 SSH 并配置 Wi-Fi。
* 如果您需要更多关于使用 Raspberry Pi Imager 的帮助,请访问 [官方文档](https://www.raspberrypi.com/documentation/computers/getting-started.html)。
这些步骤**仅适用于**您实际使用树莓派的情况。如果您想使用其他单板计算机(如香橙派或其他 Pi 衍生产品),
请查找如何将合适的 Linux 镜像写入 SD 卡(通常使用 Balena Etcher
同时确保 KIAUH 能够在您要安装的 Linux 发行版上运行。
您在使用基于 Debian 11 Bullseye 的系统时可能会获得最佳体验。
请阅读本文档下方的注意事项。
### 💾 下载并使用 KIAUH
**📢 免责声明:使用此脚本的风险由您自行承担!**
* **第一步:**
要下载此脚本,需要先安装 git。
如果您不确定是否已安装 git请运行以下命令
```shell
sudo apt-get update && sudo apt-get install git -y
```
* **第二步:**
安装完 git 后,
使用以下命令将 KIAUH 下载到您的主目录:
```shell
cd ~ && git clone https://github.com/dw-0/kiauh.git
```
* **第三步:**
最后,通过运行以下命令启动 KIAUH
```shell
./kiauh/kiauh.sh
```
* **第四步:**
您现在应该会看到 KIAUH 的主菜单。
根据您的选择,
您会看到几个可选操作。
要选择某个操作,只需在 "Perform action" 提示后输入对应的数字并按回车键确认。
## ❗ 注意事项
### **📋 请查看 [更新日志](docs/changelog.md) 以了解可能的重要更新!**
- 主要在 Raspberry Pi OS Lite (Debian 10 Buster / Debian 11 Bullseye) 上测试
- 其他基于 Debian 的发行版(如 Ubuntu 20 到 22也可能正常工作
- 据报告在 Armbian 上也可用,但未进行详细测试
- 在使用此脚本的过程中,
您会被要求输入 sudo 密码。
因为有几个功能需要 sudo 权限。
## 🌐 相关资源与更多信息
<table align="center">
<tr>
<th><h3><a href="https://github.com/Klipper3d/klipper">Klipper</a></h3></th>
<th><h3><a href="https://github.com/Arksine/moonraker">Moonraker</a></h3></th>
<th><h3><a href="https://github.com/mainsail-crew/mainsail">Mainsail</a></h3></th>
</tr>
<tr>
<th><img src="https://raw.githubusercontent.com/Klipper3d/klipper/master/docs/img/klipper-logo.png" alt="Klipper Logo" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/9563098?v=4" alt="Arksine avatar" height="64"></th>
<th><img src="https://raw.githubusercontent.com/mainsail-crew/docs/master/assets/img/logo.png" alt="Mainsail Logo" height="64"></th>
</tr>
<tr>
<th>由 <a href="https://github.com/KevinOConnor">KevinOConnor</a></th>
<th>由 <a href="https://github.com/Arksine">Arksine</a></th>
<th>由 <a href="https://github.com/mainsail-crew">mainsail-crew</a></th>
</tr>
<tr>
<th><h3><a href="https://github.com/fluidd-core/fluidd">Fluidd</a></h3></th>
<th><h3><a href="https://github.com/jordanruthe/KlipperScreen">KlipperScreen</a></h3></th>
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
</tr>
<tr>
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/31575189?v=4" alt="jordanruthe avatar" height="64"></th>
<th><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></th>
</tr>
<tr>
<th>由 <a href="https://github.com/fluidd-core">fluidd-core</a></th>
<th>由 <a href="https://github.com/jordanruthe">jordanruthe</a></th>
<th>由 <a href="https://github.com/OctoPrint">OctoPrint</a></th>
</tr>
<tr>
<th><h3><a href="https://github.com/nlef/moonraker-telegram-bot">Moonraker-Telegram-Bot</a></h3></th>
<th><h3><a href="https://github.com/Kragrathea/pgcode">PrettyGCode for Klipper</a></h3></th>
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
</tr>
<tr>
<th><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></th>
<th><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></th>
</tr>
<tr>
<th>由 <a href="https://github.com/nlef">nlef</a></th>
<th>由 <a href="https://github.com/Kragrathea">Kragrathea</a></th>
<th>由 <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
</tr>
<tr>
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</a></h3></th>
<th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
</tr>
<tr>
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="Mobileraker Logo" height="64"></a></th>
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
<th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
</tr>
<tr>
<th>由 <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
<th>由 <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
<th>由 <a href="https://github.com/crysxd">Christian Würthner</a></th>
</tr>
<tr>
<th><h3><a href="https://github.com/staubgeborener/klipper-backup">Klipper-Backup</a></h3></th>
<th><h3><a href="https://simplyprint.io/">SimplyPrint for Klipper</a></h3></th>
</tr>
<tr>
<th><a href="https://github.com/staubgeborener/klipper-backup"><img src="https://avatars.githubusercontent.com/u/28908603?v=4" alt="Staubgeroner Avatar" height="64"></a></th>
<th><a href="https://github.com/SimplyPrint"><img src="https://avatars.githubusercontent.com/u/64896552?s=200&v=4" alt="" height="64"></a></th>
</tr>
<tr>
<th>由 <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
<th>由 <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
</tr>
</table>
## 🎖️ 贡献者
<div align="center">
<a href="https://github.com/dw-0/kiauh/graphs/contributors">
<img src="https://contrib.rocks/image?repo=dw-0/kiauh" alt=""/>
</a>
</div>
<div align="center">
<img src="https://repobeats.axiom.co/api/embed/a1afbda9190c04a90cf4bd3061e5573bc836cb05.svg" alt="Repobeats analytics image"/>
</div>
## ✨ 特别感谢
* 非常感谢 [lixxbox](https://github.com/lixxbox) 设计了如此出色的 KIAUH 标志!
* 同时,非常感谢所有通过 [Ko-fi](https://ko-fi.com/dw__0) 支持我的工作的人!
* 最后但同样重要的是:感谢所有为 Klipper 社区做出贡献的成员,以及喜欢和分享这个项目的朋友们!
<h4 align="center">特别感谢 JetBrains 为本项目提供其出色的软件赞助!</h4>
<p align="center">
<a href="https://www.jetbrains.com/community/opensource/#support" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo (Main) logo." height="128">
</a>
</p>

View File

@@ -2,12 +2,30 @@
backup_before_update: False backup_before_update: False
[klipper] [klipper]
repo_url: https://github.com/Klipper3d/klipper # add custom repositories here, if at least one is given, the first in the list will be used by default
branch: master # otherwise the official repository is used
#
# format: https://github.com/username/repository, branch
# example: https://github.com/Klipper3d/klipper, master
#
# branch is optional, if given, it must be preceded by a comma, if not given, 'master' is used
repositories:
https://github.com/Klipper3d/klipper
[moonraker] [moonraker]
repo_url: https://github.com/Arksine/moonraker # Moonraker supports two optional Python packages that can be used to reduce its CPU load
branch: master # If set to true, those packages will be installed during the Moonraker installation
optional_speedups: True
# add custom repositories here, if at least one is given, the first in the list will be used by default
# otherwise the official repository is used
#
# format: https://github.com/username/repository, branch
# example: https://github.com/Arksine/moonraker, master
#
# branch is optional, if given, it must be preceded by a comma, if not given, 'master' is used
repositories:
https://github.com/Arksine/moonraker
[mainsail] [mainsail]
port: 80 port: 80

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -27,7 +27,6 @@ from components.crowsnest import (
) )
from components.klipper.klipper import Klipper from components.klipper.klipper import Klipper
from core.backup_manager.backup_manager import BackupManager from core.backup_manager.backup_manager import BackupManager
from core.constants import CURRENT_USER
from core.logger import DialogType, Logger from core.logger import DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings from core.settings.kiauh_settings import KiauhSettings
from core.types.component_status import ComponentStatus from core.types.component_status import ComponentStatus
@@ -73,7 +72,7 @@ def install_crowsnest() -> None:
Logger.print_info("Installer will prompt you for sudo password!") Logger.print_info("Installer will prompt you for sudo password!")
try: try:
run( run(
f"sudo make install BASE_USER={CURRENT_USER}", "sudo make install",
cwd=CROWSNEST_DIR, cwd=CROWSNEST_DIR,
shell=True, shell=True,
check=True, check=True,
@@ -135,7 +134,7 @@ def update_crowsnest() -> None:
target=CROWSNEST_BACKUP_DIR, target=CROWSNEST_BACKUP_DIR,
) )
git_pull_wrapper(CROWSNEST_REPO, CROWSNEST_DIR) git_pull_wrapper(CROWSNEST_DIR)
deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT) deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT)
check_install_dependencies({*deps}) check_install_dependencies({*deps})

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -25,6 +25,7 @@ KLIPPER_SERVICE_NAME = "klipper.service"
# directories # directories
KLIPPER_DIR = Path.home().joinpath("klipper") KLIPPER_DIR = Path.home().joinpath("klipper")
KLIPPER_KCONFIGS_DIR = Path.home().joinpath("klipper-kconfigs")
KLIPPER_ENV_DIR = Path.home().joinpath("klippy-env") KLIPPER_ENV_DIR = Path.home().joinpath("klippy-env")
KLIPPER_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("klipper-backups") KLIPPER_BACKUP_DIR = BACKUP_ROOT_DIR.joinpath("klipper-backups")

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,117 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 __future__ import annotations
from typing import List
from components.klipper import KLIPPER_DIR, KLIPPER_ENV_DIR
from components.klipper.klipper import Klipper
from components.klipper.klipper_dialogs import print_instance_overview
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.services.message_service import Message
from core.types.color import Color
from utils.fs_utils import run_remove_routines
from utils.input_utils import get_selection_input
from utils.instance_utils import get_instances
from utils.sys_utils import unit_file_exists
def run_klipper_removal(
remove_service: bool,
remove_dir: bool,
remove_env: bool,
) -> Message:
completion_msg = Message(
title="Klipper Removal Process completed",
color=Color.GREEN,
)
klipper_instances: List[Klipper] = get_instances(Klipper)
if remove_service:
Logger.print_status("Removing Klipper instances ...")
if klipper_instances:
instances_to_remove = select_instances_to_remove(klipper_instances)
remove_instances(instances_to_remove)
instance_names = [i.service_file_path.stem for i in instances_to_remove]
txt = f"● Klipper instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info("No Klipper Services installed! Skipped ...")
if (remove_dir or remove_env) and unit_file_exists("klipper", suffix="service"):
completion_msg.text = [
"Some Klipper services are still installed:",
f"'{KLIPPER_DIR}' was not removed, even though selected for removal.",
f"'{KLIPPER_ENV_DIR}' was not removed, even though selected for removal.",
]
else:
if remove_dir:
Logger.print_status("Removing Klipper local repository ...")
if run_remove_routines(KLIPPER_DIR):
completion_msg.text.append("● Klipper local repository removed")
if remove_env:
Logger.print_status("Removing Klipper Python environment ...")
if run_remove_routines(KLIPPER_ENV_DIR):
completion_msg.text.append("● Klipper Python environment removed")
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
return completion_msg
def select_instances_to_remove(instances: List[Klipper]) -> List[Klipper] | None:
start_index = 1
options = [str(i + start_index) for i in range(len(instances))]
options.extend(["a", "b"])
instance_map = {options[i]: instances[i] for i in range(len(instances))}
print_instance_overview(
instances,
start_index=start_index,
show_index=True,
show_select_all=True,
)
selection = get_selection_input("Select Klipper instance to remove", options)
instances_to_remove = []
if selection == "b":
return None
elif selection == "a":
instances_to_remove.extend(instances)
else:
instances_to_remove.append(instance_map[selection])
return instances_to_remove
def remove_instances(
instance_list: List[Klipper] | None,
) -> None:
if not instance_list:
return
for instance in instance_list:
Logger.print_status(f"Removing instance {instance.service_file_path.stem} ...")
InstanceManager.remove(instance)
delete_klipper_env_file(instance)
def delete_klipper_env_file(instance: Klipper):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
run_remove_routines(instance.env_file)

View File

@@ -1,239 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 __future__ import annotations
from pathlib import Path
from typing import Dict, List, Tuple
from components.klipper import (
EXIT_KLIPPER_SETUP,
KLIPPER_DIR,
KLIPPER_ENV_DIR,
KLIPPER_INSTALL_SCRIPT,
KLIPPER_REQ_FILE,
)
from components.klipper.klipper import Klipper
from components.klipper.klipper_dialogs import (
print_select_custom_name_dialog,
)
from components.klipper.klipper_utils import (
assign_custom_name,
backup_klipper_dir,
check_user_groups,
create_example_printer_cfg,
get_install_count,
handle_disruptive_system_packages,
)
from components.moonraker.moonraker import Moonraker
from components.webui_client.client_utils import (
get_existing_clients,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies
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
from utils.sys_utils import (
cmd_sysctl_manage,
cmd_sysctl_service,
create_python_venv,
install_python_requirements,
parse_packages_from_file,
)
def install_klipper() -> None:
Logger.print_status("Installing Klipper ...")
klipper_list: List[Klipper] = get_instances(Klipper)
moonraker_list: List[Moonraker] = get_instances(Moonraker)
match_moonraker: bool = False
# if there are more moonraker instances than klipper instances, ask the user to
# match the klipper instance count to the count of moonraker instances with the same suffix
if len(moonraker_list) > len(klipper_list):
is_confirmed = display_moonraker_info(moonraker_list)
if not is_confirmed:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
match_moonraker = True
install_count, name_dict = get_install_count_and_name_dict(
klipper_list, moonraker_list
)
if install_count == 0:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
is_multi_install = install_count > 1 or (len(name_dict) >= 1 and install_count >= 1)
if not name_dict and install_count == 1:
name_dict = {0: ""}
elif is_multi_install and not match_moonraker:
custom_names = use_custom_names_or_go_back()
if custom_names is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
handle_instance_names(install_count, name_dict, custom_names)
create_example_cfg = get_confirm("Create example printer.cfg?")
# run the actual installation
try:
run_klipper_setup(klipper_list, name_dict, create_example_cfg)
except Exception as e:
Logger.print_error(e)
Logger.print_error("Klipper installation failed!")
return
def run_klipper_setup(
klipper_list: List[Klipper], name_dict: Dict[int, str], create_example_cfg: bool
) -> None:
if not klipper_list:
setup_klipper_prerequesites()
for i in name_dict:
# skip this iteration if there is already an instance with the name
if name_dict[i] in [n.suffix for n in klipper_list]:
continue
instance = Klipper(suffix=name_dict[i])
instance.create()
cmd_sysctl_service(instance.service_file_path.name, "enable")
if create_example_cfg:
# if a client-config is installed, include it in the new example cfg
clients = get_existing_clients()
create_example_printer_cfg(instance, clients)
cmd_sysctl_service(instance.service_file_path.name, "start")
cmd_sysctl_manage("daemon-reload")
# step 4: check/handle conflicting packages/services
handle_disruptive_system_packages()
# step 5: check for required group membership
check_user_groups()
def handle_instance_names(
install_count: int, name_dict: Dict[int, str], custom_names: bool
) -> None:
for i in range(install_count): # 3
key: int = len(name_dict.keys()) + 1
if custom_names:
assign_custom_name(key, name_dict)
else:
name_dict[key] = str(len(name_dict) + 1)
def get_install_count_and_name_dict(
klipper_list: List[Klipper], moonraker_list: List[Moonraker]
) -> Tuple[int, Dict[int, str]]:
install_count: int | None
if len(moonraker_list) > len(klipper_list):
install_count = len(moonraker_list)
name_dict = {i: moonraker.suffix for i, moonraker in enumerate(moonraker_list)}
else:
install_count = get_install_count()
name_dict = {i: klipper.suffix for i, klipper in enumerate(klipper_list)}
if install_count is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return 0, {}
return install_count, name_dict
def setup_klipper_prerequesites() -> None:
settings = KiauhSettings()
repo = settings.klipper.repo_url
branch = settings.klipper.branch
git_clone_wrapper(repo, KLIPPER_DIR, branch)
# install klipper dependencies and create python virtualenv
try:
install_klipper_packages()
if create_python_venv(KLIPPER_ENV_DIR):
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
except Exception:
Logger.print_error("Error during installation of Klipper requirements!")
raise
def install_klipper_packages() -> None:
script = KLIPPER_INSTALL_SCRIPT
packages = parse_packages_from_file(script)
# Add dbus requirement for DietPi distro
if Path("/boot/dietpi/.version").exists():
packages.append("dbus")
check_install_dependencies({*packages})
def update_klipper() -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"Do NOT continue if there are ongoing prints running!",
"All Klipper instances will be restarted during the update process and "
"ongoing prints WILL FAIL.",
],
)
if not get_confirm("Update Klipper now?"):
return
settings = KiauhSettings()
if settings.kiauh.backup_before_update:
backup_klipper_dir()
instances = get_instances(Klipper)
InstanceManager.stop_all(instances)
git_pull_wrapper(repo=settings.klipper.repo_url, target_dir=KLIPPER_DIR)
# install possible new system packages
install_klipper_packages()
# install possible new python dependencies
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
InstanceManager.start_all(instances)
def use_custom_names_or_go_back() -> bool | None:
print_select_custom_name_dialog()
_input: bool | None = get_confirm(
"Assign custom names?",
False,
allow_go_back=True,
)
return _input
def display_moonraker_info(moonraker_list: List[Moonraker]) -> bool:
# todo: only show the klipper instances that are not already installed
Logger.print_dialog(
DialogType.INFO,
[
"Existing Moonraker instances detected:",
*[f"{m.service_file_path.stem}" for m in moonraker_list],
"\n\n",
"The following Klipper instances will be installed:",
*[f"● klipper-{m.suffix}" for m in moonraker_list],
],
)
_input: bool = get_confirm("Proceed with installation?")
return _input

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -11,6 +11,7 @@ from __future__ import annotations
import grp import grp
import os import os
import shutil import shutil
from pathlib import Path
from subprocess import CalledProcessError, run from subprocess import CalledProcessError, run
from typing import Dict, List from typing import Dict, List
@@ -18,6 +19,7 @@ from components.klipper import (
KLIPPER_BACKUP_DIR, KLIPPER_BACKUP_DIR,
KLIPPER_DIR, KLIPPER_DIR,
KLIPPER_ENV_DIR, KLIPPER_ENV_DIR,
KLIPPER_INSTALL_SCRIPT,
MODULE_PATH, MODULE_PATH,
) )
from components.klipper.klipper import Klipper from components.klipper.klipper import Klipper
@@ -37,10 +39,15 @@ from core.submodules.simple_config_parser.src.simple_config_parser.simple_config
SimpleConfigParser, SimpleConfigParser,
) )
from core.types.component_status import ComponentStatus from core.types.component_status import ComponentStatus
from utils.common import get_install_status from utils.common import check_install_dependencies, get_install_status
from utils.fs_utils import check_file_exist
from utils.input_utils import get_confirm, get_number_input, get_string_input from utils.input_utils import get_confirm, get_number_input, get_string_input
from utils.instance_utils import get_instances from utils.instance_utils import get_instances
from utils.sys_utils import cmd_sysctl_service from utils.sys_utils import (
cmd_sysctl_service,
install_python_packages,
parse_packages_from_file,
)
def get_klipper_status() -> ComponentStatus: def get_klipper_status() -> ComponentStatus:
@@ -194,3 +201,56 @@ def backup_klipper_dir() -> None:
bm = BackupManager() bm = BackupManager()
bm.backup_directory("klipper", source=KLIPPER_DIR, target=KLIPPER_BACKUP_DIR) bm.backup_directory("klipper", source=KLIPPER_DIR, target=KLIPPER_BACKUP_DIR)
bm.backup_directory("klippy-env", source=KLIPPER_ENV_DIR, target=KLIPPER_BACKUP_DIR) bm.backup_directory("klippy-env", source=KLIPPER_ENV_DIR, target=KLIPPER_BACKUP_DIR)
def install_klipper_packages() -> None:
script = KLIPPER_INSTALL_SCRIPT
packages = parse_packages_from_file(script)
# Add pkg-config for rp2040 build
packages.append("pkg-config")
# Add dbus requirement for DietPi distro
if check_file_exist(Path("/boot/dietpi/.version")):
packages.append("dbus")
check_install_dependencies({*packages})
def install_input_shaper_deps() -> None:
if not KLIPPER_ENV_DIR.exists():
Logger.print_warn("Required Klipper python environment not found!")
return
Logger.print_dialog(
DialogType.CUSTOM,
[
"Resonance measurements and shaper auto-calibration require additional "
"software dependencies which are not installed by default. "
"If you agree, the following additional system packages will be installed:",
"● python3-numpy",
"● python3-matplotlib",
"● libatlas-base-dev",
"● libopenblas-dev",
"\n\n",
"Also, the following Python package will be installed:",
"● numpy",
],
custom_title="Install Input Shaper Dependencies",
)
if not get_confirm(
"Do you want to install the required packages?", default_choice=False
):
return
apt_deps = (
"python3-numpy",
"python3-matplotlib",
"libatlas-base-dev",
"libopenblas-dev",
)
check_install_dependencies({*apt_deps})
py_deps = ("numpy",)
install_python_packages(KLIPPER_ENV_DIR, {*py_deps})

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -11,7 +11,7 @@ from __future__ import annotations
import textwrap import textwrap
from typing import Type from typing import Type
from components.klipper import klipper_remove from components.klipper.services.klipper_setup_service import KlipperSetupService
from core.menus import FooterType, Option from core.menus import FooterType, 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
@@ -27,11 +27,13 @@ class KlipperRemoveMenu(BaseMenu):
self.previous_menu: Type[BaseMenu] | None = previous_menu self.previous_menu: Type[BaseMenu] | None = previous_menu
self.footer_type = FooterType.BACK self.footer_type = FooterType.BACK
self.remove_klipper_service = False self.rm_svc = False
self.remove_klipper_dir = False self.rm_dir = False
self.remove_klipper_env = False self.rm_env = False
self.select_state = False self.select_state = False
self.klsvc = KlipperSetupService()
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.remove_menu import RemoveMenu from core.menus.remove_menu import RemoveMenu
@@ -49,10 +51,10 @@ class KlipperRemoveMenu(BaseMenu):
def print_menu(self) -> None: def print_menu(self) -> None:
checked = f"[{Color.apply('x', Color.CYAN)}]" checked = f"[{Color.apply('x', Color.CYAN)}]"
unchecked = "[ ]" unchecked = "[ ]"
o1 = checked if self.remove_klipper_service else unchecked o1 = checked if self.rm_svc else unchecked
o2 = checked if self.remove_klipper_dir else unchecked o2 = checked if self.rm_dir else unchecked
o3 = checked if self.remove_klipper_env else unchecked o3 = checked if self.rm_env else unchecked
sel_state = f"{'Select'if not self.select_state else 'Deselect'} everything" sel_state = f"{'Select' if not self.select_state else 'Deselect'} everything"
menu = textwrap.dedent( menu = textwrap.dedent(
f""" f"""
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
@@ -73,37 +75,28 @@ class KlipperRemoveMenu(BaseMenu):
def toggle_all(self, **kwargs) -> None: def toggle_all(self, **kwargs) -> None:
self.select_state = not self.select_state self.select_state = not self.select_state
self.remove_klipper_service = self.select_state self.rm_svc = self.select_state
self.remove_klipper_dir = self.select_state self.rm_dir = self.select_state
self.remove_klipper_env = self.select_state self.rm_env = self.select_state
def toggle_remove_klipper_service(self, **kwargs) -> None: def toggle_remove_klipper_service(self, **kwargs) -> None:
self.remove_klipper_service = not self.remove_klipper_service self.rm_svc = not self.rm_svc
def toggle_remove_klipper_dir(self, **kwargs) -> None: def toggle_remove_klipper_dir(self, **kwargs) -> None:
self.remove_klipper_dir = not self.remove_klipper_dir self.rm_dir = not self.rm_dir
def toggle_remove_klipper_env(self, **kwargs) -> None: def toggle_remove_klipper_env(self, **kwargs) -> None:
self.remove_klipper_env = not self.remove_klipper_env self.rm_env = not self.rm_env
def run_removal_process(self, **kwargs) -> None: def run_removal_process(self, **kwargs) -> None:
if ( if not self.rm_svc and not self.rm_dir and not self.rm_env:
not self.remove_klipper_service
and not self.remove_klipper_dir
and not self.remove_klipper_env
):
msg = "Nothing selected! Select options to remove first." msg = "Nothing selected! Select options to remove first."
print(Color.apply(msg, Color.RED)) print(Color.apply(msg, Color.RED))
return return
completion_msg = klipper_remove.run_klipper_removal( self.klsvc.remove(self.rm_svc, self.rm_dir, self.rm_env)
self.remove_klipper_service,
self.remove_klipper_dir,
self.remove_klipper_env,
)
self.message_service.set_message(completion_msg)
self.remove_klipper_service = False self.rm_svc = False
self.remove_klipper_dir = False self.rm_dir = False
self.remove_klipper_env = False self.rm_env = False
self.select_state = False self.select_state = False

View File

@@ -0,0 +1,46 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 __future__ import annotations
from typing import List
from components.klipper.klipper import Klipper
from utils.instance_utils import get_instances
class KlipperInstanceService:
__cls_instance = None
__instances: List[Klipper] = []
def __new__(cls) -> "KlipperInstanceService":
if cls.__cls_instance is None:
cls.__cls_instance = super(KlipperInstanceService, cls).__new__(cls)
return cls.__cls_instance
def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized:
return
self.__initialized = True
def load_instances(self) -> None:
self.__instances = get_instances(Klipper)
def create_new_instance(self, suffix: str) -> Klipper:
instance = Klipper(suffix)
self.__instances.append(instance)
return instance
def get_all_instances(self) -> List[Klipper]:
return self.__instances
def get_instance_by_suffix(self, suffix: str) -> Klipper | None:
instances: List[Klipper] = [i for i in self.__instances if i.suffix == suffix]
return instances[0] if instances else None

View File

@@ -0,0 +1,366 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 __future__ import annotations
from copy import copy
from typing import Dict, List, Tuple
from components.klipper import (
EXIT_KLIPPER_SETUP,
KLIPPER_DIR,
KLIPPER_ENV_DIR,
KLIPPER_REPO_URL,
KLIPPER_REQ_FILE,
)
from components.klipper.klipper import Klipper
from components.klipper.klipper_dialogs import (
print_instance_overview,
print_select_custom_name_dialog,
)
from components.klipper.klipper_utils import (
assign_custom_name,
backup_klipper_dir,
check_user_groups,
create_example_printer_cfg,
get_install_count,
handle_disruptive_system_packages,
install_klipper_packages,
)
from components.klipper.services.klipper_instance_service import KlipperInstanceService
from components.moonraker.moonraker import Moonraker
from components.moonraker.services.moonraker_instance_service import (
MoonrakerInstanceService,
)
from components.webui_client.client_utils import (
get_existing_clients,
)
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.services.message_service import Message, MessageService
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
from utils.fs_utils import run_remove_routines
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import get_confirm, get_selection_input
from utils.sys_utils import (
cmd_sysctl_manage,
create_python_venv,
install_python_requirements,
unit_file_exists,
)
# noinspection PyMethodMayBeStatic
class KlipperSetupService:
__cls_instance = None
kisvc: KlipperInstanceService
misvc: MoonrakerInstanceService
msgsvc = MessageService
settings: KiauhSettings
klipper_list: List[Klipper]
moonraker_list: List[Moonraker]
def __new__(cls) -> "KlipperSetupService":
if cls.__cls_instance is None:
cls.__cls_instance = super(KlipperSetupService, cls).__new__(cls)
return cls.__cls_instance
def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized:
return
self.__initialized = True
self.__init_state()
def __init_state(self) -> None:
self.settings = KiauhSettings()
self.kisvc = KlipperInstanceService()
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc = MoonrakerInstanceService()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
self.msgsvc = MessageService()
def __refresh_state(self) -> None:
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
def install(self) -> None:
self.__refresh_state()
Logger.print_status("Installing Klipper ...")
match_moonraker: bool = False
# if there are more moonraker instances than klipper instances, ask the user to
# match the klipper instance count to the count of moonraker instances with the same suffix
if len(self.moonraker_list) > len(self.klipper_list):
is_confirmed = self.__display_moonraker_info()
if not is_confirmed:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
match_moonraker = True
install_count, name_dict = self.__get_install_count_and_name_dict()
if install_count == 0:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
is_multi_install = install_count > 1 or (
len(name_dict) >= 1 and install_count >= 1
)
if not name_dict and install_count == 1:
name_dict = {0: ""}
elif is_multi_install and not match_moonraker:
custom_names = self.__use_custom_names_or_go_back()
if custom_names is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return
self.__handle_instance_names(install_count, name_dict, custom_names)
create_example_cfg = get_confirm("Create example printer.cfg?")
# run the actual installation
try:
self.__run_setup(name_dict, create_example_cfg)
except Exception as e:
Logger.print_error(e)
Logger.print_error("Klipper installation failed!")
return
def update(self) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"Do NOT continue if there are ongoing prints running!",
"All Klipper instances will be restarted during the update process and "
"ongoing prints WILL FAIL.",
],
)
if not get_confirm("Update Klipper now?"):
return
self.__refresh_state()
if self.settings.kiauh.backup_before_update:
backup_klipper_dir()
InstanceManager.stop_all(self.klipper_list)
git_pull_wrapper(KLIPPER_DIR)
install_klipper_packages()
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
InstanceManager.start_all(self.klipper_list)
def remove(
self,
remove_service: bool,
remove_dir: bool,
remove_env: bool,
) -> None:
self.__refresh_state()
completion_msg = Message(
title="Klipper Removal Process completed",
color=Color.GREEN,
)
if remove_service:
Logger.print_status("Removing Klipper instances ...")
if self.klipper_list:
instances_to_remove = self.__get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
]
txt = f"● Klipper instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info("No Klipper Services installed! Skipped ...")
if (remove_dir or remove_env) and unit_file_exists("klipper", suffix="service"):
completion_msg.text = [
"Some Klipper services are still installed:",
f"'{KLIPPER_DIR}' was not removed, even though selected for removal.",
f"'{KLIPPER_ENV_DIR}' was not removed, even though selected for removal.",
]
else:
if remove_dir:
Logger.print_status("Removing Klipper local repository ...")
if run_remove_routines(KLIPPER_DIR):
completion_msg.text.append("● Klipper local repository removed")
if remove_env:
Logger.print_status("Removing Klipper Python environment ...")
if run_remove_routines(KLIPPER_ENV_DIR):
completion_msg.text.append("● Klipper Python environment removed")
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
self.msgsvc.set_message(completion_msg)
def __get_install_count_and_name_dict(self) -> Tuple[int, Dict[int, str]]:
install_count: int | None
if len(self.moonraker_list) > len(self.klipper_list):
install_count = len(self.moonraker_list)
name_dict = {
i: moonraker.suffix for i, moonraker in enumerate(self.moonraker_list)
}
else:
install_count = get_install_count()
name_dict = {
i: klipper.suffix for i, klipper in enumerate(self.klipper_list)
}
if install_count is None:
Logger.print_status(EXIT_KLIPPER_SETUP)
return 0, {}
return install_count, name_dict
def __run_setup(self, name_dict: Dict[int, str], create_example_cfg: bool) -> None:
if not self.klipper_list:
self.__install_deps()
for i in name_dict:
# skip this iteration if there is already an instance with the name
if name_dict[i] in [n.suffix for n in self.klipper_list]:
continue
instance = Klipper(suffix=name_dict[i])
instance.create()
InstanceManager.enable(instance)
if create_example_cfg:
# if a client-config is installed, include it in the new example cfg
clients = get_existing_clients()
create_example_printer_cfg(instance, clients)
InstanceManager.start(instance)
cmd_sysctl_manage("daemon-reload")
# step 4: check/handle conflicting packages/services
handle_disruptive_system_packages()
# step 5: check for required group membership
check_user_groups()
def __install_deps(self) -> None:
default_repo = (KLIPPER_REPO_URL, "master")
repo = self.settings.klipper.repositories
# pull the first repo defined in kiauh.cfg or fallback to the official Klipper repo
repo, branch = (repo[0].url, repo[0].branch) if repo else default_repo
git_clone_wrapper(repo, KLIPPER_DIR, branch)
try:
install_klipper_packages()
if create_python_venv(KLIPPER_ENV_DIR, False, False, self.settings.klipper.use_python_binary):
install_python_requirements(KLIPPER_ENV_DIR, KLIPPER_REQ_FILE)
except Exception:
Logger.print_error("Error during installation of Klipper requirements!")
raise
def __display_moonraker_info(self) -> bool:
# todo: only show the klipper instances that are not already installed
Logger.print_dialog(
DialogType.INFO,
[
"Existing Moonraker instances detected:",
*[f"{m.service_file_path.stem}" for m in self.moonraker_list],
"\n\n",
"The following Klipper instances will be installed:",
*[f"● klipper-{m.suffix}" for m in self.moonraker_list],
],
)
_input: bool = get_confirm("Proceed with installation?")
return _input
def __handle_instance_names(
self, install_count: int, name_dict: Dict[int, str], custom_names: bool
) -> None:
for i in range(install_count): # 3
key: int = len(name_dict.keys()) + 1
if custom_names:
assign_custom_name(key, name_dict)
else:
name_dict[key] = str(len(name_dict) + 1)
def __use_custom_names_or_go_back(self) -> bool | None:
print_select_custom_name_dialog()
_input: bool | None = get_confirm(
"Assign custom names?",
False,
allow_go_back=True,
)
return _input
def __get_instances_to_remove(self) -> List[Klipper] | None:
start_index = 1
curr_instances: List[Klipper] = self.klipper_list
instance_count = len(curr_instances)
options = [str(i + start_index) for i in range(instance_count)]
options.extend(["a", "b"])
instance_map = {options[i]: self.klipper_list[i] for i in range(instance_count)}
print_instance_overview(
self.klipper_list,
start_index=start_index,
show_index=True,
show_select_all=True,
)
selection = get_selection_input("Select Klipper instance to remove", options)
if selection == "b":
return None
elif selection == "a":
return copy(self.klipper_list)
return [instance_map[selection]]
def __remove_instances(
self,
instance_list: List[Klipper] | None,
) -> None:
if not instance_list:
return
for instance in instance_list:
Logger.print_status(
f"Removing instance {instance.service_file_path.stem} ..."
)
InstanceManager.remove(instance)
self.__delete_klipper_env_file(instance)
self.__refresh_state()
def __delete_klipper_env_file(self, instance: Klipper):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
run_remove_routines(instance.env_file)

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -7,6 +7,7 @@
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
import re import re
from pathlib import Path
from subprocess import ( from subprocess import (
DEVNULL, DEVNULL,
PIPE, PIPE,
@@ -138,6 +139,7 @@ def start_flash_process(flash_options: FlashOptions) -> None:
if flash_options.flash_method is FlashMethod.REGULAR: if flash_options.flash_method is FlashMethod.REGULAR:
cmd = [ cmd = [
"make", "make",
f"KCONFIG_CONFIG={flash_options.selected_kconfig}",
flash_options.flash_command.value, flash_options.flash_command.value,
f"FLASH_DEVICE={flash_options.selected_mcu}", f"FLASH_DEVICE={flash_options.selected_mcu}",
] ]
@@ -165,17 +167,17 @@ def start_flash_process(flash_options: FlashOptions) -> None:
if rc != 0: if rc != 0:
raise Exception(f"Flashing failed with returncode: {rc}") raise Exception(f"Flashing failed with returncode: {rc}")
else: else:
Logger.print_ok("Flashing successfull!", start="\n", end="\n\n") Logger.print_ok("Flashing successful!", start="\n", end="\n\n")
except (Exception, CalledProcessError): except (Exception, CalledProcessError):
Logger.print_error("Flashing failed!", start="\n") Logger.print_error("Flashing failed!", start="\n")
Logger.print_error("See the console output above!", end="\n\n") Logger.print_error("See the console output above!", end="\n\n")
def run_make_clean() -> None: def run_make_clean(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
try: try:
run( run(
"make clean", f"make KCONFIG_CONFIG={kconfig} clean",
cwd=KLIPPER_DIR, cwd=KLIPPER_DIR,
shell=True, shell=True,
check=True, check=True,
@@ -185,10 +187,10 @@ def run_make_clean() -> None:
raise raise
def run_make_menuconfig() -> None: def run_make_menuconfig(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
try: try:
run( run(
"make PYTHON=python3 menuconfig", f"make PYTHON=python3 KCONFIG_CONFIG={kconfig} menuconfig",
cwd=KLIPPER_DIR, cwd=KLIPPER_DIR,
shell=True, shell=True,
check=True, check=True,
@@ -198,10 +200,10 @@ def run_make_menuconfig() -> None:
raise raise
def run_make() -> None: def run_make(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
try: try:
run( run(
"make PYTHON=python3", f"make PYTHON=python3 KCONFIG_CONFIG={kconfig}",
cwd=KLIPPER_DIR, cwd=KLIPPER_DIR,
shell=True, shell=True,
check=True, check=True,

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -39,6 +39,7 @@ class FlashOptions:
_selected_mcu: str = "" _selected_mcu: str = ""
_selected_board: str = "" _selected_board: str = ""
_selected_baudrate: int = 250000 _selected_baudrate: int = 250000
_selected_kconfig: str = ".config"
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if not cls._instance: if not cls._instance:
@@ -104,3 +105,11 @@ class FlashOptions:
@selected_baudrate.setter @selected_baudrate.setter
def selected_baudrate(self, value: int) -> None: def selected_baudrate(self, value: int) -> None:
self._selected_baudrate = value self._selected_baudrate = value
@property
def selected_kconfig(self) -> str:
return self._selected_kconfig
@selected_kconfig.setter
def selected_kconfig(self, value: str) -> None:
self._selected_kconfig = value

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -9,18 +9,22 @@
from __future__ import annotations from __future__ import annotations
import textwrap import textwrap
from pathlib import Path
from shutil import copyfile
from typing import List, Set, Type from typing import List, Set, Type
from components.klipper import KLIPPER_DIR from components.klipper import KLIPPER_DIR, KLIPPER_KCONFIGS_DIR
from components.klipper_firmware.firmware_utils import ( from components.klipper_firmware.firmware_utils import (
run_make, run_make,
run_make_clean, run_make_clean,
run_make_menuconfig, run_make_menuconfig,
) )
from core.logger import Logger from components.klipper_firmware.flash_options import FlashOptions
from core.logger import DialogType, 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 utils.input_utils import get_confirm, get_string_input
from utils.sys_utils import ( from utils.sys_utils import (
check_package_install, check_package_install,
install_system_packages, install_system_packages,
@@ -30,14 +34,25 @@ from utils.sys_utils import (
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class KlipperBuildFirmwareMenu(BaseMenu): class KlipperKConfigMenu(BaseMenu):
def __init__(self, previous_menu: Type[BaseMenu] | None = None): def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__() super().__init__()
self.title = "Build Firmware Menu" self.title = "Firmware Config Menu"
self.title_color = Color.CYAN self.title_color = Color.CYAN
self.previous_menu: Type[BaseMenu] | None = previous_menu self.previous_menu: Type[BaseMenu] | None = previous_menu
self.deps: Set[str] = {"build-essential", "dpkg-dev", "make"} self.flash_options = FlashOptions()
self.missing_deps: List[str] = check_package_install(self.deps) self.kconfigs_dirname = KLIPPER_KCONFIGS_DIR
self.kconfig_default = KLIPPER_DIR.joinpath(".config")
self.configs: List[Path] = []
self.kconfig = (
self.kconfig_default if not Path(self.kconfigs_dirname).is_dir() else None
)
def run(self) -> None:
if not self.kconfig:
super().run()
else:
self.flash_options.selected_kconfig = self.kconfig
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.advanced_menu import AdvancedMenu from core.menus.advanced_menu import AdvancedMenu
@@ -47,16 +62,107 @@ class KlipperBuildFirmwareMenu(BaseMenu):
) )
def set_options(self) -> None: def set_options(self) -> None:
if len(self.missing_deps) == 0: if not Path(self.kconfigs_dirname).is_dir():
self.input_label_txt = "Press ENTER to continue" return
self.default_option = Option(method=self.start_build_process)
else: self.input_label_txt = "Select config or action to continue (default=N)"
self.input_label_txt = "Press ENTER to install dependencies" self.default_option = Option(
self.default_option = Option(method=self.install_missing_deps) method=self.select_config, opt_data=self.kconfig_default
)
option_index = 1
for kconfig in Path(self.kconfigs_dirname).iterdir():
if not kconfig.name.endswith(".config"):
continue
kconfig_path = self.kconfigs_dirname.joinpath(kconfig)
if Path(kconfig_path).is_file():
self.configs += [kconfig]
self.options[str(option_index)] = Option(
method=self.select_config, opt_data=kconfig_path
)
option_index += 1
self.options["n"] = Option(
method=self.select_config, opt_data=self.kconfig_default
)
def print_menu(self) -> None: def print_menu(self) -> None:
cfg_found_str = Color.apply(
"Previously saved firmware configs found!", Color.GREEN
)
menu = textwrap.dedent( menu = textwrap.dedent(
f"""
╟───────────────────────────────────────────────────────╢
{cfg_found_str:^62}
║ ║
║ Select an existing config or create a new one. ║
╟───────────────────────────────────────────────────────╢
║ Available firmware configs: ║
""" """
)[1:]
start_index = 1
for i, s in enumerate(self.configs):
line = f"{start_index + i}) {s.name}"
menu += f"{line:<54}\n"
new_config = Color.apply("N) Create new firmware config", Color.GREEN)
menu += "║ ║\n"
menu += f"{new_config:<62}\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
print(menu, end="")
def select_config(self, **kwargs) -> None:
selection: str | None = kwargs.get("opt_data", None)
if selection is None:
raise Exception("opt_data is None")
if not Path(selection).is_file() and selection != self.kconfig_default:
raise Exception("opt_data does not exists")
self.kconfig = selection
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperBuildFirmwareMenu(BaseMenu):
def __init__(
self, kconfig: str | None = None, previous_menu: Type[BaseMenu] | None = None
):
super().__init__()
self.title = "Build Firmware Menu"
self.title_color = Color.CYAN
self.previous_menu: Type[BaseMenu] | None = previous_menu
self.deps: Set[str] = {"build-essential", "dpkg-dev", "make"}
self.missing_deps: List[str] = check_package_install(self.deps)
self.flash_options = FlashOptions()
self.kconfigs_dirname = KLIPPER_KCONFIGS_DIR
self.kconfig_default = KLIPPER_DIR.joinpath(".config")
self.kconfig = self.flash_options.selected_kconfig
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.advanced_menu import AdvancedMenu
self.previous_menu = (
previous_menu if previous_menu is not None else AdvancedMenu
)
def set_options(self) -> None:
self.input_label_txt = "Press ENTER to install dependencies"
self.default_option = Option(method=self.install_missing_deps)
def run(self):
# immediately start the build process if all dependencies are met
if len(self.missing_deps) == 0:
self.start_build_process()
else:
super().run()
def print_menu(self) -> None:
txt = Color.apply("Dependencies are missing!", Color.RED)
menu = textwrap.dedent(
f"""
╟───────────────────────────────────────────────────────╢
{txt:^62}
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ The following dependencies are required: ║ ║ The following dependencies are required: ║
║ ║ ║ ║
@@ -67,19 +173,11 @@ class KlipperBuildFirmwareMenu(BaseMenu):
status_ok = Color.apply("*INSTALLED*", Color.GREEN) status_ok = Color.apply("*INSTALLED*", Color.GREEN)
status_missing = Color.apply("*MISSING*", Color.RED) status_missing = Color.apply("*MISSING*", Color.RED)
status = status_missing if d in self.missing_deps else status_ok status = status_missing if d in self.missing_deps else status_ok
padding = 39 - len(d) + len(status) + (len(status_ok) - len(status)) padding = 40 - len(d) + len(status) + (len(status_ok) - len(status))
d = Color.apply(f"{d}", Color.CYAN) d = Color.apply(f"{d}", Color.CYAN)
menu += f"{d}{status:>{padding}}\n" menu += f"{d}{status:>{padding}}\n"
menu += "║ ║\n" menu += "║ ║\n"
color = Color.GREEN if len(self.missing_deps) == 0 else Color.RED
txt = (
"All dependencies are met!"
if len(self.missing_deps) == 0
else "Dependencies are missing!"
)
menu += f"{Color.apply(txt, color):<62}\n"
menu += "╟───────────────────────────────────────────────────────╢\n" menu += "╟───────────────────────────────────────────────────────╢\n"
print(menu, end="") print(menu, end="")
@@ -98,13 +196,16 @@ class KlipperBuildFirmwareMenu(BaseMenu):
def start_build_process(self, **kwargs) -> None: def start_build_process(self, **kwargs) -> None:
try: try:
run_make_clean() run_make_clean(self.kconfig)
run_make_menuconfig() run_make_menuconfig(self.kconfig)
run_make() run_make(self.kconfig)
Logger.print_ok("Firmware successfully built!") Logger.print_ok("Firmware successfully built!")
Logger.print_ok(f"Firmware file located in '{KLIPPER_DIR}/out'!") Logger.print_ok(f"Firmware file located in '{KLIPPER_DIR}/out'!")
if self.kconfig == self.kconfig_default:
self.save_firmware_config()
except Exception as e: except Exception as e:
Logger.print_error(e) Logger.print_error(e)
Logger.print_error("Building Klipper Firmware failed!") Logger.print_error("Building Klipper Firmware failed!")
@@ -112,3 +213,62 @@ class KlipperBuildFirmwareMenu(BaseMenu):
finally: finally:
if self.previous_menu is not None: if self.previous_menu is not None:
self.previous_menu().run() self.previous_menu().run()
def save_firmware_config(self) -> None:
Logger.print_dialog(
DialogType.CUSTOM,
[
"You can save the firmware build configs for multiple MCUs,"
" and use them to update the firmware after a Klipper version upgrade"
],
custom_title="Save firmware config",
)
if not get_confirm(
"Do you want to save firmware config?", default_choice=False
):
return
filename = self.kconfig_default
while True:
Logger.print_dialog(
DialogType.CUSTOM,
[
"Allowed characters: a-z, 0-9 and '-'",
"The name must not contain the following:",
"\n\n",
"● Any special characters",
"● No leading or trailing '-'",
],
)
input_name = get_string_input(
"Enter the new firmware config name",
regex=r"^[a-z0-9]+([a-z0-9-]*[a-z0-9])?$",
)
filename = self.kconfigs_dirname.joinpath(f"{input_name}.config")
if Path(filename).is_file():
if get_confirm(
f"Firmware config {input_name} already exists, overwrite?",
default_choice=False,
):
break
if Path(filename).is_dir():
Logger.print_error(f"Path {filename} exists and it's a directory")
if not Path(filename).exists():
break
if not get_confirm(
f"Save firmware config to '{filename}'?", default_choice=True
):
Logger.print_info("Aborted saving firmware config ...")
return
if not Path(self.kconfigs_dirname).exists():
Path(self.kconfigs_dirname).mkdir()
copyfile(self.kconfig_default, filename)
Logger.print_ok()
Logger.print_ok(f"Firmware config successfully saved to {filename}")

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -10,6 +10,7 @@ from __future__ import annotations
import textwrap import textwrap
import time import time
from pathlib import Path
from typing import Type from typing import Type
from components.klipper_firmware.firmware_utils import ( from components.klipper_firmware.firmware_utils import (
@@ -385,7 +386,7 @@ class KlipperSelectSDFlashBoardMenu(BaseMenu):
self.flash_options.selected_baudrate = get_number_input( self.flash_options.selected_baudrate = get_number_input(
question="Please set the baud rate", question="Please set the baud rate",
default=250000, default=250000,
min_count=0, min_value=0,
allow_go_back=True, allow_go_back=True,
) )
KlipperFlashOverviewMenu(previous_menu=self.__class__).run() KlipperFlashOverviewMenu(previous_menu=self.__class__).run()
@@ -420,6 +421,7 @@ class KlipperFlashOverviewMenu(BaseMenu):
mcu = self.flash_options.selected_mcu.split("/")[-1] mcu = self.flash_options.selected_mcu.split("/")[-1]
board = self.flash_options.selected_board board = self.flash_options.selected_board
baudrate = self.flash_options.selected_baudrate baudrate = self.flash_options.selected_baudrate
kconfig = Path(self.flash_options.selected_kconfig).name
color = Color.CYAN color = Color.CYAN
subheader = f"[{Color.apply('Overview', color)}]" subheader = f"[{Color.apply('Overview', color)}]"
menu = textwrap.dedent( menu = textwrap.dedent(
@@ -452,6 +454,13 @@ class KlipperFlashOverviewMenu(BaseMenu):
""" """
)[1:] )[1:]
if self.flash_options.flash_method is FlashMethod.REGULAR:
menu += textwrap.dedent(
f"""
║ Firmware config: {Color.apply(f"{kconfig:<36}", color)}
"""
)[1:]
menu += textwrap.dedent( menu += textwrap.dedent(
""" """
║ ║ ║ ║

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -126,7 +126,7 @@ def update_klipperscreen() -> None:
if settings.kiauh.backup_before_update: if settings.kiauh.backup_before_update:
backup_klipperscreen_dir() backup_klipperscreen_dir()
git_pull_wrapper(KLIPPERSCREEN_REPO, KLIPPERSCREEN_DIR) git_pull_wrapper(KLIPPERSCREEN_DIR)
install_python_requirements(KLIPPERSCREEN_ENV_DIR, KLIPPERSCREEN_REQ_FILE) install_python_requirements(KLIPPERSCREEN_ENV_DIR, KLIPPERSCREEN_REQ_FILE)

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -10,6 +10,7 @@ trusted_clients:
169.254.0.0/16 169.254.0.0/16
172.16.0.0/12 172.16.0.0/12
192.168.0.0/16 192.168.0.0/16
FC00::/7
FE80::/10 FE80::/10
::1/128 ::1/128
cors_domains: cors_domains:

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -11,8 +11,8 @@ from __future__ import annotations
import textwrap import textwrap
from typing import Type from typing import Type
from components.moonraker import moonraker_remove from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
from core.menus import Option from core.menus import FooterType, 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
@@ -21,14 +21,19 @@ from core.types.color import Color
class MoonrakerRemoveMenu(BaseMenu): class MoonrakerRemoveMenu(BaseMenu):
def __init__(self, previous_menu: Type[BaseMenu] | None = None): def __init__(self, previous_menu: Type[BaseMenu] | None = None):
super().__init__() super().__init__()
self.title = "Remove Moonraker" self.title = "Remove Moonraker"
self.title_color = Color.RED self.title_color = Color.RED
self.previous_menu: Type[BaseMenu] | None = previous_menu self.previous_menu: Type[BaseMenu] | None = previous_menu
self.remove_moonraker_service = False self.footer_type = FooterType.BACK
self.remove_moonraker_dir = False
self.remove_moonraker_env = False self.rm_svc = False
self.remove_moonraker_polkit = False self.rm_dir = False
self.selection_state = False self.rm_env = False
self.rm_pk = False
self.select_state = False
self.mrsvc = MoonrakerSetupService()
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.remove_menu import RemoveMenu from core.menus.remove_menu import RemoveMenu
@@ -48,17 +53,18 @@ class MoonrakerRemoveMenu(BaseMenu):
def print_menu(self) -> None: def print_menu(self) -> None:
checked = f"[{Color.apply('x', Color.CYAN)}]" checked = f"[{Color.apply('x', Color.CYAN)}]"
unchecked = "[ ]" unchecked = "[ ]"
o1 = checked if self.remove_moonraker_service else unchecked o1 = checked if self.rm_svc else unchecked
o2 = checked if self.remove_moonraker_dir else unchecked o2 = checked if self.rm_dir else unchecked
o3 = checked if self.remove_moonraker_env else unchecked o3 = checked if self.rm_env else unchecked
o4 = checked if self.remove_moonraker_polkit else unchecked o4 = checked if self.rm_pk else unchecked
sel_state = f"{'Select' if not self.select_state else 'Deselect'} everything"
menu = textwrap.dedent( menu = textwrap.dedent(
f""" f"""
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ Enter a number and hit enter to select / deselect ║ ║ Enter a number and hit enter to select / deselect ║
║ the specific option for removal. ║ ║ the specific option for removal. ║
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ a) {self._get_selection_state_str():37} ║ a) {sel_state:49}
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ 1) {o1} Remove Service ║ ║ 1) {o1} Remove Service ║
║ 2) {o2} Remove Local Repository ║ ║ 2) {o2} Remove Local Repository ║
@@ -72,57 +78,33 @@ class MoonrakerRemoveMenu(BaseMenu):
print(menu, end="") print(menu, end="")
def toggle_all(self, **kwargs) -> None: def toggle_all(self, **kwargs) -> None:
self.selection_state = not self.selection_state self.select_state = not self.select_state
self.remove_moonraker_service = self.selection_state self.rm_svc = self.select_state
self.remove_moonraker_dir = self.selection_state self.rm_dir = self.select_state
self.remove_moonraker_env = self.selection_state self.rm_env = self.select_state
self.remove_moonraker_polkit = self.selection_state self.rm_pk = self.select_state
def toggle_remove_moonraker_service(self, **kwargs) -> None: def toggle_remove_moonraker_service(self, **kwargs) -> None:
self.remove_moonraker_service = not self.remove_moonraker_service self.rm_svc = not self.rm_svc
def toggle_remove_moonraker_dir(self, **kwargs) -> None: def toggle_remove_moonraker_dir(self, **kwargs) -> None:
self.remove_moonraker_dir = not self.remove_moonraker_dir self.rm_dir = not self.rm_dir
def toggle_remove_moonraker_env(self, **kwargs) -> None: def toggle_remove_moonraker_env(self, **kwargs) -> None:
self.remove_moonraker_env = not self.remove_moonraker_env self.rm_env = not self.rm_env
def toggle_remove_moonraker_polkit(self, **kwargs) -> None: def toggle_remove_moonraker_polkit(self, **kwargs) -> None:
self.remove_moonraker_polkit = not self.remove_moonraker_polkit self.rm_pk = not self.rm_pk
def run_removal_process(self, **kwargs) -> None: def run_removal_process(self, **kwargs) -> None:
if ( if not self.rm_svc and not self.rm_dir and not self.rm_env and not self.rm_pk:
not self.remove_moonraker_service msg = "Nothing selected! Select options to remove first."
and not self.remove_moonraker_dir print(Color.apply(msg, Color.RED))
and not self.remove_moonraker_env
and not self.remove_moonraker_polkit
):
print(
Color.apply(
"Nothing selected! Select options to remove first.", Color.RED
)
)
return return
moonraker_remove.run_moonraker_removal( self.mrsvc.remove(self.rm_svc, self.rm_dir, self.rm_env, self.rm_pk)
self.remove_moonraker_service,
self.remove_moonraker_dir,
self.remove_moonraker_env,
self.remove_moonraker_polkit,
)
self.remove_moonraker_service = False self.rm_svc = False
self.remove_moonraker_dir = False self.rm_dir = False
self.remove_moonraker_env = False self.rm_env = False
self.remove_moonraker_polkit = False self.rm_pk = False
self._go_back()
def _get_selection_state_str(self) -> str:
return (
"Select everything" if not self.selection_state else "Deselect everything"
)
def _go_back(self, **kwargs) -> None:
if self.previous_menu is not None:
self.previous_menu().run()

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -48,7 +48,7 @@ def print_moonraker_overview(
for i, k in enumerate(instance_map): for i, k in enumerate(instance_map):
mr_name = instance_map.get(k) mr_name = instance_map.get(k)
m = f"<-> {mr_name}" if mr_name != "" else "" m = f"<-> {mr_name}" if mr_name != "" else ""
line = Color.apply(f"{f'{i+1})' if show_index else ''} {k} {m}", Color.CYAN) line = Color.apply(f"{f'{i + 1})' if show_index else ''} {k} {m}", Color.CYAN)
dialog += f"{line:<63}\n" dialog += f"{line:<63}\n"
warn_l1 = Color.apply("PLEASE NOTE:", Color.YELLOW) warn_l1 = Color.apply("PLEASE NOTE:", Color.YELLOW)

View File

@@ -1,121 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 __future__ import annotations
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from typing import List
from components.klipper.klipper_dialogs import print_instance_overview
from components.moonraker import MOONRAKER_DIR, MOONRAKER_ENV_DIR
from components.moonraker.moonraker import Moonraker
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from utils.fs_utils import run_remove_routines
from utils.input_utils import get_selection_input
from utils.instance_utils import get_instances
from utils.sys_utils import unit_file_exists
def run_moonraker_removal(
remove_service: bool,
remove_dir: bool,
remove_env: bool,
remove_polkit: bool,
) -> None:
instances = get_instances(Moonraker)
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if instances:
instances_to_remove = select_instances_to_remove(instances)
remove_instances(instances_to_remove)
else:
Logger.print_info("No Moonraker Services installed! Skipped ...")
delete_remaining: bool = remove_polkit or remove_dir or remove_env
if delete_remaining and unit_file_exists("moonraker", suffix="service"):
Logger.print_info("There are still other Moonraker services installed")
Logger.print_info(
"● Moonraker PolicyKit rules were not removed.", prefix=False
)
Logger.print_info(f"'{MOONRAKER_DIR}' was not removed.", prefix=False)
Logger.print_info(f"'{MOONRAKER_ENV_DIR}' was not removed.", prefix=False)
else:
if remove_polkit:
Logger.print_status("Removing all Moonraker policykit rules ...")
remove_polkit_rules()
if remove_dir:
Logger.print_status("Removing Moonraker local repository ...")
run_remove_routines(MOONRAKER_DIR)
if remove_env:
Logger.print_status("Removing Moonraker Python environment ...")
run_remove_routines(MOONRAKER_ENV_DIR)
def select_instances_to_remove(
instances: List[Moonraker],
) -> List[Moonraker] | None:
start_index = 1
options = [str(i + start_index) for i in range(len(instances))]
options.extend(["a", "b"])
instance_map = {options[i]: instances[i] for i in range(len(instances))}
print_instance_overview(
instances,
start_index=start_index,
show_index=True,
show_select_all=True,
)
selection = get_selection_input("Select Moonraker instance to remove", options)
instances_to_remove = []
if selection == "b":
return None
elif selection == "a":
instances_to_remove.extend(instances)
else:
instances_to_remove.append(instance_map[selection])
return instances_to_remove
def remove_instances(
instance_list: List[Moonraker] | None,
) -> None:
if not instance_list:
Logger.print_info("No Moonraker instances found. Skipped ...")
return
for instance in instance_list:
Logger.print_status(f"Removing instance {instance.service_file_path.stem} ...")
InstanceManager.remove(instance)
delete_moonraker_env_file(instance)
def remove_polkit_rules() -> None:
if not MOONRAKER_DIR.exists():
log = "Cannot remove policykit rules. Moonraker directory not found."
Logger.print_warn(log)
return
try:
cmd = [f"{MOONRAKER_DIR}/scripts/set-policykit-rules.sh", "--clear"]
run(cmd, stderr=PIPE, stdout=DEVNULL, check=True)
except CalledProcessError as e:
Logger.print_error(f"Error while removing policykit rules: {e}")
Logger.print_ok("Policykit rules successfully removed!")
def delete_moonraker_env_file(instance: Moonraker):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
run_remove_routines(instance.env_file)

View File

@@ -1,219 +0,0 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 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 __future__ import annotations
import json
import subprocess
from typing import List
from components.klipper.klipper import Klipper
from components.moonraker import (
EXIT_MOONRAKER_SETUP,
MOONRAKER_DEPS_JSON_FILE,
MOONRAKER_DIR,
MOONRAKER_ENV_DIR,
MOONRAKER_INSTALL_SCRIPT,
MOONRAKER_REQ_FILE,
MOONRAKER_SPEEDUPS_REQ_FILE,
POLKIT_FILE,
POLKIT_LEGACY_FILE,
POLKIT_SCRIPT,
POLKIT_USR_FILE,
)
from components.moonraker.moonraker import Moonraker
from components.moonraker.moonraker_dialogs import print_moonraker_overview
from components.moonraker.moonraker_utils import (
backup_moonraker_dir,
create_example_moonraker_conf,
)
from components.webui_client.client_utils import (
enable_mainsail_remotemode,
get_existing_clients,
)
from components.webui_client.mainsail_data import MainsailData
from core.instance_manager.instance_manager import InstanceManager
from core.logger import Logger
from core.settings.kiauh_settings import KiauhSettings
from utils.common import check_install_dependencies
from utils.fs_utils import check_file_exist
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import (
get_confirm,
get_selection_input,
)
from utils.instance_utils import get_instances
from utils.sys_utils import (
check_python_version,
cmd_sysctl_manage,
cmd_sysctl_service,
create_python_venv,
install_python_requirements,
parse_packages_from_file,
)
def install_moonraker() -> None:
klipper_list: List[Klipper] = get_instances(Klipper)
if not check_moonraker_install_requirements(klipper_list):
return
moonraker_list: List[Moonraker] = get_instances(Moonraker)
instances: List[Moonraker] = []
selected_option: str | Klipper
if len(klipper_list) == 1:
instances.append(Moonraker(klipper_list[0].suffix))
else:
print_moonraker_overview(
klipper_list,
moonraker_list,
show_index=True,
show_select_all=True,
)
options = {str(i + 1): k for i, k in enumerate(klipper_list)}
additional_options = {"a": None, "b": None}
options = {**options, **additional_options}
question = "Select Klipper instance to setup Moonraker for"
selected_option = get_selection_input(question, options)
if selected_option == "b":
Logger.print_status(EXIT_MOONRAKER_SETUP)
return
if selected_option == "a":
instances.extend([Moonraker(k.suffix) for k in klipper_list])
else:
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
instances.append(Moonraker(klipper_instance.suffix))
create_example_cfg = get_confirm("Create example moonraker.conf?")
try:
check_install_dependencies()
setup_moonraker_prerequesites()
install_moonraker_polkit()
used_ports_map = {m.suffix: m.port for m in moonraker_list}
for instance in instances:
instance.create()
cmd_sysctl_service(instance.service_file_path.name, "enable")
if create_example_cfg:
# if a webclient and/or it's config is installed, patch
# its update section to the config
clients = get_existing_clients()
create_example_moonraker_conf(instance, used_ports_map, clients)
cmd_sysctl_service(instance.service_file_path.name, "start")
cmd_sysctl_manage("daemon-reload")
# if mainsail is installed, and we installed
# multiple moonraker instances, we enable mainsails remote mode
if MainsailData().client_dir.exists() and len(moonraker_list) > 1:
enable_mainsail_remotemode()
except Exception as e:
Logger.print_error(f"Error while installing Moonraker: {e}")
return
def check_moonraker_install_requirements(klipper_list: List[Klipper]) -> bool:
def check_klipper_instances() -> bool:
if len(klipper_list) >= 1:
return True
Logger.print_warn("Klipper not installed!")
Logger.print_warn("Moonraker cannot be installed! Install Klipper first.")
return False
return check_python_version(3, 7) and check_klipper_instances()
def setup_moonraker_prerequesites() -> None:
settings = KiauhSettings()
repo = settings.moonraker.repo_url
branch = settings.moonraker.branch
git_clone_wrapper(repo, MOONRAKER_DIR, branch)
# install moonraker dependencies and create python virtualenv
install_moonraker_packages()
if create_python_venv(MOONRAKER_ENV_DIR):
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE)
def install_moonraker_packages() -> None:
moonraker_deps = []
if MOONRAKER_DEPS_JSON_FILE.exists():
with open(MOONRAKER_DEPS_JSON_FILE, "r") as deps:
moonraker_deps = json.load(deps).get("debian", [])
elif MOONRAKER_INSTALL_SCRIPT.exists():
moonraker_deps = parse_packages_from_file(MOONRAKER_INSTALL_SCRIPT)
if not moonraker_deps:
raise ValueError("Error reading Moonraker dependencies!")
check_install_dependencies({*moonraker_deps})
def install_moonraker_polkit() -> None:
Logger.print_status("Installing Moonraker policykit rules ...")
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
polkit_file_exists = check_file_exist(POLKIT_FILE, True)
usr_file_exists = check_file_exist(POLKIT_USR_FILE, True)
if legacy_file_exists or (polkit_file_exists and usr_file_exists):
Logger.print_info("Moonraker policykit rules are already installed.")
return
try:
command = [POLKIT_SCRIPT, "--disable-systemctl"]
result = subprocess.run(
command,
stderr=subprocess.PIPE,
stdout=subprocess.DEVNULL,
text=True,
)
if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Installing Moonraker policykit rules failed!")
return
Logger.print_ok("Moonraker policykit rules successfully installed!")
except subprocess.CalledProcessError as e:
log = f"Error while installing Moonraker policykit rules: {e.stderr.decode()}"
Logger.print_error(log)
def update_moonraker() -> None:
if not get_confirm("Update Moonraker now?"):
return
settings = KiauhSettings()
if settings.kiauh.backup_before_update:
backup_moonraker_dir()
instances = get_instances(Moonraker)
InstanceManager.stop_all(instances)
git_pull_wrapper(repo=settings.moonraker.repo_url, target_dir=MOONRAKER_DIR)
# install possible new system packages
install_moonraker_packages()
# install possible new python dependencies
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
InstanceManager.start_all(instances)

View File

@@ -0,0 +1,49 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 __future__ import annotations
from typing import Dict, List
from components.moonraker.moonraker import Moonraker
from utils.instance_utils import get_instances
class MoonrakerInstanceService:
__cls_instance = None
__instances: List[Moonraker] = []
def __new__(cls) -> "MoonrakerInstanceService":
if cls.__cls_instance is None:
cls.__cls_instance = super(MoonrakerInstanceService, cls).__new__(cls)
return cls.__cls_instance
def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized:
return
self.__initialized = True
def load_instances(self) -> None:
self.__instances = get_instances(Moonraker)
def create_new_instance(self, suffix: str) -> Moonraker:
instance = Moonraker(suffix)
self.__instances.append(instance)
return instance
def get_all_instances(self) -> List[Moonraker]:
return self.__instances
def get_instance_by_suffix(self, suffix: str) -> Moonraker | None:
instances: List[Moonraker] = [i for i in self.__instances if i.suffix == suffix]
return instances[0] if instances else None
def get_instance_port_map(self) -> Dict[str, int]:
return {i.suffix: i.port for i in self.__instances}

View File

@@ -0,0 +1,408 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 __future__ import annotations
from copy import copy
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from typing import List
from components.klipper.klipper import Klipper
from components.klipper.klipper_dialogs import print_instance_overview
from components.klipper.services.klipper_instance_service import KlipperInstanceService
from components.moonraker import (
EXIT_MOONRAKER_SETUP,
MOONRAKER_DIR,
MOONRAKER_ENV_DIR,
MOONRAKER_REPO_URL,
MOONRAKER_REQ_FILE,
MOONRAKER_SPEEDUPS_REQ_FILE,
POLKIT_FILE,
POLKIT_LEGACY_FILE,
POLKIT_SCRIPT,
POLKIT_USR_FILE,
)
from components.moonraker.moonraker import Moonraker
from components.moonraker.moonraker_dialogs import print_moonraker_overview
from components.moonraker.services.moonraker_instance_service import (
MoonrakerInstanceService,
)
from components.moonraker.utils.utils import (
backup_moonraker_dir,
create_example_moonraker_conf,
install_moonraker_packages,
remove_polkit_rules,
)
from components.webui_client.client_utils import (
enable_mainsail_remotemode,
get_existing_clients,
)
from components.webui_client.mainsail_data import MainsailData
from core.instance_manager.instance_manager import InstanceManager
from core.logger import DialogType, Logger
from core.services.message_service import Message, MessageService
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color
from utils.common import check_install_dependencies
from utils.fs_utils import check_file_exist, run_remove_routines
from utils.git_utils import git_clone_wrapper, git_pull_wrapper
from utils.input_utils import (
get_confirm,
get_selection_input,
)
from utils.sys_utils import (
check_python_version,
cmd_sysctl_manage,
cmd_sysctl_service,
create_python_venv,
get_ipv4_addr,
install_python_requirements,
unit_file_exists,
)
# noinspection PyMethodMayBeStatic
class MoonrakerSetupService:
__cls_instance = None
kisvc: KlipperInstanceService
misvc: MoonrakerInstanceService
msgsvc = MessageService
settings: KiauhSettings
klipper_list: List[Klipper]
moonraker_list: List[Moonraker]
def __new__(cls) -> "MoonrakerSetupService":
if cls.__cls_instance is None:
cls.__cls_instance = super(MoonrakerSetupService, cls).__new__(cls)
return cls.__cls_instance
def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized:
return
self.__initialized = True
self.__init_state()
def __init_state(self) -> None:
self.settings = KiauhSettings()
self.kisvc = KlipperInstanceService()
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc = MoonrakerInstanceService()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
self.msgsvc = MessageService()
def __refresh_state(self) -> None:
self.kisvc.load_instances()
self.klipper_list = self.kisvc.get_all_instances()
self.misvc.load_instances()
self.moonraker_list = self.misvc.get_all_instances()
def install(self) -> None:
self.__refresh_state()
if not self.__check_requirements(self.klipper_list):
return
new_instances: List[Moonraker] = []
selected_option: str | Klipper
if len(self.klipper_list) == 1:
suffix: str = self.klipper_list[0].suffix
new_inst = self.misvc.create_new_instance(suffix)
new_instances.append(new_inst)
else:
print_moonraker_overview(
self.klipper_list,
self.moonraker_list,
show_index=True,
show_select_all=True,
)
options = {str(i + 1): k for i, k in enumerate(self.klipper_list)}
additional_options = {"a": None, "b": None}
options = {**options, **additional_options}
question = "Select Klipper instance to setup Moonraker for"
selected_option = get_selection_input(question, options)
if selected_option == "b":
Logger.print_status(EXIT_MOONRAKER_SETUP)
return
if selected_option == "a":
new_inst_list: List[Moonraker] = [
self.misvc.create_new_instance(k.suffix) for k in self.klipper_list
]
new_instances.extend(new_inst_list)
else:
klipper_instance: Klipper | None = options.get(selected_option)
if klipper_instance is None:
raise Exception("Error selecting instance!")
new_inst = self.misvc.create_new_instance(klipper_instance.suffix)
new_instances.append(new_inst)
create_example_cfg = get_confirm("Create example moonraker.conf?")
try:
self.__run_setup(new_instances, create_example_cfg)
except Exception as e:
Logger.print_error(f"Error while installing Moonraker: {e}")
return
def update(self) -> None:
Logger.print_dialog(
DialogType.WARNING,
[
"Be careful if there are ongoing prints running!",
"All Moonraker instances will be restarted during the update process and "
"ongoing prints COULD FAIL.",
],
)
if not get_confirm("Update Moonraker now?"):
return
self.__refresh_state()
if self.settings.kiauh.backup_before_update:
backup_moonraker_dir()
InstanceManager.stop_all(self.moonraker_list)
git_pull_wrapper(MOONRAKER_DIR)
install_moonraker_packages()
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
InstanceManager.start_all(self.moonraker_list)
def remove(
self,
remove_service: bool,
remove_dir: bool,
remove_env: bool,
remove_polkit: bool,
) -> None:
self.__refresh_state()
completion_msg = Message(
title="Moonraker Removal Process completed",
color=Color.GREEN,
)
if remove_service:
Logger.print_status("Removing Moonraker instances ...")
if self.moonraker_list:
instances_to_remove = self.__get_instances_to_remove()
self.__remove_instances(instances_to_remove)
if instances_to_remove:
instance_names = [
i.service_file_path.stem for i in instances_to_remove
]
txt = f"● Moonraker instances removed: {', '.join(instance_names)}"
completion_msg.text.append(txt)
else:
Logger.print_info("No Moonraker Services installed! Skipped ...")
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
"moonraker", suffix="service"
):
completion_msg.text = [
"Some Klipper services are still installed:",
"● Moonraker PolicyKit rules were not removed, even though selected for removal.",
f"'{MOONRAKER_DIR}' was not removed, even though selected for removal.",
f"'{MOONRAKER_ENV_DIR}' was not removed, even though selected for removal.",
]
else:
if remove_polkit:
Logger.print_status("Removing all Moonraker policykit rules ...")
if remove_polkit_rules():
completion_msg.text.append("● Moonraker policykit rules removed")
if remove_dir:
Logger.print_status("Removing Moonraker local repository ...")
if run_remove_routines(MOONRAKER_DIR):
completion_msg.text.append("● Moonraker local repository removed")
if remove_env:
Logger.print_status("Removing Moonraker Python environment ...")
if run_remove_routines(MOONRAKER_ENV_DIR):
completion_msg.text.append("● Moonraker Python environment removed")
if completion_msg.text:
completion_msg.text.insert(0, "The following actions were performed:")
else:
completion_msg.color = Color.YELLOW
completion_msg.centered = True
completion_msg.text = ["Nothing to remove."]
self.msgsvc.set_message(completion_msg)
def __run_setup(
self, new_instances: List[Moonraker], create_example_cfg: bool
) -> None:
check_install_dependencies()
self.__install_deps()
ports_map = self.misvc.get_instance_port_map()
for i in new_instances:
i.create()
cmd_sysctl_service(i.service_file_path.name, "enable")
if create_example_cfg:
# if a webclient and/or it's config is installed, patch
# its update section to the config
clients = get_existing_clients()
create_example_moonraker_conf(i, ports_map, clients)
cmd_sysctl_service(i.service_file_path.name, "start")
cmd_sysctl_manage("daemon-reload")
# if mainsail is installed, and we installed
# multiple moonraker instances, we enable mainsails remote mode
if MainsailData().client_dir.exists() and len(self.moonraker_list) > 1:
enable_mainsail_remotemode()
self.misvc.load_instances()
new_instances = [
self.misvc.get_instance_by_suffix(i.suffix) for i in new_instances
]
ip: str = get_ipv4_addr()
# noinspection HttpUrlsUsage
url_list = [
f"{i.service_file_path.stem}: http://{ip}:{i.port}"
for i in new_instances
if i.port
]
dialog_content = []
if url_list:
dialog_content.append("You can access Moonraker via the following URL:")
dialog_content.extend(url_list)
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Moonraker successfully installed!",
custom_color=Color.GREEN,
content=dialog_content,
)
def __check_requirements(self, klipper_list: List[Klipper]) -> bool:
is_klipper_installed = len(klipper_list) >= 1
if not is_klipper_installed:
Logger.print_warn("Klipper not installed!")
Logger.print_warn("Moonraker cannot be installed! Install Klipper first.")
is_python_ok = check_python_version(3, 7)
return is_klipper_installed and is_python_ok
def __install_deps(self) -> None:
default_repo = (MOONRAKER_REPO_URL, "master")
repo = self.settings.moonraker.repositories
# pull the first repo defined in kiauh.cfg or fallback to the official Moonraker repo
repo, branch = (repo[0].url, repo[0].branch) if repo else default_repo
git_clone_wrapper(repo, MOONRAKER_DIR, branch)
try:
install_moonraker_packages()
if create_python_venv(MOONRAKER_ENV_DIR, False, False, self.settings.moonraker.use_python_binary):
install_python_requirements(MOONRAKER_ENV_DIR, MOONRAKER_REQ_FILE)
if self.settings.moonraker.optional_speedups:
install_python_requirements(
MOONRAKER_ENV_DIR, MOONRAKER_SPEEDUPS_REQ_FILE
)
self.__install_polkit()
except Exception:
Logger.print_error("Error during installation of Moonraker requirements!")
raise
def __install_polkit(self) -> None:
Logger.print_status("Installing Moonraker policykit rules ...")
legacy_file_exists = check_file_exist(POLKIT_LEGACY_FILE, True)
polkit_file_exists = check_file_exist(POLKIT_FILE, True)
usr_file_exists = check_file_exist(POLKIT_USR_FILE, True)
if legacy_file_exists or (polkit_file_exists and usr_file_exists):
Logger.print_info("Moonraker policykit rules are already installed.")
return
try:
command = [POLKIT_SCRIPT, "--disable-systemctl"]
result = run(
command,
stderr=PIPE,
stdout=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 CalledProcessError as e:
log = (
f"Error while installing Moonraker policykit rules: {e.stderr.decode()}"
)
Logger.print_error(log)
def __get_instances_to_remove(self) -> List[Moonraker] | None:
start_index = 1
curr_instances: List[Moonraker] = self.moonraker_list
instance_count = len(curr_instances)
options = [str(i + start_index) for i in range(instance_count)]
options.extend(["a", "b"])
instance_map = {
options[i]: self.moonraker_list[i] for i in range(instance_count)
}
print_instance_overview(
self.moonraker_list,
start_index=start_index,
show_index=True,
show_select_all=True,
)
selection = get_selection_input("Select Moonraker instance to remove", options)
if selection == "b":
return None
elif selection == "a":
return copy(self.moonraker_list)
return [instance_map[selection]]
def __remove_instances(
self,
instance_list: List[Moonraker] | None,
) -> None:
if not instance_list:
return
for instance in instance_list:
Logger.print_status(
f"Removing instance {instance.service_file_path.stem} ..."
)
InstanceManager.remove(instance)
self.__delete_env_file(instance)
self.__refresh_state()
def __delete_env_file(self, instance: Moonraker):
Logger.print_status(f"Remove '{instance.env_file}'")
if not instance.env_file.exists():
msg = f"Env file in {instance.base.sysd_dir} not found. Skipped ..."
Logger.print_info(msg)
return
run_remove_routines(instance.env_file)

View File

@@ -0,0 +1,173 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# It was modified by Dominik Willner <th33xitus@gmail.com> #
# #
# The original file is part of Moonraker: #
# https://github.com/Arksine/moonraker #
# Copyright (C) 2025 Eric Callahan <arksine.code@gmail.com> #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from __future__ import annotations
import logging
import pathlib
import re
import shlex
from typing import Any, Dict, List, Tuple
def _get_distro_info() -> Dict[str, Any]:
release_file = pathlib.Path("/etc/os-release")
release_info: Dict[str, str] = {}
with release_file.open("r") as f:
lexer = shlex.shlex(f, posix=True)
lexer.whitespace_split = True
for item in list(lexer):
if "=" in item:
key, val = item.split("=", maxsplit=1)
release_info[key] = val
return dict(
distro_id=release_info.get("ID", ""),
distro_version=release_info.get("VERSION_ID", ""),
aliases=release_info.get("ID_LIKE", "").split(),
)
def _convert_version(version: str) -> Tuple[str | int, ...]:
version = version.strip()
ver_match = re.match(r"\d+(\.\d+)*((?:-|\.).+)?", version)
if ver_match is not None:
return tuple(
[
int(part) if part.isdigit() else part
for part in re.split(r"\.|-", version)
]
)
return (version,)
class SysDepsParser:
def __init__(self, distro_info: Dict[str, Any] | None = None) -> None:
if distro_info is None:
distro_info = _get_distro_info()
self.distro_id: str = distro_info.get("distro_id", "")
self.aliases: List[str] = distro_info.get("aliases", [])
self.distro_version: Tuple[int | str, ...] = tuple()
version = distro_info.get("distro_version")
if version:
self.distro_version = _convert_version(version)
def _parse_spec(self, full_spec: str) -> str | None:
parts = full_spec.split(";", maxsplit=1)
if len(parts) == 1:
return full_spec
pkg_name = parts[0].strip()
expressions = re.split(r"( and | or )", parts[1].strip())
if not len(expressions) & 1:
# There should always be an odd number of expressions. Each
# expression is separated by an "and" or "or" operator
logging.info(
f"Requirement specifier is missing an expression "
f"between logical operators : {full_spec}"
)
return None
last_result: bool = True
last_logical_op: str | None = "and"
for idx, exp in enumerate(expressions):
if idx & 1:
if last_logical_op is not None:
logging.info(
"Requirement specifier contains sequential logical "
f"operators: {full_spec}"
)
return None
logical_op = exp.strip()
if logical_op not in ("and", "or"):
logging.info(
f"Invalid logical operator {logical_op} in requirement "
f"specifier: {full_spec}"
)
return None
last_logical_op = logical_op
continue
elif last_logical_op is None:
logging.info(
f"Requirement specifier contains two seqential expressions "
f"without a logical operator: {full_spec}"
)
return None
dep_parts = re.split(r"(==|!=|<=|>=|<|>)", exp.strip())
req_var = dep_parts[0].strip().lower()
if len(dep_parts) != 3:
logging.info(f"Invalid comparison, must be 3 parts: {full_spec}")
return None
elif req_var == "distro_id":
left_op: str | Tuple[int | str, ...] = self.distro_id
right_op = dep_parts[2].strip().strip("\"'")
elif req_var == "distro_version":
if not self.distro_version:
logging.info(
"Distro Version not detected, cannot satisfy requirement: "
f"{full_spec}"
)
return None
left_op = self.distro_version
right_op = _convert_version(dep_parts[2].strip().strip("\"'"))
else:
logging.info(f"Invalid requirement specifier: {full_spec}")
return None
operator = dep_parts[1].strip()
try:
compfunc = {
"<": lambda x, y: x < y,
">": lambda x, y: x > y,
"==": lambda x, y: x == y,
"!=": lambda x, y: x != y,
">=": lambda x, y: x >= y,
"<=": lambda x, y: x <= y,
}.get(operator, lambda x, y: False)
result = compfunc(left_op, right_op)
if last_logical_op == "and":
last_result &= result
else:
last_result |= result
last_logical_op = None
except Exception:
logging.exception(f"Error comparing requirements: {full_spec}")
return None
if last_result:
return pkg_name
return None
def parse_dependencies(self, sys_deps: Dict[str, List[str]]) -> List[str]:
if not self.distro_id:
logging.info(
"Failed to detect current distro ID, cannot parse dependencies"
)
return []
all_ids = [self.distro_id] + self.aliases
for distro_id in all_ids:
if distro_id in sys_deps:
if not sys_deps[distro_id]:
logging.info(
f"Dependency data contains an empty package definition "
f"for linux distro '{distro_id}'"
)
continue
processed_deps: List[str] = []
for dep in sys_deps[distro_id]:
parsed_dep = self._parse_spec(dep)
if parsed_dep is not None:
processed_deps.append(parsed_dep)
return processed_deps
else:
logging.info(
f"Dependency data has no package definition for linux "
f"distro '{self.distro_id}'"
)
return []

View File

@@ -1,13 +1,15 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
# # # #
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
import json
import shutil import shutil
from pathlib import Path
from subprocess import DEVNULL, PIPE, CalledProcessError, run
from typing import Dict, List, Optional from typing import Dict, List, Optional
from components.moonraker import ( from components.moonraker import (
@@ -15,10 +17,13 @@ from components.moonraker import (
MOONRAKER_BACKUP_DIR, MOONRAKER_BACKUP_DIR,
MOONRAKER_DB_BACKUP_DIR, MOONRAKER_DB_BACKUP_DIR,
MOONRAKER_DEFAULT_PORT, MOONRAKER_DEFAULT_PORT,
MOONRAKER_DEPS_JSON_FILE,
MOONRAKER_DIR, MOONRAKER_DIR,
MOONRAKER_ENV_DIR, MOONRAKER_ENV_DIR,
MOONRAKER_INSTALL_SCRIPT,
) )
from components.moonraker.moonraker import Moonraker from components.moonraker.moonraker import Moonraker
from components.moonraker.utils.sysdeps_parser import SysDepsParser
from components.webui_client.base_data import BaseWebClient from components.webui_client.base_data import BaseWebClient
from core.backup_manager.backup_manager import BackupManager from core.backup_manager.backup_manager import BackupManager
from core.logger import Logger from core.logger import Logger
@@ -26,10 +31,11 @@ from core.submodules.simple_config_parser.src.simple_config_parser.simple_config
SimpleConfigParser, SimpleConfigParser,
) )
from core.types.component_status import ComponentStatus from core.types.component_status import ComponentStatus
from utils.common import get_install_status from utils.common import check_install_dependencies, get_install_status
from utils.instance_utils import get_instances from utils.instance_utils import get_instances
from utils.sys_utils import ( from utils.sys_utils import (
get_ipv4_addr, get_ipv4_addr,
parse_packages_from_file,
) )
@@ -37,6 +43,46 @@ def get_moonraker_status() -> ComponentStatus:
return get_install_status(MOONRAKER_DIR, MOONRAKER_ENV_DIR, Moonraker) return get_install_status(MOONRAKER_DIR, MOONRAKER_ENV_DIR, Moonraker)
def install_moonraker_packages() -> None:
Logger.print_status("Parsing Moonraker system dependencies ...")
moonraker_deps = []
if MOONRAKER_DEPS_JSON_FILE.exists():
Logger.print_info(
f"Parsing system dependencies from {MOONRAKER_DEPS_JSON_FILE.name} ..."
)
parser = SysDepsParser()
sysdeps = load_sysdeps_json(MOONRAKER_DEPS_JSON_FILE)
moonraker_deps.extend(parser.parse_dependencies(sysdeps))
elif MOONRAKER_INSTALL_SCRIPT.exists():
Logger.print_warn(f"{MOONRAKER_DEPS_JSON_FILE.name} not found!")
Logger.print_info(
f"Parsing system dependencies from {MOONRAKER_INSTALL_SCRIPT.name} ..."
)
moonraker_deps = parse_packages_from_file(MOONRAKER_INSTALL_SCRIPT)
if not moonraker_deps:
raise ValueError("Error parsing Moonraker dependencies!")
check_install_dependencies({*moonraker_deps})
def remove_polkit_rules() -> bool:
if not MOONRAKER_DIR.exists():
log = "Cannot remove policykit rules. Moonraker directory not found."
Logger.print_warn(log)
return False
try:
cmd = [f"{MOONRAKER_DIR}/scripts/set-policykit-rules.sh", "--clear"]
run(cmd, stderr=PIPE, stdout=DEVNULL, check=True)
return True
except CalledProcessError as e:
Logger.print_error(f"Error while removing policykit rules: {e}")
return False
def create_example_moonraker_conf( def create_example_moonraker_conf(
instance: Moonraker, instance: Moonraker,
ports_map: Dict[str, int], ports_map: Dict[str, int],
@@ -80,7 +126,7 @@ def create_example_moonraker_conf(
scp.read_file(target) scp.read_file(target)
trusted_clients: List[str] = [ trusted_clients: List[str] = [
f" {'.'.join(ip)}\n", f" {'.'.join(ip)}\n",
*scp.getval("authorization", "trusted_clients"), *scp.getvals("authorization", "trusted_clients"),
] ]
scp.set_option("server", "port", str(port)) scp.set_option("server", "port", str(port))
@@ -138,3 +184,13 @@ def backup_moonraker_db_dir() -> None:
bm.backup_directory( bm.backup_directory(
name, source=instance.db_dir, target=MOONRAKER_DB_BACKUP_DIR name, source=instance.db_dir, target=MOONRAKER_DB_BACKUP_DIR
) )
def load_sysdeps_json(file: Path) -> Dict[str, List[str]]:
try:
sysdeps: Dict[str, List[str]] = json.loads(file.read_bytes())
except json.JSONDecodeError as e:
Logger.print_error(f"Unable to parse {file.name}:\n{e}")
return {}
else:
return sysdeps

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -106,7 +106,7 @@ def update_client_config(client: BaseWebClient) -> None:
if settings.kiauh.backup_before_update: if settings.kiauh.backup_before_update:
backup_client_config_data(client) backup_client_config_data(client)
git_pull_wrapper(client_config.repo_url, client_config.config_dir) git_pull_wrapper(client_config.config_dir)
Logger.print_ok(f"Successfully updated {client_config.display_name}.") Logger.print_ok(f"Successfully updated {client_config.display_name}.")
Logger.print_info("Restart Klipper to reload the configuration!") Logger.print_info("Restart Klipper to reload the configuration!")

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -102,6 +102,7 @@ def install_client(
section=f"update_manager {client.name}", section=f"update_manager {client.name}",
instances=mr_instances, instances=mr_instances,
options=[ options=[
("persistent_files", ["config.json"]),
("type", "web"), ("type", "web"),
("channel", "stable"), ("channel", "stable"),
("repo", str(client.repo_path)), ("repo", str(client.repo_path)),
@@ -144,7 +145,7 @@ def install_client(
custom_color=Color.GREEN, custom_color=Color.GREEN,
center_content=True, center_content=True,
content=[ content=[
f"Open {client.display_name} now on: http://{get_ipv4_addr()}:{port}", f"Open {client.display_name} now on: http://{get_ipv4_addr()}{'' if port == 80 else f':{port}'}",
], ],
) )

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -119,7 +119,7 @@ def enable_mainsail_remotemode() -> None:
with open(c_json, "r") as f: with open(c_json, "r") as f:
config_data = json.load(f) config_data = json.load(f)
if config_data["instancesDB"] == "browser": if config_data["instancesDB"] == "browser" or config_data["instancesDB"] == "json":
Logger.print_info("Remote mode already configured. Skipped ...") Logger.print_info("Remote mode already configured. Skipped ...")
return return
@@ -414,7 +414,7 @@ def get_client_port_selection(
while True: while True:
_type = "Reconfigure" if reconfigure else "Configure" _type = "Reconfigure" if reconfigure else "Configure"
question = f"{_type} {client.display_name} for port" question = f"{_type} {client.display_name} for port"
port_input = get_number_input(question, min_count=80, default=port) port_input = get_number_input(question, min_value=80, default=port)
if port_input not in ports_in_use: if port_input not in ports_in_use:
client_settings: WebUiSettings = settings[client.name] client_settings: WebUiSettings = settings[client.name]

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -57,7 +57,7 @@ class ClientRemoveMenu(BaseMenu):
o1 = checked if self.remove_client else unchecked o1 = checked if self.remove_client else unchecked
o2 = checked if self.remove_client_cfg else unchecked o2 = checked if self.remove_client_cfg else unchecked
o3 = checked if self.backup_config_json else unchecked o3 = checked if self.backup_config_json else unchecked
sel_state = f"{'Select'if not self.select_state else 'Deselect'} everything" sel_state = f"{'Select' if not self.select_state else 'Deselect'} everything"
menu = textwrap.dedent( menu = textwrap.dedent(
f""" f"""
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -86,7 +86,12 @@ class BackupManager:
date = get_current_date().get("date") date = get_current_date().get("date")
time = get_current_date().get("time") time = get_current_date().get("time")
backup_target = target.joinpath(f"{name.lower()}-{date}-{time}") backup_target = target.joinpath(f"{name.lower()}-{date}-{time}")
shutil.copytree(source, backup_target, ignore=self.ignore_folders_func, ignore_dangling_symlinks=True) shutil.copytree(
source,
backup_target,
ignore=self.ignore_folders_func,
ignore_dangling_symlinks=True,
)
Logger.print_ok("Backup successful!") Logger.print_ok("Backup successful!")
return backup_target return backup_target

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -16,8 +16,9 @@ from typing import List
from utils.fs_utils import get_data_dir from utils.fs_utils import get_data_dir
SUFFIX_BLACKLIST: List[str] = ["None", "mcu", "obico", "bambu", "companion"] # suffixes that are not allowed to be used for instances
# because they would cause conflicts with other components or are reserved
SUFFIX_BLACKLIST: List[str] = ["None", "mcu", "obico", "bambu", "companion", "hmi"]
@dataclass(repr=True) @dataclass(repr=True)
class BaseInstance: class BaseInstance:

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -27,6 +27,13 @@ class DialogType(Enum):
LINE_WIDTH = 53 LINE_WIDTH = 53
BORDER_TOP: str = "┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓"
BORDER_BOTTOM: str = "┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛"
BORDER_TITLE: str = "┠───────────────────────────────────────────────────────┨"
BORDER_LEFT: str = ""
BORDER_RIGHT: str = ""
class Logger: class Logger:
@staticmethod @staticmethod
def print_info(msg, prefix=True, start="", end="\n") -> None: def print_info(msg, prefix=True, start="", end="\n") -> None:
@@ -81,24 +88,32 @@ class Logger:
:param margin_top: The number of empty lines to print before the dialog. :param margin_top: The number of empty lines to print before the dialog.
:param margin_bottom: The number of empty lines to print after the dialog. :param margin_bottom: The number of empty lines to print after the dialog.
""" """
dialog_color = Logger._get_dialog_color(title, custom_color) color = Logger._get_dialog_color(title, custom_color)
dialog_title = Logger._get_dialog_title(title, custom_title) dialog_title = Logger._get_dialog_title(title, custom_title)
dialog_title_formatted = Logger._format_dialog_title(dialog_title, dialog_color)
dialog_content = Logger.format_content(
content,
LINE_WIDTH,
dialog_color,
center_content,
)
top = Logger._format_top_border(dialog_color)
bottom = Logger._format_bottom_border(dialog_color)
print("\n" * margin_top) if margin_top > 0:
print( print("\n" * margin_top, end="")
f"{top}{dialog_title_formatted}{dialog_content}{bottom}",
end="", print(Color.apply(BORDER_TOP, color))
)
print("\n" * margin_bottom) if dialog_title:
print(Color.apply(f"{dialog_title:^{LINE_WIDTH}}", color))
print(Color.apply(BORDER_TITLE, color))
if content:
print(
Logger.format_content(
content,
LINE_WIDTH,
color,
center_content,
)
)
print(Color.apply(BORDER_BOTTOM, color))
if margin_bottom > 0:
print("\n" * margin_bottom, end="")
@staticmethod @staticmethod
def _get_dialog_title( def _get_dialog_title(
@@ -119,31 +134,6 @@ class Logger:
return color return color
@staticmethod
def _format_top_border(color: Color) -> str:
_border = Color.apply(
"┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n", color
)
return _border
@staticmethod
def _format_bottom_border(color: Color) -> str:
_border = Color.apply(
"\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛", color
)
return _border
@staticmethod
def _format_dialog_title(title: str | None, color: Color) -> str:
if title is None:
return ""
_title = Color.apply(f"{title:^{LINE_WIDTH}}\n", color)
_title += Color.apply(
"┠───────────────────────────────────────────────────────┨\n", color
)
return _title
@staticmethod @staticmethod
def format_content( def format_content(
content: List[str], content: List[str],

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -13,8 +13,10 @@ from typing import Type
from components.klipper import KLIPPER_DIR from components.klipper import KLIPPER_DIR
from components.klipper.klipper import Klipper from components.klipper.klipper import Klipper
from components.klipper.klipper_utils import install_input_shaper_deps
from components.klipper_firmware.menus.klipper_build_menu import ( from components.klipper_firmware.menus.klipper_build_menu import (
KlipperBuildFirmwareMenu, KlipperBuildFirmwareMenu,
KlipperKConfigMenu,
) )
from components.klipper_firmware.menus.klipper_flash_menu import ( from components.klipper_firmware.menus.klipper_flash_menu import (
KlipperFlashMethodMenu, KlipperFlashMethodMenu,
@@ -49,9 +51,10 @@ class AdvancedMenu(BaseMenu):
"2": Option(method=self.flash), "2": Option(method=self.flash),
"3": Option(method=self.build_flash), "3": Option(method=self.build_flash),
"4": Option(method=self.get_id), "4": Option(method=self.get_id),
"5": Option(method=self.klipper_rollback), "5": Option(method=self.input_shaper),
"6": Option(method=self.moonraker_rollback), "6": Option(method=self.klipper_rollback),
"7": Option(method=self.change_hostname), "7": Option(method=self.moonraker_rollback),
"8": Option(method=self.change_hostname),
} }
def print_menu(self) -> None: def print_menu(self) -> None:
@@ -59,11 +62,13 @@ class AdvancedMenu(BaseMenu):
""" """
╟───────────────────────────┬───────────────────────────╢ ╟───────────────────────────┬───────────────────────────╢
║ Klipper Firmware: │ Repository Rollback: ║ ║ Klipper Firmware: │ Repository Rollback: ║
║ 1) [Build] │ 5) [Klipper] ║ ║ 1) [Build] │ 6) [Klipper] ║
║ 2) [Flash] │ 6) [Moonraker] ║ ║ 2) [Flash] │ 7) [Moonraker] ║
║ 3) [Build + Flash] │ ║ ║ 3) [Build + Flash] │ ║
║ 4) [Get MCU ID] │ System: ║ ║ 4) [Get MCU ID] │ System: ║
║ │ 7) [Change hostname] ║ ║ │ 8) [Change hostname] ║
║ Extra Dependencies: │ ║
║ 5) [Input Shaper] │ ║
╟───────────────────────────┴───────────────────────────╢ ╟───────────────────────────┴───────────────────────────╢
""" """
)[1:] )[1:]
@@ -76,12 +81,15 @@ class AdvancedMenu(BaseMenu):
rollback_repository(MOONRAKER_DIR, Moonraker) rollback_repository(MOONRAKER_DIR, Moonraker)
def build(self, **kwargs) -> None: def build(self, **kwargs) -> None:
KlipperKConfigMenu().run()
KlipperBuildFirmwareMenu(previous_menu=self.__class__).run() KlipperBuildFirmwareMenu(previous_menu=self.__class__).run()
def flash(self, **kwargs) -> None: def flash(self, **kwargs) -> None:
KlipperKConfigMenu().run()
KlipperFlashMethodMenu(previous_menu=self.__class__).run() KlipperFlashMethodMenu(previous_menu=self.__class__).run()
def build_flash(self, **kwargs) -> None: def build_flash(self, **kwargs) -> None:
KlipperKConfigMenu().run()
KlipperBuildFirmwareMenu(previous_menu=KlipperFlashMethodMenu).run() KlipperBuildFirmwareMenu(previous_menu=KlipperFlashMethodMenu).run()
KlipperFlashMethodMenu(previous_menu=self.__class__).run() KlipperFlashMethodMenu(previous_menu=self.__class__).run()
@@ -93,3 +101,6 @@ class AdvancedMenu(BaseMenu):
def change_hostname(self, **kwargs) -> None: def change_hostname(self, **kwargs) -> None:
change_system_hostname() change_system_hostname()
def input_shaper(self, **kwargs) -> None:
install_input_shaper_deps()

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -13,7 +13,7 @@ from typing import Type
from components.klipper.klipper_utils import backup_klipper_dir from components.klipper.klipper_utils import backup_klipper_dir
from components.klipperscreen.klipperscreen import backup_klipperscreen_dir from components.klipperscreen.klipperscreen import backup_klipperscreen_dir
from components.moonraker.moonraker_utils import ( from components.moonraker.utils.utils import (
backup_moonraker_db_dir, backup_moonraker_db_dir,
backup_moonraker_dir, backup_moonraker_dir,
) )

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -12,9 +12,9 @@ import textwrap
from typing import Type from typing import Type
from components.crowsnest.crowsnest import install_crowsnest from components.crowsnest.crowsnest import install_crowsnest
from components.klipper import klipper_setup from components.klipper.services.klipper_setup_service import KlipperSetupService
from components.klipperscreen.klipperscreen import install_klipperscreen from components.klipperscreen.klipperscreen import install_klipperscreen
from components.moonraker import moonraker_setup from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
from components.webui_client.client_config.client_config_setup import ( from components.webui_client.client_config.client_config_setup import (
install_client_config, install_client_config,
) )
@@ -36,6 +36,8 @@ class InstallMenu(BaseMenu):
self.title = "Installation Menu" self.title = "Installation Menu"
self.title_color = Color.GREEN self.title_color = Color.GREEN
self.previous_menu: Type[BaseMenu] | None = previous_menu self.previous_menu: Type[BaseMenu] | None = previous_menu
self.klsvc = KlipperSetupService()
self.mrsvc = MoonrakerSetupService()
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu from core.menus.main_menu import MainMenu
@@ -75,10 +77,10 @@ class InstallMenu(BaseMenu):
print(menu, end="") print(menu, end="")
def install_klipper(self, **kwargs) -> None: def install_klipper(self, **kwargs) -> None:
klipper_setup.install_klipper() self.klsvc.install()
def install_moonraker(self, **kwargs) -> None: def install_moonraker(self, **kwargs) -> None:
moonraker_setup.install_moonraker() self.mrsvc.install()
def install_mainsail(self, **kwargs) -> None: def install_mainsail(self, **kwargs) -> None:
client: MainsailData = MainsailData() client: MainsailData = MainsailData()

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -16,7 +16,7 @@ from components.crowsnest.crowsnest import get_crowsnest_status
from components.klipper.klipper_utils import get_klipper_status from components.klipper.klipper_utils import get_klipper_status
from components.klipperscreen.klipperscreen import get_klipperscreen_status from components.klipperscreen.klipperscreen import get_klipperscreen_status
from components.log_uploads.menus.log_upload_menu import LogUploadMenu from components.log_uploads.menus.log_upload_menu import LogUploadMenu
from components.moonraker.moonraker_utils import get_moonraker_status from components.moonraker.utils.utils import get_moonraker_status
from components.webui_client.client_utils import ( from components.webui_client.client_utils import (
get_client_status, get_client_status,
get_current_client_config, get_current_client_config,

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -0,0 +1,162 @@
# ======================================================================= #
# Copyright (C) 2020 - 2025 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 __future__ import annotations
from typing import List, Literal, Type
from core.logger import Logger, DialogType
from core.menus import Option
from core.menus.base_menu import BaseMenu
from core.settings.kiauh_settings import KiauhSettings, Repository
from core.types.color import Color
from procedures.switch_repo import run_switch_repo_routine
from utils.input_utils import get_string_input, get_number_input, get_confirm
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class RepoSelectMenu(BaseMenu):
def __init__(
self,
name: Literal["klipper", "moonraker"],
repos: List[Repository],
previous_menu: Type[BaseMenu] | None = None,
) -> None:
super().__init__()
self.title_color = Color.CYAN
self.previous_menu = previous_menu
self.settings = KiauhSettings()
self.input_label_txt = "Select repository"
self.name = name
self.repos = repos
if self.name == "klipper":
self.title = "Klipper Repository Selection Menu"
elif self.name == "moonraker":
self.title = "Moonraker Repository Selection Menu"
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.settings_menu import SettingsMenu
self.previous_menu = (
previous_menu if previous_menu is not None else SettingsMenu
)
def set_options(self) -> None:
self.options = {}
if self.repos:
for idx, repo in enumerate(self.repos, start=1):
self.options[str(idx)] = Option(
method=self.select_repository, opt_data=repo
)
self.options["a"] = Option(method=self.add_repository)
self.options["r"] = Option(method=self.remove_repository)
self.options["b"] = Option(method=self.go_back)
def print_menu(self) -> None:
menu = "╟───────────────────────────────────────────────────────╢\n"
menu += "║ Available Repositories: ║\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
for idx, repo in enumerate(self.repos, start=1):
url = f"● Repo: {repo.url.replace('.git', '')}"
branch = f"└► Branch: {repo.branch}"
menu += f"{idx}) {Color.apply(url, Color.CYAN):<59}\n"
menu += f"{Color.apply(branch, Color.CYAN):<59}\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
menu += "║ A) Add repository ║\n"
menu += "║ R) Remove repository ║\n"
menu += "╟───────────────────────────────────────────────────────╢\n"
print(menu, end="")
def select_repository(self, **kwargs) -> None:
repo: Repository = kwargs.get("opt_data")
Logger.print_status(
f"Switching to {self.name.capitalize()}'s new source repository ..."
)
run_switch_repo_routine(self.name, repo.url, repo.branch)
def add_repository(self, **kwargs) -> None:
while True:
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Enter the repository URL",
content=[
"NOTE: There is no input validation in place, "
"please check your input for correctness",
],
)
url = get_string_input("Repository URL", allow_special_chars=True).strip()
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Enter the branch name",
content=[ "Press Enter to use the default branch (master)." ],
center_content=False,
)
branch = get_string_input("Branch", allow_special_chars=True, default="master").strip()
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Summary",
content=[
f"● URL: {url}",
f"● Branch: {branch}",
],
)
confirm = get_confirm("Save repository")
if confirm:
repo = Repository(url, branch)
if self.name == "klipper":
self.settings.klipper.repositories.append(repo)
self.settings.save()
self.repos = self.settings.klipper.repositories
else:
self.settings.moonraker.repositories.append(repo)
self.settings.save()
self.repos = self.settings.moonraker.repositories
Logger.print_ok("Repository added and saved.")
# Refresh menu to show new repo immediately and update options
self.set_options()
self.run()
break
else:
Logger.print_info("Operation cancelled by user.")
break
def remove_repository(self, **kwargs) -> None:
repos = self.repos
if not repos:
Logger.print_info("No repositories configured.")
return
repo_lines = [f"{idx}) {repo.url} [{repo.branch}]" for idx, repo in enumerate(repos, start=1)]
Logger.print_dialog(
DialogType.CUSTOM,
custom_title="Available Repositories",
content=[*repo_lines],
)
idx = get_number_input("Select the repository to remove", 1, len(repos))
removed = repos.pop(idx - 1)
if self.name == "klipper":
self.settings.klipper.repositories = repos
self.settings.save()
self.repos = self.settings.klipper.repositories
else:
self.settings.moonraker.repositories = repos
self.settings.save()
self.repos = self.settings.moonraker.repositories
Logger.print_ok(f"Removed repository: {removed.url} [{removed.branch}]")
# Refresh menu to show updated repo list and options
self.set_options()
self.run()
def go_back(self, **kwargs) -> None:
from core.menus.settings_menu import SettingsMenu
SettingsMenu().run()

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -9,22 +9,17 @@
from __future__ import annotations from __future__ import annotations
import textwrap import textwrap
from pathlib import Path from typing import Type
from typing import Literal, Tuple, Type
from components.klipper import KLIPPER_DIR, KLIPPER_REPO_URL
from components.klipper.klipper_utils import get_klipper_status from components.klipper.klipper_utils import get_klipper_status
from components.moonraker import MOONRAKER_DIR, MOONRAKER_REPO_URL from components.moonraker.utils.utils import get_moonraker_status
from components.moonraker.moonraker_utils import get_moonraker_status
from core.logger import DialogType, Logger from core.logger import DialogType, 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.settings.kiauh_settings import KiauhSettings, RepoSettings from core.menus.repo_select_menu import RepoSelectMenu
from core.settings.kiauh_settings import KiauhSettings
from core.types.color import Color from core.types.color import Color
from core.types.component_status import ComponentStatus from core.types.component_status import ComponentStatus
from procedures.switch_repo import run_switch_repo_routine
from utils.git_utils import get_repo_name
from utils.input_utils import get_confirm, get_string_input
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
@@ -39,9 +34,14 @@ class SettingsMenu(BaseMenu):
self.mainsail_unstable: bool | None = None self.mainsail_unstable: bool | None = None
self.fluidd_unstable: bool | None = None self.fluidd_unstable: bool | None = None
self.auto_backups_enabled: bool | None = None self.auto_backups_enabled: bool | None = None
self._load_settings()
print(self.klipper_status)
na: str = "Not available!"
self.kl_repo_url: str = Color.apply(na, Color.RED)
self.kl_branch: str = Color.apply(na, Color.RED)
self.mr_repo_url: str = Color.apply(na, Color.RED)
self.mr_branch: str = Color.apply(na, Color.RED)
self._load_settings()
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None: def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
from core.menus.main_menu import MainMenu from core.menus.main_menu import MainMenu
@@ -50,54 +50,39 @@ class SettingsMenu(BaseMenu):
def set_options(self) -> None: def set_options(self) -> None:
self.options = { self.options = {
"1": Option(method=self.set_klipper_repo), "1": Option(method=self.switch_klipper_repo),
"2": Option(method=self.set_moonraker_repo), "2": Option(method=self.switch_moonraker_repo),
"3": Option(method=self.toggle_mainsail_release), "3": Option(method=self.toggle_mainsail_release),
"4": Option(method=self.toggle_fluidd_release), "4": Option(method=self.toggle_fluidd_release),
"5": Option(method=self.toggle_backup_before_update), "5": Option(method=self.toggle_backup_before_update),
} }
def print_menu(self) -> None: def print_menu(self) -> None:
color = Color.CYAN
checked = f"[{Color.apply('x', Color.GREEN)}]" checked = f"[{Color.apply('x', Color.GREEN)}]"
unchecked = "[ ]" unchecked = "[ ]"
kl_repo: str = Color.apply(self.klipper_status.repo, color)
kl_branch: str = Color.apply(self.klipper_status.branch, color)
kl_owner: str = Color.apply(self.klipper_status.owner, color)
mr_repo: str = Color.apply(self.moonraker_status.repo, color)
mr_branch: str = Color.apply(self.moonraker_status.branch, color)
mr_owner: str = Color.apply(self.moonraker_status.owner, color)
o1 = checked if self.mainsail_unstable else unchecked o1 = checked if self.mainsail_unstable else unchecked
o2 = checked if self.fluidd_unstable else unchecked o2 = checked if self.fluidd_unstable else unchecked
o3 = checked if self.auto_backups_enabled else unchecked o3 = checked if self.auto_backups_enabled else unchecked
menu = textwrap.dedent( menu = textwrap.dedent(
f""" f"""
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
Klipper: 1) Switch Klipper source repository
● Repo: {kl_repo:51} ● Current repository:
● Owner: {kl_owner:51} └► Repo: {self.kl_repo_url:50}
Branch: {kl_branch:51} └► Branch: {self.kl_branch:48}
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
Moonraker: 2) Switch Moonraker source repository
● Repo: {mr_repo:51} ● Current repository:
● Owner: {mr_owner:51} └► Repo: {self.mr_repo_url:50}
Branch: {mr_branch:51} └► Branch: {self.mr_branch:48}
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ Install unstable releases: ║ ║ Install unstable releases: ║
{o1} Mainsail 3) {o1} Mainsail ║
{o2} Fluidd 4) {o2} Fluidd ║
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
║ Auto-Backup: ║ ║ Auto-Backup: ║
{o3} Automatic backup before update ║ 5) {o3} Backup before update
╟───────────────────────────────────────────────────────╢
║ 1) Set Klipper source repository ║
║ 2) Set Moonraker source repository ║
║ ║
║ 3) Toggle unstable Mainsail releases ║
║ 4) Toggle unstable Fluidd releases ║
║ ║
║ 5) Toggle automatic backups before updates ║
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢
""" """
)[1:] )[1:]
@@ -109,93 +94,35 @@ class SettingsMenu(BaseMenu):
self.mainsail_unstable = self.settings.mainsail.unstable_releases self.mainsail_unstable = self.settings.mainsail.unstable_releases
self.fluidd_unstable = self.settings.fluidd.unstable_releases self.fluidd_unstable = self.settings.fluidd.unstable_releases
# by default, we show the status of the installed repositories klipper_status: ComponentStatus = get_klipper_status()
self.klipper_status = get_klipper_status() moonraker_status: ComponentStatus = get_moonraker_status()
self.moonraker_status = get_moonraker_status()
# if the repository is not installed, we show the status of the settings from the config file
if self.klipper_status.repo == "-":
url_parts = self.settings.klipper.repo_url.split("/")
self.klipper_status.repo = url_parts[-1]
self.klipper_status.owner = url_parts[-2]
self.klipper_status.branch = self.settings.klipper.branch
if self.moonraker_status.repo == "-":
url_parts = self.settings.moonraker.repo_url.split("/")
self.moonraker_status.repo = url_parts[-1]
self.moonraker_status.owner = url_parts[-2]
self.moonraker_status.branch = self.settings.moonraker.branch
def _gather_input(self, repo_name: Literal["klipper", "moonraker"], repo_dir: Path) -> Tuple[str, str]: def trim_repo_url(repo: str) -> str:
warn_msg = [ return repo.replace(".git", "").replace("https://", "").replace("git@", "")
"There is only basic input validation in place! "
"Make sure your the input is valid and has no typos or invalid characters!"]
if repo_dir.exists(): if not klipper_status.repo == "-":
warn_msg.extend([ url = trim_repo_url(klipper_status.repo_url)
"For the change to take effect, the new repository will be cloned. " self.kl_repo_url = Color.apply(url, Color.CYAN)
"A backup of the old repository will be created.", self.kl_branch = Color.apply(klipper_status.branch, Color.CYAN)
"\n\n", if not moonraker_status.repo == "-":
"Make sure you don't have any ongoing prints running, as the services " url = trim_repo_url(moonraker_status.repo_url)
"will be restarted during this process! You will loose any ongoing print!"]) self.mr_repo_url = Color.apply(url, Color.CYAN)
self.mr_branch = Color.apply(moonraker_status.branch, Color.CYAN)
Logger.print_dialog(DialogType.ATTENTION, warn_msg) def _warn_no_repos(self, name: str) -> None:
repo = get_string_input(
"Enter new repository URL",
regex="^[\w/.:-]+$",
default=KLIPPER_REPO_URL if repo_name == "klipper" else MOONRAKER_REPO_URL,
)
branch = get_string_input(
"Enter new branch name",
regex="^.+$",
default="master"
)
return repo, branch
def _set_repo(self, repo_name: Literal["klipper", "moonraker"], repo_dir: Path) -> None:
repo_url, branch = self._gather_input(repo_name, repo_dir)
display_name = repo_name.capitalize()
Logger.print_dialog( Logger.print_dialog(
DialogType.CUSTOM, DialogType.WARNING,
[ [f"No {name} repositories configured in kiauh.cfg!"],
f"New {display_name} repository URL:", center_content=True,
f"{repo_url}",
f"New {display_name} repository branch:",
f"{branch}",
],
) )
if get_confirm("Apply changes?", allow_go_back=True): def switch_klipper_repo(self, **kwargs) -> None:
repo: RepoSettings = self.settings[repo_name] repos = self.settings.klipper.repositories
repo.repo_url = repo_url RepoSelectMenu("klipper", repos=repos, previous_menu=self.__class__).run()
repo.branch = branch
self.settings.save() def switch_moonraker_repo(self, **kwargs) -> None:
self._load_settings() repos = self.settings.moonraker.repositories
RepoSelectMenu("moonraker", repos=repos, previous_menu=self.__class__).run()
Logger.print_ok("Changes saved!")
else:
Logger.print_info(
f"Changing of {display_name} source repository canceled ..."
)
return
self._switch_repo(repo_name, repo_dir)
def _switch_repo(self, name: Literal["klipper", "moonraker"], repo_dir: Path ) -> None:
if not repo_dir.exists():
return
Logger.print_status(f"Switching to {name.capitalize()}'s new source repository ...")
repo: RepoSettings = self.settings[name]
run_switch_repo_routine(name, repo)
def set_klipper_repo(self, **kwargs) -> None:
self._set_repo("klipper", KLIPPER_DIR)
def set_moonraker_repo(self, **kwargs) -> None:
self._set_repo("moonraker", MOONRAKER_DIR)
def toggle_mainsail_release(self, **kwargs) -> None: def toggle_mainsail_release(self, **kwargs) -> None:
self.mainsail_unstable = not self.mainsail_unstable self.mainsail_unstable = not self.mainsail_unstable

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -12,16 +12,16 @@ import textwrap
from typing import Callable, List, Type from typing import Callable, List, Type
from components.crowsnest.crowsnest import get_crowsnest_status, update_crowsnest from components.crowsnest.crowsnest import get_crowsnest_status, update_crowsnest
from components.klipper.klipper_setup import update_klipper
from components.klipper.klipper_utils import ( from components.klipper.klipper_utils import (
get_klipper_status, get_klipper_status,
) )
from components.klipper.services.klipper_setup_service import KlipperSetupService
from components.klipperscreen.klipperscreen import ( from components.klipperscreen.klipperscreen import (
get_klipperscreen_status, get_klipperscreen_status,
update_klipperscreen, update_klipperscreen,
) )
from components.moonraker.moonraker_setup import update_moonraker from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
from components.moonraker.moonraker_utils import get_moonraker_status from components.moonraker.utils.utils import get_moonraker_status
from components.webui_client.client_config.client_config_setup import ( from components.webui_client.client_config.client_config_setup import (
update_client_config, update_client_config,
) )
@@ -193,10 +193,12 @@ class UpdateMenu(BaseMenu):
self.upgrade_system_packages() self.upgrade_system_packages()
def update_klipper(self, **kwargs) -> None: def update_klipper(self, **kwargs) -> None:
self._run_update_routine("klipper", update_klipper) klsvc = KlipperSetupService()
self._run_update_routine("klipper", klsvc.update)
def update_moonraker(self, **kwargs) -> None: def update_moonraker(self, **kwargs) -> None:
self._run_update_routine("moonraker", update_moonraker) mrsvc = MoonrakerSetupService()
self._run_update_routine("moonraker", mrsvc.update)
def update_mainsail(self, **kwargs) -> None: def update_mainsail(self, **kwargs) -> None:
self._run_update_routine( self._run_update_routine(

View File

@@ -1,11 +1,12 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
# # # #
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import List
@@ -23,12 +24,13 @@ class Message:
class MessageService: class MessageService:
_instance = None __cls_instance = None
__message: Message | None
def __new__(cls) -> "MessageService": def __new__(cls) -> "MessageService":
if cls._instance is None: if cls.__cls_instance is None:
cls._instance = super(MessageService, cls).__new__(cls) cls.__cls_instance = super(MessageService, cls).__new__(cls)
return cls._instance return cls.__cls_instance
def __init__(self) -> None: def __init__(self) -> None:
if not hasattr(self, "__initialized"): if not hasattr(self, "__initialized"):
@@ -36,24 +38,24 @@ class MessageService:
if self.__initialized: if self.__initialized:
return return
self.__initialized = True self.__initialized = True
self.message = None self.__message = None
def set_message(self, message: Message) -> None: def set_message(self, message: Message) -> None:
self.message = message self.__message = message
def display_message(self) -> None: def display_message(self) -> None:
if self.message is None: if self.__message is None:
return return
Logger.print_dialog( Logger.print_dialog(
title=DialogType.CUSTOM, title=DialogType.CUSTOM,
content=self.message.text, content=self.__message.text,
custom_title=self.message.title, custom_title=self.__message.title,
custom_color=self.message.color, custom_color=self.__message.color,
center_content=self.message.centered, center_content=self.__message.centered,
) )
self.__clear_message() self.__clear_message()
def __clear_message(self) -> None: def __clear_message(self) -> None:
self.message = None self.__message = None

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -8,15 +8,18 @@
# ======================================================================= # # ======================================================================= #
from __future__ import annotations from __future__ import annotations
import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, Callable, List, TypeVar
from components.klipper import KLIPPER_REPO_URL
from components.moonraker import MOONRAKER_REPO_URL
from core.backup_manager.backup_manager import BackupManager
from core.logger import DialogType, Logger from core.logger import DialogType, Logger
from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import ( from core.submodules.simple_config_parser.src.simple_config_parser.simple_config_parser import (
NoOptionError,
NoSectionError,
SimpleConfigParser, SimpleConfigParser,
) )
from utils.input_utils import get_confirm
from utils.sys_utils import kill from utils.sys_utils import kill
from kiauh import PROJECT_ROOT from kiauh import PROJECT_ROOT
@@ -24,6 +27,16 @@ from kiauh import PROJECT_ROOT
DEFAULT_CFG = PROJECT_ROOT.joinpath("default.kiauh.cfg") DEFAULT_CFG = PROJECT_ROOT.joinpath("default.kiauh.cfg")
CUSTOM_CFG = PROJECT_ROOT.joinpath("kiauh.cfg") CUSTOM_CFG = PROJECT_ROOT.joinpath("kiauh.cfg")
T = TypeVar("T")
class InvalidValueError(Exception):
"""Raised when a value is invalid for an option"""
def __init__(self, section: str, option: str, value: str):
msg = f"Invalid value '{value}' for option '{option}' in section '{section}'"
super().__init__(msg)
@dataclass @dataclass
class AppSettings: class AppSettings:
@@ -31,26 +44,40 @@ class AppSettings:
@dataclass @dataclass
class RepoSettings: class Repository:
repo_url: str | None = field(default=None) url: str
branch: str | None = field(default=None) branch: str
@dataclass
class KlipperSettings:
repositories: List[Repository] | None = field(default=None)
use_python_binary: str | None = field(default=None)
@dataclass
class MoonrakerSettings:
optional_speedups: bool | None = field(default=None)
repositories: List[Repository] | None = field(default=None)
use_python_binary: str | None = field(default=None)
@dataclass @dataclass
class WebUiSettings: class WebUiSettings:
port: str | None = field(default=None) port: int | None = field(default=None)
unstable_releases: bool | None = field(default=None) unstable_releases: bool | None = field(default=None)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class KiauhSettings: class KiauhSettings:
_instance = None __instance = None
__initialized = False
def __new__(cls, *args, **kwargs) -> "KiauhSettings": def __new__(cls, *args, **kwargs) -> "KiauhSettings":
if cls._instance is None: if cls.__instance is None:
cls._instance = super(KiauhSettings, cls).__new__(cls, *args, **kwargs) cls.__instance = super(KiauhSettings, cls).__new__(cls, *args, **kwargs)
return cls._instance return cls.__instance
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
@@ -63,20 +90,20 @@ class KiauhSettings:
return getattr(self, item) return getattr(self, item)
def __init__(self) -> None: def __init__(self) -> None:
if not hasattr(self, "__initialized"):
self.__initialized = False
if self.__initialized: if self.__initialized:
return return
self.__initialized = True self.__initialized = True
self.config = SimpleConfigParser() self.config = SimpleConfigParser()
self.kiauh = AppSettings() self.kiauh = AppSettings()
self.klipper = RepoSettings() self.klipper = KlipperSettings()
self.moonraker = RepoSettings() self.moonraker = MoonrakerSettings()
self.mainsail = WebUiSettings() self.mainsail = WebUiSettings()
self.fluidd = WebUiSettings() self.fluidd = WebUiSettings()
self._load_config() self.__read_config_set_internal_state()
# todo: refactor this, at least rename to something else!
def get(self, section: str, option: str) -> str | int | bool: def get(self, section: str, option: str) -> str | int | bool:
""" """
Get a value from the settings state by providing the section and option name as Get a value from the settings state by providing the section and option name as
@@ -94,107 +121,294 @@ class KiauhSettings:
raise raise
def save(self) -> None: def save(self) -> None:
self._set_config_options_state() self.__write_internal_state_to_cfg()
self.config.write_file(CUSTOM_CFG) self.__read_config_set_internal_state()
self._load_config()
def _load_config(self) -> None: def __read_config_set_internal_state(self) -> None:
if not CUSTOM_CFG.exists() and not DEFAULT_CFG.exists(): if not CUSTOM_CFG.exists() and not DEFAULT_CFG.exists():
self._kill() Logger.print_dialog(
DialogType.ERROR,
cfg = CUSTOM_CFG if CUSTOM_CFG.exists() else DEFAULT_CFG [
self.config.read_file(cfg) "No KIAUH configuration file found! Please make sure you have at least "
"one of the following configuration files in KIAUH's root directory:",
self._validate_cfg() "● default.kiauh.cfg",
self._apply_settings_from_file() "● kiauh.cfg",
],
def _validate_cfg(self) -> None: )
try:
self._validate_bool("kiauh", "backup_before_update")
self._validate_str("klipper", "repo_url")
self._validate_str("klipper", "branch")
self._validate_int("mainsail", "port")
self._validate_bool("mainsail", "unstable_releases")
self._validate_int("fluidd", "port")
self._validate_bool("fluidd", "unstable_releases")
except ValueError:
err = f"Invalid value for option '{self._v_option}' in section '{self._v_section}'"
Logger.print_error(err)
kill()
except NoSectionError:
err = f"Missing section '{self._v_section}' in config file"
Logger.print_error(err)
kill()
except NoOptionError:
err = f"Missing option '{self._v_option}' in section '{self._v_section}'"
Logger.print_error(err)
kill() kill()
def _validate_bool(self, section: str, option: str) -> None: # copy default config to custom config if it does not exist
self._v_section, self._v_option = (section, option) if not CUSTOM_CFG.exists():
(bool(self.config.getboolean(section, option))) shutil.copyfile(DEFAULT_CFG, CUSTOM_CFG)
def _validate_int(self, section: str, option: str) -> None: self.config.read_file(CUSTOM_CFG)
self._v_section, self._v_option = (section, option)
int(self.config.getint(section, option))
def _validate_str(self, section: str, option: str) -> None: # check if there are deprecated repo_url and branch options in the kiauh.cfg
self._v_section, self._v_option = (section, option) if self._check_deprecated_repo_config():
v = self.config.getval(section, option) self._prompt_migration_dialog()
if not v: self.__set_internal_state()
raise ValueError
def _apply_settings_from_file(self) -> None: def __set_internal_state(self) -> None:
self.kiauh.backup_before_update = self.config.getboolean( # parse Kiauh options
"kiauh", "backup_before_update" self.kiauh.backup_before_update = self.__read_from_cfg(
)
self.klipper.repo_url = self.config.getval("klipper", "repo_url")
self.klipper.branch = self.config.getval("klipper", "branch")
self.moonraker.repo_url = self.config.getval("moonraker", "repo_url")
self.moonraker.branch = self.config.getval("moonraker", "branch")
self.mainsail.port = self.config.getint("mainsail", "port")
self.mainsail.unstable_releases = self.config.getboolean(
"mainsail", "unstable_releases"
)
self.fluidd.port = self.config.getint("fluidd", "port")
self.fluidd.unstable_releases = self.config.getboolean(
"fluidd", "unstable_releases"
)
def _set_config_options_state(self) -> None:
self.config.set_option(
"kiauh", "kiauh",
"backup_before_update", "backup_before_update",
str(self.kiauh.backup_before_update), self.config.getboolean,
) False,
self.config.set_option("klipper", "repo_url", self.klipper.repo_url)
self.config.set_option("klipper", "branch", self.klipper.branch)
self.config.set_option("moonraker", "repo_url", self.moonraker.repo_url)
self.config.set_option("moonraker", "branch", self.moonraker.branch)
self.config.set_option("mainsail", "port", str(self.mainsail.port))
self.config.set_option(
"mainsail",
"unstable_releases",
str(self.mainsail.unstable_releases),
)
self.config.set_option("fluidd", "port", str(self.fluidd.port))
self.config.set_option(
"fluidd", "unstable_releases", str(self.fluidd.unstable_releases)
) )
def _kill(self) -> None: # parse Klipper options
self.klipper.use_python_binary = self.__read_from_cfg(
"klipper",
"use_python_binary",
self.config.getval,
None,
True,
)
kl_repos: List[str] = self.__read_from_cfg(
"klipper",
"repositories",
self.config.getvals,
[KLIPPER_REPO_URL],
)
self.klipper.repositories = self.__set_repo_state("klipper", kl_repos)
# parse Moonraker options
self.moonraker.use_python_binary = self.__read_from_cfg(
"moonraker",
"use_python_binary",
self.config.getval,
None,
True,
)
self.moonraker.optional_speedups = self.__read_from_cfg(
"moonraker",
"optional_speedups",
self.config.getboolean,
True,
)
mr_repos: List[str] = self.__read_from_cfg(
"moonraker",
"repositories",
self.config.getvals,
[MOONRAKER_REPO_URL],
)
self.moonraker.repositories = self.__set_repo_state("moonraker", mr_repos)
# parse Mainsail options
self.mainsail.port = self.__read_from_cfg(
"mainsail",
"port",
self.config.getint,
80,
)
self.mainsail.unstable_releases = self.__read_from_cfg(
"mainsail",
"unstable_releases",
self.config.getboolean,
False,
)
# parse Fluidd options
self.fluidd.port = self.__read_from_cfg(
"fluidd",
"port",
self.config.getint,
80,
)
self.fluidd.unstable_releases = self.__read_from_cfg(
"fluidd",
"unstable_releases",
self.config.getboolean,
False,
)
def __check_option_exists(
self, section: str, option: str, fallback: Any, silent: bool = False
) -> bool:
has_section = self.config.has_section(section)
has_option = self.config.has_option(section, option)
if not (has_section and has_option):
if not silent:
Logger.print_warn(
f"Option '{option}' in section '{section}' not defined. Falling back to '{fallback}'."
)
return False
return True
def __read_bool_from_cfg(
self,
section: str,
option: str,
fallback: bool | None = None,
silent: bool = False,
) -> bool | None:
if not self.__check_option_exists(section, option, fallback, silent):
return fallback
return self.config.getboolean(section, option, fallback)
def __read_from_cfg(
self,
section: str,
option: str,
getter: Callable[[str, str, T | None], T],
fallback: T = None,
silent: bool = False,
) -> T:
if not self.__check_option_exists(section, option, fallback, silent):
return fallback
return getter(section, option, fallback)
def __set_repo_state(self, section: str, repos: List[str]) -> List[Repository]:
_repos: List[Repository] = []
for repo in repos:
try:
if repo.strip().startswith("#") or repo.strip().startswith(";"):
continue
if "," in repo:
url, branch = repo.strip().split(",")
if not branch:
branch = "master"
else:
url = repo.strip()
branch = "master"
# url must not be empty otherwise it's considered
# as an unrecoverable, invalid configuration
if not url:
raise InvalidValueError(section, "repositories", repo)
_repos.append(Repository(url.strip(), branch.strip()))
except InvalidValueError as e:
Logger.print_error(f"Error parsing kiauh.cfg: {e}")
kill()
return _repos
def __write_internal_state_to_cfg(self) -> None:
"""Updates the config with current settings, preserving values that haven't been modified"""
if self.kiauh.backup_before_update is not None:
self.config.set_option(
"kiauh",
"backup_before_update",
str(self.kiauh.backup_before_update),
)
# Handle repositories
if self.klipper.repositories is not None:
repos = [f"{repo.url}, {repo.branch}" for repo in self.klipper.repositories]
self.config.set_option("klipper", "repositories", repos)
if self.moonraker.repositories is not None:
repos = [
f"{repo.url}, {repo.branch}" for repo in self.moonraker.repositories
]
self.config.set_option("moonraker", "repositories", repos)
# Handle Mainsail settings
if self.mainsail.port is not None:
self.config.set_option("mainsail", "port", str(self.mainsail.port))
if self.mainsail.unstable_releases is not None:
self.config.set_option(
"mainsail",
"unstable_releases",
str(self.mainsail.unstable_releases),
)
# Handle Fluidd settings
if self.fluidd.port is not None:
self.config.set_option("fluidd", "port", str(self.fluidd.port))
if self.fluidd.unstable_releases is not None:
self.config.set_option(
"fluidd", "unstable_releases", str(self.fluidd.unstable_releases)
)
self.config.write_file(CUSTOM_CFG)
def _check_deprecated_repo_config(self) -> bool:
# repo_url and branch are deprecated - 2025.03.23
for section in ["klipper", "moonraker"]:
if self.config.has_option(section, "repo_url") or self.config.has_option(
section, "branch"
):
return True
return False
def _prompt_migration_dialog(self) -> None:
migration_1: List[str] = [
"Options 'repo_url' and 'branch' are now combined into a 'repositories' option.",
"\n\n",
"● Old format:",
" [klipper]",
" repo_url: https://github.com/Klipper3d/klipper",
" branch: master",
"\n\n",
"● New format:",
" [klipper]",
" repositories:",
" https://github.com/Klipper3d/klipper, master",
]
Logger.print_dialog( Logger.print_dialog(
DialogType.ERROR, DialogType.ATTENTION,
[ [
"No KIAUH configuration file found! Please make sure you have at least " "Deprecated kiauh.cfg configuration found!",
"one of the following configuration files in KIAUH's root directory:", "KAIUH can now attempt to automatically migrate the configuration.",
"● default.kiauh.cfg", "\n\n",
"● kiauh.cfg", *migration_1,
], ],
) )
kill() if get_confirm("Migrate to the new format?"):
self._migrate_repo_config()
else:
Logger.print_dialog(
DialogType.ERROR,
[
"Please update the configuration file manually.",
],
center_content=True,
)
kill()
def _migrate_repo_config(self) -> None:
bm = BackupManager()
if not bm.backup_file(CUSTOM_CFG):
Logger.print_dialog(
DialogType.ERROR,
[
"Failed to create backup of kiauh.cfg. Aborting migration. Please migrate manually."
],
)
kill()
# run migrations
try:
# migrate deprecated repo_url and branch options - 2025.03.23
for section in ["klipper", "moonraker"]:
if not self.config.has_section(section):
continue
repo_url = self.config.getval(section, "repo_url", fallback="")
branch = self.config.getval(section, "branch", fallback="master")
if repo_url:
# create repositories option with the old values
repositories = [f"{repo_url}, {branch}\n"]
self.config.set_option(section, "repositories", repositories)
# remove deprecated options
self.config.remove_option(section, "repo_url")
self.config.remove_option(section, "branch")
Logger.print_ok(f"Successfully migrated {section} configuration")
self.config.write_file(CUSTOM_CFG)
self.config.read_file(CUSTOM_CFG) # reload config
except Exception as e:
Logger.print_error(f"Error migrating configuration: {e}")
Logger.print_error("Please migrate manually.")
kill()

View File

@@ -3,4 +3,48 @@
A custom config parser inspired by Python's configparser module. A custom config parser inspired by Python's configparser module.
Specialized for handling Klipper style config files. Specialized for handling Klipper style config files.
---
### When parsing a config file, it will be split into the following elements:
- Header: All lines before the first section
- Section: A section is defined by a line starting with a `[` and ending with a `]`
- Option: A line starting with a word, followed by a `:` or `=` and a value
- Option Block: A line starting with a word, followed by a `:` or `=` and a newline
- Comment: A line starting with a `#` or `;`
- Blank: A line containing only whitespace characters
---
### Internally, the config is stored as a dictionary of sections, each containing a header and a list of elements:
```python
config = {
"section_name": {
"header": "[section_name]\n",
"elements": [
{
"type": "comment",
"content": "# This is a comment\n"
},
{
"type": "option",
"name": "option1",
"value": "value1",
"raw": "option1: value1\n"
},
{
"type": "blank",
"content": "\n"
},
{
"type": "option_block",
"name": "option2",
"value": [
"value2",
"value3"
],
"raw": "option2:"
}
]
}
}
```

View File

@@ -36,7 +36,7 @@ extend-select = ["I"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
minversion = "8.2.1" minversion = "8.2.1"
testpaths = ["tests/**/*.py"] testpaths = ["tests/**/*.py"]
addopts = "--cov --cov-config=pyproject.toml --cov-report=html" addopts = "-svvv --cov --cov-config=pyproject.toml --cov-report=html"
[tool.coverage.run] [tool.coverage.run]
branch = true branch = true

View File

@@ -6,6 +6,7 @@
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
import re import re
from enum import Enum
# definition of section line: # definition of section line:
# - then line MUST start with an opening square bracket - it is the first section marker # - then line MUST start with an opening square bracket - it is the first section marker
@@ -60,3 +61,11 @@ BOOLEAN_STATES = {
} }
HEADER_IDENT = "#_header" HEADER_IDENT = "#_header"
INDENT = " " * 4
class LineType(Enum):
OPTION = "option"
OPTION_BLOCK = "option_block"
COMMENT = "comment"
BLANK = "blank"

View File

@@ -8,8 +8,6 @@
from __future__ import annotations from __future__ import annotations
import secrets
import string
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List from typing import Callable, Dict, List
@@ -20,7 +18,7 @@ from ..simple_config_parser.constants import (
LINE_COMMENT_RE, LINE_COMMENT_RE,
OPTION_RE, OPTION_RE,
OPTIONS_BLOCK_START_RE, OPTIONS_BLOCK_START_RE,
SECTION_RE, SECTION_RE, LineType, INDENT,
) )
_UNSET = object() _UNSET = object()
@@ -49,6 +47,13 @@ class NoOptionError(Exception):
msg = f"Option '{option}' in section '{section}' is not defined" msg = f"Option '{option}' in section '{section}' is not defined"
super().__init__(msg) super().__init__(msg)
class UnknownLineError(Exception):
"""Raised when a line is not recognized as any known type"""
def __init__(self, line: str):
msg = f"Unknown line: '{line}'"
super().__init__(msg)
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class SimpleConfigParser: class SimpleConfigParser:
@@ -59,7 +64,6 @@ class SimpleConfigParser:
self.config: Dict = {} self.config: Dict = {}
self.current_section: str | None = None self.current_section: str | None = None
self.current_opt_block: str | None = None self.current_opt_block: str | None = None
self.current_collector: str | None = None
self.in_option_block: bool = False self.in_option_block: bool = False
def _match_section(self, line: str) -> bool: def _match_section(self, line: str) -> bool:
@@ -85,28 +89,40 @@ class SimpleConfigParser:
def _parse_line(self, line: str) -> None: def _parse_line(self, line: str) -> None:
"""Parses a line and determines its type""" """Parses a line and determines its type"""
if self._match_section(line): if self._match_section(line):
self.current_collector = None
self.current_opt_block = None self.current_opt_block = None
self.current_section = SECTION_RE.match(line).group(1) self.current_section = SECTION_RE.match(line).group(1)
self.config[self.current_section] = {"_raw": line} self.config[self.current_section] = {
"header": line,
"elements": []
}
elif self._match_option(line): elif self._match_option(line):
self.current_collector = None
self.current_opt_block = None self.current_opt_block = None
option = OPTION_RE.match(line).group(1) option = OPTION_RE.match(line).group(1)
value = OPTION_RE.match(line).group(2) value = OPTION_RE.match(line).group(2)
self.config[self.current_section][option] = {"_raw": line, "value": value} self.config[self.current_section]["elements"].append({
"type": LineType.OPTION.value,
"name": option,
"value": value,
"raw": line
})
elif self._match_options_block_start(line): elif self._match_options_block_start(line):
self.current_collector = None
option = OPTIONS_BLOCK_START_RE.match(line).group(1) option = OPTIONS_BLOCK_START_RE.match(line).group(1)
self.current_opt_block = option self.current_opt_block = option
self.config[self.current_section][option] = {"_raw": line, "value": []} self.config[self.current_section]["elements"].append({
"type": LineType.OPTION_BLOCK.value,
"name": option,
"value": [],
"raw": line
})
elif self.current_opt_block is not None: elif self.current_opt_block is not None:
self.config[self.current_section][self.current_opt_block]["value"].append( # we are in an option block, so we add the line to the option's value
line for element in reversed(self.config[self.current_section]["elements"]):
) if element["type"] == LineType.OPTION_BLOCK.value and element["name"] == self.current_opt_block:
element["value"].append(line.strip()) # indentation is removed
break
elif self._match_empty_line(line) or self._match_line_comment(line): elif self._match_empty_line(line) or self._match_line_comment(line):
self.current_opt_block = None self.current_opt_block = None
@@ -116,15 +132,11 @@ class SimpleConfigParser:
if not self.current_section: if not self.current_section:
self.config.setdefault(HEADER_IDENT, []).append(line) self.config.setdefault(HEADER_IDENT, []).append(line)
else: else:
section = self.config[self.current_section] element_type = LineType.BLANK.value if self._match_empty_line(line) else LineType.COMMENT.value
self.config[self.current_section]["elements"].append({
# set the current collector to a new value, so that continuous "type": element_type,
# empty lines or comments are collected into the same collector "content": line
if not self.current_collector: })
self.current_collector = self._generate_rand_id()
section[self.current_collector] = []
section[self.current_collector].append(line)
def read_file(self, file: Path) -> None: def read_file(self, file: Path) -> None:
"""Read and parse a config file""" """Read and parse a config file"""
@@ -132,41 +144,46 @@ class SimpleConfigParser:
for line in file: for line in file:
self._parse_line(line) self._parse_line(line)
# print(json.dumps(self.config, indent=4)) def write_file(self, path: str | Path) -> None:
"""Write the config to a file"""
if path is None:
raise ValueError("File path cannot be None")
def write_file(self, file: Path) -> None: with open(path, "w", encoding="utf-8") as f:
"""Write the current config to the config file""" if HEADER_IDENT in self.config:
if not file: for line in self.config[HEADER_IDENT]:
raise ValueError("No config file specified") f.write(line)
with open(file, "w") as file: sections = self.get_sections()
self._write_header(file) for i, section in enumerate(sections):
self._write_sections(file) f.write(self.config[section]["header"])
def _write_header(self, file) -> None: for element in self.config[section]["elements"]:
"""Write the header to the config file""" if element["type"] == LineType.OPTION.value:
for line in self.config.get(HEADER_IDENT, []): f.write(element["raw"])
file.write(line) elif element["type"] == LineType.OPTION_BLOCK.value:
f.write(element["raw"])
for line in element["value"]:
f.write(INDENT + line.strip() + "\n")
elif element["type"] in [LineType.COMMENT.value, LineType.BLANK.value]:
f.write(element["content"])
else:
raise UnknownLineError(element["raw"])
def _write_sections(self, file) -> None: # Ensure file ends with a single newline
"""Write the sections to the config file""" if sections: # Only if we have any sections
for section in self.get_sections(): last_section = sections[-1]
for key, value in self.config[section].items(): last_elements = self.config[last_section]["elements"]
self._write_section_content(file, key, value)
def _write_section_content(self, file, key, value) -> None: if last_elements:
"""Write the content of a section to the config file""" last_element = last_elements[-1]
if key == "_raw": if "raw" in last_element:
file.write(value) last_line = last_element["raw"]
elif key.startswith("#_"): else: # comment or blank line
for line in value: last_line = last_element["content"]
file.write(line)
elif isinstance(value["value"], list): if not last_line.endswith("\n"):
file.write(value["_raw"]) f.write("\n")
for line in value["value"]:
file.write(line)
else:
file.write(value["_raw"])
def get_sections(self) -> List[str]: def get_sections(self) -> List[str]:
"""Return a list of all section names, but exclude any section starting with '#_'""" """Return a list of all section names, but exclude any section starting with '#_'"""
@@ -189,29 +206,40 @@ class SimpleConfigParser:
if len(self.get_sections()) >= 1: if len(self.get_sections()) >= 1:
self._check_set_section_spacing() self._check_set_section_spacing()
self.config[section] = {"_raw": f"[{section}]\n"} self.config[section] = {
"header": f"[{section}]\n",
"elements": []
}
def _check_set_section_spacing(self): def _check_set_section_spacing(self):
"""Check if there is a blank line between the last section and the new section"""
prev_section_name: str = self.get_sections()[-1] prev_section_name: str = self.get_sections()[-1]
prev_section_content: Dict = self.config[prev_section_name] prev_section = self.config[prev_section_name]
last_option_name: str = list(prev_section_content.keys())[-1] prev_elements = prev_section["elements"]
if last_option_name.startswith("#_"): if prev_elements:
last_elem_value: str = prev_section_content[last_option_name][-1] last_element = prev_elements[-1]
# if the last section is a collector, we first check if the last element # If the last element is a comment or blank line
# in the collector ends with a newline. if it does not, we append a newline. if last_element["type"] in [LineType.COMMENT.value, LineType.BLANK.value]:
# this can happen if the config file does not end with a newline. last_content = last_element["content"]
if not last_elem_value.endswith("\n"):
prev_section_content[last_option_name][-1] = f"{last_elem_value}\n"
# if the last item in a collector is not a newline, we append a newline, so # If the last element doesn't end with a newline, add one
# that the new section is seperated from the options of the previous section if not last_content.endswith("\n"):
# by a newline last_element["content"] += "\n"
if last_elem_value != "\n":
prev_section_content[last_option_name].append("\n") # If the last element is not a blank line, add a blank line
else: if last_content.strip() != "":
prev_section_content[self._generate_rand_id()] = ["\n"] prev_elements.append({
"type": "blank",
"content": "\n"
})
else:
# If the last element is an option, add a blank line
prev_elements.append({
"type": LineType.BLANK.value,
"content": "\n"
})
def remove_section(self, section: str) -> None: def remove_section(self, section: str) -> None:
"""Remove a section from the config""" """Remove a section from the config"""
@@ -219,12 +247,12 @@ class SimpleConfigParser:
def get_options(self, section: str) -> List[str]: def get_options(self, section: str) -> List[str]:
"""Return a list of all option names for a given section""" """Return a list of all option names for a given section"""
return list( options = []
filter( if self.has_section(section):
lambda option: option != "_raw" and not option.startswith("#_"), for element in self.config[section]["elements"]:
self.config[section].keys(), if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value]:
) options.append(element["name"])
) return options
def has_option(self, section: str, option: str) -> bool: def has_option(self, section: str, option: str) -> bool:
"""Check if an option exists in a section""" """Check if an option exists in a section"""
@@ -238,26 +266,55 @@ class SimpleConfigParser:
if not self.has_section(section): if not self.has_section(section):
self.add_section(section) self.add_section(section)
if not self.has_option(section, option): # Check if option already exists
self.config[section][option] = { for element in self.config[section]["elements"]:
"_raw": f"{option}:\n" if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value] and element["name"] == option:
if isinstance(value, list) # Update existing option
else f"{option}: {value}\n", if isinstance(value, list):
element["type"] = LineType.OPTION_BLOCK.value
element["value"] = value
element["raw"] = f"{option}:\n"
else:
element["type"] = LineType.OPTION.value
element["value"] = value
element["raw"] = f"{option}: {value}\n"
return
# Option doesn't exist, create new one
if isinstance(value, list):
new_element = {
"type": LineType.OPTION_BLOCK.value,
"name": option,
"value": value, "value": value,
"raw": f"{option}:\n"
} }
else: else:
opt = self.config[section][option] new_element = {
if not isinstance(value, list): "type": LineType.OPTION.value,
opt["_raw"] = opt["_raw"].replace(opt["value"], value) "name": option,
opt["value"] = value "value": value,
"raw": f"{option}: {value}\n"
}
# scan through elements to find the last option, after which we insert the new option
insert_pos = 0
elements = self.config[section]["elements"]
for i, element in enumerate(elements):
if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value]:
insert_pos = i + 1
elements.insert(insert_pos, new_element)
def remove_option(self, section: str, option: str) -> None: def remove_option(self, section: str, option: str) -> None:
"""Remove an option from a section""" """Remove an option from a section"""
self.config[section].pop(option, None) if self.has_section(section):
elements = self.config[section]["elements"]
for i, element in enumerate(elements):
if element["type"] in [LineType.OPTION.value, LineType.OPTION_BLOCK.value] and element["name"] == option:
elements.pop(i)
break
def getval( def getval(self, section: str, option: str, fallback: str | _UNSET = _UNSET) -> str:
self, section: str, option: str, fallback: str | _UNSET = _UNSET
) -> str | List[str]:
""" """
Return the value of the given option in the given section Return the value of the given option in the given section
@@ -269,7 +326,35 @@ class SimpleConfigParser:
raise NoSectionError(section) raise NoSectionError(section)
if option not in self.get_options(section): if option not in self.get_options(section):
raise NoOptionError(option, section) raise NoOptionError(option, section)
return self.config[section][option]["value"]
for element in self.config[section]["elements"]:
if element["type"] is LineType.OPTION.value and element["name"] == option:
return str(element["value"].strip().replace("\n", ""))
return ""
except (NoSectionError, NoOptionError):
if fallback is _UNSET:
raise
return fallback
def getvals(self, section: str, option: str, fallback: List[str] | _UNSET = _UNSET) -> List[str]:
"""
Return the values of the given multi-line option in the given section
If the key is not found and 'fallback' is provided, it is used as
a fallback value.
"""
try:
if section not in self.get_sections():
raise NoSectionError(section)
if option not in self.get_options(section):
raise NoOptionError(option, section)
for element in self.config[section]["elements"]:
if element["type"] is LineType.OPTION_BLOCK.value and element["name"] == option:
return [val.strip() for val in element["value"] if val.strip()]
return []
except (NoSectionError, NoOptionError): except (NoSectionError, NoOptionError):
if fallback is _UNSET: if fallback is _UNSET:
raise raise
@@ -317,9 +402,3 @@ class SimpleConfigParser:
raise ValueError( raise ValueError(
f"Cannot convert {self.getval(section, option)} to {conv.__name__}" f"Cannot convert {self.getval(section, option)} to {conv.__name__}"
) from e ) from e
def _generate_rand_id(self) -> str:
"""Generate a random id with 6 characters"""
chars = string.ascii_letters + string.digits
rand_string = "".join(secrets.choice(chars) for _ in range(12))
return f"#_{rand_string}"

View File

@@ -25,8 +25,8 @@ option_4: value_4
#option_5: value_5 #option_5: value_5
option_5 = this.is.value-5 option_5 = this.is.value-5
multi_option: multi_option:
# these are multi-line values # these are multi-line values
value_5_1 value_5_1
value_5_2 ; here is a comment value_5_2 ; here is a comment
value_5_3 value_5_3
option_5_1: value_5_1 option_5_1: value_5_1

View File

@@ -25,9 +25,9 @@ option_4: value_4
#option_5: value_5 #option_5: value_5
option_5 = this.is.value-5 option_5 = this.is.value-5
multi_option: multi_option:
# these are multi-line values # these are multi-line values
value_5_1 value_5_1
value_5_2 ; here is a comment value_5_2 ; here is a comment
value_5_3 value_5_3
option_5_1: value_5_1 option_5_1: value_5_1
# config ending with a comment # config ending with a comment

View File

@@ -25,22 +25,22 @@ option_4: value_4
#option_5: value_5 #option_5: value_5
option_5 = this.is.value-5 option_5 = this.is.value-5
multi_option: multi_option:
# these are multi-line values # these are multi-line values
value_5_1 value_5_1
value_5_2 ; here is a comment value_5_2 ; here is a comment
value_5_3 value_5_3
option_5_1: value_5_1 option_5_1: value_5_1
[gcode_macro M117] [gcode_macro M117]
rename_existing: M117.1 rename_existing: M117.1
gcode: gcode:
{% if rawparams %} {% if rawparams %}
{% set escaped_msg = rawparams.split(';', 1)[0].split('\x23', 1)[0]|replace('"', '\\"') %} {% set escaped_msg = rawparams.split(';', 1)[0].split('\x23', 1)[0]|replace('"', '\\"') %}
SET_DISPLAY_TEXT MSG="{escaped_msg}" SET_DISPLAY_TEXT MSG="{escaped_msg}"
RESPOND TYPE=command MSG="{escaped_msg}" RESPOND TYPE=command MSG="{escaped_msg}"
{% else %} {% else %}
SET_DISPLAY_TEXT SET_DISPLAY_TEXT
{% endif %} {% endif %}
# SDCard 'looping' (aka Marlin M808 commands) support # SDCard 'looping' (aka Marlin M808 commands) support
# #
@@ -48,47 +48,47 @@ gcode:
[sdcard_loop] [sdcard_loop]
[gcode_macro M486] [gcode_macro M486]
gcode: gcode:
# Parameters known to M486 are as follows: # Parameters known to M486 are as follows:
# [C<flag>] Cancel the current object # [C<flag>] Cancel the current object
# [P<index>] Cancel the object with the given index # [P<index>] Cancel the object with the given index
# [S<index>] Set the index of the current object. # [S<index>] Set the index of the current object.
# If the object with the given index has been canceled, this will cause # If the object with the given index has been canceled, this will cause
# the firmware to skip to the next object. The value -1 is used to # the firmware to skip to the next object. The value -1 is used to
# indicate something that isnt an object and shouldnt be skipped. # indicate something that isnt an object and shouldnt be skipped.
# [T<count>] Reset the state and set the number of objects # [T<count>] Reset the state and set the number of objects
# [U<index>] Un-cancel the object with the given index. This command will be # [U<index>] Un-cancel the object with the given index. This command will be
# ignored if the object has already been skipped # ignored if the object has already been skipped
{% if 'exclude_object' not in printer %} {% if 'exclude_object' not in printer %}
{action_raise_error("[exclude_object] is not enabled")} {action_raise_error("[exclude_object] is not enabled")}
{% endif %}
{% if 'T' in params %}
EXCLUDE_OBJECT RESET=1
{% for i in range(params.T | int) %}
EXCLUDE_OBJECT_DEFINE NAME={i}
{% endfor %}
{% endif %}
{% if 'C' in params %}
EXCLUDE_OBJECT CURRENT=1
{% endif %}
{% if 'P' in params %}
EXCLUDE_OBJECT NAME={params.P}
{% endif %}
{% if 'S' in params %}
{% if params.S == '-1' %}
{% if printer.exclude_object.current_object %}
EXCLUDE_OBJECT_END NAME={printer.exclude_object.current_object}
{% endif %}
{% else %}
EXCLUDE_OBJECT_START NAME={params.S}
{% endif %} {% endif %}
{% endif %}
{% if 'U' in params %} {% if 'T' in params %}
EXCLUDE_OBJECT RESET=1 NAME={params.U} EXCLUDE_OBJECT RESET=1
{% endif %}
{% for i in range(params.T | int) %}
EXCLUDE_OBJECT_DEFINE NAME={i}
{% endfor %}
{% endif %}
{% if 'C' in params %}
EXCLUDE_OBJECT CURRENT=1
{% endif %}
{% if 'P' in params %}
EXCLUDE_OBJECT NAME={params.P}
{% endif %}
{% if 'S' in params %}
{% if params.S == '-1' %}
{% if printer.exclude_object.current_object %}
EXCLUDE_OBJECT_END NAME={printer.exclude_object.current_object}
{% endif %}
{% else %}
EXCLUDE_OBJECT_START NAME={params.S}
{% endif %}
{% endif %}
{% if 'U' in params %}
EXCLUDE_OBJECT RESET=1 NAME={params.U}
{% endif %}

View File

@@ -0,0 +1,8 @@
[section_1]
# comment
option_1: value_1
option_2: value_2 ; comment
new_option: new_value
[section_2]
option_3: value_3

View File

@@ -0,0 +1,7 @@
[section_1]
# comment
option_1: value_1
option_2: value_2 ; comment
[section_2]
option_3: value_3

View File

@@ -0,0 +1,7 @@
[section_1]
# comment
option_1: value_1
option_2: value_2 ; comment
[section_2]
option_3: value_3

View File

@@ -0,0 +1,8 @@
[section_1]
# comment
option_1: value_1
option_to_remove: value_to_remove
option_2: value_2 ; comment
[section_2]
option_3: value_3

View File

@@ -0,0 +1,7 @@
[section_1]
option_1: value_1
option_2: value_2
# comment
[section_2]
option_5: value_5

View File

@@ -0,0 +1,11 @@
[section_1]
option_1: value_1
option_2: value_2
# comment
[section_to_remove]
option_3: value_3
option_4: value_4
[section_2]
option_5: value_5

View File

@@ -5,11 +5,12 @@
# # # #
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from src.simple_config_parser.constants import HEADER_IDENT from src.simple_config_parser.constants import HEADER_IDENT, LineType
from src.simple_config_parser.simple_config_parser import SimpleConfigParser from src.simple_config_parser.simple_config_parser import SimpleConfigParser
from tests.utils import load_testdata_from_file from tests.utils import load_testdata_from_file
@@ -33,16 +34,17 @@ def test_section_parsing(parser):
), f"Expected keys: {expected_keys}, got: {parser.config.keys()}" ), f"Expected keys: {expected_keys}, got: {parser.config.keys()}"
assert parser.in_option_block is False assert parser.in_option_block is False
assert parser.current_section == parser.get_sections()[-1] assert parser.current_section == parser.get_sections()[-1]
assert parser.config["section_2"]["_raw"] == "[section_2] ; comment" assert parser.config["section_2"] is not None
assert parser.config["section_2"]["header"] == "[section_2] ; comment"
assert parser.config["section_2"]["elements"] is not None
assert len(parser.config["section_2"]["elements"]) > 0
def test_option_parsing(parser): def test_option_parsing(parser):
assert parser.config["section_1"]["option_1"]["value"] == "value_1" assert parser.config["section_1"]["elements"][0]["type"] == LineType.OPTION.value
assert parser.config["section_1"]["option_1"]["_raw"] == "option_1: value_1" assert parser.config["section_1"]["elements"][0]["name"] == "option_1"
assert parser.config["section_3"]["option_3"]["value"] == "value_3" assert parser.config["section_1"]["elements"][0]["value"] == "value_1"
assert ( assert parser.config["section_1"]["elements"][0]["raw"] == "option_1: value_1"
parser.config["section_3"]["option_3"]["_raw"] == "option_3: value_3 # comment"
)
def test_header_parsing(parser): def test_header_parsing(parser):
@@ -51,12 +53,27 @@ def test_header_parsing(parser):
assert len(header) > 0 assert len(header) > 0
def test_collector_parsing(parser): def test_option_block_parsing(parser):
section = "section_2" section = "section number 5"
section_content = list(parser.config[section].keys()) option_block = None
coll_name = [name for name in section_content if name.startswith("#_")][0] for element in parser.config[section]["elements"]:
collector = parser.config[section][coll_name] if (element["type"] == LineType.OPTION_BLOCK.value and
assert collector is not None element["name"] == "multi_option"):
assert isinstance(collector, list) option_block = element
assert len(collector) > 0 break
assert "; comment" in collector
assert option_block is not None, "multi_option block not found"
assert option_block["type"] == LineType.OPTION_BLOCK.value
assert option_block["name"] == "multi_option"
assert option_block["raw"] == "multi_option:"
expected_values = [
"# these are multi-line values",
"value_5_1",
"value_5_2 ; here is a comment",
"value_5_3"
]
assert option_block["value"] == expected_values, (
f"Expected values: {expected_values}, "
f"got: {option_block['value']}"
)

View File

@@ -8,6 +8,7 @@
import pytest import pytest
from src.simple_config_parser.constants import LineType
from src.simple_config_parser.simple_config_parser import ( from src.simple_config_parser.simple_config_parser import (
NoOptionError, NoOptionError,
NoSectionError, NoSectionError,
@@ -50,7 +51,7 @@ def test_getval(parser):
assert parser.getval("section_2", "option_2") == "value_2" assert parser.getval("section_2", "option_2") == "value_2"
# test multiline option values # test multiline option values
ml_val = parser.getval("section number 5", "multi_option") ml_val = parser.getvals("section number 5", "multi_option")
assert isinstance(ml_val, list) assert isinstance(ml_val, list)
assert len(ml_val) > 0 assert len(ml_val) > 0
@@ -148,13 +149,11 @@ def test_getfloat_fallback(parser):
def test_set_existing_option(parser): def test_set_existing_option(parser):
parser.set_option("section_1", "new_option", "new_value") parser.set_option("section_1", "new_option", "new_value")
assert parser.getval("section_1", "new_option") == "new_value" assert parser.getval("section_1", "new_option") == "new_value"
assert parser.config["section_1"]["new_option"]["_raw"] == "new_option: new_value\n" assert parser.config["section_1"]["elements"][4] is not None
assert parser.config["section_1"]["elements"][4]["type"] == LineType.OPTION.value
parser.set_option("section_1", "new_option", "new_value_2") assert parser.config["section_1"]["elements"][4]["name"] == "new_option"
assert parser.getval("section_1", "new_option") == "new_value_2" assert parser.config["section_1"]["elements"][4]["value"] == "new_value"
assert ( assert parser.config["section_1"]["elements"][4]["raw"] == "new_option: new_value\n"
parser.config["section_1"]["new_option"]["_raw"] == "new_option: new_value_2\n"
)
def test_set_new_option(parser): def test_set_new_option(parser):
@@ -165,12 +164,21 @@ def test_set_new_option(parser):
assert parser.getval("new_section", "very_new_option") == "very_new_value" assert parser.getval("new_section", "very_new_option") == "very_new_value"
parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"]) parser.set_option("section_2", "array_option", ["value_1", "value_2", "value_3"])
assert parser.getval("section_2", "array_option") == [ assert parser.getvals("section_2", "array_option") == [
"value_1", "value_1",
"value_2", "value_2",
"value_3", "value_3",
] ]
assert parser.config["section_2"]["array_option"]["_raw"] == "array_option:\n"
assert parser.config["section_2"]["elements"][1] is not None
assert parser.config["section_2"]["elements"][1]["type"] == LineType.OPTION_BLOCK.value
assert parser.config["section_2"]["elements"][1]["name"] == "array_option"
assert parser.config["section_2"]["elements"][1]["value"] == [
"value_1",
"value_2",
"value_3",
]
assert parser.config["section_2"]["elements"][1]["raw"] == "array_option:\n"
def test_remove_option(parser): def test_remove_option(parser):

View File

@@ -41,16 +41,15 @@ def test_add_section(parser):
new_section = parser.config["new_section"] new_section = parser.config["new_section"]
assert isinstance(new_section, dict) assert isinstance(new_section, dict)
assert new_section["_raw"] == "[new_section]\n" assert new_section["header"] == "[new_section]\n"
assert new_section["elements"] is not None
# this should be the collector, added by the parser before assert new_section["elements"] == []
# then second section was added
assert list(new_section.keys())[-1].startswith("#_")
assert "\n" in new_section[list(new_section.keys())[-1]]
new_section2 = parser.config["new_section2"] new_section2 = parser.config["new_section2"]
assert isinstance(new_section2, dict) assert isinstance(new_section2, dict)
assert new_section2["_raw"] == "[new_section2]\n" assert new_section2["header"] == "[new_section2]\n"
assert new_section2["elements"] is not None
assert new_section2["elements"] == []
def test_add_section_duplicate(parser): def test_add_section_duplicate(parser):

View File

@@ -39,3 +39,81 @@ def test_write_to_file(tmp_path):
with open(TEST_DATA_PATH, "r") as original, open(tmp_file, "r") as written: with open(TEST_DATA_PATH, "r") as original, open(tmp_file, "r") as written:
assert original.read() == written.read() assert original.read() == written.read()
def test_remove_option_and_write(tmp_path):
# Setup paths
test_dir = BASE_DIR.joinpath("write_tests/remove_option")
input_file = test_dir.joinpath("input.cfg")
expected_file = test_dir.joinpath("expected.cfg")
output_file = Path(tmp_path).joinpath("output.cfg")
# Read input file and remove option
parser = SimpleConfigParser()
parser.read_file(input_file)
parser.remove_option("section_1", "option_to_remove")
# Write modified config
parser.write_file(output_file)
# parser.write_file(test_dir.joinpath("output.cfg"))
# Compare with expected output
with open(expected_file, "r") as expected, open(output_file, "r") as actual:
assert expected.read() == actual.read()
# Additional verification
parser2 = SimpleConfigParser()
parser2.read_file(output_file)
assert not parser2.has_option("section_1", "option_to_remove")
def test_remove_section_and_write(tmp_path):
# Setup paths
test_dir = BASE_DIR.joinpath("write_tests/remove_section")
input_file = test_dir.joinpath("input.cfg")
expected_file = test_dir.joinpath("expected.cfg")
output_file = Path(tmp_path).joinpath("output.cfg")
# Read input file and remove section
parser = SimpleConfigParser()
parser.read_file(input_file)
parser.remove_section("section_to_remove")
# Write modified config
parser.write_file(output_file)
# parser.write_file(test_dir.joinpath("output.cfg"))
# Compare with expected output
with open(expected_file, "r") as expected, open(output_file, "r") as actual:
assert expected.read() == actual.read()
# Additional verification
parser2 = SimpleConfigParser()
parser2.read_file(output_file)
assert not parser2.has_section("section_to_remove")
assert "section_1" in parser2.get_sections()
assert "section_2" in parser2.get_sections()
def test_add_option_and_write(tmp_path):
# Setup paths
test_dir = BASE_DIR.joinpath("write_tests/add_option")
input_file = test_dir.joinpath("input.cfg")
expected_file = test_dir.joinpath("expected.cfg")
output_file = Path(tmp_path).joinpath("output.cfg")
# Read input file and add option
parser = SimpleConfigParser()
parser.read_file(input_file)
parser.set_option("section_1", "new_option", "new_value")
# Write modified config
parser.write_file(output_file)
# parser.write_file(test_dir.joinpath("output.cfg"))
# Compare with expected output
with open(expected_file, "r") as expected, open(output_file, "r") as actual:
assert expected.read() == actual.read()
# Additional verification
parser2 = SimpleConfigParser()
parser2.read_file(output_file)
assert parser2.has_option("section_1", "new_option")
assert parser2.getval("section_1", "new_option") == "new_value"

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -25,6 +25,7 @@ class ComponentStatus:
status: StatusCode status: StatusCode
owner: str | None = None owner: str | None = None
repo: str | None = None repo: str | None = None
repo_url: str | None = None
branch: str = "" branch: str = ""
local: str | None = None local: str | None = None
remote: str | None = None remote: str | None = None

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #
@@ -143,6 +143,31 @@ class ExtensionSubmenu(BaseMenu):
""" """
)[1:] )[1:]
menu += f"{description_text}\n" menu += f"{description_text}\n"
# add links if available
website: str = (self.extension.metadata.get("website") or "").strip()
repo: str = (self.extension.metadata.get("repo") or "").strip()
if website or repo:
links_lines: List[str] = ["Links:"]
if website:
links_lines.append(f"{website}")
if repo:
links_lines.append(f"{repo}")
links_text = Logger.format_content(
links_lines,
line_width,
border_left="",
border_right="",
)
menu += textwrap.dedent(
"""
╟───────────────────────────────────────────────────────╢
"""
)[1:]
menu += f"{links_text}\n"
menu += textwrap.dedent( menu += textwrap.dedent(
""" """
╟───────────────────────────────────────────────────────╢ ╟───────────────────────────────────────────────────────╢

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -1,5 +1,5 @@
# ======================================================================= # # ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> # # Copyright (C) 2020 - 2025 Dominik Willner <th33xitus@gmail.com> #
# # # #
# This file is part of KIAUH - Klipper Installation And Update Helper # # This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh # # https://github.com/dw-0/kiauh #

View File

@@ -4,6 +4,7 @@
"module": "gcode_shell_cmd_extension", "module": "gcode_shell_cmd_extension",
"maintained_by": "dw-0", "maintained_by": "dw-0",
"display_name": "G-Code Shell Command", "display_name": "G-Code Shell Command",
"description": ["Run a shell commands from gcode."] "description": ["Run a shell commands from gcode."],
"updates": false
} }
} }

View File

@@ -10,11 +10,11 @@
# ======================================================================= # # ======================================================================= #
import os import os
import shutil
import subprocess import subprocess
from pathlib import Path
from core.constants import SYSTEMD from core.constants import SYSTEMD
from core.logger import Logger from core.logger import Logger
from pathlib import Path
from extensions.base_extension import BaseExtension from extensions.base_extension import BaseExtension
from extensions.klipper_backup import ( from extensions.klipper_backup import (
KLIPPERBACKUP_CONFIG_DIR, KLIPPERBACKUP_CONFIG_DIR,
@@ -29,7 +29,6 @@ from utils.sys_utils import cmd_sysctl_manage, remove_system_service, unit_file_
class KlipperbackupExtension(BaseExtension): class KlipperbackupExtension(BaseExtension):
def remove_extension(self, **kwargs) -> None: def remove_extension(self, **kwargs) -> None:
if not check_file_exist(KLIPPERBACKUP_DIR): if not check_file_exist(KLIPPERBACKUP_DIR):
Logger.print_info("Extension does not seem to be installed! Skipping ...") Logger.print_info("Extension does not seem to be installed! Skipping ...")
@@ -48,29 +47,44 @@ class KlipperbackupExtension(BaseExtension):
cmd_sysctl_manage("daemon-reload") cmd_sysctl_manage("daemon-reload")
cmd_sysctl_manage("reset-failed") cmd_sysctl_manage("reset-failed")
else: else:
Logger.print_error(f"Unknown unit type {unit_type} of {full_service_name}") Logger.print_error(
f"Unknown unit type {unit_type} of {full_service_name}"
)
except: except:
Logger.print_error(f"Failed to remove {full_service_name}: {str(e)}") Logger.print_error(f"Failed to remove {full_service_name}: {str(e)}")
def check_crontab_entry(entry) -> bool: def check_crontab_entry(entry) -> bool:
try: try:
crontab_content = subprocess.check_output(["crontab", "-l"], stderr=subprocess.DEVNULL, text=True) crontab_content = subprocess.check_output(
["crontab", "-l"], stderr=subprocess.DEVNULL, text=True
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return False return False
return any(entry in line for line in crontab_content.splitlines()) return any(entry in line for line in crontab_content.splitlines())
def remove_moonraker_entry(): def remove_moonraker_entry():
original_file_path = MOONRAKER_CONF original_file_path = MOONRAKER_CONF
comparison_file_path = os.path.join(str(KLIPPERBACKUP_DIR), "install-files", "moonraker.conf") comparison_file_path = os.path.join(
if not (os.path.exists(original_file_path) and os.path.exists(comparison_file_path)): str(KLIPPERBACKUP_DIR), "install-files", "moonraker.conf"
)
if not (
os.path.exists(original_file_path)
and os.path.exists(comparison_file_path)
):
return False return False
with open(original_file_path, "r") as original_file, open(comparison_file_path, "r") as comparison_file: with open(original_file_path, "r") as original_file, open(
comparison_file_path, "r"
) as comparison_file:
original_content = original_file.read() original_content = original_file.read()
comparison_content = comparison_file.read() comparison_content = comparison_file.read()
if comparison_content in original_content: if comparison_content in original_content:
Logger.print_status("Removing Klipper-Backup moonraker entry ...") Logger.print_status("Removing Klipper-Backup moonraker entry ...")
modified_content = original_content.replace(comparison_content, "").strip() modified_content = original_content.replace(
modified_content = "\n".join(line for line in modified_content.split("\n") if line.strip()) comparison_content, ""
).strip()
modified_content = "\n".join(
line for line in modified_content.split("\n") if line.strip()
)
with open(original_file_path, "w") as original_file: with open(original_file_path, "w") as original_file:
original_file.write(modified_content) original_file.write(modified_content)
Logger.print_ok("Klipper-Backup moonraker entry successfully removed!") Logger.print_ok("Klipper-Backup moonraker entry successfully removed!")
@@ -79,7 +93,11 @@ class KlipperbackupExtension(BaseExtension):
if get_confirm("Do you really want to remove the extension?", True, False): if get_confirm("Do you really want to remove the extension?", True, False):
# Remove systemd timer and services # Remove systemd timer and services
service_names = ["klipper-backup-on-boot", "klipper-backup-filewatch", "klipper-backup"] service_names = [
"klipper-backup-on-boot",
"klipper-backup-filewatch",
"klipper-backup",
]
unit_types = ["timer", "service"] unit_types = ["timer", "service"]
for service_name in service_names: for service_name in service_names:
@@ -91,10 +109,23 @@ class KlipperbackupExtension(BaseExtension):
try: try:
if check_crontab_entry("/klipper-backup/script.sh"): if check_crontab_entry("/klipper-backup/script.sh"):
Logger.print_status("Removing Klipper-Backup crontab entry ...") Logger.print_status("Removing Klipper-Backup crontab entry ...")
crontab_content = subprocess.check_output(["crontab", "-l"], text=True) crontab_content = subprocess.check_output(
modified_content = "\n".join(line for line in crontab_content.splitlines() if "/klipper-backup/script.sh" not in line) ["crontab", "-l"], text=True
subprocess.run(["crontab", "-"], input=modified_content + "\n", text=True, check=True) )
Logger.print_ok("Klipper-Backup crontab entry successfully removed!") modified_content = "\n".join(
line
for line in crontab_content.splitlines()
if "/klipper-backup/script.sh" not in line
)
subprocess.run(
["crontab", "-"],
input=modified_content + "\n",
text=True,
check=True,
)
Logger.print_ok(
"Klipper-Backup crontab entry successfully removed!"
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
Logger.print_error("Unable to remove the Klipper-Backup cron entry") Logger.print_error("Unable to remove the Klipper-Backup cron entry")
@@ -102,7 +133,9 @@ class KlipperbackupExtension(BaseExtension):
try: try:
remove_moonraker_entry() remove_moonraker_entry()
except: except:
Logger.print_error("Unable to remove the Klipper-Backup moonraker entry") Logger.print_error(
"Unable to remove the Klipper-Backup moonraker entry"
)
# Remove Klipper-backup extension # Remove Klipper-backup extension
Logger.print_status("Removing Klipper-Backup extension ...") Logger.print_status("Removing Klipper-Backup extension ...")
@@ -112,7 +145,7 @@ class KlipperbackupExtension(BaseExtension):
remove_with_sudo(KLIPPERBACKUP_CONFIG_DIR) remove_with_sudo(KLIPPERBACKUP_CONFIG_DIR)
Logger.print_ok("Extension Klipper-Backup successfully removed!") Logger.print_ok("Extension Klipper-Backup successfully removed!")
except: except:
Logger.print_error(f"Unable to remove Klipper-Backup extension") Logger.print_error("Unable to remove Klipper-Backup extension")
def install_extension(self, **kwargs) -> None: def install_extension(self, **kwargs) -> None:
if not KLIPPERBACKUP_DIR.exists(): if not KLIPPERBACKUP_DIR.exists():

Some files were not shown because too many files have changed in this diff Show More