mirror of
https://github.com/dw-0/kiauh.git
synced 2026-08-03 21:07:53 +05:00
Compare commits
877
Commits
v2.0.0
..
a78a6c76f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78a6c76f5 | ||
|
|
b6a08ce4e5 | ||
|
|
a3f9ee1faf | ||
|
|
477d737489 | ||
|
|
9b93450d98 | ||
|
|
adf3e292fb | ||
|
|
58e3eb9f06 | ||
|
|
09dd8298f7 | ||
|
|
93e7bb7212 | ||
|
|
e4eb36d72f | ||
|
|
8b9d172805 | ||
|
|
6aa170fd68 | ||
|
|
861cb2bff4 | ||
|
|
7b5522ac94 | ||
|
|
a42b730688 | ||
|
|
77d6c87aa0 | ||
|
|
b893ff14f7 | ||
|
|
43b0994ac5 | ||
|
|
040edc1d4f | ||
|
|
750dba1dbe | ||
|
|
6f4b471008 | ||
|
|
5077765fd6 | ||
|
|
9f97ae6c2a | ||
|
|
b90a8f13b1 | ||
|
|
ea1b794afd | ||
|
|
647a7d9400 | ||
|
|
aa0609d143 | ||
|
|
2175bd55f9 | ||
|
|
a935e67431 | ||
|
|
2f41e52189 | ||
|
|
757344128a | ||
|
|
7ca08f9b30 | ||
|
|
308079a821 | ||
|
|
09a5d96b63 | ||
|
|
1f9d4c823a | ||
|
|
c8df9427b3 | ||
|
|
5414aba299 | ||
|
|
80948edbb4 | ||
|
|
a455edba93 | ||
|
|
810ab3a2fa | ||
|
|
6c9a78496a | ||
|
|
123ccde378 | ||
|
|
45fde808d2 | ||
|
|
8ba134f574 | ||
|
|
f951936b20 | ||
|
|
657d919378 | ||
|
|
24c9b9daa9 | ||
|
|
4c511017f1 | ||
|
|
372bab8847 | ||
|
|
d5062d41de | ||
|
|
e9459bd68e | ||
|
|
ee460663c9 | ||
|
|
6f0e0146ef | ||
|
|
229f317025 | ||
|
|
48c0ae7227 | ||
|
|
9c7b5fcb10 | ||
|
|
191bdd4874 | ||
|
|
ae0a6b697e | ||
|
|
b6521fd721 | ||
|
|
62b0f4f0f5 | ||
|
|
fa9a032aad | ||
|
|
5241d9c21f | ||
|
|
31150c98e2 | ||
|
|
3317114780 | ||
|
|
8851bd68f8 | ||
|
|
9168ad88a6 | ||
|
|
03c0d46a2e | ||
|
|
8a8afc60ee | ||
|
|
5b68710b23 | ||
|
|
6cee0252ee | ||
|
|
aff63665de | ||
|
|
1ed1e0fc4c | ||
|
|
81ac102644 | ||
|
|
89b48168f4 | ||
|
|
195b7fa926 | ||
|
|
12919c7140 | ||
|
|
e590f668e6 | ||
|
|
075f2d384b | ||
|
|
afdde34721 | ||
|
|
393dd1d5bf | ||
|
|
8170057434 | ||
|
|
985b66d41f | ||
|
|
f95d2586bf | ||
|
|
f5141e7eff | ||
|
|
33113e72e9 | ||
|
|
6f59fd06aa | ||
|
|
56ea43ccb6 | ||
|
|
25e22c993f | ||
|
|
ead521b377 | ||
|
|
3c952ccc12 | ||
|
|
c8f713c00e | ||
|
|
95cf809378 | ||
|
|
c91816d13f | ||
|
|
1a6f06eaf2 | ||
|
|
ea8621af0c | ||
|
|
88742ab496 | ||
|
|
b99e6612e2 | ||
|
|
cf4e915430 | ||
|
|
c901cd1fdf | ||
|
|
da3c37a872 | ||
|
|
8f436646cd | ||
|
|
760f131d1c | ||
|
|
41804f0eaa | ||
|
|
d3c9bcc38c | ||
|
|
7fc36f3e68 | ||
|
|
a4942b9404 | ||
|
|
9e0a8a0081 | ||
|
|
6082528628 | ||
|
|
9e92e4a36a | ||
|
|
7e8f1f3d81 | ||
|
|
234cf2c751 | ||
|
|
3bc98eed13 | ||
|
|
777f5e45e7 | ||
|
|
acf0faf158 | ||
|
|
5c219ec544 | ||
|
|
70055e891e | ||
|
|
e3a0a9dec0 | ||
|
|
1cf81377ee | ||
|
|
aa4ea99c5c | ||
|
|
20ffc82a04 | ||
|
|
0becf9d574 | ||
|
|
ed1bfcdeb4 | ||
|
|
033916216c | ||
|
|
d8f47c0960 | ||
|
|
4978f22101 | ||
|
|
8330f90b56 | ||
|
|
2a08e3eb15 | ||
|
|
a2a3e92b50 | ||
|
|
a58288e7e3 | ||
|
|
3852464ab7 | ||
|
|
d9626adc98 | ||
|
|
4ae5a37ec6 | ||
|
|
935f81aab6 | ||
|
|
b02df9a1e0 | ||
|
|
dbbc87f18e | ||
|
|
243ea6582a | ||
|
|
91cba3637e | ||
|
|
3fc190ff25 | ||
|
|
6ff45aab41 | ||
|
|
b9c9feef3c | ||
|
|
d37d047aaa | ||
|
|
a3fb57aee3 | ||
|
|
8aee23830a | ||
|
|
dd14de9a41 | ||
|
|
1ca1e8ff6f | ||
|
|
12127efa21 | ||
|
|
66a5cdf9b1 | ||
|
|
9b1aba207c | ||
|
|
e274e3c00d | ||
|
|
dd99b0e1a6 | ||
|
|
a616876ace | ||
|
|
4925021aa8 | ||
|
|
e63d9d67ec | ||
|
|
106bf7675f | ||
|
|
a63cf8c9d9 | ||
|
|
02ed3e7da0 | ||
|
|
4427ae94af | ||
|
|
81b7b156b9 | ||
|
|
2df364512b | ||
|
|
dfa0036326 | ||
|
|
425d86a12f | ||
|
|
ff6162d799 | ||
|
|
674c174224 | ||
|
|
a368331693 | ||
|
|
406b64d1e5 | ||
|
|
1b5691f2f5 | ||
|
|
e7eae5a0d1 | ||
|
|
dc561a562c | ||
|
|
55cfe124b2 | ||
|
|
43d6598be6 | ||
|
|
dc026a7a2b | ||
|
|
ac54d04b40 | ||
|
|
c19364360c | ||
|
|
2e6c66e524 | ||
|
|
cd8003add9 | ||
|
|
1f75395063 | ||
|
|
6e1bffa975 | ||
|
|
a8a73249a5 | ||
|
|
4138c71920 | ||
|
|
ec3f93eeda | ||
|
|
afeb2bf02e | ||
|
|
4b17c68454 | ||
|
|
df414ce37e | ||
|
|
975629f097 | ||
|
|
fd2910ba67 | ||
|
|
6b6607c5ab | ||
|
|
b604d93d0c | ||
|
|
7e87f8af32 | ||
|
|
29b5ab00cd | ||
|
|
4cf523a758 | ||
|
|
694a4c20c5 | ||
|
|
a54514c400 | ||
|
|
1d06bf76f3 | ||
|
|
e438081c35 | ||
|
|
9f50f6fdd7 | ||
|
|
0ee0fa3325 | ||
|
|
8547942986 | ||
|
|
d33ac6b15a | ||
|
|
6cd9133a15 | ||
|
|
a929c6983d | ||
|
|
bce92001a6 | ||
|
|
7993b98ee1 | ||
|
|
62296e112e | ||
|
|
a374ac8fac | ||
|
|
f2691f33d3 | ||
|
|
d800d356ca | ||
|
|
b6c6edb622 | ||
|
|
099d47df2f | ||
|
|
ba1cdb3739 | ||
|
|
8e7d4db988 | ||
|
|
8f960495ba | ||
|
|
095823bf28 | ||
|
|
397038e43e | ||
|
|
061e222664 | ||
|
|
3f5ff50d69 | ||
|
|
5ebe941125 | ||
|
|
f5eb9486cc | ||
|
|
7a9e752f9c | ||
|
|
30bc56b198 | ||
|
|
b2567995de | ||
|
|
6fcd7a3f08 | ||
|
|
25dfbb83df | ||
|
|
899b204dc7 | ||
|
|
5cf4b018fc | ||
|
|
ae9d1b98da | ||
|
|
16d3388ff2 | ||
|
|
b88d0085ba | ||
|
|
0b6613e464 | ||
|
|
d99cda544a | ||
|
|
a50dce20de | ||
|
|
f45da66e9e | ||
|
|
2822499344 | ||
|
|
c777ba3e6b | ||
|
|
9f410450d7 | ||
|
|
0497d49066 | ||
|
|
229da227b0 | ||
|
|
65854c8da6 | ||
|
|
5985646633 | ||
|
|
979c39dc02 | ||
|
|
197058bd00 | ||
|
|
d3b5122ebb | ||
|
|
8ce4daf403 | ||
|
|
b0a65fe14e | ||
|
|
98866caefa | ||
|
|
345b7b66a3 | ||
|
|
8eb2924832 | ||
|
|
5d7debd65e | ||
|
|
7df3dd489f | ||
|
|
0cd058320f | ||
|
|
bcbb185bd7 | ||
|
|
477f3ca72c | ||
|
|
c19acb1694 | ||
|
|
8228943850 | ||
|
|
5b890fb0fb | ||
|
|
7989cec8d4 | ||
|
|
858301aa9a | ||
|
|
ae9e79c579 | ||
|
|
1215446a6c | ||
|
|
8526acf8b6 | ||
|
|
cc27aaec7c | ||
|
|
1e9493461c | ||
|
|
31616ebad5 | ||
|
|
faf56ed1b1 | ||
|
|
d6837af2a2 | ||
|
|
afe6f7499a | ||
|
|
e3ed223b5c | ||
|
|
fd27db28d4 | ||
|
|
68a02ad3f5 | ||
|
|
99b7672dc9 | ||
|
|
bb3ec79756 | ||
|
|
ce595abd60 | ||
|
|
c79dc280e3 | ||
|
|
7aa186e8b9 | ||
|
|
8493269c6f | ||
|
|
150ef0142f | ||
|
|
f70faa52cc | ||
|
|
e796f74640 | ||
|
|
2c9f5bed60 | ||
|
|
e9c23ca93e | ||
|
|
67afa26ed7 | ||
|
|
54be7e4e21 | ||
|
|
811c071b74 | ||
|
|
6116fc92cf | ||
|
|
5524a40f04 | ||
|
|
cb3661b8b5 | ||
|
|
2cec90b29c | ||
|
|
d2c009df9a | ||
|
|
046178f801 | ||
|
|
442980dbd0 | ||
|
|
798e56f4dc | ||
|
|
9d90daec7f | ||
|
|
f25726cfed | ||
|
|
f46b099b74 | ||
|
|
03be46f012 | ||
|
|
c11e628c55 | ||
|
|
4c8d43e365 | ||
|
|
9d7144b493 | ||
|
|
6df388f42b | ||
|
|
1d7fb010af | ||
|
|
d4207d710c | ||
|
|
6cb8d70b63 | ||
|
|
ae011963da | ||
|
|
491d6f40bb | ||
|
|
8bbe2f79ea | ||
|
|
0bdf61a714 | ||
|
|
b07a83c8ad | ||
|
|
39e22acbed | ||
|
|
8ba46fa4ac | ||
|
|
d6b95c9d10 | ||
|
|
f3a769e03e | ||
|
|
646e5acd3a | ||
|
|
fcf059df73 | ||
|
|
4bf9e8f0a8 | ||
|
|
dd58229fee | ||
|
|
6c4635fa4e | ||
|
|
4517415e9d | ||
|
|
5c45bc7617 | ||
|
|
d8ce465126 | ||
|
|
2f8c95a8c7 | ||
|
|
4c083ceade | ||
|
|
259a6919f0 | ||
|
|
4f7a49d85a | ||
|
|
005a5061a7 | ||
|
|
634d795557 | ||
|
|
a14e321df9 | ||
|
|
1682642e47 | ||
|
|
7e9d18b54c | ||
|
|
d049d4c770 | ||
|
|
108cda3cd6 | ||
|
|
d7de58f538 | ||
|
|
572afa0396 | ||
|
|
63a5e1e323 | ||
|
|
8c68eaa995 | ||
|
|
e8c0b3cf39 | ||
|
|
cfad7a1fb0 | ||
|
|
4113732daa | ||
|
|
95808a0d5b | ||
|
|
e551c02507 | ||
|
|
1f40686ea1 | ||
|
|
9b3d96545b | ||
|
|
a632fae8f6 | ||
|
|
4e3a701db4 | ||
|
|
0c760b5aa2 | ||
|
|
3bc2f3b498 | ||
|
|
b92cfc3984 | ||
|
|
01790b5c11 | ||
|
|
8c7891e360 | ||
|
|
852f7c056a | ||
|
|
8cffd07aef | ||
|
|
40745e90df | ||
|
|
a43645cca0 | ||
|
|
4d834db5df | ||
|
|
771191ab69 | ||
|
|
7afe943ecc | ||
|
|
b06c17c184 | ||
|
|
3af46b45ee | ||
|
|
b58e79634c | ||
|
|
1ef9b0f58f | ||
|
|
8333ae1dc4 | ||
|
|
e0ae312a9e | ||
|
|
4ce1ce72d3 | ||
|
|
60842a330d | ||
|
|
05a59e9261 | ||
|
|
36a8757cfd | ||
|
|
fe4625d3e1 | ||
|
|
19ddf3e023 | ||
|
|
ba888b1f97 | ||
|
|
0284a36e7f | ||
|
|
22f705e06c | ||
|
|
4c34245da0 | ||
|
|
f7cb3d6c97 | ||
|
|
aaf4f7dd5c | ||
|
|
26bac791aa | ||
|
|
aa4bdfc7b2 | ||
|
|
311f3be864 | ||
|
|
511df1a889 | ||
|
|
8d3ddc273a | ||
|
|
f231fa9c69 | ||
|
|
9b6925e9c4 | ||
|
|
7f8ee7939c | ||
|
|
4d4c49d4c9 | ||
|
|
7692227946 | ||
|
|
3da993a67c | ||
|
|
6b74c59d15 | ||
|
|
9cd27f7052 | ||
|
|
fc4fe130cd | ||
|
|
2a46b00cda | ||
|
|
560186a40b | ||
|
|
75bca847f8 | ||
|
|
bb1f2eadca | ||
|
|
6d87716b1d | ||
|
|
1e8c379623 | ||
|
|
6a8991d51e | ||
|
|
fb4367bb41 | ||
|
|
9463b719e4 | ||
|
|
65bf3d5251 | ||
|
|
68327262fc | ||
|
|
14ef39b87c | ||
|
|
969d3b5dab | ||
|
|
05842f8e1d | ||
|
|
39219c105e | ||
|
|
7984c28fe5 | ||
|
|
b44e855a98 | ||
|
|
52ab909ba5 | ||
|
|
4d7e10e5c3 | ||
|
|
d3726733e5 | ||
|
|
765f016ea2 | ||
|
|
5deb987b8a | ||
|
|
47d1321979 | ||
|
|
b47a9cf7ed | ||
|
|
a9f23e9b23 | ||
|
|
814acbe92a | ||
|
|
991dd79d01 | ||
|
|
40875dfe49 | ||
|
|
806c6fd275 | ||
|
|
e9706b52d8 | ||
|
|
412e084d6d | ||
|
|
3ebee823ad | ||
|
|
72312422e3 | ||
|
|
54089582e4 | ||
|
|
9fd3f930df | ||
|
|
98f0aa4b8f | ||
|
|
19b37e4dc4 | ||
|
|
d344b1c5f6 | ||
|
|
f804fcb65d | ||
|
|
0edfc746d4 | ||
|
|
02ef0578e3 | ||
|
|
5d11cd212a | ||
|
|
bf33c77db7 | ||
|
|
0815d7778c | ||
|
|
9a3814f480 | ||
|
|
a83585fb06 | ||
|
|
dc27fe47e1 | ||
|
|
11b3d7a961 | ||
|
|
9e0cdb0715 | ||
|
|
8abda56749 | ||
|
|
cf20fc3c48 | ||
|
|
6c8845d7b4 | ||
|
|
fb0a30814d | ||
|
|
87f229c62d | ||
|
|
5b1da45688 | ||
|
|
545b978e80 | ||
|
|
44f5609de6 | ||
|
|
f2a26d9b3d | ||
|
|
bb2cdab02b | ||
|
|
d6596d0a3d | ||
|
|
865fbc07dc | ||
|
|
40ba33eb19 | ||
|
|
49b77162b0 | ||
|
|
adf087e3e5 | ||
|
|
8220647564 | ||
|
|
57075ff525 | ||
|
|
2f9de620bc | ||
|
|
d658c3a4cd | ||
|
|
67f6a2c599 | ||
|
|
f369c132d2 | ||
|
|
cce6ac3c88 | ||
|
|
b9fe29068d | ||
|
|
104089ea3d | ||
|
|
48d97dab01 | ||
|
|
e4a56564a1 | ||
|
|
6de8ce3278 | ||
|
|
b46457328f | ||
|
|
7c7dd4ec3c | ||
|
|
749293dde0 | ||
|
|
85f8ab4f24 | ||
|
|
e894496a6e | ||
|
|
19b220b772 | ||
|
|
a4eaa8952a | ||
|
|
74a9bf783b | ||
|
|
7597e999a1 | ||
|
|
64d73ca86a | ||
|
|
d3283f7ab7 | ||
|
|
cb443f6fee | ||
|
|
7162256e3f | ||
|
|
938b9d24f5 | ||
|
|
38a3517c89 | ||
|
|
0b15cd0d9b | ||
|
|
9d7752a6c5 | ||
|
|
d1dff4d3b5 | ||
|
|
49b7fe395b | ||
|
|
51469d8992 | ||
|
|
16e6dde998 | ||
|
|
431f58af4b | ||
|
|
c3af82745b | ||
|
|
26eecbc94b | ||
|
|
fa8a9ff7ba | ||
|
|
dc08d8e7e3 | ||
|
|
d0b895a469 | ||
|
|
5aaebc2bec | ||
|
|
538c179671 | ||
|
|
dc6fea8c3b | ||
|
|
82227f8d0b | ||
|
|
31c3f344e5 | ||
|
|
a7b7b362f3 | ||
|
|
5f142da1b6 | ||
|
|
99209f97de | ||
|
|
8a64fe495e | ||
|
|
c3468c04e4 | ||
|
|
2ef3273b74 | ||
|
|
47cf96ef74 | ||
|
|
17524bf49b | ||
|
|
0d29bf9f31 | ||
|
|
6105122467 | ||
|
|
55dad5ffb6 | ||
|
|
53aa86d138 | ||
|
|
a0e53655bd | ||
|
|
69d134d9d1 | ||
|
|
7224040f8a | ||
|
|
1570fd588e | ||
|
|
0eb2d066be | ||
|
|
61d1c46694 | ||
|
|
18e85235c1 | ||
|
|
04f204e4c7 | ||
|
|
f8aa88f480 | ||
|
|
0c4f1a5378 | ||
|
|
4652e5c177 | ||
|
|
a6156ebdf3 | ||
|
|
1c92987b42 | ||
|
|
d01140638a | ||
|
|
254e1b8244 | ||
|
|
19aed15db0 | ||
|
|
b2b19f446b | ||
|
|
8ad7f5d47c | ||
|
|
69eb96d006 | ||
|
|
e233d8435a | ||
|
|
fb52c7e4ab | ||
|
|
bc53de626f | ||
|
|
f10a604f97 | ||
|
|
1527d7a25d | ||
|
|
65062bdfda | ||
|
|
e3d242017d | ||
|
|
c14289a8a6 | ||
|
|
d12554d2b7 | ||
|
|
036fb08132 | ||
|
|
d9e697216f | ||
|
|
d958c1ba2a | ||
|
|
fb1d3a8cd9 | ||
|
|
7eb0fe24f8 | ||
|
|
738269adae | ||
|
|
d098eef08e | ||
|
|
598c106c3a | ||
|
|
8bf20aa059 | ||
|
|
748a700c36 | ||
|
|
51207e2eb9 | ||
|
|
ee252d185a | ||
|
|
cf1e37fb2a | ||
|
|
3d9722f877 | ||
|
|
1ef67fa05c | ||
|
|
db856de8e5 | ||
|
|
4748c15424 | ||
|
|
ab9d73b5b5 | ||
|
|
6c33282d43 | ||
|
|
e71f1b9dfd | ||
|
|
e24afa42ac | ||
|
|
2fa975e3c2 | ||
|
|
feb912da8a | ||
|
|
e5ce642018 | ||
|
|
7a4ccfe4df | ||
|
|
6b2175e439 | ||
|
|
dd2a26e2d0 | ||
|
|
cd1489f0e5 | ||
|
|
d705a043c9 | ||
|
|
c1a21c52b6 | ||
|
|
94762727ae | ||
|
|
9cde005e08 | ||
|
|
86e7f37f7b | ||
|
|
4d2eb49e85 | ||
|
|
715ab5f4e2 | ||
|
|
146586f894 | ||
|
|
bd78fa8ef7 | ||
|
|
b12d344b40 | ||
|
|
9ea29bf995 | ||
|
|
d066999d0b | ||
|
|
1ab681e71a | ||
|
|
f782a32e9c | ||
|
|
7dc1bc6cc8 | ||
|
|
2a29716bb9 | ||
|
|
33a9fef676 | ||
|
|
88afa55268 | ||
|
|
65ec2e5e1a | ||
|
|
08c1807cc7 | ||
|
|
218700b63d | ||
|
|
bb1c5c4900 | ||
|
|
43a724b80e | ||
|
|
08ae57d349 | ||
|
|
61dfab5683 | ||
|
|
8ee277fe2c | ||
|
|
a44ae4c2b9 | ||
|
|
240746e1b6 | ||
|
|
1fd839bc7a | ||
|
|
a6bfb30235 | ||
|
|
7854fe3d95 | ||
|
|
1eda775571 | ||
|
|
06eb86efd7 | ||
|
|
0339f3f4f5 | ||
|
|
3032356f89 | ||
|
|
f1289504bf | ||
|
|
d3b7c923bd | ||
|
|
194b8a54cc | ||
|
|
1b7b8a1cf9 | ||
|
|
e6dde2cf23 | ||
|
|
94926d5985 | ||
|
|
acf8a817d8 | ||
|
|
10558103a5 | ||
|
|
c61e101627 | ||
|
|
ddcaee0838 | ||
|
|
622e8251a3 | ||
|
|
b2556641ac | ||
|
|
93f8e5e265 | ||
|
|
dde5812c6b | ||
|
|
42a5a0b6d8 | ||
|
|
d95b8ac763 | ||
|
|
865177d0fd | ||
|
|
8923620414 | ||
|
|
ecc349e550 | ||
|
|
bc35a82cc8 | ||
|
|
4fe964e886 | ||
|
|
8371a014a0 | ||
|
|
90af76402b | ||
|
|
25b911e867 | ||
|
|
4e9776cac3 | ||
|
|
2e3975374a | ||
|
|
ed9b8d844b | ||
|
|
d3a12f7db7 | ||
|
|
3b74bb0b77 | ||
|
|
4f7ac68af6 | ||
|
|
e22c6be490 | ||
|
|
3fd9f95da3 | ||
|
|
065786fea7 | ||
|
|
e370c54739 | ||
|
|
88fd83638c | ||
|
|
3d065c0f1b | ||
|
|
d98151cfef | ||
|
|
41d2dd80a7 | ||
|
|
7ff1d972b4 | ||
|
|
efe482ea58 | ||
|
|
8ba6aee952 | ||
|
|
9ecf7068f1 | ||
|
|
74a6b04762 | ||
|
|
55382b1ecb | ||
|
|
b48319e24d | ||
|
|
8b03b27902 | ||
|
|
be2734e640 | ||
|
|
7431124d87 | ||
|
|
4ae696857f | ||
|
|
9fc7f4120a | ||
|
|
dc32470a7c | ||
|
|
d91d01ae2c | ||
|
|
62b0f2afd6 | ||
|
|
6ce475e27a | ||
|
|
e6d5a73a79 | ||
|
|
5fd19aef0b | ||
|
|
911d0d37e5 | ||
|
|
cbffa1b31a | ||
|
|
32c176bf2f | ||
|
|
b4fc691da1 | ||
|
|
6129f15386 | ||
|
|
759cc8e196 | ||
|
|
dec1c0e83c | ||
|
|
b20cda1d15 | ||
|
|
2c522e717f | ||
|
|
c06aa0457f | ||
|
|
253f4e1bf9 | ||
|
|
29e3d8b3d2 | ||
|
|
e0b7a75116 | ||
|
|
083ca8b7fd | ||
|
|
4e2c6aa2cc | ||
|
|
a4a85b1045 | ||
|
|
ccf5b003fe | ||
|
|
0027886f4f | ||
|
|
26599d734d | ||
|
|
d58841a269 | ||
|
|
ab6fa29bd2 | ||
|
|
6142c0c18f | ||
|
|
fb14d9c717 | ||
|
|
bc658f38bc | ||
|
|
976d9cb5a9 | ||
|
|
029127bf00 | ||
|
|
9b424c8343 | ||
|
|
3658137a1c | ||
|
|
4e38e85962 | ||
|
|
fe941f4227 | ||
|
|
9a4ca4114c | ||
|
|
1c41abe40f | ||
|
|
3257c845ee | ||
|
|
3d6b4bec95 | ||
|
|
c55a010bcd | ||
|
|
78dcc0a1ad | ||
|
|
2a2bc6b377 | ||
|
|
1159d74748 | ||
|
|
40f34f26ea | ||
|
|
668331e7ea | ||
|
|
d2bdf6b089 | ||
|
|
10f75268ab | ||
|
|
46e6b7d40a | ||
|
|
bd876c744d | ||
|
|
db0a4b4e03 | ||
|
|
efa67b5843 | ||
|
|
d393b0a9e9 | ||
|
|
85f0c26215 | ||
|
|
35dcf10c9a | ||
|
|
64b6d6b26a | ||
|
|
a2201ab3f7 | ||
|
|
63a2f95cc0 | ||
|
|
043f09aeeb | ||
|
|
1fa1636f75 | ||
|
|
3770f0fd7a | ||
|
|
ecd9e7f489 | ||
|
|
125eea921f | ||
|
|
2e9be1adc5 | ||
|
|
3e35be5681 | ||
|
|
d9d3006be4 | ||
|
|
6abab9fb06 | ||
|
|
ef4b6b092f | ||
|
|
b2a8c52995 | ||
|
|
9bfce238d7 | ||
|
|
d0ac497a60 | ||
|
|
4c44b8714a | ||
|
|
da1820a7da | ||
|
|
b576491320 | ||
|
|
0c9e967ebe | ||
|
|
4d53a92c83 | ||
|
|
0b474514b1 | ||
|
|
9b7bde7da0 | ||
|
|
fc61f17297 | ||
|
|
3a4fbdbd61 | ||
|
|
1ec269969b | ||
|
|
fa669a8690 | ||
|
|
599b85c47d | ||
|
|
83e9618a75 | ||
|
|
c98584dde8 | ||
|
|
53f4b9a9f5 | ||
|
|
a23fb6b79e | ||
|
|
262e489d1a | ||
|
|
9e13c2d0ea | ||
|
|
34162c4b65 | ||
|
|
7384ed8a0e | ||
|
|
8899275022 | ||
|
|
03c3c3d50f | ||
|
|
45c8b5a33f | ||
|
|
4a60b9c8ec | ||
|
|
d964523558 | ||
|
|
49a28cff22 | ||
|
|
8a5e358a73 | ||
|
|
9ec63be673 | ||
|
|
bae0c1f0fd | ||
|
|
fc777ccb96 | ||
|
|
96be30343d | ||
|
|
24a59cffb7 | ||
|
|
dba14aadb6 | ||
|
|
d51062c5ae | ||
|
|
1b4baad654 | ||
|
|
5a25bd3686 | ||
|
|
f08d6ecdc2 | ||
|
|
be56439587 | ||
|
|
dd16a2503b | ||
|
|
613e0d779f | ||
|
|
612a4225be | ||
|
|
35ecf3ae65 | ||
|
|
e32cc9d3cb | ||
|
|
9a3e41716e | ||
|
|
09e4c7bea5 | ||
|
|
5392e2a9ab | ||
|
|
41789fd158 | ||
|
|
2532fc7e4c | ||
|
|
63f65a02b4 | ||
|
|
f3a139f317 | ||
|
|
68cb5427b6 | ||
|
|
2be2ce12c6 | ||
|
|
eb6b2465c7 | ||
|
|
cc1c804730 | ||
|
|
7dda59649b | ||
|
|
60dc9d623e | ||
|
|
92b4bc41ac | ||
|
|
db5b906e5b | ||
|
|
d78670e0ab | ||
|
|
716b21a629 | ||
|
|
1a037c687b | ||
|
|
0dc5edfd7b | ||
|
|
c82e21419c | ||
|
|
7b220c6546 | ||
|
|
15c6296e2c | ||
|
|
e00d2951a9 | ||
|
|
68f7c98985 | ||
|
|
82408fee2e | ||
|
|
3076d400a0 | ||
|
|
c91718cfc3 | ||
|
|
9c20ad7d99 | ||
|
|
7614d596ab | ||
|
|
5bcd9a73b5 | ||
|
|
a06b06eb46 | ||
|
|
66e9069687 | ||
|
|
a782fecd25 | ||
|
|
5dda935f58 | ||
|
|
8cfe4a81ef | ||
|
|
2e4da7f220 | ||
|
|
be6a63f95b | ||
|
|
9fcbcfcba8 | ||
|
|
3ec26ba319 | ||
|
|
188239d7ea | ||
|
|
b850aa4d96 | ||
|
|
2e5e06c1ff | ||
|
|
96c094d36b | ||
|
|
3be01528d9 | ||
|
|
6b77070baa | ||
|
|
4e2743d587 | ||
|
|
1d9cbecbb5 | ||
|
|
bd90677cfa | ||
|
|
e75df6d46c | ||
|
|
b6c07fc357 | ||
|
|
fce1eb5a8c | ||
|
|
82d76b18f0 | ||
|
|
dc4cc59038 | ||
|
|
6f30208863 | ||
|
|
303c96cbb1 | ||
|
|
da57fe7ccc | ||
|
|
e5e135c68b | ||
|
|
22888f5756 | ||
|
|
d84a740f0d | ||
|
|
64a7dec09d | ||
|
|
a76cab0fe6 | ||
|
|
e0b9a1ef3b | ||
|
|
6c4e6b057e | ||
|
|
37b23129c0 | ||
|
|
5651ce2d63 | ||
|
|
0a0a9f2f93 | ||
|
|
876b316af4 | ||
|
|
f51ba88f64 | ||
|
|
02c6a46a78 | ||
|
|
84fc849caf | ||
|
|
87ed15a117 | ||
|
|
2bbf006475 | ||
|
|
2596e8d08a | ||
|
|
80344ec41c | ||
|
|
a2042ec378 | ||
|
|
734aed1f7b | ||
|
|
108b5c7b0c | ||
|
|
3091ce55ad | ||
|
|
e6b6dbfa74 | ||
|
|
e22105a728 | ||
|
|
092f2793b3 | ||
|
|
3ab4244709 | ||
|
|
7b48061281 | ||
|
|
2b6dbaab76 | ||
|
|
4cb2ba51bd | ||
|
|
8ffdf12a4a | ||
|
|
28d7a4aeda | ||
|
|
fed882298e | ||
|
|
e4cfad4162 | ||
|
|
b4496603ed | ||
|
|
1cc94d4874 | ||
|
|
d19c4d33cb | ||
|
|
fd71b0ee62 | ||
|
|
49d0a65bcd | ||
|
|
ba53517f09 | ||
|
|
1be6cd0a3d | ||
|
|
8c0e231ade | ||
|
|
1551920fe3 | ||
|
|
a5155c50cc | ||
|
|
57d86b9ef4 | ||
|
|
182164af9c | ||
|
|
45fe688f87 | ||
|
|
fe27d054f8 | ||
|
|
21aa2f45e5 | ||
|
|
eea1f5540e | ||
|
|
2771184e48 | ||
|
|
2b6abac0c9 | ||
|
|
85a9186c78 | ||
|
|
7106994bf6 | ||
|
|
c57acefc16 | ||
|
|
e2bb6cd849 | ||
|
|
f1d9b9c21b | ||
|
|
6cf253eb8c | ||
|
|
3f7b454710 | ||
|
|
50eb2621d7 | ||
|
|
bf70157a64 |
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
|
||||
[*.py]
|
||||
max_line_length = 88
|
||||
|
||||
[*.{sh,yml,yaml,json}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: dw__0
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: https://paypal.me/dwillner0
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue form is for reporting bugs only!
|
||||
If you have a feature request, please use [feature_request](/new?template=feature_request.yml)
|
||||
- type: textarea
|
||||
id: distro
|
||||
attributes:
|
||||
label: Linux Distribution
|
||||
description: >-
|
||||
The linux distribution the issue occured on
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: >-
|
||||
A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: What did you expect to happen
|
||||
description: >-
|
||||
A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro-steps
|
||||
attributes:
|
||||
label: How to reproduce
|
||||
description: >-
|
||||
Minimal and precise steps to reproduce this bug.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: |
|
||||
If you have any additional information for us, use the field below.
|
||||
|
||||
Please note, you can attach screenshots or screen recordings here, by
|
||||
dragging and dropping files in the field below.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Klipper Discord
|
||||
url: https://discord.klipper3d.org/
|
||||
about: Quickest way to get in contact
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This issue form is for feature requests only!
|
||||
If you've found a bug, please use [bug_report](/new?template=bug_report.yml)
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe
|
||||
description: >-
|
||||
A clear and concise description of what the problem is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: >-
|
||||
A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: possible-alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: >-
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: |
|
||||
If you have any additional information for us, use the field below.
|
||||
|
||||
Please note, you can attach screenshots or screen recordings here, by
|
||||
dragging and dropping files in the field below.
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.11"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: pytest --cov=kiauh --cov-report=xml --cov-report=term
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
.idea
|
||||
.vscode
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.jupyter
|
||||
*.ipynb
|
||||
*.ipynb_checkpoints
|
||||
*.tmp
|
||||
__pycache__
|
||||
.kiauh-env
|
||||
.venv
|
||||
*.code-workspace
|
||||
*.iml
|
||||
*.egg-info
|
||||
.coverage
|
||||
kiauh.cfg
|
||||
klipper_repos.txt
|
||||
@@ -1,12 +1,252 @@
|
||||
# KIAUH
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo-large.png" alt="KIAUH Logo" height="181">
|
||||
<h1 align="center">Klipper Installation And Update Helper</h1>
|
||||
</p>
|
||||
|
||||
## Klipper Installation And Update Helper
|
||||
<p align="center">
|
||||
A handy installation script that makes installing Klipper (and more) a breeze!
|
||||
</p>
|
||||
|
||||
This script was actually created for my personal use only but i then decided to make the script accessible for everyone.
|
||||
It is meant to help guiding you through a complete fresh install of Klipper and optionally the DWC2 web UI + DWC2-for-Klipper.
|
||||
There are also functions for updating your current installations or removing them from your system.
|
||||
<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>
|
||||
|
||||
## First things first: When you decide to use this script, you use it at your own risk!
|
||||
<hr>
|
||||
|
||||
# THIS VERSION IS WORK IN PROGRESS!!!
|
||||
# BUGS MAY BE STILL PRESENT!!!
|
||||
<h2 align="center">
|
||||
📄️ Instructions 📄
|
||||
</h2>
|
||||
|
||||
### 📋 Prerequisites
|
||||
|
||||
KIAUH is a script that assists you in installing Klipper on a Linux operating
|
||||
system that has
|
||||
already been flashed to your Raspberry Pi's (or other SBC's) SD card. As a
|
||||
result, you must ensure
|
||||
that you have a functional Linux system on hand.
|
||||
`Raspberry Pi OS Lite (either 32bit or 64bit)` is a recommended Linux image
|
||||
if you are using a Raspberry Pi.
|
||||
The [official Raspberry Pi Imager](https://www.raspberrypi.com/software/)
|
||||
is the simplest way to flash an image like this to an SD card.
|
||||
|
||||
* Once you have downloaded, installed and launched the Raspberry Pi Imager,
|
||||
select `Choose OS -> Raspberry Pi OS (other)`: \
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/rpi_imager1.png" alt="KIAUH logo" height="350">
|
||||
</p>
|
||||
|
||||
* Then select `Raspberry Pi OS Lite (32bit)` (or 64bit if you want to use that
|
||||
instead):
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/rpi_imager2.png" alt="KIAUH logo" height="350">
|
||||
</p>
|
||||
|
||||
* Back in the Raspberry Pi Imager's main menu, select the corresponding SD card
|
||||
to which
|
||||
you want to flash the image.
|
||||
|
||||
* Make sure to go into the Advanced Option (the cog icon in the lower left
|
||||
corner of the main menu)
|
||||
and enable SSH and configure Wi-Fi.
|
||||
|
||||
* If you need more help for using the Raspberry Pi Imager, please visit
|
||||
the [official documentation](https://www.raspberrypi.com/documentation/computers/getting-started.html).
|
||||
|
||||
These steps **only** apply if you are actually using a Raspberry Pi. In case you
|
||||
want
|
||||
to use a different SBC (like an Orange Pi or any other Pi derivates), please
|
||||
look up on how to get an appropriate Linux image flashed
|
||||
to the SD card before proceeding further (usually done with Balena Etcher in
|
||||
those cases). Also make sure that KIAUH will be able to run
|
||||
and operate on the Linux Distribution you are going to flash. You likely will
|
||||
have the most success with
|
||||
distributions based on Debian 11 Bullseye. Read the notes further down below in
|
||||
this document.
|
||||
|
||||
### 💾 Download and use KIAUH
|
||||
|
||||
**📢 Disclaimer: Usage of this script happens at your own risk!**
|
||||
|
||||
* **Step 1:** \
|
||||
To download this script, it is necessary to have git installed. If you don't
|
||||
have git already installed, or if you are unsure, run the following command:
|
||||
|
||||
```shell
|
||||
sudo apt-get update && sudo apt-get install git -y
|
||||
```
|
||||
|
||||
* **Step 2:** \
|
||||
Once git is installed, use the following command to download KIAUH into your
|
||||
home-directory:
|
||||
|
||||
```shell
|
||||
cd ~ && git clone https://github.com/dw-0/kiauh.git
|
||||
```
|
||||
|
||||
* **Step 3:** \
|
||||
Finally, start KIAUH by running the next command:
|
||||
|
||||
```shell
|
||||
./kiauh/kiauh.sh
|
||||
```
|
||||
|
||||
* **Step 4:** \
|
||||
You should now find yourself in the main menu of KIAUH. You will see several
|
||||
actions to choose from depending
|
||||
on what you want to do. To choose an action, simply type the corresponding
|
||||
number into the "Perform action"
|
||||
prompt and confirm by hitting ENTER.
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">❗ Notes ❗</h2>
|
||||
|
||||
### **📋 Please see the [Changelog](docs/changelog.md) for possible important
|
||||
|
||||
changes!**
|
||||
|
||||
- Mainly tested on Raspberry Pi OS Lite (Debian 10 Buster / Debian 11 Bullseye)
|
||||
- Other Debian based distributions (like Ubuntu 20 to 22) likely work too
|
||||
- Reported to work on Armbian as well but not tested in detail
|
||||
- During the use of this script you will be asked for your sudo password. There
|
||||
are several functions involved which need sudo privileges.
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">⚙️ Core Components ⚙️</h2>
|
||||
|
||||
<table align="center" style="text-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>by <a href="https://github.com/KevinOConnor">KevinOConnor</a></th>
|
||||
<th>by <a href="https://github.com/Arksine">Arksine</a></th>
|
||||
<th>by <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/KlipperScreen/KlipperScreen">KlipperScreen</a></h3></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
||||
<th><img src="https://avatars.githubusercontent.com/KlipperScreen?v=4" alt="KlipperScreen Logo" height="64"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>by <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
||||
<th>by <a href="https://github.com/alfrix">alfrix</a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">🧩 Community Extensions 🧩</h2>
|
||||
|
||||
<table align="center" style="text-align: center;">
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/OctoPrint/OctoPrint"><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></a></th>
|
||||
<th><a href="https://github.com/nlef/moonraker-telegram-bot"><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></a></th>
|
||||
<th><a href="https://github.com/Kragrathea/pgcode"><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>by <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
||||
<th>by <a href="https://github.com/nlef">nlef</a></th>
|
||||
<th>by <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/TheSpaghettiDetective/moonraker-obico"><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></a></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>by <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
||||
<th>by <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
||||
<th>by <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
||||
<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://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
||||
<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="SimplyPrint Logo" height="64"></a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>by <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
||||
<th>by <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
||||
<th>by <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/CodeMasterCody3D/DroidKlipp">DroidKlipp</a></h3></th>
|
||||
<th><h3><a href="https://github.com/PEEKYPAUL/Moongate">Moongate</a></h3></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/CodeMasterCody3D/DroidKlipp"><img src="https://raw.githubusercontent.com/CodeMasterCody3D/DroidKlipp/main/logo.png" alt="DroidKlipp Logo" height="64"></a></th>
|
||||
<th><a href="https://github.com/PEEKYPAUL/Moongate"><img src="https://raw.githubusercontent.com/PEEKYPAUL/Moongate/master/docs/moongate-icon.png" alt="Moongate Logo" height="64"></a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>by <a href="https://github.com/CodeMasterCody3D">CodeMasterCody3D</a></th>
|
||||
<th>by <a href="https://github.com/PEEKYPAUL">PEEKYPAUL</a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">🎖️ Contributors 🎖️</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://repobeats.axiom.co/api/embed/a1afbda9190c04a90cf4bd3061e5573bc836cb05.svg" alt="Repobeats analytics image"/>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">✨ Credits ✨</h2>
|
||||
|
||||
* A big thank you to [lixxbox](https://github.com/lixxbox) for that awesome
|
||||
KIAUH-Logo!
|
||||
* Also, a big thank you to everyone who supported my work with
|
||||
a [Ko-fi](https://ko-fi.com/dw__0) !
|
||||
* Last but not least: Thank you to all contributors and members of the Klipper
|
||||
Community who like and share this project!
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
# KIAUH - Klipper 安装与更新助手
|
||||
|
||||
<p align="center">
|
||||
<a>
|
||||
<img src="docs/assets/logo-large.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="docs/assets/rpi_imager1.png" alt="KIAUH logo" height="350">
|
||||
</p>
|
||||
|
||||
* 然后选择 `Raspberry Pi OS Lite (32位)` (或如果您想使用64位版本):
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/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 权限。
|
||||
|
||||
<h2 align="center">⚙️ 核心组件 ⚙️</h2>
|
||||
|
||||
<table align="center" style="text-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/KlipperScreen/KlipperScreen">KlipperScreen</a></h3></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><img src="https://raw.githubusercontent.com/fluidd-core/fluidd/master/docs/docs/assets/images/logo.svg" alt="Fluidd Logo" height="64"></th>
|
||||
<th><img src="https://avatars.githubusercontent.com/KlipperScreen?v=4" alt="KlipperScreen Logo" height="64"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>由 <a href="https://github.com/fluidd-core">fluidd-core</a></th>
|
||||
<th>由 <a href="https://github.com/alfrix">alfrix</a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">🧩 社区扩展 🧩</h2>
|
||||
|
||||
<table align="center" style="text-align: center;">
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/OctoPrint/OctoPrint">OctoPrint</a></h3></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/OctoPrint/OctoPrint"><img src="https://raw.githubusercontent.com/OctoPrint/OctoPrint/master/docs/images/octoprint-logo.png" alt="OctoPrint Logo" height="64"></a></th>
|
||||
<th><a href="https://github.com/nlef/moonraker-telegram-bot"><img src="https://avatars.githubusercontent.com/u/52351624?v=4" alt="nlef avatar" height="64"></a></th>
|
||||
<th><a href="https://github.com/Kragrathea/pgcode"><img src="https://avatars.githubusercontent.com/u/5917231?v=4" alt="Kragrathea avatar" height="64"></a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>由 <a href="https://github.com/OctoPrint">OctoPrint</a></th>
|
||||
<th>由 <a href="https://github.com/nlef">nlef</a></th>
|
||||
<th>由 <a href="https://github.com/Kragrathea">Kragrathea</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/TheSpaghettiDetective/moonraker-obico">Obico for Klipper</a></h3></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/TheSpaghettiDetective/moonraker-obico"><img src="https://avatars.githubusercontent.com/u/46323662?s=200&v=4" alt="Obico logo" height="64"></a></th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>由 <a href="https://github.com/TheSpaghettiDetective">Obico</a></th>
|
||||
<th>由 <a href="https://github.com/Clon1998">Patrick Schmidt</a></th>
|
||||
<th>由 <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/crysxd/OctoApp-Plugin">OctoApp For Klipper</a></h3></th>
|
||||
<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://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
|
||||
<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="SimplyPrint Logo" height="64"></a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>由 <a href="https://github.com/crysxd">Christian Würthner</a></th>
|
||||
<th>由 <a href="https://github.com/Staubgeborener">Staubgeborener</a></th>
|
||||
<th>由 <a href="https://github.com/SimplyPrint">SimplyPrint</a></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><h3><a href="https://github.com/CodeMasterCody3D/DroidKlipp">DroidKlipp</a></h3></th>
|
||||
<th><h3><a href="https://github.com/PEEKYPAUL/Moongate">Moongate</a></h3></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><a href="https://github.com/CodeMasterCody3D/DroidKlipp"><img src="https://raw.githubusercontent.com/CodeMasterCody3D/DroidKlipp/main/logo.png" alt="DroidKlipp Logo" height="64"></a></th>
|
||||
<th><a href="https://github.com/PEEKYPAUL/Moongate"><img src="https://raw.githubusercontent.com/PEEKYPAUL/Moongate/master/docs/moongate-icon.png" alt="Moongate Logo" height="64"></a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>由 <a href="https://github.com/CodeMasterCody3D">CodeMasterCody3D</a></th>
|
||||
<th>由 <a href="https://github.com/PEEKYPAUL">PEEKYPAUL</a></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">🎖️ 贡献者 🎖️</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 align="center">✨ 特别感谢 ✨</h2>
|
||||
|
||||
* 非常感谢 [lixxbox](https://github.com/lixxbox) 设计了如此出色的 KIAUH 标志!
|
||||
* 同时,非常感谢所有通过 [Ko-fi](https://ko-fi.com/dw__0) 支持我的工作的人!
|
||||
* 最后但同样重要的是:感谢所有为 Klipper 社区做出贡献的成员,以及喜欢和分享这个项目的朋友们!
|
||||
@@ -0,0 +1,36 @@
|
||||
[kiauh]
|
||||
backup_before_update: False
|
||||
|
||||
[klipper]
|
||||
# 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/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 supports two optional Python packages that can be used to reduce its CPU load
|
||||
# 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]
|
||||
port: 80
|
||||
unstable_releases: False
|
||||
|
||||
[fluidd]
|
||||
port: 80
|
||||
unstable_releases: False
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,549 @@
|
||||
## Changelog
|
||||
|
||||
This document covers possible important changes to KIAUH.
|
||||
|
||||
### 2025-10-10 (v6.0.0)
|
||||
|
||||
KIAUH has now reached version 6! The majority of the changes mentioned in the
|
||||
previous changelog are now available in the final version.
|
||||
Most notible are the following changes:
|
||||
|
||||
- The dialog for selecting between v5 and v6 is gone and all v5 code was
|
||||
removed. V6 is the new default
|
||||
- You can add/remove alternative repositories for Klipper and Moonraker from
|
||||
within KIAUH, no need to manually edit any file
|
||||
- You can store and load firmware configurations for Klipper during the firmware
|
||||
compilation process
|
||||
- Spoolman is available as an extension, it does not use the bare-metal
|
||||
installation anymore, instead it uses the Docker Container approach
|
||||
- OctoApp is available as an extension
|
||||
- OctoPrint support was NOT killed. OctoPrint is available as an extension
|
||||
- I probably forgot to mention some other changes, but the idea is to create
|
||||
official docs for KIAUH where the new changelog will live in the future and
|
||||
available features and mechanics are explained in detail
|
||||
|
||||
If you really want to use v5, there is a v5 branch available in the repository.
|
||||
Keep in mind that v5 will not be updated anymore.
|
||||
|
||||
### 2024-08-31 (v6.0.0-alpha.1)
|
||||
|
||||
Long time no see, but here we are again!
|
||||
A lot has happened in the background, but now it is time to take it out into the
|
||||
wild.
|
||||
|
||||
#### KIAUH has now reached version 6! Well, at least in an alpha state...
|
||||
|
||||
The project has seen a complete rewrite of the script from scratch in Python.
|
||||
It requires Python 3.8 or newer to run. Because this update is still in an alpha
|
||||
state, bugs may or will occur.
|
||||
During startup, you will be asked if you want to start the new version 6 or the
|
||||
old version 5.
|
||||
As long as version 6 is in a pre-release state, version 5 will still be
|
||||
available. If there are any critical issues
|
||||
with the new version that were overlooked, you can always switch back to the old
|
||||
version.
|
||||
|
||||
In case you selected not to get asked about which version to start (option 3 or
|
||||
4 in the startup dialog) and you want to
|
||||
revert that decision, you will find a line called `version_to_launch=` within
|
||||
the `.kiauh.ini` file in your home directory.
|
||||
Just delete that line, save the file and restart KIAUH. KIAUH will then ask you
|
||||
again which version you want to start.
|
||||
|
||||
Here is a list of the most important changes to KIAUH in regard to version 6:
|
||||
|
||||
- The majority of features available in KIAUH v5 are still available; they just
|
||||
got migrated from Bash to Python.
|
||||
- It is now possible to add new/remove instances to/from existing multi-instance
|
||||
installations of Klipper and Moonraker
|
||||
- KIAUH now has an Extension-System. This allows contributors to add new
|
||||
installers to KIAUH without having to modify the main script.
|
||||
- You will now find some of the features that were previously available in
|
||||
the Installer-Menu in the Extensions-Menu.
|
||||
- The current extensions are:
|
||||
- G-Code Shell Command (previously found in the Advanced-Menu)
|
||||
- Mainsail Theme Installer (previously found in the Advanced-Menu)
|
||||
- Klipper-Backup (new in v6!)
|
||||
- Moonraker Telegram Bot (previously found in the Installer-Menu)
|
||||
- PrettyGCode for Klipper (previously found in the Installer-Menu)
|
||||
- Obico for Klipper (previously found in the Installer-Menu)
|
||||
- The following additional extensions are planned, but not yet available:
|
||||
- Spoolman (available in v5 in the Installer-Menu)
|
||||
- OctoApp (available in v5 in the Installer-Menu)
|
||||
- KIAUH has its own config file now
|
||||
- The file has some default values for the currently supported options
|
||||
- There might be more options in the future
|
||||
- It is located in KIAUH's root directory and is called `default.kiauh.cfg`
|
||||
- DO NOT EDIT the default file directly, instead make a copy of it and
|
||||
call it `kiauh.cfg`
|
||||
- Settings changed via the Advanced-Menu will be written to the
|
||||
`kiauh.cfg`
|
||||
- Support for OctoPrint was removed
|
||||
|
||||
Feel free to give version 6 a try and report any bugs or issues you encounter!
|
||||
Every feedback is appreciated.
|
||||
|
||||
### 2023-06-17
|
||||
|
||||
KIAUH has now added support for installing Mobileraker's companion!
|
||||
Mobileraker is a free and Open Source Android and iOS App for Klipper, utilizing
|
||||
the Moonraker API, allowing you
|
||||
to control your printer. Thank you to [Clon1998](https://github.com/Clon1998)
|
||||
for adding this feature!
|
||||
|
||||
### 2023-02-03
|
||||
|
||||
The installer for MJPG-Streamer got replaced by crowsnest. It is an improved
|
||||
webcam service, utilizing ustreamer.
|
||||
Please have a look here for additional info about crowsnest and how to configure
|
||||
it: https://github.com/mainsail-crew/crowsnest \
|
||||
It's unsure if the previous MJPG-Streamer installer will be updated and make its
|
||||
way back into KIAUH.
|
||||
A big thanks to [KwadFan](https://github.com/KwadFan) for writing the crowsnest
|
||||
implementation.
|
||||
|
||||
### 2022-10-31
|
||||
|
||||
Some functions got updated, though not all of them.
|
||||
|
||||
The following functions are still currently unavailable:
|
||||
|
||||
- Installation of: MJPG-Streamer
|
||||
- All backup functions and the Log-Upload
|
||||
|
||||
### 2022-10-20
|
||||
|
||||
KIAUH has now reached major version 5 !
|
||||
|
||||
Recently Moonraker introduced some changes which makes it necessary to change
|
||||
the folder structure of printer setups.
|
||||
If you are interested in the details, check out this
|
||||
PR: https://github.com/Arksine/moonraker/pull/491 \
|
||||
Although Moonraker has some mechanics available to migrate existing setups to
|
||||
the new file structure with the use of symlinks, fresh and clean installs
|
||||
should be considered.
|
||||
|
||||
The version jump of KIAUH to v5 is a breaking change due to those major changes!
|
||||
That means v4 and v5 are not compatible with each other!
|
||||
This is also the reason why you will currently be greeted by a yellow
|
||||
notification in the main menu of KIAUH leading to this changelog.
|
||||
I decided to disable a few functions of the script and focus on releasing the
|
||||
required changes to the core components of this script.
|
||||
I will work on updating the other parts of the script piece by piece during the
|
||||
next days/weeks.
|
||||
So I am already sorry in advance if one of your desired components you wanted to
|
||||
install or use temporarily cannot be installed or used right now.
|
||||
|
||||
The following functions are currently unavailable:
|
||||
|
||||
- Installation of: KlipperScreen, Obico, Octoprint, MJPG-Streamer, Telegram Bot
|
||||
and PrettyGCode
|
||||
- All backup functions and the Log-Upload
|
||||
|
||||
**So what is working?**\
|
||||
Installation of Klipper, Moonraker, Mainsail and Fluidd. Both, single and
|
||||
multi-instance setups work!\
|
||||
As already said, the rest will follow in the near future. Updating and removal
|
||||
of already installed components should continue to work.
|
||||
|
||||
**What was removed?**\
|
||||
The option to change Klippers configuration directory got removed. From now on
|
||||
it will not be possible anymore to change
|
||||
the configuration directory from within KIAUH and the new filestructure is
|
||||
enforced.
|
||||
|
||||
**What if I don't have an existing Klipper/Moonraker install right now?**\
|
||||
Nothing important to think about, install Klipper and Moonraker. KIAUH will
|
||||
install both of them with the new filestructure.
|
||||
|
||||
**What if I have an existing Klipper/Moonraker install?**\
|
||||
First of all: Backups! Please copy all of your config files and the Moonraker
|
||||
database (it is a hidden folder, usually `~/.moonraker_database`) to a safe
|
||||
location.
|
||||
After that, uninstall Klipper and Moonraker with KIAUH. You can then proceed and
|
||||
re-install both of them with KIAUH again. It is important that you are on KIAUH
|
||||
v5 for that!
|
||||
Once everything is installed again, you need to manually copy your configuration
|
||||
files from the old `~/klipper_config` folder to the new `~/printer_data/config`
|
||||
folder.
|
||||
Previous, by Moonraker created symlinks to folder of the old filestructure will
|
||||
not work anymore, you need to move the files to their new location now!
|
||||
Do the same with the two files inside of `~/.moonraker_database`. Move/copy them
|
||||
into `~/printer_data/database`. If `~/printer_data/database` is already
|
||||
populated with a `data.mdb` and `lock.mdb`
|
||||
delete them or simply overwrite them. Nothing should be lost as those should be
|
||||
empty database files. Anyway, you made backups, right?
|
||||
You can now proceed and restart Moonraker. Either from within Mainsail or
|
||||
Fluidd, or use SSH and execute `sudo systemctl restart moonraker`.
|
||||
If everything went smooth, you should be good to go again. If you see some
|
||||
Moonraker warnings about deprecated options in the `moonraker.conf`, go ahead
|
||||
and resolve them.
|
||||
I will not cover them in detail here. A good source is the Moonraker
|
||||
documentation: https://moonraker.readthedocs.io/en/latest/configuration/
|
||||
|
||||
**What if I have an existing Klipper/Moonraker multi-instance install?**\
|
||||
Pretty much the same steps that are required for single instance installs apply
|
||||
to multi-instance setups. So please go ahead and read the previous paragraph if
|
||||
you didn't already.
|
||||
Make backups of everything first. Then remove and install the desired amount of
|
||||
Klipper and Moonraker instances again.
|
||||
Now you need to move all config and database files to their new locations.\
|
||||
Example with an instance called `printer_1`:\
|
||||
The config files go from `~/klipper_config/printer_1` to
|
||||
`~/printer_1_data/config`.
|
||||
The database files go from `~/.moonraker_database_1` to
|
||||
`~/printer_1_data/database`.
|
||||
Now restart all Moonraker services. You can restart all of them at once if you
|
||||
launch KIAUH, and in the main menu type `restart moonraker` and hit Enter.
|
||||
|
||||
I hope I have covered the most important things. In case you need further
|
||||
support, the official Klipper Discord is a good place to ask for help.
|
||||
|
||||
### 2022-08-15
|
||||
|
||||
Support for "Obico for Klipper" was added! Huge thanks
|
||||
to [kennethjiang](https://github.com/kennethjiang) for helping me with the
|
||||
implementation!
|
||||
|
||||
### 2022-05-29
|
||||
|
||||
KIAUH has now reached major version 4 !
|
||||
|
||||
* feat: Klipper can be installed under Python3 (still considered as
|
||||
experimental)
|
||||
* feat: Klipper can be installed from custom repositories / inofficial forks
|
||||
* feat: Custom instance name for multi instance installations of Klipper
|
||||
* Any other multi instance will share the same name given to the
|
||||
corresponding Klipper instance
|
||||
* E.g. klipper-voron2 -> moonraker-voron2 -> moonraker-telegram-bot-voron2
|
||||
* feat: Option to allow installation of / updating to unstable Mainsail and
|
||||
Fluidd versions
|
||||
* by default only stable versions get installed/updated
|
||||
* feat: Multi-Instance OctoPrint installations now each have their own virtual
|
||||
python environment
|
||||
* allows independent installation of plugins for each instance
|
||||
* feat: Implementing the use of shellcheck during development
|
||||
* feat: Implementing a simple logging mechanic
|
||||
* feat: Log-upload function now also allows uploading other logfiles (kiauh.log,
|
||||
webcamd.log etc.)
|
||||
* feat: added several new help dialogs which try to explain various functions
|
||||
* fix: During Klipper installation, checks for group membership of `tty` and
|
||||
`dialout` are made
|
||||
* refactor: rework of the settings menu for better control the new KIAUH
|
||||
features
|
||||
* refactor: Support for DWC and DWC-for-Klipper has been removed
|
||||
* refactor: The backup before update settings were moved to the KIAUH settings
|
||||
menu
|
||||
* refactor: Switch branch function has been removed (was replaced by the custom
|
||||
Klipper repo feature)
|
||||
* refactor: The update manager sections for Mainsail, Fluidd and KlipperScreen
|
||||
were removed from the moonraker.conf template
|
||||
* They will now be individually added during installation of the
|
||||
corresponding interface
|
||||
* refactor: The rollback function was reworked and now also allows rollbacks of
|
||||
Moonraker
|
||||
* It now takes numerical inputs and reverts the corresponding repository by
|
||||
the given amount instead
|
||||
* KIAUH does not save previous states to its config anymore like it did with
|
||||
the previous approach
|
||||
|
||||
### 2022-01-29
|
||||
|
||||
* Starting from the 28th of January, Moonraker can make use of PackageKit and
|
||||
PolicyKit.\
|
||||
More details on that can be found [here](
|
||||
https://github.com/Arksine/moonraker/issues/349)
|
||||
and [here](https://github.com/Arksine/moonraker/pull/346)
|
||||
* KIAUH will install Moonrakers PolicyKit rules by default when __installing__
|
||||
Moonraker
|
||||
* KIAUH will also install Moonrakers PolicyKit rules when __updating__ Moonraker
|
||||
__via KIAUH__ as of now
|
||||
|
||||
### 2021-12-30
|
||||
|
||||
* Updated the doc for the usage of
|
||||
the [G-Code Shell Command Extension](docs/gcode_shell_command.md)
|
||||
* It became apparent, that some user groups are missing on some systems. A
|
||||
missing video group \
|
||||
membership for example caused issues when installing mjpg-streamer while not
|
||||
using the default pi user. \
|
||||
Other issues could occur when trying to flash an MCU on Debian or Ubuntu
|
||||
distributions where a user might not be part
|
||||
of the dialout group by default. A check for the tty group is also done. The
|
||||
tty group is needed for setting
|
||||
up a linux MCU (currently not yet supported by KIAUH).
|
||||
* There is an issue when trying to install Mainsail or Fluidd on Ubuntu 21.10.
|
||||
Permissions on that distro seem to have seen a rework
|
||||
in comparison to 20.04 and users will be greeted with an "Error 403 -
|
||||
Permission denied" message after installing one of Klippers webinterfaces.
|
||||
I still have to figure out a viable solution for that.
|
||||
|
||||
### 2021-09-28
|
||||
|
||||
* New Feature! Added an installer for the Telegram Bot for Moonraker
|
||||
by [nlef](https://github.com/nlef).
|
||||
Checkout his project! Remember to report all issues and/or bugs regarding that
|
||||
project in its corresponding repo and not here 😛.\
|
||||
You can find it here: https://github.com/nlef/moonraker-telegram-bot
|
||||
|
||||
### 2021-09-24
|
||||
|
||||
* The flashing function got adjusted a bit. It is now possible to also flash
|
||||
controllers which are connected over UART and thus accessible via
|
||||
`/dev/ttyAMA0`. You now have to select a connection methop prior flashing
|
||||
which is either USB or UART.
|
||||
* Due to several requests over time I have now created a Ko-fi account for those
|
||||
who want to support this project and my work with a small donation. Many
|
||||
thanks in advance to all future donors. You can support me on Ko-fi with this
|
||||
link: https://ko-fi.com/th33xitus
|
||||
* As usual, if you find any bugs or issues please report them. I tested the
|
||||
little rework i did with the hardware i have available and haven't encountered
|
||||
any malfunctions of flashing them yet.
|
||||
|
||||
### 2021-08-10
|
||||
|
||||
* KIAUH now supports the installation of the "PrettyGCode for Klipper"
|
||||
GCode-Viewer created by [Kragrathea](https://github.com/Kragrathea)!
|
||||
Installation, updating and removal are possible with KIAUH. For more details
|
||||
to this cool piece of software, please have a look
|
||||
here: https://github.com/Kragrathea/pgcode
|
||||
|
||||
### 2021-07-10
|
||||
|
||||
* The NGINX configuration files got updated to be in sync with MainsailOS and
|
||||
FluiddPi. Issues with the NGINX service not starting up due to wrong
|
||||
configuration should be resolved now. To get the updated configuration files,
|
||||
please remove Moonraker and Mainsail / Fluidd with KIAUH first and then
|
||||
re-install it. An automated file check for those configuration files might
|
||||
follow in the future which then automates updating those files if there were
|
||||
important changes.
|
||||
|
||||
* The default `moonraker.conf` was updated to reflect the recent changes to the
|
||||
update manager section. The update channel is set to `dev`.
|
||||
|
||||
### 2021-06-29
|
||||
|
||||
* KIAUH will now patch the new `log_path` to existing moonraker.conf files when
|
||||
updating Moonraker and the entry is missing. Before that, it was necessary
|
||||
that the user provided that path manually to make Fluidd display the logfiles
|
||||
in its interface. This issue should be resolved now.
|
||||
|
||||
### 2021-06-15
|
||||
|
||||
* Moonraker introduced an optional `log_path` which clients can make use of to
|
||||
show log files located in that folder to their users. More info
|
||||
here: https://github.com/Arksine/moonraker/commit/829b3a4ee80579af35dd64a37ccc092a1f67682a \
|
||||
Client developers agreed upon using `~/klipper_logs` as the new default log
|
||||
path.\
|
||||
That means, from now on, Klipper and Moonraker services installed with KIAUH
|
||||
will place their logfiles in that mentioned folder.
|
||||
* Additionally, KIAUH will now detect Klipper and Moonraker systemd services
|
||||
that still use the old default location of `/tmp/<service>.log` and will
|
||||
update them next time the user updates Klipper and/or Moonraker with the KIAUH
|
||||
update function.
|
||||
* Additional symlinks for the following logfiles will get created along those
|
||||
update procedures to make them accessible through the webinterface once its
|
||||
supported:
|
||||
- webcamd.log
|
||||
- mainsail-access.log
|
||||
- mainsail-error.log
|
||||
- fluidd-access.log
|
||||
- fluidd-error.log
|
||||
* For MainsailOS and FluiddPi users:\
|
||||
MainsailOS and FluiddPi will switch the shipped Klipper service from SysVinit
|
||||
to systemd probably with their next release. KIAUH can already help migrate
|
||||
older MainsailOS (0.4.0 and below) and FluiddPi (v1.13.0) releases to match
|
||||
their new service-, file- and folder-structure so you don't have to re-flash
|
||||
the SD-Card of your Raspberry Pi.\
|
||||
In detail here is what is going to happen when you use the new "CustomPiOS
|
||||
Migration Helper" from the Advanced Menu\
|
||||
`(Main Menu -> 4 -> Enter -> 10 -> Enter)` in a short summary:
|
||||
* The Klipper SysVinit service will get replaced by a Klipper systemd
|
||||
service
|
||||
* Klipper and Moonraker will use the new log-directory `~/klipper_logs`
|
||||
* The webcamd service gets updated
|
||||
* The webcamd script gets updated and moved from `/root/bin/webcamd` to
|
||||
`/usr/local/bin/webcamd`
|
||||
* The NGINX `upstreams.conf` gets updated to be able to configure up to 4
|
||||
webcams
|
||||
* The `mainsail.txt` / `fluiddpi.txt` gets moved from `/boot` to
|
||||
`~/klipper_config` and renamed to `webcam.txt`
|
||||
* Symlinks for the webcamd.log and various NGINX logs get created in
|
||||
`~/klipper_config`
|
||||
* Configuration files for Klipper, Moonraker and webcamd get added to
|
||||
`/etc/logrotate.d`
|
||||
* If they still exist, two lines will be removed from the mainsail.cfg or
|
||||
client_macros.cfg macro configurations:\
|
||||
`SAVE_GCODE_STATE NAME=PAUSE_state` and
|
||||
`RESTORE_GCODE_STATE NAME=PAUSE_state`
|
||||
* **Please note:**\
|
||||
The "CustomPiOS Migration Helper" is intended to only work on "vanilla"
|
||||
MainsailOS and FluiddPi systems. Do not try to migrate a modified MainsailOS
|
||||
or FluiddPi system (for example if you already used KIAUH to re-install
|
||||
services or to set up a multi-instance installation for Klipper / Moonraker).
|
||||
This won't work.
|
||||
|
||||
### 2021-01-31
|
||||
|
||||
* **This is a big one... KIAUH v3.0 is out.**\
|
||||
With this update you can now install multiple instances of Klipper, Moonraker,
|
||||
Duet Web Control or Octoprint on the same Pi. This was quite a big rework of
|
||||
the whole script. So bugs can appear but with the help of some testers, i
|
||||
think there shouldn't be any critical ones anymore. In this regards thanks to
|
||||
@lixxbox and @zellneralex for testing.
|
||||
|
||||
* Important changes to how installations are set up now: All components get
|
||||
installed as systemd services. Installation via init.d was dropped completely!
|
||||
This shouldn't affect you at all, since the common linux distributions like
|
||||
RaspberryPi OS or custom distributions like MainsailOS, FluiddPi or OctoPi
|
||||
support both ways of installing services. I just wanted to mention it here.
|
||||
|
||||
* Now with KIAUH v3.0 and multi-instance installation capabilities, there are
|
||||
some things to point out. You will now need to tell KIAUH where your printers
|
||||
configurations are located when installing Klipper for the first time. Even
|
||||
though it is not recommended, you can change this location with the help of
|
||||
KIAUH and rewrite Klipper and Moonraker to use the new location.
|
||||
|
||||
* When setting up a multi-instance system, the folder structure will only change
|
||||
slightly. The goal was to keep it as compatible as possible with the custom
|
||||
distributions like mainsailOS and FluiddPi. This should help converting a
|
||||
single-instance setup of mainsailOS/FluiddPi to a multi-instance setup in no
|
||||
time, but keeping single-instance backwards compatibility if needed at a later
|
||||
point in time.
|
||||
|
||||
* The folder structure is as follows when setting up multi-instances:\
|
||||
Each printer instance will get its own folder within your configuration
|
||||
location. The decision to this specific structure was made to make it as
|
||||
painless and easy as possible to convert to a multi-instance setup.
|
||||
Here is an example:
|
||||
```shell
|
||||
/home/<username>
|
||||
└── klipper_config
|
||||
├── printer_1
|
||||
│ ├── printer.cfg
|
||||
│ └── moonraker.conf
|
||||
├── printer_2
|
||||
│ ├── printer.cfg
|
||||
│ └── moonraker.conf
|
||||
└── printer_n
|
||||
├── printer.cfg
|
||||
└── moonraker.conf
|
||||
```
|
||||
* Also when setting up multi-instances of each service, the name of each service
|
||||
slightly changes.
|
||||
Each service gets its corresponding instance added to the service filename.
|
||||
|
||||
**This only applies to multi-instances! Single instance installations with
|
||||
KIAUH will keep their original names!**
|
||||
|
||||
Corresponding to the filetree example from above that would mean:
|
||||
```
|
||||
Klipper services:
|
||||
--> klipper-1.service
|
||||
--> klipper-2.service
|
||||
--> klipper-n.service
|
||||
|
||||
Moonraker services:
|
||||
--> moonraker-1.service
|
||||
--> moonraker-2.service
|
||||
--> moonraker-n.service
|
||||
```
|
||||
* The same service file rules from above apply to OctoPrint even though only
|
||||
Klipper and Moonraker are shown in this example.
|
||||
|
||||
* You can start, stop and restart all Klipper, Moonraker and OctoPrint instances
|
||||
from the KIAUH main menu. For doing this, just type "stop klipper", "start
|
||||
moonraker", "restart octoprint" and so on.
|
||||
|
||||
* KIAUH v3.0 relocated its ini-file. It is now a hidden file in the users
|
||||
home-directory calles `.kiauh.ini`. This has the benefit of keeping all values
|
||||
in that file between possible re-installations of KIAUH. Otherwise that file
|
||||
would be lost.
|
||||
|
||||
* The option of adding more trusted clients to the moonraker.conf file was
|
||||
dropped. Since you can edit this file right inside of Mainsail or Fluidd, only
|
||||
some basic entries are made which get you running.
|
||||
|
||||
* I bet i have missed mentioning other stuff as well because it took me quite
|
||||
some time to re-write many functions. So i just hope you like the new version
|
||||
😄
|
||||
|
||||
### 2020-11-28
|
||||
|
||||
* KIAUH now supports the installation, update and removal
|
||||
of [KlipperScreen](https://github.com/jordanruthe/KlipperScreen). This feature
|
||||
was was provided by [jordanruthe](https://github.com/jordanruthe)! Thank you!
|
||||
|
||||
### 2020-11-18
|
||||
|
||||
* Some changes to Fluidd caused a little rework on how KIAUH will install/update
|
||||
Fluidd from now on. Please see
|
||||
the [fluidd v1.0.0-rc0 release notes](https://github.com/cadriel/fluidd/releases/tag/v1.0.0-rc.0)
|
||||
for further information about what modifications to the moonraker.conf file
|
||||
exactly had to be done. In a nutshell, KIAUH will now always patch the
|
||||
required entries to the moonraker.conf if not already there.
|
||||
|
||||
### 2020-10-30:
|
||||
|
||||
* The user can now choose to install Klipper as a systemd service.
|
||||
|
||||
* The Shell Command extension and `shell_command.py` got renamed to G-Code Shell
|
||||
Command extension and `gcode_shell_command.py`. In case
|
||||
the [pending PR](https://github.com/KevinOConnor/klipper/pull/2173) will be
|
||||
merged in the future, this was an early attempt to dodge possible
|
||||
incompatibilities. The [G-Code Shell Command docs](gcode_shell_command.md) has
|
||||
been updated accordingly.
|
||||
|
||||
* The way how KIAUH interacts and writes to the users printer.cfg got changed.
|
||||
Usually KIAUH wrote everything directly into the printer.cfg. The way it will
|
||||
work from now on is, that a new file called `kiauh.cfg` will be created if
|
||||
there is something that needs to be written to the printer.cfg and everything
|
||||
gets written to `kiauh.cfg` instead. The only thing which then gets written to
|
||||
the users printer.cfg is `[include kiauh.cfg]`. This line will be located at
|
||||
the very top of the existing printer.cfg with a little comment as a note. The
|
||||
user can then decide to either keep the `kiauh.cfg` or take its content,
|
||||
places it into the printer.cfg directly and remove the `[include kiauh.cfg]`.
|
||||
|
||||
* The `mainsail_macros.cfg` got renamed to `webui_macros.cfg`. Since Mainsail
|
||||
and Fluidd both use the same kind of pause, cancel and resume macros, a more
|
||||
generic name was chosen for the file containing the example macros one can
|
||||
choose to install when installing those webinterfaces.
|
||||
|
||||
### 2020-10-10:
|
||||
|
||||
* Support for changing the Klipper branch to the moonraker-dev branch from
|
||||
@Arksine has been dropped. Support for Moonraker has been merged into Klipper
|
||||
mainline a long time ago.
|
||||
|
||||
* A new function is available from the main menu. You can now upload your log
|
||||
files to http://paste.c-net.org/ to share them for debugging purposes.
|
||||
|
||||
### 2020-10-06:
|
||||
|
||||
* Fluidd, a new Klipper interface got added to the list of available installers.
|
||||
At the same time some installation routines have changed or have seen some
|
||||
rework. Changes were made to the installation of NGINX configurations. A
|
||||
method was introduced to change the listen port of a webinterface
|
||||
configuration if there is already another webinterface listening on the
|
||||
default port (80).
|
||||
|
||||
* At the moment, the Moonraker installer no longer asks you whether you want to
|
||||
install a web interface too. For now you therefore have to install them with
|
||||
their respective installers. Please report any bugs or issues you encounter.
|
||||
|
||||
### 2020-09-17:
|
||||
|
||||
* The dev-2.0 branch will be abandoned as of today. If you did a checkout to
|
||||
that branch in the past, you have to checkout back to master to receive
|
||||
updates.
|
||||
|
||||
### 2020-09-12:
|
||||
|
||||
* The old [dwc2-for-klipper](https://github.com/Stephan3/dwc2-for-klipper) won't
|
||||
be supported anymore!\
|
||||
The is a new, fully rewritten project
|
||||
available: [dwc2-for-klipper-socket](https://github.com/Stephan3/dwc2-for-klipper-socket).\
|
||||
The installer of this script also got rewritten to make use of that new
|
||||
project. You will not be able to install or remove the
|
||||
old [dwc2-for-klipper](https://github.com/Stephan3/dwc2-for-klipper) with
|
||||
KIAUH anymore if you updated KIAUH to the newest version.
|
||||
@@ -0,0 +1,74 @@
|
||||
# G-Code Shell Command Extension
|
||||
|
||||
### Creator of this extension is [Arksine](https://github.com/Arksine).
|
||||
|
||||
This is a brief explanation of how to use the shell command extension for Klipper, which you can install with KIAUH.
|
||||
|
||||
After installing the extension you can execute linux commands or even scripts from within Klipper with custom commands defined in your printer.cfg.
|
||||
|
||||
#### How to configure a shell command:
|
||||
|
||||
```shell
|
||||
# Runs a linux command or script from within klipper. Note that sudo commands
|
||||
# that require password authentication are disallowed. All executable scripts
|
||||
# should include a shebang.
|
||||
# [gcode_shell_command my_shell_cmd]
|
||||
#command:
|
||||
# The linux shell command/script to be executed. This parameter must be
|
||||
# provided
|
||||
#timeout: 2.
|
||||
# The timeout in seconds until the command is forcably terminated. Default
|
||||
# is 2 seconds.
|
||||
#verbose: True
|
||||
# If enabled, the command's output will be forwarded to the terminal. Its
|
||||
# recommended to set this to false for commands that my run in quick
|
||||
# succession. Default is True.
|
||||
```
|
||||
|
||||
Once you have set up a shell command with the given parameters from above in your printer.cfg you can run the command as follows:
|
||||
`RUN_SHELL_COMMAND CMD=name`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
[gcode_shell_command hello_world]
|
||||
command: echo hello world
|
||||
timeout: 2.
|
||||
verbose: True
|
||||
```
|
||||
|
||||
Execute with:
|
||||
`RUN_SHELL_COMMAND CMD=hello_world`
|
||||
|
||||
### Passing parameters:
|
||||
As of commit [f231fa9](https://github.com/dw-0/kiauh/commit/f231fa9c69191f23277b4e3319f6b675bfa0ee42) it is also possible to pass optional parameters to a `gcode_shell_command`.
|
||||
The following short example shows storing the extruder temperature into a variable, passing that value with a parameter to a `gcode_shell_command`, which then,
|
||||
once the gcode_macro runs and the gcode_shell_command gets called, executes the `script.sh`. The script then echoes a message to the console (if `verbose: True`)
|
||||
and writes the value of the parameter into a textfile called `test.txt` located in the home directory.
|
||||
|
||||
Content of the `gcode_shell_command` and the `gcode_macro`:
|
||||
```
|
||||
[gcode_shell_command print_to_file]
|
||||
command: sh /home/pi/klipper_config/script.sh
|
||||
timeout: 30.
|
||||
verbose: True
|
||||
|
||||
[gcode_macro GET_TEMP]
|
||||
gcode:
|
||||
{% set temp = printer.extruder.temperature %}
|
||||
{ action_respond_info("%s" % (temp)) }
|
||||
RUN_SHELL_COMMAND CMD=print_to_file PARAMS={temp}
|
||||
```
|
||||
|
||||
Content of `script.sh`:
|
||||
```shell
|
||||
#!/bin/sh
|
||||
|
||||
echo "temp is: $1"
|
||||
echo "$1" >> "${HOME}/test.txt"
|
||||
```
|
||||
|
||||
## Warning
|
||||
|
||||
This extension may have a high potential for abuse if not used carefully! Also, depending on the command you execute, high system loads may occur and can cause system instabilities.
|
||||
Use this extension at your own risk and only if you know what you are doing!
|
||||
@@ -1,387 +1,146 @@
|
||||
#!/bin/bash
|
||||
clear
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#=======================================================================#
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
#=======================================================================#
|
||||
|
||||
set -e
|
||||
clear -x
|
||||
|
||||
### set some variables
|
||||
ERROR_MSG=""
|
||||
green="\e[92m"
|
||||
yellow="\e[93m"
|
||||
red="\e[91m"
|
||||
cyan="\e[96m"
|
||||
default="\e[39m"
|
||||
# make sure we have the correct permissions while running the script
|
||||
umask 022
|
||||
|
||||
# gets the path where this script is located
|
||||
KIAUH_SRCDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
|
||||
|
||||
# colors
|
||||
white="\033[37m"
|
||||
cyan="\033[96m"
|
||||
red="\033[91m"
|
||||
yellow="\033[93m"
|
||||
green="\033[92m"
|
||||
|
||||
#===================================================#
|
||||
#=================== UPDATE KIAUH ==================#
|
||||
#===================================================#
|
||||
|
||||
function update_kiauh() {
|
||||
echo "Updating KIAUH ..."
|
||||
|
||||
cd "${KIAUH_SRCDIR}"
|
||||
git reset --hard && git pull
|
||||
|
||||
echo "Update complete! Restarting..."
|
||||
sleep 1
|
||||
exec "$0" "$@" # restarts the script
|
||||
|
||||
### set some messages
|
||||
warn_msg(){
|
||||
echo -e "${red}<!!!!> $1${default}"
|
||||
}
|
||||
status_msg(){
|
||||
echo; echo -e "${yellow}###### $1${default}"
|
||||
}
|
||||
ok_msg(){
|
||||
echo -e "${green}>>>>>> $1${default}"
|
||||
}
|
||||
title_msg(){
|
||||
echo -e "${cyan}$1${default}"
|
||||
}
|
||||
get_date(){
|
||||
current_date=`date +"%Y-%m-%d_%H%M%S"`
|
||||
}
|
||||
|
||||
### sourcing all additional scripts
|
||||
for script in ${HOME}/kiauh/scripts/*; do . $script; done
|
||||
#===================================================#
|
||||
#=================== KIAUH STATUS ==================#
|
||||
#===================================================#
|
||||
|
||||
### set important directories
|
||||
#klipper
|
||||
KLIPPER_DIR=${HOME}/klipper
|
||||
KLIPPY_ENV_DIR=${HOME}/klippy-env
|
||||
KLIPPER_SERVICE1=/etc/init.d/klipper
|
||||
KLIPPER_SERVICE2=/etc/default/klipper
|
||||
#dwc2
|
||||
DWC2FK_DIR=${HOME}/dwc2-for-klipper
|
||||
DWC2_DIR=${HOME}/sdcard/dwc2
|
||||
WEB_DWC2=${HOME}/klipper/klippy/extras/web_dwc2.py
|
||||
TORNADO_DIR1=${HOME}/klippy-env/lib/python2.7/site-packages/tornado
|
||||
TORNADO_DIR2=${HOME}/klippy-env/lib/python2.7/site-packages/tornado-5.1.1.dist-info
|
||||
#mainsail/moonraker
|
||||
MAINSAIL_DIR=${HOME}/mainsail
|
||||
MAINSAIL_SERVICE1=/etc/init.d/moonraker
|
||||
MAINSAIL_SERVICE2=/etc/default/moonraker
|
||||
#misc
|
||||
BACKUP_DIR=${HOME}/kiauh-backups
|
||||
PRINTER_CFG=${HOME}/printer.cfg
|
||||
function kiauh_update_avail() {
|
||||
[[ ! -d "${KIAUH_SRCDIR}/.git" ]] && return
|
||||
local origin head
|
||||
|
||||
### set github repos
|
||||
KLIPPER_REPO=https://github.com/KevinOConnor/klipper.git
|
||||
ARKSINE_REPO=https://github.com/Arksine/klipper.git
|
||||
DMBUTYUGIN_REPO=https://github.com/dmbutyugin/klipper.git
|
||||
DWC2FK_REPO=https://github.com/Stephan3/dwc2-for-klipper.git
|
||||
#branches
|
||||
BRANCH_MOONRAKER=Arksine/work-web_server-20200131
|
||||
BRANCH_DEV_MOONRAKER=Arksine/dev-moonraker-testing
|
||||
BRANCH_SCURVE_SMOOTHING=dmbutyugin/scurve-smoothing
|
||||
BRANCH_SCURVE_SHAPING=dmbutyugin/scurve-shaping
|
||||
cd "${KIAUH_SRCDIR}"
|
||||
|
||||
print_error_msg(){
|
||||
if [[ "$ERROR_MSG" != "" ]]; then
|
||||
### abort if not on master branch
|
||||
! git branch -a | grep -q "\* master" && return
|
||||
|
||||
### compare commit hash
|
||||
git fetch -q
|
||||
origin=$(git rev-parse --short=8 origin/master)
|
||||
head=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
if [[ ${origin} != "${head}" ]]; then
|
||||
echo "true"
|
||||
fi
|
||||
}
|
||||
|
||||
function kiauh_update_dialog() {
|
||||
[[ ! $(kiauh_update_avail) == "true" ]] && return
|
||||
echo -e "/-------------------------------------------------------\\"
|
||||
echo -e "|${green} New KIAUH update available! ${white}|"
|
||||
echo -e "|-------------------------------------------------------|"
|
||||
echo -e "|${green} View Changelog: https://git.io/JnmlX ${white}|"
|
||||
echo -e "| |"
|
||||
echo -e "|${yellow} It is recommended to keep KIAUH up to date. Updates ${white}|"
|
||||
echo -e "|${yellow} usually contain bugfixes, important changes or new ${white}|"
|
||||
echo -e "|${yellow} features. Please consider updating! ${white}|"
|
||||
echo -e "\-------------------------------------------------------/"
|
||||
|
||||
local yn
|
||||
echo -ne "${cyan}###### Do you want to update now? (Y/n):${white} "
|
||||
read yn
|
||||
while true; do
|
||||
case "${yn}" in
|
||||
Y|y|Yes|yes|"")
|
||||
update_kiauh
|
||||
break;;
|
||||
N|n|No|no)
|
||||
break;;
|
||||
*)
|
||||
echo -e "${red}Invalid input. Please try again.${white}"
|
||||
kiauh_update_dialog;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
function check_euid() {
|
||||
if [[ ${EUID} -eq 0 ]]; then
|
||||
echo -e "${red}"
|
||||
echo -e "#########################################################"
|
||||
echo -e "$ERROR_MSG "
|
||||
echo -e "#########################################################"
|
||||
echo -e "${default}"
|
||||
echo -e "/-------------------------------------------------------\\"
|
||||
echo -e "| !!! THIS SCRIPT MUST NOT RUN AS ROOT !!! |"
|
||||
echo -e "| |"
|
||||
echo -e "| It will ask for credentials as needed. |"
|
||||
echo -e "\-------------------------------------------------------/"
|
||||
echo -e "${white}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main_menu(){
|
||||
print_header
|
||||
print_error_msg && ERROR_MSG=""
|
||||
#check install status
|
||||
klipper_status
|
||||
dwc2_status
|
||||
mainsail_status
|
||||
print_branch
|
||||
main_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
0)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Sorry this function is not implemented yet!"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
main_ui;;
|
||||
1)
|
||||
clear
|
||||
install_menu
|
||||
break;;
|
||||
2)
|
||||
clear
|
||||
update_menu
|
||||
break;;
|
||||
3)
|
||||
clear
|
||||
remove_menu
|
||||
break;;
|
||||
4)
|
||||
clear
|
||||
advanced_menu
|
||||
break;;
|
||||
5)
|
||||
clear
|
||||
backup_menu
|
||||
break;;
|
||||
Q|q)
|
||||
exit -1;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
main_ui;;
|
||||
esac
|
||||
done
|
||||
clear; main_menu
|
||||
}
|
||||
|
||||
install_menu(){
|
||||
print_header
|
||||
install_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
1)
|
||||
clear
|
||||
print_header
|
||||
install_klipper
|
||||
print_error_msg && ERROR_MSG=""
|
||||
install_ui;;
|
||||
2)
|
||||
clear
|
||||
print_header
|
||||
dwc2_install_routine
|
||||
print_error_msg && ERROR_MSG=""
|
||||
install_ui;;
|
||||
3)
|
||||
clear
|
||||
print_header
|
||||
mainsail_install_routine
|
||||
print_error_msg && ERROR_MSG=""
|
||||
install_ui;;
|
||||
Q|q)
|
||||
clear; main_menu; break;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
install_ui;;
|
||||
esac
|
||||
done
|
||||
install_menu
|
||||
}
|
||||
|
||||
update_menu(){
|
||||
print_header
|
||||
read_bb4u_stat
|
||||
#compare versions
|
||||
ui_print_versions
|
||||
update_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
0)
|
||||
clear
|
||||
print_header
|
||||
toggle_backups
|
||||
print_error_msg && ERROR_MSG=""
|
||||
update_ui;;
|
||||
1)
|
||||
clear
|
||||
print_header
|
||||
update_klipper && ui_print_versions
|
||||
print_error_msg && ERROR_MSG=""
|
||||
update_ui;;
|
||||
2)
|
||||
clear
|
||||
print_header
|
||||
update_dwc2fk && ui_print_versions
|
||||
print_error_msg && ERROR_MSG=""
|
||||
update_ui;;
|
||||
3)
|
||||
clear
|
||||
print_header
|
||||
update_dwc2 && ui_print_versions
|
||||
print_error_msg && ERROR_MSG=""
|
||||
update_ui;;
|
||||
4)
|
||||
clear
|
||||
print_header
|
||||
update_mainsail && ui_print_versions
|
||||
print_error_msg && ERROR_MSG=""
|
||||
update_ui;;
|
||||
Q|q)
|
||||
clear; main_menu; break;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
ui_print_versions
|
||||
update_ui;;
|
||||
esac
|
||||
done
|
||||
update_menu
|
||||
}
|
||||
|
||||
remove_menu(){
|
||||
print_header
|
||||
remove_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
1)
|
||||
clear
|
||||
print_header
|
||||
remove_klipper
|
||||
print_error_msg && ERROR_MSG=""
|
||||
remove_ui;;
|
||||
2)
|
||||
clear
|
||||
print_header
|
||||
remove_dwc2
|
||||
print_error_msg && ERROR_MSG=""
|
||||
remove_ui;;
|
||||
3)
|
||||
clear
|
||||
print_header
|
||||
remove_mainsail
|
||||
print_error_msg && ERROR_MSG=""
|
||||
remove_ui;;
|
||||
Q|q)
|
||||
clear; main_menu; break;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
remove_ui;;
|
||||
esac
|
||||
done
|
||||
remove_menu
|
||||
}
|
||||
|
||||
advanced_menu(){
|
||||
print_header
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
1)
|
||||
clear
|
||||
switch_menu
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
2)
|
||||
clear
|
||||
print_header
|
||||
build_fw
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
3)
|
||||
clear
|
||||
print_header
|
||||
flash_routine
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
4)
|
||||
clear
|
||||
print_header
|
||||
get_usb_id
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
5)
|
||||
clear
|
||||
print_header
|
||||
get_usb_id && write_printer_id
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
6)
|
||||
clear
|
||||
print_header
|
||||
create_dwc2fk_cfg
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
Q|q)
|
||||
clear; main_menu; break;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
advanced_ui;;
|
||||
esac
|
||||
done
|
||||
advanced_menu
|
||||
}
|
||||
|
||||
switch_menu(){
|
||||
print_header
|
||||
if [ -d $KLIPPER_DIR ]; then
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
1)
|
||||
clear
|
||||
print_header
|
||||
switch_to_origin
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui;;
|
||||
2)
|
||||
clear
|
||||
print_header
|
||||
switch_to_scurve_shaping
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui;;
|
||||
3)
|
||||
clear
|
||||
print_header
|
||||
switch_to_scurve_smoothing
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui;;
|
||||
4)
|
||||
clear
|
||||
print_header
|
||||
switch_to_moonraker
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui;;
|
||||
5)
|
||||
clear
|
||||
print_header
|
||||
switch_to_dev_moonraker
|
||||
read_branch
|
||||
print_error_msg && ERROR_MSG=""
|
||||
switch_ui;;
|
||||
Q|q)
|
||||
clear; advanced_menu; break;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
ERROR_MSG=" No klipper directory found! Download klipper first!"
|
||||
function check_if_ratos() {
|
||||
if [[ -n $(which ratos) ]]; then
|
||||
echo -e "${red}"
|
||||
echo -e "/-------------------------------------------------------\\"
|
||||
echo -e "| !!! RatOS 2.1 or greater detected !!! |"
|
||||
echo -e "| |"
|
||||
echo -e "| KIAUH does currently not support RatOS. |"
|
||||
echo -e "| If you have any questions, please ask for help on the |"
|
||||
echo -e "| RatRig Community Discord: https://discord.gg/ratrig |"
|
||||
echo -e "\-------------------------------------------------------/"
|
||||
echo -e "${white}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_menu(){
|
||||
print_header
|
||||
print_error_msg && ERROR_MSG=""
|
||||
backup_ui
|
||||
while true; do
|
||||
read -p "Perform action: " action; echo
|
||||
case "$action" in
|
||||
1)
|
||||
clear
|
||||
print_header
|
||||
#function goes here
|
||||
print_error_msg && ERROR_MSG=""
|
||||
backup_ui;;
|
||||
Q|q)
|
||||
clear; main_menu; break;;
|
||||
*)
|
||||
clear
|
||||
print_header
|
||||
ERROR_MSG=" Unknown command '$action'"
|
||||
print_error_msg && ERROR_MSG=""
|
||||
backup_ui;;
|
||||
esac
|
||||
done
|
||||
backup_menu
|
||||
function main() {
|
||||
local entrypoint
|
||||
|
||||
if ! command -v python3 &>/dev/null || [[ $(python3 -V | cut -d " " -f2 | cut -d "." -f2) -lt 8 ]]; then
|
||||
echo "Python 3.8 or higher is not installed!"
|
||||
echo "Please install Python 3.8 or higher and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
entrypoint=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
|
||||
|
||||
export PYTHONPATH="${entrypoint}"
|
||||
|
||||
clear -x
|
||||
python3 "${entrypoint}/kiauh/main.py" "$@"
|
||||
}
|
||||
|
||||
check_euid
|
||||
main_menu
|
||||
# skip update prompt when arguments are passed -> dont block cli runs
|
||||
if [[ $# -eq 0 ]]; then
|
||||
kiauh_update_dialog
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
APPLICATION_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.append(str(APPLICATION_ROOT))
|
||||
@@ -0,0 +1,30 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from core.constants import SYSTEMD
|
||||
|
||||
# repo
|
||||
CROWSNEST_REPO = "https://github.com/mainsail-crew/crowsnest.git"
|
||||
|
||||
# names
|
||||
CROWSNEST_SERVICE_NAME = "crowsnest.service"
|
||||
|
||||
# directories
|
||||
CROWSNEST_DIR = Path.home().joinpath("crowsnest")
|
||||
CROWSNEST_ENV_DIR = Path.home().joinpath("crowsnest-env")
|
||||
|
||||
# files
|
||||
CROWSNEST_MULTI_CONFIG = CROWSNEST_DIR.joinpath("tools/.config")
|
||||
CROWSNEST_INSTALL_SCRIPT = CROWSNEST_DIR.joinpath("tools/install.sh")
|
||||
CROWSNEST_DEPS_JSON_FILE = CROWSNEST_DIR.joinpath("system-dependencies.json")
|
||||
CROWSNEST_BIN_FILE = Path("/usr/local/bin/crowsnest")
|
||||
CROWSNEST_LOGROTATE_FILE = Path("/etc/logrotate.d/crowsnest")
|
||||
CROWSNEST_SERVICE_FILE = SYSTEMD.joinpath(CROWSNEST_SERVICE_NAME)
|
||||
@@ -0,0 +1,236 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError, run
|
||||
from typing import List
|
||||
|
||||
from components.crowsnest import (
|
||||
CROWSNEST_BIN_FILE,
|
||||
CROWSNEST_DEPS_JSON_FILE,
|
||||
CROWSNEST_DIR,
|
||||
CROWSNEST_ENV_DIR,
|
||||
CROWSNEST_INSTALL_SCRIPT,
|
||||
CROWSNEST_LOGROTATE_FILE,
|
||||
CROWSNEST_MULTI_CONFIG,
|
||||
CROWSNEST_REPO,
|
||||
CROWSNEST_SERVICE_FILE,
|
||||
CROWSNEST_SERVICE_NAME,
|
||||
)
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.moonraker.utils.sysdeps_parser import SysDepsParser
|
||||
from components.moonraker.utils.utils import load_sysdeps_json
|
||||
from core.logger import DialogType, Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
from core.types.component_status import ComponentStatus
|
||||
from utils.common import (
|
||||
check_install_dependencies,
|
||||
get_install_status,
|
||||
)
|
||||
from utils.git_utils import (
|
||||
get_current_branch,
|
||||
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_service,
|
||||
parse_packages_from_file,
|
||||
)
|
||||
|
||||
|
||||
def install_crowsnest() -> None:
|
||||
# Step 1: Clone crowsnest repo
|
||||
git_clone_wrapper(CROWSNEST_REPO, CROWSNEST_DIR)
|
||||
|
||||
# Step 2: Install dependencies
|
||||
check_install_dependencies({"make"})
|
||||
|
||||
# Step 3: Check for Multi Instance
|
||||
instances: List[Klipper] = get_instances(Klipper)
|
||||
|
||||
if len(instances) > 1:
|
||||
print_multi_instance_warning(instances)
|
||||
|
||||
if not get_confirm("Do you want to continue with the installation?"):
|
||||
Logger.print_info("Crowsnest installation aborted!")
|
||||
return
|
||||
|
||||
Logger.print_status("Launching crowsnest's install configurator ...")
|
||||
time.sleep(3)
|
||||
configure_multi_instance()
|
||||
|
||||
# Step 4: Launch crowsnest installer
|
||||
Logger.print_status("Launching crowsnest installer ...")
|
||||
Logger.print_info("Installer will prompt you for sudo password!")
|
||||
try:
|
||||
run(
|
||||
"sudo make install",
|
||||
cwd=CROWSNEST_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Something went wrong! Please try again...\n{e}")
|
||||
return
|
||||
|
||||
|
||||
def print_multi_instance_warning(instances: List[Klipper]) -> None:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
"Multi instance install detected!",
|
||||
"\n\n",
|
||||
"Crowsnest is NOT designed to support multi instances. A workaround "
|
||||
"for this is to choose the most used instance as a 'master' and use "
|
||||
"this instance to set up your 'crowsnest.conf' and steering it's service.",
|
||||
"\n\n",
|
||||
"The following instances were found:",
|
||||
*[f"● {instance.data_dir.name}" for instance in instances],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def configure_multi_instance() -> None:
|
||||
try:
|
||||
run(
|
||||
"make config",
|
||||
cwd=CROWSNEST_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Something went wrong! Please try again...\n{e}")
|
||||
if CROWSNEST_MULTI_CONFIG.exists():
|
||||
Path.unlink(CROWSNEST_MULTI_CONFIG)
|
||||
return
|
||||
|
||||
if not CROWSNEST_MULTI_CONFIG.exists():
|
||||
Logger.print_error("Generating .config failed, installation aborted")
|
||||
|
||||
|
||||
def update_crowsnest() -> None:
|
||||
try:
|
||||
cmd_sysctl_service(CROWSNEST_SERVICE_NAME, "stop")
|
||||
|
||||
if not CROWSNEST_DIR.exists():
|
||||
git_clone_wrapper(CROWSNEST_REPO, CROWSNEST_DIR, "master")
|
||||
else:
|
||||
Logger.print_status("Updating Crowsnest ...")
|
||||
|
||||
settings = KiauhSettings()
|
||||
if settings.kiauh.backup_before_update:
|
||||
svc = BackupService()
|
||||
svc.backup_directory(
|
||||
source_path=CROWSNEST_DIR,
|
||||
target_path="crowsnest",
|
||||
backup_name="crowsnest",
|
||||
)
|
||||
|
||||
git_pull_wrapper(CROWSNEST_DIR)
|
||||
|
||||
install_crowsnest_packages()
|
||||
|
||||
cmd_sysctl_service(CROWSNEST_SERVICE_NAME, "restart")
|
||||
|
||||
Logger.print_ok("Crowsnest updated successfully.", end="\n\n")
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Something went wrong! Please try again...\n{e}")
|
||||
return
|
||||
|
||||
|
||||
def get_crowsnest_status() -> ComponentStatus:
|
||||
"""
|
||||
Get the current install status of Crowsnest. Depending on the version the installed
|
||||
files are different. If a version is not yet specified, it will search for a
|
||||
non_existant file resulting in 'Incomplete' status.
|
||||
:return: Installation status
|
||||
"""
|
||||
files_dict = {
|
||||
4: [
|
||||
CROWSNEST_BIN_FILE,
|
||||
CROWSNEST_LOGROTATE_FILE,
|
||||
CROWSNEST_SERVICE_FILE,
|
||||
],
|
||||
5: [CROWSNEST_SERVICE_FILE],
|
||||
}
|
||||
version = get_crowsnest_version()
|
||||
|
||||
non_existant = CROWSNEST_DIR.joinpath("non_existant")
|
||||
files = files_dict.get(version, [non_existant])
|
||||
|
||||
env_dir = None
|
||||
if version >= 5:
|
||||
env_dir = CROWSNEST_ENV_DIR
|
||||
return get_install_status(CROWSNEST_DIR, files=files, env_dir=env_dir)
|
||||
|
||||
|
||||
def get_crowsnest_version() -> int:
|
||||
"""
|
||||
Get the current major version. Starting with v5 the default branch will be named
|
||||
after the major version.
|
||||
:return: Current major version
|
||||
"""
|
||||
version = get_current_branch(CROWSNEST_DIR)
|
||||
if version is None:
|
||||
return 0
|
||||
if version == "master":
|
||||
return 4
|
||||
return int(version.removeprefix("v"))
|
||||
|
||||
|
||||
def install_crowsnest_packages() -> None:
|
||||
Logger.print_status("Parsing Crowsnest system dependencies ...")
|
||||
|
||||
crowsnest_deps = []
|
||||
crowsnest_version = get_crowsnest_version()
|
||||
if crowsnest_version >= 5 and CROWSNEST_DEPS_JSON_FILE.exists():
|
||||
Logger.print_info(
|
||||
f"Parsing system dependencies from {CROWSNEST_DEPS_JSON_FILE.name} ..."
|
||||
)
|
||||
parser = SysDepsParser()
|
||||
sysdeps = load_sysdeps_json(CROWSNEST_DEPS_JSON_FILE)
|
||||
crowsnest_deps.extend(parser.parse_dependencies(sysdeps))
|
||||
|
||||
elif crowsnest_version <= 4 and CROWSNEST_INSTALL_SCRIPT.exists():
|
||||
Logger.print_info(
|
||||
f"Parsing system dependencies from {CROWSNEST_INSTALL_SCRIPT.name} ..."
|
||||
)
|
||||
crowsnest_deps = parse_packages_from_file(CROWSNEST_INSTALL_SCRIPT)
|
||||
|
||||
if not crowsnest_deps:
|
||||
raise ValueError("Error parsing crowsnest dependencies!")
|
||||
|
||||
check_install_dependencies({*crowsnest_deps})
|
||||
|
||||
|
||||
def remove_crowsnest() -> None:
|
||||
if not CROWSNEST_DIR.exists():
|
||||
Logger.print_info("Crowsnest does not seem to be installed! Skipping ...")
|
||||
return
|
||||
|
||||
try:
|
||||
run(
|
||||
"make uninstall",
|
||||
cwd=CROWSNEST_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Something went wrong! Please try again...\n{e}")
|
||||
return
|
||||
|
||||
Logger.print_status("Removing crowsnest directory ...")
|
||||
shutil.rmtree(CROWSNEST_DIR)
|
||||
Logger.print_ok("Directory removed!")
|
||||
@@ -0,0 +1,36 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parent
|
||||
|
||||
KLIPPER_REPO_URL = "https://github.com/Klipper3d/klipper.git"
|
||||
|
||||
# names
|
||||
KLIPPER_LOG_NAME = "klippy.log"
|
||||
KLIPPER_CFG_NAME = "printer.cfg"
|
||||
KLIPPER_SERIAL_NAME = "klippy.serial"
|
||||
KLIPPER_UDS_NAME = "klippy.sock"
|
||||
KLIPPER_ENV_FILE_NAME = "klipper.env"
|
||||
KLIPPER_SERVICE_NAME = "klipper.service"
|
||||
|
||||
# directories
|
||||
KLIPPER_DIR = Path.home().joinpath("klipper")
|
||||
KLIPPER_KCONFIGS_DIR = Path.home().joinpath("klipper-kconfigs")
|
||||
KLIPPER_ENV_DIR = Path.home().joinpath("klippy-env")
|
||||
|
||||
# files
|
||||
KLIPPER_REQ_FILE = KLIPPER_DIR.joinpath("scripts/klippy-requirements.txt")
|
||||
KLIPPER_INSTALL_SCRIPT = KLIPPER_DIR.joinpath("scripts/install-ubuntu-22.04.sh")
|
||||
KLIPPER_SERVICE_TEMPLATE = MODULE_PATH.joinpath(f"assets/{KLIPPER_SERVICE_NAME}")
|
||||
KLIPPER_ENV_FILE_TEMPLATE = MODULE_PATH.joinpath(f"assets/{KLIPPER_ENV_FILE_NAME}")
|
||||
|
||||
|
||||
EXIT_KLIPPER_SETUP = "Exiting Klipper setup ..."
|
||||
@@ -0,0 +1 @@
|
||||
KLIPPER_ARGS="%KLIPPER_DIR%/klippy/klippy.py %CFG% -I %SERIAL% -l %LOG% -a %UDS%"
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Klipper 3D Printer Firmware SV1
|
||||
Documentation=https://www.klipper3d.org/
|
||||
After=network-online.target
|
||||
Wants=udev.target
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%USER%
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=%KLIPPER_DIR%
|
||||
EnvironmentFile=%ENV_FILE%
|
||||
ExecStart=%ENV%/bin/python $KLIPPER_ARGS
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
@@ -0,0 +1,11 @@
|
||||
[mcu]
|
||||
serial: /dev/serial/by-id/<your-mcu-id>
|
||||
|
||||
[virtual_sdcard]
|
||||
path: %GCODES_DIR%
|
||||
on_error_gcode: CANCEL_PRINT
|
||||
|
||||
[printer]
|
||||
kinematics: none
|
||||
max_velocity: 1000
|
||||
max_accel: 1000
|
||||
@@ -0,0 +1,142 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
from components.klipper import (
|
||||
KLIPPER_CFG_NAME,
|
||||
KLIPPER_DIR,
|
||||
KLIPPER_ENV_DIR,
|
||||
KLIPPER_ENV_FILE_NAME,
|
||||
KLIPPER_ENV_FILE_TEMPLATE,
|
||||
KLIPPER_LOG_NAME,
|
||||
KLIPPER_SERIAL_NAME,
|
||||
KLIPPER_SERVICE_TEMPLATE,
|
||||
KLIPPER_UDS_NAME,
|
||||
)
|
||||
from core.constants import CURRENT_USER
|
||||
from core.instance_manager.base_instance import BaseInstance
|
||||
from core.logger import Logger
|
||||
from utils.fs_utils import create_folders, get_data_dir
|
||||
from utils.sys_utils import get_service_file_path
|
||||
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
@dataclass(repr=True)
|
||||
class Klipper:
|
||||
suffix: str
|
||||
base: BaseInstance = field(init=False, repr=False)
|
||||
service_file_path: Path = field(init=False)
|
||||
log_file_name: str = KLIPPER_LOG_NAME
|
||||
klipper_dir: Path = KLIPPER_DIR
|
||||
env_dir: Path = KLIPPER_ENV_DIR
|
||||
data_dir: Path = field(init=False)
|
||||
cfg_file: Path = field(init=False)
|
||||
env_file: Path = field(init=False)
|
||||
serial: Path = field(init=False)
|
||||
uds: Path = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.base: BaseInstance = BaseInstance(Klipper, self.suffix)
|
||||
self.base.log_file_name = self.log_file_name
|
||||
|
||||
self.service_file_path: Path = get_service_file_path(Klipper, self.suffix)
|
||||
self.data_dir: Path = get_data_dir(Klipper, self.suffix)
|
||||
self.cfg_file: Path = self.base.cfg_dir.joinpath(KLIPPER_CFG_NAME)
|
||||
self.env_file: Path = self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME)
|
||||
self.serial: Path = self.base.comms_dir.joinpath(KLIPPER_SERIAL_NAME)
|
||||
self.uds: Path = self.base.comms_dir.joinpath(KLIPPER_UDS_NAME)
|
||||
|
||||
def create(self) -> None:
|
||||
from utils.sys_utils import create_env_file, create_service_file
|
||||
|
||||
Logger.print_status("Creating new Klipper Instance ...")
|
||||
|
||||
try:
|
||||
create_folders(self.base.base_folders)
|
||||
|
||||
create_service_file(
|
||||
name=self.service_file_path.name,
|
||||
content=self._prep_service_file_content(),
|
||||
)
|
||||
|
||||
create_env_file(
|
||||
path=self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME),
|
||||
content=self._prep_env_file_content(),
|
||||
)
|
||||
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Error creating instance: {e}")
|
||||
raise
|
||||
except OSError as e:
|
||||
Logger.print_error(f"Error creating env file: {e}")
|
||||
raise
|
||||
|
||||
def _prep_service_file_content(self) -> str:
|
||||
template = KLIPPER_SERVICE_TEMPLATE
|
||||
|
||||
try:
|
||||
with open(template, "r") as template_file:
|
||||
template_content = template_file.read()
|
||||
except FileNotFoundError:
|
||||
Logger.print_error(f"Unable to open {template} - File not found")
|
||||
raise
|
||||
|
||||
service_content = template_content.replace(
|
||||
"%USER%",
|
||||
CURRENT_USER,
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%KLIPPER_DIR%",
|
||||
self.klipper_dir.as_posix(),
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%ENV%",
|
||||
self.env_dir.as_posix(),
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%ENV_FILE%",
|
||||
self.base.sysd_dir.joinpath(KLIPPER_ENV_FILE_NAME).as_posix(),
|
||||
)
|
||||
return service_content
|
||||
|
||||
def _prep_env_file_content(self) -> str:
|
||||
template = KLIPPER_ENV_FILE_TEMPLATE
|
||||
|
||||
try:
|
||||
with open(template, "r") as env_file:
|
||||
env_template_file_content = env_file.read()
|
||||
except FileNotFoundError:
|
||||
Logger.print_error(f"Unable to open {template} - File not found")
|
||||
raise
|
||||
|
||||
env_file_content = env_template_file_content.replace(
|
||||
"%KLIPPER_DIR%", self.klipper_dir.as_posix()
|
||||
)
|
||||
env_file_content = env_file_content.replace(
|
||||
"%CFG%",
|
||||
f"{self.base.cfg_dir}/{KLIPPER_CFG_NAME}",
|
||||
)
|
||||
env_file_content = env_file_content.replace(
|
||||
"%SERIAL%",
|
||||
self.serial.as_posix() if self.serial else "",
|
||||
)
|
||||
env_file_content = env_file_content.replace(
|
||||
"%LOG%",
|
||||
self.base.log_dir.joinpath(self.log_file_name).as_posix(),
|
||||
)
|
||||
env_file_content = env_file_content.replace(
|
||||
"%UDS%",
|
||||
self.uds.as_posix() if self.uds else "",
|
||||
)
|
||||
|
||||
return env_file_content
|
||||
@@ -0,0 +1,113 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
import textwrap
|
||||
from enum import Enum, unique
|
||||
from typing import List
|
||||
|
||||
from core.menus.base_menu import print_back_footer
|
||||
from core.types.color import Color
|
||||
from utils.instance_type import InstanceType
|
||||
|
||||
|
||||
@unique
|
||||
class DisplayType(Enum):
|
||||
SERVICE_NAME = "SERVICE_NAME"
|
||||
PRINTER_NAME = "PRINTER_NAME"
|
||||
|
||||
|
||||
def print_instance_overview(
|
||||
instances: List[InstanceType],
|
||||
display_type: DisplayType = DisplayType.SERVICE_NAME,
|
||||
show_headline=True,
|
||||
show_index=False,
|
||||
start_index=0,
|
||||
show_select_all=False,
|
||||
) -> None:
|
||||
dialog = "╔═══════════════════════════════════════════════════════╗\n"
|
||||
if show_headline:
|
||||
d_type = (
|
||||
"Klipper instances"
|
||||
if display_type is DisplayType.SERVICE_NAME
|
||||
else "printer directories"
|
||||
)
|
||||
headline = Color.apply(f"The following {d_type} were found:", Color.GREEN)
|
||||
dialog += f"║{headline:^64}║\n"
|
||||
dialog += "╟───────────────────────────────────────────────────────╢\n"
|
||||
|
||||
if show_select_all:
|
||||
select_all = Color.apply("a) Select all", Color.YELLOW)
|
||||
dialog += f"║ {select_all:<63}║\n"
|
||||
dialog += "║ ║\n"
|
||||
|
||||
for i, s in enumerate(instances):
|
||||
if display_type is DisplayType.SERVICE_NAME:
|
||||
name = s.service_file_path.stem
|
||||
else:
|
||||
name = s.data_dir
|
||||
line = Color.apply(
|
||||
f"{f'{i + start_index})' if show_index else '●'} {name}", Color.CYAN
|
||||
)
|
||||
dialog += f"║ {line:<63}║\n"
|
||||
dialog += "╟───────────────────────────────────────────────────────╢\n"
|
||||
|
||||
print(dialog, end="")
|
||||
print_back_footer()
|
||||
|
||||
|
||||
def print_select_instance_count_dialog() -> None:
|
||||
line1 = Color.apply("WARNING:", Color.YELLOW)
|
||||
line2 = Color.apply(
|
||||
"Setting up too many instances may crash your system.", Color.YELLOW
|
||||
)
|
||||
dialog = textwrap.dedent(
|
||||
f"""
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Please select the number of Klipper instances to set ║
|
||||
║ up. The number of Klipper instances will determine ║
|
||||
║ the amount of printers you can run from this host. ║
|
||||
║ ║
|
||||
║ {line1:<63}║
|
||||
║ {line2:<63}║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
print(dialog, end="")
|
||||
print_back_footer()
|
||||
|
||||
|
||||
def print_select_custom_name_dialog() -> None:
|
||||
line1 = Color.apply("INFO:", Color.YELLOW)
|
||||
line2 = Color.apply("Only alphanumeric characters are allowed!", Color.YELLOW)
|
||||
dialog = textwrap.dedent(
|
||||
f"""
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Do you want to assign a custom name to each instance? ║
|
||||
║ ║
|
||||
║ Assigning a custom name will create a Klipper service ║
|
||||
║ and a printer directory with the chosen name. ║
|
||||
║ ║
|
||||
║ Example for custom name 'kiauh': ║
|
||||
║ ● Klipper service: klipper-kiauh.service ║
|
||||
║ ● Printer directory: printer_kiauh_data ║
|
||||
║ ║
|
||||
║ If skipped, each instance will get an index assigned ║
|
||||
║ in ascending order, starting at '1' in case of a new ║
|
||||
║ installation. Otherwise, the index will be derived ║
|
||||
║ from amount of already existing instances. ║
|
||||
║ ║
|
||||
║ {line1:<63}║
|
||||
║ {line2:<63}║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
print(dialog, end="")
|
||||
print_back_footer()
|
||||
@@ -0,0 +1,272 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError, run
|
||||
from typing import Dict, List
|
||||
|
||||
from components.klipper import (
|
||||
KLIPPER_DIR,
|
||||
KLIPPER_ENV_DIR,
|
||||
KLIPPER_INSTALL_SCRIPT,
|
||||
MODULE_PATH,
|
||||
)
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.klipper.klipper_dialogs import (
|
||||
print_instance_overview,
|
||||
print_select_instance_count_dialog,
|
||||
)
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from components.webui_client.client_utils import create_client_config_symlink
|
||||
from core.constants import CURRENT_USER
|
||||
from core.instance_manager.base_instance import SUFFIX_BLACKLIST
|
||||
from core.logger import DialogType, Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.simple_config_parser.simple_config_parser import (
|
||||
SimpleConfigParser,
|
||||
)
|
||||
from core.types.component_status import ComponentStatus
|
||||
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.instance_utils import get_instances
|
||||
from utils.sys_utils import (
|
||||
cmd_sysctl_service,
|
||||
install_python_packages,
|
||||
parse_packages_from_file,
|
||||
)
|
||||
|
||||
|
||||
def get_klipper_status() -> ComponentStatus:
|
||||
return get_install_status(KLIPPER_DIR, KLIPPER_ENV_DIR, Klipper)
|
||||
|
||||
|
||||
def add_to_existing() -> bool | None:
|
||||
kl_instances: List[Klipper] = get_instances(Klipper)
|
||||
print_instance_overview(kl_instances)
|
||||
_input: bool | None = get_confirm("Add new instances?", allow_go_back=True)
|
||||
return _input
|
||||
|
||||
|
||||
def get_install_count() -> int | None:
|
||||
"""
|
||||
Print a dialog for selecting the amount of Klipper instances
|
||||
to set up with an option to navigate back. Returns None if the
|
||||
user selected to go back, otherwise an integer greater or equal than 1 |
|
||||
:return: Integer >= 1 or None
|
||||
"""
|
||||
kl_instances = get_instances(Klipper)
|
||||
print_select_instance_count_dialog()
|
||||
question = (
|
||||
f"Number of"
|
||||
f"{' additional' if len(kl_instances) > 0 else ''} "
|
||||
f"Klipper instances to set up"
|
||||
)
|
||||
_input: int | None = get_number_input(question, 1, default=1, allow_go_back=True)
|
||||
return _input
|
||||
|
||||
|
||||
def assign_custom_name(key: int, name_dict: Dict[int, str]) -> None:
|
||||
existing_names = []
|
||||
existing_names.extend(SUFFIX_BLACKLIST)
|
||||
existing_names.extend(name_dict[n] for n in name_dict)
|
||||
pattern = r"^[a-zA-Z0-9]+$"
|
||||
|
||||
question = f"Enter name for instance {key}"
|
||||
name_dict[key] = get_string_input(question, exclude=existing_names, regex=pattern)
|
||||
|
||||
|
||||
def check_user_groups(interactive: bool = True) -> None:
|
||||
"""Ensure the current user is in the ``tty`` and ``dialout`` groups.
|
||||
|
||||
When ``interactive`` is true (the TUI path), the user is shown a dialog and
|
||||
must confirm before groups are modified. When ``interactive`` is false (the
|
||||
headless CLI path), groups are added automatically without prompting.
|
||||
"""
|
||||
user_groups = [grp.getgrgid(gid).gr_name for gid in os.getgroups()]
|
||||
missing_groups = [g for g in ["tty", "dialout"] if g not in user_groups]
|
||||
|
||||
if not missing_groups:
|
||||
return
|
||||
|
||||
if interactive:
|
||||
Logger.print_dialog(
|
||||
DialogType.ATTENTION,
|
||||
[
|
||||
"Your current user is not in group:",
|
||||
*[f"● {g}" for g in missing_groups],
|
||||
"\n\n",
|
||||
"It is possible that you won't be able to successfully connect and/or "
|
||||
"flash the controller board without your user being a member of that "
|
||||
"group. If you want to add the current user to the group(s) listed above, "
|
||||
"answer with 'Y'. Else skip with 'n'.",
|
||||
"\n\n",
|
||||
"INFO:",
|
||||
"Relog required for group assignments to take effect!",
|
||||
],
|
||||
)
|
||||
|
||||
if not get_confirm(f"Add user '{CURRENT_USER}' to group(s) now?"):
|
||||
log = "Skipped adding user to required groups. You might encounter issues."
|
||||
Logger.print_warn(log)
|
||||
return
|
||||
else:
|
||||
Logger.print_info(
|
||||
f"Adding user '{CURRENT_USER}' to required groups: "
|
||||
f"{', '.join(missing_groups)}"
|
||||
)
|
||||
|
||||
try:
|
||||
for group in missing_groups:
|
||||
Logger.print_status(f"Adding user '{CURRENT_USER}' to group {group} ...")
|
||||
command = ["sudo", "usermod", "-a", "-G", group, CURRENT_USER]
|
||||
run(command, check=True)
|
||||
Logger.print_ok(f"Group {group} assigned to user '{CURRENT_USER}'.")
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Unable to add user to usergroups: {e}")
|
||||
raise
|
||||
|
||||
if interactive:
|
||||
log = "Remember to relog/restart this machine for the group(s) to be applied!"
|
||||
Logger.print_warn(log)
|
||||
|
||||
|
||||
def handle_disruptive_system_packages() -> None:
|
||||
services = []
|
||||
|
||||
command = ["systemctl", "is-enabled", "brltty"]
|
||||
brltty_status = run(command, capture_output=True, text=True)
|
||||
|
||||
command = ["systemctl", "is-enabled", "brltty-udev"]
|
||||
brltty_udev_status = run(command, capture_output=True, text=True)
|
||||
|
||||
command = ["systemctl", "is-enabled", "ModemManager"]
|
||||
modem_manager_status = run(command, capture_output=True, text=True)
|
||||
|
||||
if "enabled" in brltty_status.stdout:
|
||||
services.append("brltty")
|
||||
if "enabled" in brltty_udev_status.stdout:
|
||||
services.append("brltty-udev")
|
||||
if "enabled" in modem_manager_status.stdout:
|
||||
services.append("ModemManager")
|
||||
|
||||
for service in services if services else []:
|
||||
try:
|
||||
cmd_sysctl_service(service, "mask")
|
||||
except CalledProcessError:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
f"KIAUH was unable to mask the {service} system service. "
|
||||
"Please fix the problem manually. Otherwise, this may have "
|
||||
"undesirable effects on the operation of Klipper."
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def create_example_printer_cfg(
|
||||
instance: Klipper, clients: List[BaseWebClient] | None = None
|
||||
) -> None:
|
||||
Logger.print_status(f"Creating example printer.cfg in '{instance.base.cfg_dir}'")
|
||||
if instance.cfg_file.is_file():
|
||||
Logger.print_info(f"'{instance.cfg_file}' already exists.")
|
||||
return
|
||||
|
||||
source = MODULE_PATH.joinpath("assets/printer.cfg")
|
||||
target = instance.cfg_file
|
||||
try:
|
||||
shutil.copy(source, target)
|
||||
except OSError as e:
|
||||
Logger.print_error(f"Unable to create example printer.cfg:\n{e}")
|
||||
return
|
||||
|
||||
scp = SimpleConfigParser()
|
||||
scp.read_file(target)
|
||||
scp.set_option("virtual_sdcard", "path", str(instance.base.gcodes_dir))
|
||||
|
||||
# include existing client configs in the example config
|
||||
if clients is not None and len(clients) > 0:
|
||||
for c in clients:
|
||||
client_config = c.client_config
|
||||
section = client_config.config_section
|
||||
scp.add_section(section=section)
|
||||
create_client_config_symlink(client_config, [instance])
|
||||
|
||||
scp.write_file(target)
|
||||
|
||||
Logger.print_ok(f"Example printer.cfg created in '{instance.base.cfg_dir}'")
|
||||
|
||||
|
||||
def backup_klipper_dir() -> None:
|
||||
svc = BackupService()
|
||||
svc.backup_directory(
|
||||
source_path=KLIPPER_DIR,
|
||||
backup_name="klipper",
|
||||
target_path="klipper",
|
||||
)
|
||||
svc.backup_directory(
|
||||
source_path=KLIPPER_ENV_DIR,
|
||||
backup_name="klippy-env",
|
||||
target_path="klipper",
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
"● 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",
|
||||
"libopenblas-dev",
|
||||
)
|
||||
check_install_dependencies({*apt_deps})
|
||||
|
||||
py_deps = ("numpy",)
|
||||
|
||||
install_python_packages(KLIPPER_ENV_DIR, {*py_deps})
|
||||
@@ -0,0 +1,102 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||
from core.menus import FooterType, Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
class KlipperRemoveMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
|
||||
self.title = "Remove Klipper"
|
||||
self.title_color = Color.RED
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.footer_type = FooterType.BACK
|
||||
|
||||
self.rm_svc = False
|
||||
self.rm_dir = False
|
||||
self.rm_env = False
|
||||
self.select_state = False
|
||||
|
||||
self.klsvc = KlipperSetupService()
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from core.menus.remove_menu import RemoveMenu
|
||||
|
||||
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"a": Option(method=self.toggle_all),
|
||||
"1": Option(method=self.toggle_remove_klipper_service),
|
||||
"2": Option(method=self.toggle_remove_klipper_dir),
|
||||
"3": Option(method=self.toggle_remove_klipper_env),
|
||||
"c": Option(method=self.run_removal_process),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
checked = f"[{Color.apply('x', Color.CYAN)}]"
|
||||
unchecked = "[ ]"
|
||||
o1 = checked if self.rm_svc else unchecked
|
||||
o2 = checked if self.rm_dir else unchecked
|
||||
o3 = checked if self.rm_env else unchecked
|
||||
sel_state = f"{'Select' if not self.select_state else 'Deselect'} everything"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Enter a number and hit enter to select / deselect ║
|
||||
║ the specific option for removal. ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ a) {sel_state:49} ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) {o1} Remove Service ║
|
||||
║ 2) {o2} Remove Local Repository ║
|
||||
║ 3) {o3} Remove Python Environment ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ C) Continue ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def toggle_all(self, **kwargs) -> None:
|
||||
self.select_state = not self.select_state
|
||||
self.rm_svc = self.select_state
|
||||
self.rm_dir = self.select_state
|
||||
self.rm_env = self.select_state
|
||||
|
||||
def toggle_remove_klipper_service(self, **kwargs) -> None:
|
||||
self.rm_svc = not self.rm_svc
|
||||
|
||||
def toggle_remove_klipper_dir(self, **kwargs) -> None:
|
||||
self.rm_dir = not self.rm_dir
|
||||
|
||||
def toggle_remove_klipper_env(self, **kwargs) -> None:
|
||||
self.rm_env = not self.rm_env
|
||||
|
||||
def run_removal_process(self, **kwargs) -> None:
|
||||
if not self.rm_svc and not self.rm_dir and not self.rm_env:
|
||||
msg = "Nothing selected! Select options to remove first."
|
||||
print(Color.apply(msg, Color.RED))
|
||||
return
|
||||
|
||||
self.klsvc.remove(self.rm_svc, self.rm_dir, self.rm_env)
|
||||
|
||||
self.rm_svc = False
|
||||
self.rm_dir = False
|
||||
self.rm_env = False
|
||||
self.select_state = False
|
||||
@@ -0,0 +1,46 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __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
|
||||
@@ -0,0 +1,513 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
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,
|
||||
count: int | None = None,
|
||||
custom_names: Dict[int, str] | None = None,
|
||||
create_example_cfg: bool | None = None,
|
||||
match_moonraker: bool = False,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Install Klipper.
|
||||
|
||||
When called without arguments from the TUI, all choices are prompted
|
||||
interactively. The CLI passes explicit values and ``interactive=False``.
|
||||
|
||||
Returns ``True`` on success and ``False`` when installation cannot proceed.
|
||||
"""
|
||||
self._refresh_state()
|
||||
|
||||
Logger.print_status("Installing Klipper ...")
|
||||
|
||||
name_dict: Dict[int, str] = {}
|
||||
|
||||
if custom_names is not None:
|
||||
name_dict = custom_names
|
||||
elif match_moonraker and len(self.moonraker_list) > len(self.klipper_list):
|
||||
if interactive:
|
||||
if not self._display_moonraker_info():
|
||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||
return True
|
||||
name_dict = {
|
||||
i: moonraker.suffix for i, moonraker in enumerate(self.moonraker_list)
|
||||
}
|
||||
elif count is not None:
|
||||
name_dict = {i: "" for i in range(count)}
|
||||
elif interactive:
|
||||
install_count, name_dict = self.__get_install_count_and_name_dict()
|
||||
|
||||
if install_count == 0:
|
||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||
return True
|
||||
|
||||
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 self.__count_from_moonraker_match(
|
||||
install_count, name_dict
|
||||
):
|
||||
use_custom_names = self.__use_custom_names_or_go_back()
|
||||
if use_custom_names is None:
|
||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||
return True
|
||||
|
||||
self.__handle_instance_names(install_count, name_dict, use_custom_names)
|
||||
else:
|
||||
name_dict = {0: ""}
|
||||
|
||||
if not name_dict:
|
||||
Logger.print_status(EXIT_KLIPPER_SETUP)
|
||||
return True
|
||||
|
||||
if create_example_cfg is None:
|
||||
create_example_cfg = (
|
||||
get_confirm("Create example printer.cfg?") if interactive else False
|
||||
)
|
||||
|
||||
try:
|
||||
self.__run_setup(name_dict, create_example_cfg, interactive=interactive)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Klipper installation failed!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def update(self, interactive: bool = True) -> bool:
|
||||
"""Update Klipper.
|
||||
|
||||
When called from the TUI, a warning and confirmation are shown. The CLI
|
||||
passes ``interactive=False`` to run silently.
|
||||
|
||||
Returns ``True`` on success and ``False`` if the update could not be completed.
|
||||
"""
|
||||
if interactive:
|
||||
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 False
|
||||
|
||||
self._refresh_state()
|
||||
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Error while updating Klipper!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def remove(
|
||||
self,
|
||||
remove_service: bool,
|
||||
remove_dir: bool,
|
||||
remove_env: bool,
|
||||
*,
|
||||
remove_all: bool = False,
|
||||
instance_suffixes: List[str] | None = None,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Remove Klipper.
|
||||
|
||||
When called from the TUI, the user selects instances interactively. In
|
||||
headless mode (``interactive=False``) the caller MUST express explicit
|
||||
intent: pass ``remove_all=True`` to wipe every instance or
|
||||
``instance_suffixes=[...]`` to remove a named subset. Without explicit
|
||||
intent the service refuses and removes nothing so a CLI user can never
|
||||
accidentally destroy every Klipper instance on the machine.
|
||||
|
||||
Returns ``True`` on success and ``False`` if removal could not be completed.
|
||||
"""
|
||||
self._refresh_state()
|
||||
|
||||
try:
|
||||
if interactive:
|
||||
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)
|
||||
else:
|
||||
if remove_service and self.klipper_list:
|
||||
selected = self._select_instances_for_headless_removal(
|
||||
remove_all, instance_suffixes
|
||||
)
|
||||
if selected is None:
|
||||
Logger.print_error(
|
||||
"Refusing to remove Klipper instances: no explicit "
|
||||
"intent. Pass remove_all=True or instance_suffixes."
|
||||
)
|
||||
return False
|
||||
self.__remove_instances(selected)
|
||||
|
||||
if (remove_dir or remove_env) and unit_file_exists(
|
||||
"klipper", suffix="service"
|
||||
):
|
||||
Logger.print_info(
|
||||
"Klipper services still installed; skipping repository/env removal."
|
||||
)
|
||||
return True
|
||||
|
||||
if remove_dir:
|
||||
run_remove_routines(KLIPPER_DIR)
|
||||
if remove_env:
|
||||
run_remove_routines(KLIPPER_ENV_DIR)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Error while removing Klipper!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
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,
|
||||
interactive: bool = True,
|
||||
) -> None:
|
||||
if not self.klipper_list:
|
||||
# Only create a fresh venv when none exists; existing venvs are
|
||||
# preserved in both TUI and CLI modes.
|
||||
self.__install_deps(interactive=interactive)
|
||||
|
||||
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(interactive=interactive)
|
||||
|
||||
def __install_deps(self, interactive: bool = True) -> 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,
|
||||
force=False,
|
||||
allow_access_to_system_site_packages=False,
|
||||
use_python_binary=self.settings.klipper.use_python_binary,
|
||||
interactive=interactive,
|
||||
):
|
||||
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 __count_from_moonraker_match(
|
||||
self, install_count: int, name_dict: Dict[int, str]
|
||||
) -> bool:
|
||||
"""Return True when the count/names came from matching Moonraker instances."""
|
||||
if len(self.moonraker_list) <= len(self.klipper_list):
|
||||
return False
|
||||
if install_count != len(self.moonraker_list):
|
||||
return False
|
||||
expected = [m.suffix for m in self.moonraker_list]
|
||||
return list(name_dict.values()) == expected
|
||||
|
||||
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 _select_instances_for_headless_removal(
|
||||
self,
|
||||
remove_all: bool,
|
||||
instance_suffixes: List[str] | None,
|
||||
) -> List[Klipper] | None:
|
||||
"""Resolve which instances to remove in headless mode.
|
||||
|
||||
Returns the list of instances to remove, or ``None`` when the caller did
|
||||
not express explicit intent (no ``remove_all`` and no ``instance_suffixes``).
|
||||
A ``None`` return is the "refuse to wipe everything" signal the CLI path
|
||||
relies on. Kept as a single-public-seam helper (no name mangling) so
|
||||
tests can patch it without brittle ``_Class__method`` access.
|
||||
"""
|
||||
if remove_all:
|
||||
return list(self.klipper_list)
|
||||
if instance_suffixes:
|
||||
wanted = set(instance_suffixes)
|
||||
return [i for i in self.klipper_list if i.suffix in wanted]
|
||||
return None
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,445 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(KlipperSetupService, "_KlipperSetupService__cls_instance", None)
|
||||
|
||||
|
||||
class FakeKlipper:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
self.create_calls: List[Any] = []
|
||||
|
||||
def create(self) -> None:
|
||||
self.create_calls.append(True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_install_deps(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_service
|
||||
) -> Dict[str, List[Any]]:
|
||||
calls: Dict[str, List[Any]] = {
|
||||
"klipper_create": [],
|
||||
"enable": [],
|
||||
"start": [],
|
||||
}
|
||||
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
|
||||
def fake_klipper(suffix: str = "") -> FakeKlipper:
|
||||
instance = FakeKlipper(suffix)
|
||||
calls["klipper_create"].append(instance)
|
||||
return instance
|
||||
|
||||
monkeypatch.setattr(f"{module}.Klipper", fake_klipper)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.enable",
|
||||
staticmethod(lambda instance: calls["enable"].append(instance.suffix)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.start",
|
||||
staticmethod(lambda instance: calls["start"].append(instance.suffix)),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.git_clone_wrapper", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.install_klipper_packages", lambda: None)
|
||||
monkeypatch.setattr(f"{module}.create_python_venv", lambda *a, **k: True)
|
||||
monkeypatch.setattr(f"{module}.install_python_requirements", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.handle_disruptive_system_packages", lambda: None)
|
||||
monkeypatch.setattr(f"{module}.check_user_groups", lambda interactive=True: None)
|
||||
monkeypatch.setattr(f"{module}.cmd_sysctl_manage", lambda *a, **k: None)
|
||||
|
||||
return calls
|
||||
|
||||
|
||||
class TestKlipperInstallHeadless:
|
||||
def test_installs_single_instance_by_default(
|
||||
self, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
service = KlipperSetupService()
|
||||
service.install(interactive=False)
|
||||
|
||||
assert len(patched_install_deps["klipper_create"]) == 1
|
||||
assert patched_install_deps["enable"] == [""]
|
||||
assert patched_install_deps["start"] == [""]
|
||||
|
||||
def test_installs_multiple_instances_by_count(
|
||||
self, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
service = KlipperSetupService()
|
||||
service.install(count=2, interactive=False)
|
||||
|
||||
assert len(patched_install_deps["klipper_create"]) == 2
|
||||
assert patched_install_deps["enable"] == ["", ""]
|
||||
assert patched_install_deps["start"] == ["", ""]
|
||||
|
||||
def test_installs_with_custom_names(
|
||||
self, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
service = KlipperSetupService()
|
||||
service.install(custom_names={0: "a", 1: "b"}, interactive=False)
|
||||
|
||||
assert len(patched_install_deps["klipper_create"]) == 2
|
||||
instances = patched_install_deps["klipper_create"]
|
||||
assert instances[0].suffix == "a"
|
||||
assert instances[1].suffix == "b"
|
||||
|
||||
|
||||
class TestKlipperRemoveHeadless:
|
||||
def _make_fake_instance(self, suffix: str = ""):
|
||||
Path = __import__("pathlib").Path
|
||||
return type(
|
||||
"FakeInstance",
|
||||
(),
|
||||
{
|
||||
"suffix": suffix,
|
||||
"service_file_path": Path(f"klipper-{suffix}.service"),
|
||||
"env_file": Path("/tmp/klipper.env"),
|
||||
"base": type("Base", (), {"sysd_dir": Path("/tmp")})(),
|
||||
},
|
||||
)()
|
||||
|
||||
def _patch_remove_internals(self, monkeypatch, removed):
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.remove",
|
||||
staticmethod(lambda instance: removed["instances"].append(instance.suffix)),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda path: removed["paths"].append(str(path)) or True,
|
||||
)
|
||||
|
||||
def test_removes_explicit_all_services_and_files(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
fake_instance = self._make_fake_instance("")
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = [fake_instance]
|
||||
service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=True,
|
||||
remove_env=True,
|
||||
remove_all=True,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert removed["instances"] == [""]
|
||||
assert any("klipper" in p for p in removed["paths"])
|
||||
|
||||
def test_without_explicit_intent_removes_nothing(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
# non-interactive remove with no --all and no --instance must
|
||||
# NOT call InstanceManager.remove or run_remove_routines and must
|
||||
# refuse with a non-zero (False) result.
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
fake_instance = self._make_fake_instance("a")
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = [fake_instance]
|
||||
result = service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=False,
|
||||
remove_env=False,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert removed["instances"] == []
|
||||
assert removed["paths"] == []
|
||||
|
||||
def test_with_instance_suffix_removes_only_matching(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = [
|
||||
self._make_fake_instance("a"),
|
||||
self._make_fake_instance("b"),
|
||||
]
|
||||
service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=False,
|
||||
remove_env=False,
|
||||
instance_suffixes=["a"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert removed["instances"] == ["a"]
|
||||
|
||||
|
||||
class TestKlipperUpdateHeadless:
|
||||
def test_update_runs_expected_steps(self, reset_service, monkeypatch) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
calls: List[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
f"{module}.backup_klipper_dir", lambda: calls.append("backup")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.stop_all",
|
||||
staticmethod(lambda instances: calls.append("stop")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_klipper_packages", lambda: calls.append("packages")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_python_requirements",
|
||||
lambda *a, **k: calls.append("requirements"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.start_all",
|
||||
staticmethod(lambda instances: calls.append("start")),
|
||||
)
|
||||
|
||||
service = KlipperSetupService()
|
||||
service.settings.kiauh.backup_before_update = True
|
||||
result = service.update(interactive=False)
|
||||
|
||||
assert result is True
|
||||
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
|
||||
|
||||
def test_update_cancelled_by_user_returns_false(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
pulled: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
|
||||
|
||||
service = KlipperSetupService()
|
||||
result = service.update(interactive=True)
|
||||
|
||||
assert result is False
|
||||
assert pulled == []
|
||||
|
||||
|
||||
class FakeMoonraker:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
|
||||
|
||||
class TestKlipperInteractiveMoonrakerMatch:
|
||||
def test_installs_exactly_one_klipper_per_moonraker(
|
||||
self, reset_service, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._display_moonraker_info",
|
||||
lambda self: True,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = []
|
||||
service.moonraker_list = [FakeMoonraker(""), FakeMoonraker("b")]
|
||||
|
||||
result = service.install(interactive=True, create_example_cfg=False)
|
||||
|
||||
assert result is True
|
||||
assert len(patched_install_deps["klipper_create"]) == 2
|
||||
instances = patched_install_deps["klipper_create"]
|
||||
assert instances[0].suffix == ""
|
||||
assert instances[1].suffix == "b"
|
||||
|
||||
def test_headless_match_moonraker_skips_dialog(
|
||||
self, reset_service, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
dialog_calls: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._display_moonraker_info",
|
||||
lambda self: dialog_calls.append(True) or False,
|
||||
)
|
||||
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = []
|
||||
service.moonraker_list = [FakeMoonraker("a"), FakeMoonraker("b")]
|
||||
|
||||
result = service.install(match_moonraker=True, interactive=False)
|
||||
|
||||
assert result is True
|
||||
assert dialog_calls == []
|
||||
assert len(patched_install_deps["klipper_create"]) == 2
|
||||
instances = patched_install_deps["klipper_create"]
|
||||
assert [i.suffix for i in instances] == ["a", "b"]
|
||||
|
||||
|
||||
class TestKlipperVenvNonDestructive:
|
||||
"""a headless install must not force-recreate an existing Klipper
|
||||
venv. ``__install_deps`` must pass ``force=False`` and ``interactive=False``
|
||||
to ``create_python_venv`` so an existing venv is left untouched (no prompt,
|
||||
no ``rmtree``)."""
|
||||
|
||||
def test_headless_install_does_not_force_recreate_venv(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
venv_calls: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.create_python_venv",
|
||||
lambda *a, **k: venv_calls.append(k) or True,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.git_clone_wrapper", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.install_klipper_packages", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_python_requirements", lambda *a, **k: None
|
||||
)
|
||||
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = []
|
||||
service.install(interactive=False)
|
||||
|
||||
assert venv_calls, "create_python_venv should have been called"
|
||||
assert venv_calls[0]["force"] is False
|
||||
assert venv_calls[0]["interactive"] is False
|
||||
|
||||
|
||||
class TestCheckUserGroups:
|
||||
def test_interactive_mode_prompts_before_adding_user(self, monkeypatch) -> None:
|
||||
from components.klipper.klipper_utils import check_user_groups
|
||||
|
||||
monkeypatch.setattr("os.getgroups", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
"grp.getgrgid",
|
||||
lambda gid: type("Group", (), {"gr_name": "tty"})(),
|
||||
)
|
||||
|
||||
prompted: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
"components.klipper.klipper_utils.get_confirm",
|
||||
lambda question, *a, **k: prompted.append(question) or True,
|
||||
)
|
||||
run_calls: List[List[str]] = []
|
||||
monkeypatch.setattr(
|
||||
"components.klipper.klipper_utils.run",
|
||||
lambda cmd, **kwargs: (
|
||||
run_calls.append(cmd) or type("R", (), {"returncode": 0})()
|
||||
),
|
||||
)
|
||||
|
||||
check_user_groups(interactive=True)
|
||||
|
||||
assert any("group" in q.lower() for q in prompted)
|
||||
assert run_calls
|
||||
|
||||
def test_headless_mode_auto_adds_without_prompt(self, monkeypatch) -> None:
|
||||
from components.klipper.klipper_utils import check_user_groups
|
||||
|
||||
monkeypatch.setattr("os.getgroups", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
"grp.getgrgid",
|
||||
lambda gid: type("Group", (), {"gr_name": "tty"})(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"components.klipper.klipper_utils.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
|
||||
)
|
||||
run_calls: List[List[str]] = []
|
||||
monkeypatch.setattr(
|
||||
"components.klipper.klipper_utils.run",
|
||||
lambda cmd, **kwargs: (
|
||||
run_calls.append(cmd) or type("R", (), {"returncode": 0})()
|
||||
),
|
||||
)
|
||||
|
||||
check_user_groups(interactive=False)
|
||||
|
||||
assert run_calls
|
||||
|
||||
|
||||
class TestKlipperRemoveInteractiveTui:
|
||||
"""Exercise the interactive (TUI) remove branch so the message-assembly
|
||||
path stays covered: the TUI path must remain unchanged."""
|
||||
|
||||
def _make_fake_instance(self, suffix: str = ""):
|
||||
Path = __import__("pathlib").Path
|
||||
return type(
|
||||
"FakeInstance",
|
||||
(),
|
||||
{
|
||||
"suffix": suffix,
|
||||
"service_file_path": Path(f"klipper-{suffix}.service"),
|
||||
"env_file": Path("/tmp/klipper.env"),
|
||||
"base": type("Base", (), {"sysd_dir": Path("/tmp")})(),
|
||||
},
|
||||
)()
|
||||
|
||||
def test_interactive_remove_sets_completion_message(
|
||||
self, reset_service, monkeypatch
|
||||
) -> None:
|
||||
module = "components.klipper.services.klipper_setup_service"
|
||||
fake_instance = self._make_fake_instance("a")
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._get_instances_to_remove",
|
||||
lambda self: [fake_instance],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.remove",
|
||||
staticmethod(lambda instance: removed["instances"].append(instance)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.KlipperSetupService._delete_klipper_env_file",
|
||||
lambda self, inst: None,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda path: removed["paths"].append(str(path)) or True,
|
||||
)
|
||||
set_messages: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type(
|
||||
"MS", (), {"set_message": lambda self, m: set_messages.append(m)}
|
||||
)(),
|
||||
)
|
||||
|
||||
service = KlipperSetupService()
|
||||
service.klipper_list = [fake_instance]
|
||||
result = service.remove(
|
||||
remove_service=True, remove_dir=True, remove_env=True, interactive=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert removed["instances"] == [fake_instance]
|
||||
assert set_messages, "TUI remove must set the completion message"
|
||||
assert any("klipper-a" in line for line in set_messages[0].text)
|
||||
@@ -0,0 +1,12 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from components.klipper import KLIPPER_DIR
|
||||
|
||||
SD_FLASH_SCRIPT = KLIPPER_DIR.joinpath("scripts/flash-sdcard.sh")
|
||||
@@ -0,0 +1,213 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
import re
|
||||
from pathlib import Path
|
||||
from subprocess import (
|
||||
DEVNULL,
|
||||
PIPE,
|
||||
STDOUT,
|
||||
CalledProcessError,
|
||||
Popen,
|
||||
check_output,
|
||||
run,
|
||||
)
|
||||
from typing import List
|
||||
|
||||
from components.klipper import KLIPPER_DIR
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.klipper_firmware import SD_FLASH_SCRIPT
|
||||
from components.klipper_firmware.flash_options import (
|
||||
FlashMethod,
|
||||
FlashOptions,
|
||||
)
|
||||
from core.instance_manager.instance_manager import InstanceManager
|
||||
from core.logger import Logger
|
||||
from utils.instance_utils import get_instances
|
||||
from utils.sys_utils import log_process
|
||||
|
||||
|
||||
def find_firmware_file() -> bool:
|
||||
target = KLIPPER_DIR.joinpath("out")
|
||||
target_exists: bool = target.exists()
|
||||
|
||||
f1 = "klipper.elf.hex"
|
||||
f2 = "klipper.elf"
|
||||
f3 = "klipper.bin"
|
||||
f4 = "klipper.uf2"
|
||||
fw_file_exists: bool = (
|
||||
(target.joinpath(f1).exists() and target.joinpath(f2).exists())
|
||||
or target.joinpath(f3).exists()
|
||||
or target.joinpath(f4).exists()
|
||||
)
|
||||
|
||||
return target_exists and fw_file_exists
|
||||
|
||||
|
||||
def find_usb_device_by_id() -> List[str]:
|
||||
try:
|
||||
command = "find /dev/serial/by-id/*"
|
||||
output = check_output(command, shell=True, text=True, stderr=DEVNULL)
|
||||
return output.splitlines()
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error("Unable to find a USB device!")
|
||||
Logger.print_error(e, prefix=False)
|
||||
return []
|
||||
|
||||
|
||||
def find_uart_device() -> List[str]:
|
||||
try:
|
||||
cmd = "find /dev -maxdepth 1"
|
||||
output = check_output(cmd, shell=True, text=True, stderr=DEVNULL)
|
||||
device_list = []
|
||||
if output:
|
||||
pattern = r"^/dev/tty(AMA0|S0)$"
|
||||
devices = output.splitlines()
|
||||
device_list = [d for d in devices if re.search(pattern, d)]
|
||||
return device_list
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error("Unable to find a UART device!")
|
||||
Logger.print_error(e, prefix=False)
|
||||
return []
|
||||
|
||||
|
||||
def find_usb_dfu_device() -> List[str]:
|
||||
try:
|
||||
output = check_output("lsusb", shell=True, text=True, stderr=DEVNULL)
|
||||
device_list = []
|
||||
if output:
|
||||
devices = output.splitlines()
|
||||
device_list = [d.split(" ")[5] for d in devices if "DFU" in d]
|
||||
return device_list
|
||||
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error("Unable to find a USB DFU device!")
|
||||
Logger.print_error(e, prefix=False)
|
||||
return []
|
||||
|
||||
|
||||
def find_usb_rp2_boot_device() -> List[str]:
|
||||
try:
|
||||
output = check_output("lsusb", shell=True, text=True, stderr=DEVNULL)
|
||||
device_list = []
|
||||
if output:
|
||||
devices = output.splitlines()
|
||||
device_list = [d.split(" ")[5] for d in devices if "RP2 Boot" in d]
|
||||
return device_list
|
||||
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error("Unable to find a USB RP2 Boot device!")
|
||||
Logger.print_error(e, prefix=False)
|
||||
return []
|
||||
|
||||
|
||||
def get_sd_flash_board_list() -> List[str]:
|
||||
if not KLIPPER_DIR.exists() or not SD_FLASH_SCRIPT.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
cmd = f"{SD_FLASH_SCRIPT} -l"
|
||||
blist: List[str] = check_output(cmd, shell=True, text=True).splitlines()[1:]
|
||||
return blist
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"An unexpected error occured:\n{e}")
|
||||
return []
|
||||
|
||||
|
||||
def start_flash_process(flash_options: FlashOptions) -> None:
|
||||
Logger.print_status(f"Flashing '{flash_options.selected_mcu}' ...")
|
||||
try:
|
||||
if not flash_options.flash_method:
|
||||
raise Exception("Missing value for flash_method!")
|
||||
if not flash_options.flash_command:
|
||||
raise Exception("Missing value for flash_command!")
|
||||
if not flash_options.selected_mcu:
|
||||
raise Exception("Missing value for selected_mcu!")
|
||||
if not flash_options.connection_type:
|
||||
raise Exception("Missing value for connection_type!")
|
||||
if (
|
||||
flash_options.flash_method == FlashMethod.SD_CARD
|
||||
and not flash_options.selected_board
|
||||
):
|
||||
raise Exception("Missing value for selected_board!")
|
||||
|
||||
if flash_options.flash_method is FlashMethod.REGULAR:
|
||||
cmd = [
|
||||
"make",
|
||||
f"KCONFIG_CONFIG={flash_options.selected_kconfig}",
|
||||
flash_options.flash_command.value,
|
||||
f"FLASH_DEVICE={flash_options.selected_mcu}",
|
||||
]
|
||||
elif flash_options.flash_method is FlashMethod.SD_CARD:
|
||||
if not SD_FLASH_SCRIPT.exists():
|
||||
raise Exception("Unable to find Klippers sdcard flash script!")
|
||||
cmd = [
|
||||
SD_FLASH_SCRIPT.as_posix(),
|
||||
f"-b {flash_options.selected_baudrate}",
|
||||
flash_options.selected_mcu,
|
||||
flash_options.selected_board,
|
||||
]
|
||||
else:
|
||||
raise Exception("Invalid value for flash_method!")
|
||||
|
||||
instances = get_instances(Klipper)
|
||||
InstanceManager.stop_all(instances)
|
||||
|
||||
process = Popen(cmd, cwd=KLIPPER_DIR, stdout=PIPE, stderr=STDOUT, text=True)
|
||||
log_process(process)
|
||||
|
||||
InstanceManager.start_all(instances)
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
raise Exception(f"Flashing failed with returncode: {rc}")
|
||||
else:
|
||||
Logger.print_ok("Flashing successful!", start="\n", end="\n\n")
|
||||
|
||||
except (Exception, CalledProcessError):
|
||||
Logger.print_error("Flashing failed!", start="\n")
|
||||
Logger.print_error("See the console output above!", end="\n\n")
|
||||
|
||||
|
||||
def run_make_clean(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
|
||||
try:
|
||||
run(
|
||||
f"make KCONFIG_CONFIG={kconfig} clean",
|
||||
cwd=KLIPPER_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Unexpected error:\n{e}")
|
||||
raise
|
||||
|
||||
|
||||
def run_make_menuconfig(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
|
||||
try:
|
||||
run(
|
||||
f"make PYTHON=python3 KCONFIG_CONFIG={kconfig} menuconfig",
|
||||
cwd=KLIPPER_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Unexpected error:\n{e}")
|
||||
raise
|
||||
|
||||
|
||||
def run_make(kconfig=Path(KLIPPER_DIR.joinpath(".config"))) -> None:
|
||||
try:
|
||||
run(
|
||||
f"make PYTHON=python3 KCONFIG_CONFIG={kconfig}",
|
||||
cwd=KLIPPER_DIR,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Unexpected error:\n{e}")
|
||||
raise
|
||||
@@ -0,0 +1,115 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import field
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
|
||||
class FlashMethod(Enum):
|
||||
REGULAR = "Regular"
|
||||
SD_CARD = "SD Card"
|
||||
|
||||
|
||||
class FlashCommand(Enum):
|
||||
FLASH = "flash"
|
||||
SERIAL_FLASH = "serialflash"
|
||||
|
||||
|
||||
class ConnectionType(Enum):
|
||||
USB = "USB"
|
||||
USB_DFU = "USB (DFU)"
|
||||
USB_RP2040 = "USB (RP2040)"
|
||||
UART = "UART"
|
||||
|
||||
|
||||
class FlashOptions:
|
||||
_instance = None
|
||||
_flash_method: FlashMethod | None = None
|
||||
_flash_command: FlashCommand | None = None
|
||||
_connection_type: ConnectionType | None = None
|
||||
_mcu_list: List[str] = field(default_factory=list)
|
||||
_selected_mcu: str = ""
|
||||
_selected_board: str = ""
|
||||
_selected_baudrate: int = 250000
|
||||
_selected_kconfig: str = ".config"
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not cls._instance:
|
||||
cls._instance = super(FlashOptions, cls).__new__(cls, *args, **kwargs)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def destroy(cls) -> None:
|
||||
cls._instance = None
|
||||
|
||||
@property
|
||||
def flash_method(self) -> FlashMethod | None:
|
||||
return self._flash_method
|
||||
|
||||
@flash_method.setter
|
||||
def flash_method(self, value: FlashMethod | None):
|
||||
self._flash_method = value
|
||||
|
||||
@property
|
||||
def flash_command(self) -> FlashCommand | None:
|
||||
return self._flash_command
|
||||
|
||||
@flash_command.setter
|
||||
def flash_command(self, value: FlashCommand | None):
|
||||
self._flash_command = value
|
||||
|
||||
@property
|
||||
def connection_type(self) -> ConnectionType | None:
|
||||
return self._connection_type
|
||||
|
||||
@connection_type.setter
|
||||
def connection_type(self, value: ConnectionType | None):
|
||||
self._connection_type = value
|
||||
|
||||
@property
|
||||
def mcu_list(self) -> List[str]:
|
||||
return self._mcu_list
|
||||
|
||||
@mcu_list.setter
|
||||
def mcu_list(self, value: List[str]) -> None:
|
||||
self._mcu_list = value
|
||||
|
||||
@property
|
||||
def selected_mcu(self) -> str:
|
||||
return self._selected_mcu
|
||||
|
||||
@selected_mcu.setter
|
||||
def selected_mcu(self, value: str) -> None:
|
||||
self._selected_mcu = value
|
||||
|
||||
@property
|
||||
def selected_board(self) -> str:
|
||||
return self._selected_board
|
||||
|
||||
@selected_board.setter
|
||||
def selected_board(self, value: str) -> None:
|
||||
self._selected_board = value
|
||||
|
||||
@property
|
||||
def selected_baudrate(self) -> int:
|
||||
return self._selected_baudrate
|
||||
|
||||
@selected_baudrate.setter
|
||||
def selected_baudrate(self, value: int) -> None:
|
||||
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
|
||||
@@ -0,0 +1,274 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
from typing import List, Set, Type
|
||||
|
||||
from components.klipper import KLIPPER_DIR, KLIPPER_KCONFIGS_DIR
|
||||
from components.klipper_firmware.firmware_utils import (
|
||||
run_make,
|
||||
run_make_clean,
|
||||
run_make_menuconfig,
|
||||
)
|
||||
from components.klipper_firmware.flash_options import FlashOptions
|
||||
from core.logger import DialogType, Logger
|
||||
from core.menus import Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.types.color import Color
|
||||
from utils.input_utils import get_confirm, get_string_input
|
||||
from utils.sys_utils import (
|
||||
check_package_install,
|
||||
install_system_packages,
|
||||
update_system_package_lists,
|
||||
)
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperKConfigMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "Firmware Config Menu"
|
||||
self.title_color = Color.CYAN
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.flash_options = FlashOptions()
|
||||
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:
|
||||
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:
|
||||
if not Path(self.kconfigs_dirname).is_dir():
|
||||
return
|
||||
|
||||
self.input_label_txt = "Select config or action to continue (default=N)"
|
||||
self.default_option = Option(
|
||||
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:
|
||||
cfg_found_str = Color.apply(
|
||||
"Previously saved firmware configs found!", Color.GREEN
|
||||
)
|
||||
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: ║
|
||||
║ ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
for d in self.deps:
|
||||
status_ok = Color.apply("*INSTALLED*", Color.GREEN)
|
||||
status_missing = Color.apply("*MISSING*", Color.RED)
|
||||
status = status_missing if d in self.missing_deps else status_ok
|
||||
padding = 40 - len(d) + len(status) + (len(status_ok) - len(status))
|
||||
d = Color.apply(f"● {d}", Color.CYAN)
|
||||
menu += f"║ {d}{status:>{padding}} ║\n"
|
||||
|
||||
menu += "║ ║\n"
|
||||
menu += "╟───────────────────────────────────────────────────────╢\n"
|
||||
|
||||
print(menu, end="")
|
||||
|
||||
def install_missing_deps(self, **kwargs) -> None:
|
||||
try:
|
||||
update_system_package_lists(silent=False)
|
||||
Logger.print_status("Installing system packages...")
|
||||
install_system_packages(self.missing_deps)
|
||||
except Exception as e:
|
||||
Logger.print_error(e)
|
||||
Logger.print_error("Installing dependencies failed!")
|
||||
finally:
|
||||
# restart this menu
|
||||
KlipperBuildFirmwareMenu().run()
|
||||
|
||||
def start_build_process(self, **kwargs) -> None:
|
||||
try:
|
||||
run_make_clean(self.kconfig)
|
||||
run_make_menuconfig(self.kconfig)
|
||||
run_make(self.kconfig)
|
||||
|
||||
Logger.print_ok("Firmware successfully built!")
|
||||
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:
|
||||
Logger.print_error(e)
|
||||
Logger.print_error("Building Klipper Firmware failed!")
|
||||
|
||||
finally:
|
||||
if self.previous_menu is not None:
|
||||
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}")
|
||||
@@ -0,0 +1,107 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.klipper_firmware.flash_options import FlashMethod, FlashOptions
|
||||
from core.menus import FooterType, Option
|
||||
from core.menus.base_menu import BaseMenu, MenuTitleStyle
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperNoFirmwareErrorMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "!!! NO FIRMWARE FILE FOUND !!!"
|
||||
self.title_color = Color.RED
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
|
||||
self.flash_options = FlashOptions()
|
||||
self.footer_type = FooterType.BLANK
|
||||
self.input_label_txt = "Press ENTER to go back to [Advanced Menu]"
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = previous_menu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.default_option = Option(method=self.go_back)
|
||||
|
||||
def print_menu(self) -> None:
|
||||
line1 = "Unable to find a compiled firmware file!"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {Color.apply(line1, Color.RED):<62} ║
|
||||
║ ║
|
||||
║ Make sure, that: ║
|
||||
║ ● the folder '~/klipper/out' and its content exist ║
|
||||
║ ● the folder contains the following file: ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
if self.flash_options.flash_method is FlashMethod.REGULAR:
|
||||
menu += "║ ● 'klipper.elf' ║\n"
|
||||
menu += "║ ● 'klipper.elf.hex' ║\n"
|
||||
else:
|
||||
menu += "║ ● 'klipper.bin' ║\n"
|
||||
|
||||
print(menu, end="")
|
||||
|
||||
def go_back(self, **kwargs) -> None:
|
||||
from core.menus.advanced_menu import AdvancedMenu
|
||||
|
||||
AdvancedMenu().run()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperNoBoardTypesErrorMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "!!! ERROR GETTING BOARD LIST !!!"
|
||||
self.title_color = Color.RED
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.footer_type = FooterType.BLANK
|
||||
self.input_label_txt = "Press ENTER to go back to [Main Menu]"
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = previous_menu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.default_option = Option(method=self.go_back)
|
||||
|
||||
def print_menu(self) -> None:
|
||||
line1 = "Reading the list of supported boards failed!"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {Color.apply(line1, Color.RED):<62} ║
|
||||
║ ║
|
||||
║ Make sure, that: ║
|
||||
║ ● the folder '~/klipper' and all its content exist ║
|
||||
║ ● the content of folder '~/klipper' is not currupted ║
|
||||
║ ● the file '~/klipper/scripts/flash-sd.py' exist ║
|
||||
║ ● your current user has access to those files/folders ║
|
||||
║ ║
|
||||
║ If in doubt or this process continues to fail, please ║
|
||||
║ consider to download Klipper again. ║
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def go_back(self, **kwargs) -> None:
|
||||
from core.menus.main_menu import MainMenu
|
||||
|
||||
MainMenu().run()
|
||||
@@ -0,0 +1,177 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Tuple, Type
|
||||
|
||||
from core.menus.base_menu import BaseMenu, MenuTitleStyle
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
def __title_config__() -> Tuple[str, Color, MenuTitleStyle]:
|
||||
return "< ? > Help: Flash MCU < ? >", Color.YELLOW, MenuTitleStyle.PLAIN
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
class KlipperFlashMethodHelpMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title, self.title_color, self.title_style = __title_config__()
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from components.klipper_firmware.menus.klipper_flash_menu import (
|
||||
KlipperFlashMethodMenu,
|
||||
)
|
||||
|
||||
self.previous_menu = (
|
||||
previous_menu if previous_menu is not None else KlipperFlashMethodMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
pass
|
||||
|
||||
def print_menu(self) -> None:
|
||||
subheader1 = Color.apply("Regular flashing method:", Color.CYAN)
|
||||
subheader2 = Color.apply("Updating via SD-Card Update:", Color.CYAN)
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {subheader1:<62} ║
|
||||
║ The default method to flash controller boards which ║
|
||||
║ are connected and updated over USB and not by placing ║
|
||||
║ a compiled firmware file onto an internal SD-Card. ║
|
||||
║ ║
|
||||
║ Common controllers that get flashed that way are: ║
|
||||
║ - Arduino Mega 2560 ║
|
||||
║ - Fysetc F6 / S6 (used without a Display + SD-Slot) ║
|
||||
║ ║
|
||||
║ {subheader2:<62} ║
|
||||
║ Many popular controller boards ship with a bootloader ║
|
||||
║ capable of updating the firmware via SD-Card. ║
|
||||
║ Choose this method if your controller board supports ║
|
||||
║ this way of updating. This method ONLY works for up- ║
|
||||
║ grading firmware. The initial flashing procedure must ║
|
||||
║ be done manually per the instructions that apply to ║
|
||||
║ your controller board. ║
|
||||
║ ║
|
||||
║ Common controllers that can be flashed that way are: ║
|
||||
║ - BigTreeTech SKR 1.3 / 1.4 (Turbo) / E3 / Mini E3 ║
|
||||
║ - Fysetc F6 / S6 (used with a Display + SD-Slot) ║
|
||||
║ - Fysetc Spider ║
|
||||
║ ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
class KlipperFlashCommandHelpMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title, self.title_color, self.title_style = __title_config__()
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from components.klipper_firmware.menus.klipper_flash_menu import (
|
||||
KlipperFlashCommandMenu,
|
||||
)
|
||||
|
||||
self.previous_menu = (
|
||||
previous_menu if previous_menu is not None else KlipperFlashCommandMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
pass
|
||||
|
||||
def print_menu(self) -> None:
|
||||
subheader1 = Color.apply("make flash:", Color.CYAN)
|
||||
subheader2 = Color.apply("make serialflash:", Color.CYAN)
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {subheader1:<62} ║
|
||||
║ The default command to flash controller board, it ║
|
||||
║ will detect selected microcontroller and use suitable ║
|
||||
║ tool for flashing it. ║
|
||||
║ ║
|
||||
║ {subheader2:<62} ║
|
||||
║ Special command to flash STM32 microcontrollers in ║
|
||||
║ DFU mode but connected via serial. stm32flash command ║
|
||||
║ will be used internally. ║
|
||||
║ ║
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
|
||||
# noinspection DuplicatedCode
|
||||
class KlipperMcuConnectionHelpMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title, self.title_color, self.title_style = __title_config__()
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from components.klipper_firmware.menus.klipper_flash_menu import (
|
||||
KlipperSelectMcuConnectionMenu,
|
||||
)
|
||||
|
||||
self.previous_menu = (
|
||||
previous_menu
|
||||
if previous_menu is not None
|
||||
else KlipperSelectMcuConnectionMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
pass
|
||||
|
||||
def print_menu(self) -> None:
|
||||
subheader1 = Color.apply("USB:", Color.CYAN)
|
||||
subheader2 = Color.apply("UART:", Color.CYAN)
|
||||
subheader3 = Color.apply("USB DFU:", Color.CYAN)
|
||||
subheader4 = Color.apply("USB RP2040 Boot:", Color.CYAN)
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {subheader1:<62} ║
|
||||
║ Selecting USB as the connection method will scan the ║
|
||||
║ USB ports for connected controller boards. This will ║
|
||||
║ be similar to the 'ls /dev/serial/by-id/*' command ║
|
||||
║ suggested by the official Klipper documentation for ║
|
||||
║ determining successfull USB connections! ║
|
||||
║ ║
|
||||
║ {subheader2:<62} ║
|
||||
║ Selecting UART as the connection method will list all ║
|
||||
║ possible UART serial ports. Note: This method ALWAYS ║
|
||||
║ returns something as it seems impossible to determine ║
|
||||
║ if a valid Klipper controller board is connected or ║
|
||||
║ not. Because of that, you MUST know which UART serial ║
|
||||
║ port your controller board is connected to when using ║
|
||||
║ this connection method. ║
|
||||
║ ║
|
||||
║ {subheader3:<62} ║
|
||||
║ Selecting USB DFU as the connection method will scan ║
|
||||
║ the USB ports for connected controller boards in ║
|
||||
║ STM32 DFU mode, which is usually done by holding down ║
|
||||
║ the BOOT button or setting a special jumper on the ║
|
||||
║ board before powering up. ║
|
||||
║ ║
|
||||
║ {subheader4:<62} ║
|
||||
║ Selecting USB RP2 Boot as the connection method will ║
|
||||
║ scan the USB ports for connected RP2040 controller ║
|
||||
║ boards in Boot mode, which is usually done by holding ║
|
||||
║ down the BOOT button before powering up. ║
|
||||
║ ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
@@ -0,0 +1,486 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Type
|
||||
|
||||
from components.klipper_firmware.firmware_utils import (
|
||||
find_firmware_file,
|
||||
find_uart_device,
|
||||
find_usb_device_by_id,
|
||||
find_usb_dfu_device,
|
||||
find_usb_rp2_boot_device,
|
||||
get_sd_flash_board_list,
|
||||
start_flash_process,
|
||||
)
|
||||
from components.klipper_firmware.flash_options import (
|
||||
ConnectionType,
|
||||
FlashCommand,
|
||||
FlashMethod,
|
||||
FlashOptions,
|
||||
)
|
||||
from components.klipper_firmware.menus.klipper_flash_error_menu import (
|
||||
KlipperNoBoardTypesErrorMenu,
|
||||
KlipperNoFirmwareErrorMenu,
|
||||
)
|
||||
from components.klipper_firmware.menus.klipper_flash_help_menu import (
|
||||
KlipperFlashCommandHelpMenu,
|
||||
KlipperFlashMethodHelpMenu,
|
||||
KlipperMcuConnectionHelpMenu,
|
||||
)
|
||||
from core.logger import DialogType, Logger
|
||||
from core.menus import FooterType, Option
|
||||
from core.menus.base_menu import BaseMenu, MenuTitleStyle
|
||||
from core.types.color import Color
|
||||
from utils.input_utils import get_number_input
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperFlashMethodMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "MCU Flash Menu"
|
||||
self.title_color = Color.CYAN
|
||||
self.help_menu = KlipperFlashMethodHelpMenu
|
||||
self.input_label_txt = "Select flash method"
|
||||
self.footer_type = FooterType.BACK_HELP
|
||||
self.flash_options = FlashOptions()
|
||||
|
||||
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.options = {
|
||||
"1": Option(self.select_regular),
|
||||
"2": Option(self.select_sdcard),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
subheader = Color.apply("ATTENTION:", Color.YELLOW)
|
||||
subline1 = Color.apply(
|
||||
"Make sure to select the correct method for the MCU!", Color.YELLOW
|
||||
)
|
||||
subline2 = Color.apply("Not all MCUs support both methods!", Color.YELLOW)
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Select the flash method for flashing the MCU. ║
|
||||
║ ║
|
||||
║ {subheader:<62} ║
|
||||
║ {subline1:<62} ║
|
||||
║ {subline2:<62} ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) Regular flashing method ║
|
||||
║ 2) Updating via SD-Card Update ║
|
||||
╟───────────────────────────┬───────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def select_regular(self, **kwargs):
|
||||
self.flash_options.flash_method = FlashMethod.REGULAR
|
||||
self.goto_next_menu()
|
||||
|
||||
def select_sdcard(self, **kwargs):
|
||||
self.flash_options.flash_method = FlashMethod.SD_CARD
|
||||
self.goto_next_menu()
|
||||
|
||||
def goto_next_menu(self, **kwargs):
|
||||
if find_firmware_file():
|
||||
KlipperFlashCommandMenu(previous_menu=self.__class__).run()
|
||||
else:
|
||||
KlipperNoFirmwareErrorMenu().run()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperFlashCommandMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "Which flash command to use for flashing the MCU?"
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.title_color = Color.YELLOW
|
||||
self.help_menu = KlipperFlashCommandHelpMenu
|
||||
self.input_label_txt = "Select flash command"
|
||||
self.footer_type = FooterType.BACK_HELP
|
||||
self.flash_options = FlashOptions()
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = (
|
||||
previous_menu if previous_menu is not None else KlipperFlashMethodMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"1": Option(self.select_flash),
|
||||
"2": Option(self.select_serialflash),
|
||||
}
|
||||
self.default_option = Option(self.select_flash)
|
||||
|
||||
def print_menu(self) -> None:
|
||||
menu = textwrap.dedent(
|
||||
"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) make flash (default) ║
|
||||
║ 2) make serialflash (stm32flash) ║
|
||||
╟───────────────────────────┬───────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def select_flash(self, **kwargs):
|
||||
self.flash_options.flash_command = FlashCommand.FLASH
|
||||
self.goto_next_menu()
|
||||
|
||||
def select_serialflash(self, **kwargs):
|
||||
self.flash_options.flash_command = FlashCommand.SERIAL_FLASH
|
||||
self.goto_next_menu()
|
||||
|
||||
def goto_next_menu(self, **kwargs):
|
||||
KlipperSelectMcuConnectionMenu(previous_menu=self.__class__).run()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperSelectMcuConnectionMenu(BaseMenu):
|
||||
def __init__(
|
||||
self, previous_menu: Type[BaseMenu] | None = None, standalone: bool = False
|
||||
):
|
||||
super().__init__()
|
||||
self.title = "Make sure that the controller board is connected now!"
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.title_color = Color.YELLOW
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.__standalone = standalone
|
||||
self.help_menu = KlipperMcuConnectionHelpMenu
|
||||
self.input_label_txt = "Select connection type"
|
||||
self.footer_type = FooterType.BACK_HELP
|
||||
self.flash_options = FlashOptions()
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = (
|
||||
previous_menu if previous_menu is not None else KlipperFlashCommandMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"1": Option(method=self.select_usb),
|
||||
"2": Option(method=self.select_dfu),
|
||||
"3": Option(method=self.select_usb_dfu),
|
||||
"4": Option(method=self.select_usb_rp2040),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
menu = textwrap.dedent(
|
||||
"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ How is the controller board connected to the host? ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) USB ║
|
||||
║ 2) UART ║
|
||||
║ 3) USB (DFU mode) ║
|
||||
║ 4) USB (RP2040 mode) ║
|
||||
╟───────────────────────────┬───────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def select_usb(self, **kwargs):
|
||||
self.flash_options.connection_type = ConnectionType.USB
|
||||
self.get_mcu_list()
|
||||
|
||||
def select_dfu(self, **kwargs):
|
||||
self.flash_options.connection_type = ConnectionType.UART
|
||||
self.get_mcu_list()
|
||||
|
||||
def select_usb_dfu(self, **kwargs):
|
||||
self.flash_options.connection_type = ConnectionType.USB_DFU
|
||||
self.get_mcu_list()
|
||||
|
||||
def select_usb_rp2040(self, **kwargs):
|
||||
self.flash_options.connection_type = ConnectionType.USB_RP2040
|
||||
self.get_mcu_list()
|
||||
|
||||
def get_mcu_list(self, **kwargs):
|
||||
conn_type = self.flash_options.connection_type
|
||||
|
||||
if conn_type is ConnectionType.USB:
|
||||
Logger.print_status("Identifying MCU connected via USB ...")
|
||||
self.flash_options.mcu_list = find_usb_device_by_id()
|
||||
elif conn_type is ConnectionType.UART:
|
||||
Logger.print_status("Identifying MCU possibly connected via UART ...")
|
||||
self.flash_options.mcu_list = find_uart_device()
|
||||
elif conn_type is ConnectionType.USB_DFU:
|
||||
Logger.print_status("Identifying MCU connected via USB in DFU mode ...")
|
||||
self.flash_options.mcu_list = find_usb_dfu_device()
|
||||
elif conn_type is ConnectionType.USB_RP2040:
|
||||
Logger.print_status(
|
||||
"Identifying MCU connected via USB in RP2 Boot mode ..."
|
||||
)
|
||||
self.flash_options.mcu_list = find_usb_rp2_boot_device()
|
||||
|
||||
if len(self.flash_options.mcu_list) < 1:
|
||||
Logger.print_warn("No MCUs found!")
|
||||
Logger.print_warn("Make sure they are connected and repeat this step.")
|
||||
time.sleep(3)
|
||||
return
|
||||
|
||||
# if standalone is True, we only display the MCUs to the user and return
|
||||
if self.__standalone:
|
||||
Logger.print_ok("The following MCUs were found:", prefix=False)
|
||||
for i, mcu in enumerate(self.flash_options.mcu_list):
|
||||
print(f" ● MCU #{i}: {Color.CYAN}{mcu}{Color.RST}")
|
||||
time.sleep(3)
|
||||
return
|
||||
|
||||
self.goto_next_menu()
|
||||
|
||||
def goto_next_menu(self, **kwargs):
|
||||
KlipperSelectMcuIdMenu(previous_menu=self.__class__).run()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperSelectMcuIdMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "!!! ATTENTION !!!"
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.title_color = Color.RED
|
||||
self.flash_options = FlashOptions()
|
||||
self.mcu_list = self.flash_options.mcu_list
|
||||
self.input_label_txt = "Select MCU to flash"
|
||||
self.footer_type = FooterType.BACK
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = (
|
||||
previous_menu
|
||||
if previous_menu is not None
|
||||
else KlipperSelectMcuConnectionMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
f"{i}": Option(self.flash_mcu, f"{i}") for i in range(len(self.mcu_list))
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
header2 = f"[{Color.apply('List of detected MCUs', Color.CYAN)}]"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Make sure, to select the correct MCU! ║
|
||||
║ ONLY flash a firmware created for the respective MCU! ║
|
||||
║ ║
|
||||
╟{header2:─^64}╢
|
||||
║ ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
for i, mcu in enumerate(self.mcu_list):
|
||||
mcu = mcu.split("/")[-1]
|
||||
menu += f"║ {i}) {Color.apply(f'{mcu:<51}', Color.CYAN)}║\n"
|
||||
|
||||
menu += textwrap.dedent(
|
||||
"""
|
||||
║ ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def flash_mcu(self, **kwargs):
|
||||
try:
|
||||
index: int | None = kwargs.get("opt_index", None)
|
||||
if index is None:
|
||||
raise Exception("opt_index is None")
|
||||
|
||||
index = int(index)
|
||||
selected_mcu = self.mcu_list[index]
|
||||
self.flash_options.selected_mcu = selected_mcu
|
||||
|
||||
if self.flash_options.flash_method == FlashMethod.SD_CARD:
|
||||
KlipperSelectSDFlashBoardMenu(previous_menu=self.__class__).run()
|
||||
elif self.flash_options.flash_method == FlashMethod.REGULAR:
|
||||
KlipperFlashOverviewMenu(previous_menu=self.__class__).run()
|
||||
except Exception as e:
|
||||
Logger.print_error(e)
|
||||
Logger.print_error("Flashing failed!")
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperSelectSDFlashBoardMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.flash_options = FlashOptions()
|
||||
self.available_boards = get_sd_flash_board_list()
|
||||
self.input_label_txt = "Select board type"
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu = (
|
||||
previous_menu if previous_menu is not None else KlipperSelectMcuIdMenu
|
||||
)
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
f"{i}": Option(self.board_select, f"{i}")
|
||||
for i in range(len(self.available_boards))
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
if len(self.available_boards) < 1:
|
||||
KlipperNoBoardTypesErrorMenu().run()
|
||||
else:
|
||||
menu = textwrap.dedent(
|
||||
"""
|
||||
║ Please select the type of board that corresponds to ║
|
||||
║ the currently selected MCU ID you chose before. ║
|
||||
║ ║
|
||||
║ The following boards are currently supported: ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
for i, board in enumerate(self.available_boards):
|
||||
line = f" {i}) {board}"
|
||||
menu += f"║{line:<55}║\n"
|
||||
menu += "╟───────────────────────────────────────────────────────╢"
|
||||
print(menu, end="")
|
||||
|
||||
def board_select(self, **kwargs):
|
||||
try:
|
||||
index: int | None = kwargs.get("opt_index", None)
|
||||
if index is None:
|
||||
raise Exception("opt_index is None")
|
||||
|
||||
index = int(index)
|
||||
self.flash_options.selected_board = self.available_boards[index]
|
||||
self.baudrate_select()
|
||||
except Exception as e:
|
||||
Logger.print_error(e)
|
||||
Logger.print_error("Board selection failed!")
|
||||
|
||||
def baudrate_select(self, **kwargs):
|
||||
Logger.print_dialog(
|
||||
DialogType.CUSTOM,
|
||||
[
|
||||
"If your board is flashed with firmware that connects "
|
||||
"at a custom baud rate, please change it now.",
|
||||
"\n\n",
|
||||
"If you are unsure, stick to the default 250000!",
|
||||
],
|
||||
)
|
||||
self.flash_options.selected_baudrate = get_number_input(
|
||||
question="Please set the baud rate",
|
||||
default=250000,
|
||||
min_value=0,
|
||||
allow_go_back=True,
|
||||
)
|
||||
KlipperFlashOverviewMenu(previous_menu=self.__class__).run()
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class KlipperFlashOverviewMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "!!! ATTENTION !!!"
|
||||
self.title_style = MenuTitleStyle.PLAIN
|
||||
self.title_color = Color.RED
|
||||
self.flash_options = FlashOptions()
|
||||
self.input_label_txt = "Perform action (default=Y)"
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"y": Option(self.execute_flash),
|
||||
"n": Option(self.abort_process),
|
||||
}
|
||||
|
||||
self.default_option = Option(self.execute_flash)
|
||||
|
||||
def print_menu(self) -> None:
|
||||
method = self.flash_options.flash_method.value
|
||||
command = self.flash_options.flash_command.value
|
||||
conn_type = self.flash_options.connection_type.value
|
||||
mcu = self.flash_options.selected_mcu.split("/")[-1]
|
||||
board = self.flash_options.selected_board
|
||||
baudrate = self.flash_options.selected_baudrate
|
||||
kconfig = Path(self.flash_options.selected_kconfig).name
|
||||
color = Color.CYAN
|
||||
subheader = f"[{Color.apply('Overview', color)}]"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Before contuining the flashing process, please check ║
|
||||
║ if all parameters were set correctly! Once you made ║
|
||||
║ sure everything is correct, start the process. If any ║
|
||||
║ parameter needs to be changed, you can go back (B) ║
|
||||
║ step by step or abort and start from the beginning. ║
|
||||
║{subheader:─^64}║
|
||||
║ ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
menu += textwrap.dedent(
|
||||
f"""
|
||||
║ MCU: {Color.apply(f"{mcu:<48}", color)} ║
|
||||
║ Connection: {Color.apply(f"{conn_type:<41}", color)} ║
|
||||
║ Flash method: {Color.apply(f"{method:<39}", color)} ║
|
||||
║ Flash command: {Color.apply(f"{command:<38}", color)} ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
if self.flash_options.flash_method is FlashMethod.SD_CARD:
|
||||
menu += textwrap.dedent(
|
||||
f"""
|
||||
║ Board type: {Color.apply(f"{board:<41}", color)} ║
|
||||
║ Baudrate: {Color.apply(f"{baudrate:<43}", color)} ║
|
||||
"""
|
||||
)[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(
|
||||
"""
|
||||
║ ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Y) Start flash process ║
|
||||
║ N) Abort - Return to Advanced Menu ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def execute_flash(self, **kwargs):
|
||||
start_flash_process(self.flash_options)
|
||||
Logger.print_info("Returning to MCU Flash Menu in 5 seconds ...")
|
||||
time.sleep(5)
|
||||
KlipperFlashMethodMenu().run()
|
||||
|
||||
def abort_process(self, **kwargs):
|
||||
from core.menus.advanced_menu import AdvancedMenu
|
||||
|
||||
AdvancedMenu().run()
|
||||
@@ -0,0 +1,32 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from pathlib import Path
|
||||
|
||||
from core.constants import SYSTEMD
|
||||
|
||||
# repo
|
||||
KLIPPERSCREEN_REPO = "https://github.com/KlipperScreen/KlipperScreen.git"
|
||||
|
||||
# names
|
||||
KLIPPERSCREEN_SERVICE_NAME = "KlipperScreen.service"
|
||||
KLIPPERSCREEN_UPDATER_SECTION_NAME = "update_manager KlipperScreen"
|
||||
KLIPPERSCREEN_LOG_NAME = "KlipperScreen.log"
|
||||
|
||||
# directories
|
||||
KLIPPERSCREEN_DIR = Path.home().joinpath("KlipperScreen")
|
||||
KLIPPERSCREEN_ENV_DIR = Path.home().joinpath(".KlipperScreen-env")
|
||||
|
||||
# files
|
||||
KLIPPERSCREEN_REQ_FILE = KLIPPERSCREEN_DIR.joinpath(
|
||||
"scripts/KlipperScreen-requirements.txt"
|
||||
)
|
||||
KLIPPERSCREEN_INSTALL_SCRIPT = KLIPPERSCREEN_DIR.joinpath(
|
||||
"scripts/KlipperScreen-install.sh"
|
||||
)
|
||||
KLIPPERSCREEN_SERVICE_FILE = SYSTEMD.joinpath(KLIPPERSCREEN_SERVICE_NAME)
|
||||
@@ -0,0 +1,207 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError, run
|
||||
from typing import List
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.klipperscreen import (
|
||||
KLIPPERSCREEN_DIR,
|
||||
KLIPPERSCREEN_ENV_DIR,
|
||||
KLIPPERSCREEN_INSTALL_SCRIPT,
|
||||
KLIPPERSCREEN_LOG_NAME,
|
||||
KLIPPERSCREEN_REPO,
|
||||
KLIPPERSCREEN_REQ_FILE,
|
||||
KLIPPERSCREEN_SERVICE_FILE,
|
||||
KLIPPERSCREEN_SERVICE_NAME,
|
||||
KLIPPERSCREEN_UPDATER_SECTION_NAME,
|
||||
)
|
||||
from components.moonraker.moonraker import Moonraker
|
||||
from core.constants import SYSTEMD
|
||||
from core.instance_manager.instance_manager import InstanceManager
|
||||
from core.logger import DialogType, Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
from core.types.component_status import ComponentStatus
|
||||
from utils.common import (
|
||||
check_install_dependencies,
|
||||
get_install_status,
|
||||
)
|
||||
from utils.config_utils import add_config_section, remove_config_section
|
||||
from utils.fs_utils import remove_with_sudo
|
||||
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 (
|
||||
check_python_version,
|
||||
cmd_sysctl_service,
|
||||
install_python_requirements,
|
||||
remove_system_service,
|
||||
)
|
||||
|
||||
|
||||
def install_klipperscreen() -> None:
|
||||
Logger.print_status("Installing KlipperScreen ...")
|
||||
|
||||
if not check_python_version(3, 7):
|
||||
return
|
||||
|
||||
mr_instances = get_instances(Moonraker)
|
||||
if not mr_instances:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
"Moonraker not found! KlipperScreen will not properly work "
|
||||
"without a working Moonraker installation.",
|
||||
"\n\n",
|
||||
"KlipperScreens update manager configuration for Moonraker "
|
||||
"will not be added to any moonraker.conf.",
|
||||
],
|
||||
)
|
||||
if not get_confirm(
|
||||
"Continue KlipperScreen installation?",
|
||||
default_choice=False,
|
||||
allow_go_back=True,
|
||||
):
|
||||
return
|
||||
|
||||
check_install_dependencies()
|
||||
|
||||
git_clone_wrapper(KLIPPERSCREEN_REPO, KLIPPERSCREEN_DIR)
|
||||
|
||||
try:
|
||||
run(KLIPPERSCREEN_INSTALL_SCRIPT.as_posix(), shell=True, check=True)
|
||||
if mr_instances:
|
||||
patch_klipperscreen_update_manager(mr_instances)
|
||||
InstanceManager.restart_all(mr_instances)
|
||||
else:
|
||||
Logger.print_info(
|
||||
"Moonraker is not installed! Cannot add "
|
||||
"KlipperScreen to update manager!"
|
||||
)
|
||||
Logger.print_ok("KlipperScreen successfully installed!")
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Error installing KlipperScreen:\n{e}")
|
||||
return
|
||||
|
||||
|
||||
def patch_klipperscreen_update_manager(instances: List[Moonraker]) -> None:
|
||||
BackupService().backup_moonraker_conf()
|
||||
add_config_section(
|
||||
section=KLIPPERSCREEN_UPDATER_SECTION_NAME,
|
||||
instances=instances,
|
||||
options=[
|
||||
("type", "git_repo"),
|
||||
("path", KLIPPERSCREEN_DIR.as_posix()),
|
||||
("origin", KLIPPERSCREEN_REPO),
|
||||
("managed_services", "KlipperScreen"),
|
||||
("env", f"{KLIPPERSCREEN_ENV_DIR}/bin/python"),
|
||||
("requirements", KLIPPERSCREEN_REQ_FILE.as_posix()),
|
||||
("install_script", KLIPPERSCREEN_INSTALL_SCRIPT.as_posix()),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def update_klipperscreen() -> None:
|
||||
if not KLIPPERSCREEN_DIR.exists():
|
||||
Logger.print_info("KlipperScreen does not seem to be installed! Skipping ...")
|
||||
return
|
||||
|
||||
try:
|
||||
Logger.print_status("Updating KlipperScreen ...")
|
||||
|
||||
cmd_sysctl_service(KLIPPERSCREEN_SERVICE_NAME, "stop")
|
||||
|
||||
settings = KiauhSettings()
|
||||
if settings.kiauh.backup_before_update:
|
||||
backup_klipperscreen_dir()
|
||||
|
||||
git_pull_wrapper(KLIPPERSCREEN_DIR)
|
||||
|
||||
install_python_requirements(KLIPPERSCREEN_ENV_DIR, KLIPPERSCREEN_REQ_FILE)
|
||||
|
||||
cmd_sysctl_service(KLIPPERSCREEN_SERVICE_NAME, "start")
|
||||
|
||||
Logger.print_ok("KlipperScreen updated successfully.", end="\n\n")
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Error updating KlipperScreen:\n{e}")
|
||||
return
|
||||
|
||||
|
||||
def get_klipperscreen_status() -> ComponentStatus:
|
||||
return get_install_status(
|
||||
KLIPPERSCREEN_DIR,
|
||||
KLIPPERSCREEN_ENV_DIR,
|
||||
files=[SYSTEMD.joinpath(KLIPPERSCREEN_SERVICE_NAME)],
|
||||
)
|
||||
|
||||
|
||||
def remove_klipperscreen() -> None:
|
||||
Logger.print_status("Removing KlipperScreen ...")
|
||||
try:
|
||||
if KLIPPERSCREEN_DIR.exists():
|
||||
Logger.print_status("Removing KlipperScreen directory ...")
|
||||
shutil.rmtree(KLIPPERSCREEN_DIR)
|
||||
Logger.print_ok("KlipperScreen directory successfully removed!")
|
||||
else:
|
||||
Logger.print_warn("KlipperScreen directory not found!")
|
||||
|
||||
if KLIPPERSCREEN_ENV_DIR.exists():
|
||||
Logger.print_status("Removing KlipperScreen environment ...")
|
||||
shutil.rmtree(KLIPPERSCREEN_ENV_DIR)
|
||||
Logger.print_ok("KlipperScreen environment successfully removed!")
|
||||
else:
|
||||
Logger.print_warn("KlipperScreen environment not found!")
|
||||
|
||||
if KLIPPERSCREEN_SERVICE_FILE.exists():
|
||||
remove_system_service(KLIPPERSCREEN_SERVICE_NAME)
|
||||
|
||||
logfile = Path(f"/tmp/{KLIPPERSCREEN_LOG_NAME}")
|
||||
if logfile.exists():
|
||||
Logger.print_status("Removing KlipperScreen log file ...")
|
||||
remove_with_sudo(logfile)
|
||||
Logger.print_ok("KlipperScreen log file successfully removed!")
|
||||
|
||||
kl_instances: List[Klipper] = get_instances(Klipper)
|
||||
for instance in kl_instances:
|
||||
logfile = instance.base.log_dir.joinpath(KLIPPERSCREEN_LOG_NAME)
|
||||
if logfile.exists():
|
||||
Logger.print_status(f"Removing {logfile} ...")
|
||||
Path(logfile).unlink()
|
||||
Logger.print_ok(f"{logfile} successfully removed!")
|
||||
|
||||
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||
if mr_instances:
|
||||
Logger.print_status("Removing KlipperScreen from update manager ...")
|
||||
BackupService().backup_moonraker_conf()
|
||||
remove_config_section("update_manager KlipperScreen", mr_instances)
|
||||
Logger.print_ok("KlipperScreen successfully removed from update manager!")
|
||||
|
||||
Logger.print_ok("KlipperScreen successfully removed!")
|
||||
|
||||
except Exception as e:
|
||||
Logger.print_error(f"Error removing KlipperScreen:\n{e}")
|
||||
|
||||
|
||||
def backup_klipperscreen_dir() -> None:
|
||||
svc = BackupService()
|
||||
svc.backup_directory(
|
||||
source_path=KLIPPERSCREEN_DIR,
|
||||
backup_name="KlipperScreen",
|
||||
target_path="KlipperScreen",
|
||||
)
|
||||
svc.backup_directory(
|
||||
source_path=KLIPPERSCREEN_ENV_DIR,
|
||||
backup_name="KlipperScreen-env",
|
||||
target_path="KlipperScreen",
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Literal, Union
|
||||
|
||||
FileKey = Literal["filepath", "display_name"]
|
||||
LogFile = Dict[FileKey, Union[str, Path]]
|
||||
@@ -0,0 +1,55 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.log_uploads import LogFile
|
||||
from core.logger import Logger
|
||||
from utils.instance_utils import get_instances
|
||||
|
||||
|
||||
def get_logfile_list() -> List[LogFile]:
|
||||
log_dirs: List[Path] = [
|
||||
instance.base.log_dir for instance in get_instances(Klipper)
|
||||
]
|
||||
|
||||
logfiles: List[LogFile] = []
|
||||
for _dir in log_dirs:
|
||||
for f in _dir.iterdir():
|
||||
logfiles.append({"filepath": f, "display_name": get_display_name(f)})
|
||||
|
||||
return logfiles
|
||||
|
||||
|
||||
def get_display_name(filepath: Path) -> str:
|
||||
printer = " ".join(filepath.parts[-3].split("_")[:-1])
|
||||
name = filepath.name
|
||||
|
||||
return f"{printer}: {name}"
|
||||
|
||||
|
||||
def upload_logfile(logfile: LogFile) -> None:
|
||||
file = logfile.get("filepath")
|
||||
name = logfile.get("display_name")
|
||||
Logger.print_status(f"Uploading the following logfile from {name} ...")
|
||||
|
||||
with open(file, "rb") as f:
|
||||
headers = {"x-random": ""}
|
||||
req = urllib.request.Request("http://paste.c-net.org/", headers=headers, data=f)
|
||||
try:
|
||||
response = urllib.request.urlopen(req)
|
||||
link = response.read().decode("utf-8")
|
||||
Logger.print_ok("Upload successful! Access it via the following link:")
|
||||
Logger.print_ok(f">>>> {link}", False)
|
||||
except Exception as e:
|
||||
Logger.print_error("Uploading logfile failed!")
|
||||
Logger.print_error(str(e))
|
||||
@@ -0,0 +1,67 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.log_uploads.log_upload_utils import get_logfile_list, upload_logfile
|
||||
from core.logger import Logger
|
||||
from core.menus import Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class LogUploadMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
self.title = "Log Upload"
|
||||
self.title_color = Color.YELLOW
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.logfile_list = get_logfile_list()
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from core.menus.main_menu import MainMenu
|
||||
|
||||
self.previous_menu = previous_menu if previous_menu is not None else MainMenu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
f"{index}": Option(self.upload, opt_index=f"{index}")
|
||||
for index in range(len(self.logfile_list))
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
menu = textwrap.dedent(
|
||||
"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ You can select the following logfiles for uploading: ║
|
||||
║ ║
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
for logfile in enumerate(self.logfile_list):
|
||||
line = f"{logfile[0]}) {logfile[1].get('display_name')}"
|
||||
menu += f"║ {line:<54}║\n"
|
||||
menu += "╟───────────────────────────────────────────────────────╢\n"
|
||||
|
||||
print(menu, end="")
|
||||
|
||||
def upload(self, **kwargs):
|
||||
try:
|
||||
index: int | None = kwargs.get("opt_index", None)
|
||||
if index is None:
|
||||
raise Exception("opt_index is None")
|
||||
|
||||
index = int(index)
|
||||
upload_logfile(self.logfile_list[index])
|
||||
except Exception as e:
|
||||
Logger.print_error(e)
|
||||
Logger.print_error("Log upload failed!")
|
||||
@@ -0,0 +1,43 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parent
|
||||
|
||||
MOONRAKER_REPO_URL = "https://github.com/Arksine/moonraker.git"
|
||||
|
||||
# names
|
||||
MOONRAKER_CFG_NAME = "moonraker.conf"
|
||||
MOONRAKER_LOG_NAME = "moonraker.log"
|
||||
MOONRAKER_SERVICE_NAME = "moonraker.service"
|
||||
MOONRAKER_DEFAULT_PORT = 7125
|
||||
MOONRAKER_ENV_FILE_NAME = "moonraker.env"
|
||||
|
||||
# directories
|
||||
MOONRAKER_DIR = Path.home().joinpath("moonraker")
|
||||
MOONRAKER_ENV_DIR = Path.home().joinpath("moonraker-env")
|
||||
|
||||
# files
|
||||
MOONRAKER_INSTALL_SCRIPT = MOONRAKER_DIR.joinpath("scripts/install-moonraker.sh")
|
||||
MOONRAKER_REQ_FILE = MOONRAKER_DIR.joinpath("scripts/moonraker-requirements.txt")
|
||||
MOONRAKER_SPEEDUPS_REQ_FILE = MOONRAKER_DIR.joinpath("scripts/moonraker-speedups.txt")
|
||||
MOONRAKER_DEPS_JSON_FILE = MOONRAKER_DIR.joinpath("scripts/system-dependencies.json")
|
||||
# introduced due to
|
||||
# https://github.com/Arksine/moonraker/issues/349
|
||||
# https://github.com/Arksine/moonraker/pull/346
|
||||
POLKIT_LEGACY_FILE = Path("/etc/polkit-1/localauthority/50-local.d/10-moonraker.pkla")
|
||||
POLKIT_FILE = Path("/etc/polkit-1/rules.d/moonraker.rules")
|
||||
POLKIT_USR_FILE = Path("/usr/share/polkit-1/rules.d/moonraker.rules")
|
||||
POLKIT_SCRIPT = MOONRAKER_DIR.joinpath("scripts/set-policykit-rules.sh")
|
||||
MOONRAKER_SERVICE_TEMPLATE = MODULE_PATH.joinpath(f"assets/{MOONRAKER_SERVICE_NAME}")
|
||||
MOONRAKER_ENV_FILE_TEMPLATE = MODULE_PATH.joinpath(f"assets/{MOONRAKER_ENV_FILE_NAME}")
|
||||
|
||||
|
||||
EXIT_MOONRAKER_SETUP = "Exiting Moonraker setup ..."
|
||||
@@ -0,0 +1,30 @@
|
||||
[server]
|
||||
host: 0.0.0.0
|
||||
port: %PORT%
|
||||
klippy_uds_address: %UDS%
|
||||
|
||||
[authorization]
|
||||
trusted_clients:
|
||||
10.0.0.0/8
|
||||
127.0.0.0/8
|
||||
169.254.0.0/16
|
||||
172.16.0.0/12
|
||||
192.168.0.0/16
|
||||
FC00::/7
|
||||
FE80::/10
|
||||
::1/128
|
||||
cors_domains:
|
||||
*.lan
|
||||
*.local
|
||||
*://localhost
|
||||
*://localhost:*
|
||||
*://my.mainsail.xyz
|
||||
*://app.fluidd.xyz
|
||||
|
||||
[octoprint_compat]
|
||||
|
||||
[history]
|
||||
|
||||
[update_manager]
|
||||
channel: dev
|
||||
refresh_interval: 168
|
||||
@@ -0,0 +1 @@
|
||||
MOONRAKER_ARGS="%MOONRAKER_DIR%/moonraker/moonraker.py -d %PRINTER_DATA%"
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=API Server for Klipper SV1
|
||||
Documentation=https://moonraker.readthedocs.io/
|
||||
Requires=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%USER%
|
||||
SupplementaryGroups=moonraker-admin
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=%MOONRAKER_DIR%
|
||||
EnvironmentFile=%ENV_FILE%
|
||||
ExecStart=%ENV%/bin/python $MOONRAKER_ARGS
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
@@ -0,0 +1,110 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||
from core.menus import FooterType, Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
class MoonrakerRemoveMenu(BaseMenu):
|
||||
def __init__(self, previous_menu: Type[BaseMenu] | None = None):
|
||||
super().__init__()
|
||||
|
||||
self.title = "Remove Moonraker"
|
||||
self.title_color = Color.RED
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.footer_type = FooterType.BACK
|
||||
|
||||
self.rm_svc = False
|
||||
self.rm_dir = 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:
|
||||
from core.menus.remove_menu import RemoveMenu
|
||||
|
||||
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"a": Option(method=self.toggle_all),
|
||||
"1": Option(method=self.toggle_remove_moonraker_service),
|
||||
"2": Option(method=self.toggle_remove_moonraker_dir),
|
||||
"3": Option(method=self.toggle_remove_moonraker_env),
|
||||
"4": Option(method=self.toggle_remove_moonraker_polkit),
|
||||
"c": Option(method=self.run_removal_process),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
checked = f"[{Color.apply('x', Color.CYAN)}]"
|
||||
unchecked = "[ ]"
|
||||
o1 = checked if self.rm_svc else unchecked
|
||||
o2 = checked if self.rm_dir else unchecked
|
||||
o3 = checked if self.rm_env 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(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Enter a number and hit enter to select / deselect ║
|
||||
║ the specific option for removal. ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ a) {sel_state:49} ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) {o1} Remove Service ║
|
||||
║ 2) {o2} Remove Local Repository ║
|
||||
║ 3) {o3} Remove Python Environment ║
|
||||
║ 4) {o4} Remove Policy Kit Rules ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ C) Continue ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def toggle_all(self, **kwargs) -> None:
|
||||
self.select_state = not self.select_state
|
||||
self.rm_svc = self.select_state
|
||||
self.rm_dir = self.select_state
|
||||
self.rm_env = self.select_state
|
||||
self.rm_pk = self.select_state
|
||||
|
||||
def toggle_remove_moonraker_service(self, **kwargs) -> None:
|
||||
self.rm_svc = not self.rm_svc
|
||||
|
||||
def toggle_remove_moonraker_dir(self, **kwargs) -> None:
|
||||
self.rm_dir = not self.rm_dir
|
||||
|
||||
def toggle_remove_moonraker_env(self, **kwargs) -> None:
|
||||
self.rm_env = not self.rm_env
|
||||
|
||||
def toggle_remove_moonraker_polkit(self, **kwargs) -> None:
|
||||
self.rm_pk = not self.rm_pk
|
||||
|
||||
def run_removal_process(self, **kwargs) -> None:
|
||||
if not self.rm_svc and not self.rm_dir and not self.rm_env and not self.rm_pk:
|
||||
msg = "Nothing selected! Select options to remove first."
|
||||
print(Color.apply(msg, Color.RED))
|
||||
return
|
||||
|
||||
self.mrsvc.remove(self.rm_svc, self.rm_dir, self.rm_env, self.rm_pk)
|
||||
|
||||
self.rm_svc = False
|
||||
self.rm_dir = False
|
||||
self.rm_env = False
|
||||
self.rm_pk = False
|
||||
@@ -0,0 +1,146 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.moonraker import (
|
||||
MOONRAKER_CFG_NAME,
|
||||
MOONRAKER_DIR,
|
||||
MOONRAKER_ENV_DIR,
|
||||
MOONRAKER_ENV_FILE_NAME,
|
||||
MOONRAKER_ENV_FILE_TEMPLATE,
|
||||
MOONRAKER_LOG_NAME,
|
||||
MOONRAKER_SERVICE_TEMPLATE,
|
||||
)
|
||||
from core.constants import CURRENT_USER
|
||||
from core.instance_manager.base_instance import BaseInstance
|
||||
from core.logger import Logger
|
||||
from core.simple_config_parser.simple_config_parser import (
|
||||
SimpleConfigParser,
|
||||
)
|
||||
from utils.fs_utils import create_folders
|
||||
from utils.sys_utils import get_service_file_path
|
||||
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
@dataclass
|
||||
class Moonraker:
|
||||
suffix: str
|
||||
base: BaseInstance = field(init=False, repr=False)
|
||||
service_file_path: Path = field(init=False)
|
||||
log_file_name: str = MOONRAKER_LOG_NAME
|
||||
moonraker_dir: Path = MOONRAKER_DIR
|
||||
env_dir: Path = MOONRAKER_ENV_DIR
|
||||
data_dir: Path = field(init=False)
|
||||
cfg_file: Path = field(init=False)
|
||||
env_file: Path = field(init=False)
|
||||
backup_dir: Path = field(init=False)
|
||||
certs_dir: Path = field(init=False)
|
||||
db_dir: Path = field(init=False)
|
||||
port: int | None = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.base: BaseInstance = BaseInstance(Klipper, self.suffix)
|
||||
self.base.log_file_name = self.log_file_name
|
||||
|
||||
self.service_file_path: Path = get_service_file_path(Moonraker, self.suffix)
|
||||
self.data_dir: Path = self.base.data_dir
|
||||
self.cfg_file: Path = self.base.cfg_dir.joinpath(MOONRAKER_CFG_NAME)
|
||||
self.env_file: Path = self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME)
|
||||
self.backup_dir: Path = self.base.data_dir.joinpath("backup")
|
||||
self.certs_dir: Path = self.base.data_dir.joinpath("certs")
|
||||
self.db_dir: Path = self.base.data_dir.joinpath("database")
|
||||
self.port: int | None = self._get_port()
|
||||
|
||||
def create(self) -> None:
|
||||
from utils.sys_utils import create_env_file, create_service_file
|
||||
|
||||
Logger.print_status("Creating new Moonraker Instance ...")
|
||||
|
||||
try:
|
||||
create_folders(self.base.base_folders)
|
||||
|
||||
create_service_file(
|
||||
name=self.service_file_path.name,
|
||||
content=self._prep_service_file_content(),
|
||||
)
|
||||
create_env_file(
|
||||
path=self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME),
|
||||
content=self._prep_env_file_content(),
|
||||
)
|
||||
|
||||
except CalledProcessError as e:
|
||||
Logger.print_error(f"Error creating instance: {e}")
|
||||
raise
|
||||
except OSError as e:
|
||||
Logger.print_error(f"Error creating env file: {e}")
|
||||
raise
|
||||
|
||||
def _prep_service_file_content(self) -> str:
|
||||
template = MOONRAKER_SERVICE_TEMPLATE
|
||||
|
||||
try:
|
||||
with open(template, "r") as template_file:
|
||||
template_content = template_file.read()
|
||||
except FileNotFoundError:
|
||||
Logger.print_error(f"Unable to open {template} - File not found")
|
||||
raise
|
||||
|
||||
service_content = template_content.replace(
|
||||
"%USER%",
|
||||
CURRENT_USER,
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%MOONRAKER_DIR%",
|
||||
self.moonraker_dir.as_posix(),
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%ENV%",
|
||||
self.env_dir.as_posix(),
|
||||
)
|
||||
service_content = service_content.replace(
|
||||
"%ENV_FILE%",
|
||||
self.base.sysd_dir.joinpath(MOONRAKER_ENV_FILE_NAME).as_posix(),
|
||||
)
|
||||
return service_content
|
||||
|
||||
def _prep_env_file_content(self) -> str:
|
||||
template = MOONRAKER_ENV_FILE_TEMPLATE
|
||||
|
||||
try:
|
||||
with open(template, "r") as env_file:
|
||||
env_template_file_content = env_file.read()
|
||||
except FileNotFoundError:
|
||||
Logger.print_error(f"Unable to open {template} - File not found")
|
||||
raise
|
||||
|
||||
env_file_content = env_template_file_content.replace(
|
||||
"%MOONRAKER_DIR%",
|
||||
self.moonraker_dir.as_posix(),
|
||||
)
|
||||
env_file_content = env_file_content.replace(
|
||||
"%PRINTER_DATA%",
|
||||
self.base.data_dir.as_posix(),
|
||||
)
|
||||
|
||||
return env_file_content
|
||||
|
||||
def _get_port(self) -> int | None:
|
||||
if not self.cfg_file or not self.cfg_file.is_file():
|
||||
return None
|
||||
|
||||
scp = SimpleConfigParser()
|
||||
scp.read_file(self.cfg_file)
|
||||
port: int | None = scp.getint("server", "port", fallback=None)
|
||||
|
||||
return port
|
||||
@@ -0,0 +1,75 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
import textwrap
|
||||
from typing import List
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.moonraker.moonraker import Moonraker
|
||||
from core.menus.base_menu import print_back_footer
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
def print_moonraker_overview(
|
||||
klipper_instances: List[Klipper],
|
||||
moonraker_instances: List[Moonraker],
|
||||
show_index=False,
|
||||
show_select_all=False,
|
||||
):
|
||||
headline = Color.apply("The following instances were found:", Color.GREEN)
|
||||
dialog = textwrap.dedent(
|
||||
f"""
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║{headline:^64}║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
if show_select_all:
|
||||
select_all = Color.apply("a) Select all", Color.YELLOW)
|
||||
dialog += f"║ {select_all:<63}║\n"
|
||||
dialog += "║ ║\n"
|
||||
|
||||
instance_map = {
|
||||
k.service_file_path.stem: (
|
||||
k.service_file_path.stem.replace("klipper", "moonraker")
|
||||
if k.suffix in [m.suffix for m in moonraker_instances]
|
||||
else ""
|
||||
)
|
||||
for k in klipper_instances
|
||||
}
|
||||
|
||||
for i, k in enumerate(instance_map):
|
||||
mr_name = instance_map.get(k)
|
||||
m = f"<-> {mr_name}" if mr_name != "" else ""
|
||||
line = Color.apply(f"{f'{i + 1})' if show_index else '●'} {k} {m}", Color.CYAN)
|
||||
dialog += f"║ {line:<63}║\n"
|
||||
|
||||
warn_l1 = Color.apply("PLEASE NOTE:", Color.YELLOW)
|
||||
warn_l2 = Color.apply(
|
||||
"If you select an instance with an existing Moonraker", Color.YELLOW
|
||||
)
|
||||
warn_l3 = Color.apply(
|
||||
"instance, that Moonraker instance will be re-created!", Color.YELLOW
|
||||
)
|
||||
warning = textwrap.dedent(
|
||||
f"""
|
||||
║ ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ {warn_l1:<63}║
|
||||
║ {warn_l2:<63}║
|
||||
║ {warn_l3:<63}║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
|
||||
dialog += warning
|
||||
|
||||
print(dialog, end="")
|
||||
print_back_footer()
|
||||
@@ -0,0 +1,49 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __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}
|
||||
@@ -0,0 +1,546 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from copy import copy
|
||||
from subprocess import DEVNULL, PIPE, 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,
|
||||
klipper_suffixes: List[str] | None = None,
|
||||
create_example_cfg: bool | None = None,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Install Moonraker.
|
||||
|
||||
When called from the TUI, the Klipper instance is selected interactively.
|
||||
The CLI passes explicit suffixes and ``interactive=False``.
|
||||
|
||||
Returns ``True`` on success and ``False`` when installation cannot proceed.
|
||||
"""
|
||||
self._refresh_state()
|
||||
|
||||
if not self._check_requirements(self.klipper_list):
|
||||
return False
|
||||
|
||||
new_instances: List[Moonraker] = []
|
||||
|
||||
if klipper_suffixes is not None:
|
||||
for suffix in klipper_suffixes:
|
||||
new_instances.append(self.misvc.create_new_instance(suffix))
|
||||
elif interactive:
|
||||
selected_option: str | Klipper
|
||||
|
||||
if len(self.klipper_list) == 1:
|
||||
selected_suffix: str = self.klipper_list[0].suffix
|
||||
new_instances.append(self.misvc.create_new_instance(selected_suffix))
|
||||
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 True
|
||||
|
||||
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_instances.append(
|
||||
self.misvc.create_new_instance(klipper_instance.suffix)
|
||||
)
|
||||
else:
|
||||
for k in self.klipper_list:
|
||||
new_instances.append(self.misvc.create_new_instance(k.suffix))
|
||||
|
||||
if create_example_cfg is None:
|
||||
create_example_cfg = (
|
||||
get_confirm("Create example moonraker.conf?") if interactive else False
|
||||
)
|
||||
|
||||
try:
|
||||
self._run_setup(new_instances, create_example_cfg, interactive=interactive)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Error while installing Moonraker!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def update(self, interactive: bool = True) -> bool:
|
||||
"""Update Moonraker.
|
||||
|
||||
When called from the TUI, a warning and confirmation are shown. The CLI
|
||||
passes ``interactive=False`` to run silently.
|
||||
|
||||
Returns ``True`` on success and ``False`` if the update could not be completed.
|
||||
"""
|
||||
if interactive:
|
||||
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 False
|
||||
|
||||
self._refresh_state()
|
||||
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Error while updating Moonraker!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def remove(
|
||||
self,
|
||||
remove_service: bool,
|
||||
remove_dir: bool,
|
||||
remove_env: bool,
|
||||
remove_polkit: bool,
|
||||
*,
|
||||
remove_all: bool = False,
|
||||
instance_suffixes: List[str] | None = None,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Remove Moonraker.
|
||||
|
||||
When called from the TUI, the user selects instances interactively. In
|
||||
headless mode (``interactive=False``) the caller MUST express explicit
|
||||
intent: pass ``remove_all=True`` to wipe every instance or
|
||||
``instance_suffixes=[...]`` to remove a named subset. Without explicit
|
||||
intent the service refuses and removes nothing, so a CLI user can never
|
||||
accidentally destroy every Moonraker instance.
|
||||
|
||||
Returns ``True`` on success and ``False`` if removal could not be completed.
|
||||
"""
|
||||
self._refresh_state()
|
||||
|
||||
try:
|
||||
if interactive:
|
||||
completion_msg = Message(
|
||||
title="Moonraker Removal Process completed",
|
||||
color=Color.GREEN,
|
||||
)
|
||||
|
||||
if remove_service:
|
||||
Logger.print_status("Removing Moonraker instances ...")
|
||||
if self.moonraker_list:
|
||||
selected = self._get_instances_to_remove()
|
||||
self.__remove_instances(selected)
|
||||
if selected:
|
||||
instance_names = [
|
||||
i.service_file_path.stem for i in selected
|
||||
]
|
||||
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)
|
||||
else:
|
||||
if remove_service and self.moonraker_list:
|
||||
selected = self._select_instances_for_headless_removal(
|
||||
remove_all, instance_suffixes
|
||||
)
|
||||
if selected is None:
|
||||
Logger.print_error(
|
||||
"Refusing to remove Moonraker instances: no explicit "
|
||||
"intent. Pass remove_all=True or instance_suffixes."
|
||||
)
|
||||
return False
|
||||
self.__remove_instances(selected)
|
||||
|
||||
if (remove_polkit or remove_dir or remove_env) and unit_file_exists(
|
||||
"moonraker", suffix="service"
|
||||
):
|
||||
Logger.print_info(
|
||||
"Moonraker services still installed; skipping repository/env removal."
|
||||
)
|
||||
return True
|
||||
|
||||
if remove_polkit:
|
||||
remove_polkit_rules()
|
||||
if remove_dir:
|
||||
run_remove_routines(MOONRAKER_DIR)
|
||||
if remove_env:
|
||||
run_remove_routines(MOONRAKER_ENV_DIR)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error("Error while removing Moonraker!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _run_setup(
|
||||
self,
|
||||
new_instances: List[Moonraker],
|
||||
create_example_cfg: bool,
|
||||
interactive: bool = True,
|
||||
) -> None:
|
||||
check_install_dependencies()
|
||||
# Only create a fresh venv when none exists; existing venvs are
|
||||
# preserved in both TUI and CLI modes.
|
||||
self._install_deps(interactive=interactive)
|
||||
|
||||
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)
|
||||
|
||||
if interactive:
|
||||
Logger.print_dialog(
|
||||
DialogType.CUSTOM,
|
||||
custom_title="Moonraker successfully installed!",
|
||||
custom_color=Color.GREEN,
|
||||
content=dialog_content,
|
||||
)
|
||||
else:
|
||||
if url_list:
|
||||
for url in url_list:
|
||||
Logger.print_info(url)
|
||||
else:
|
||||
Logger.print_info("Moonraker successfully installed!")
|
||||
|
||||
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, interactive: bool = True) -> 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,
|
||||
force=False,
|
||||
allow_access_to_system_site_packages=False,
|
||||
use_python_binary=self.settings.moonraker.use_python_binary,
|
||||
interactive=interactive,
|
||||
):
|
||||
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
|
||||
|
||||
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!")
|
||||
# Intentional fail-soft: polkit rules are optional on many systems
|
||||
# and a failure here must not abort the whole Moonraker installation.
|
||||
return
|
||||
|
||||
Logger.print_ok("Moonraker policykit rules successfully installed!")
|
||||
|
||||
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 _select_instances_for_headless_removal(
|
||||
self,
|
||||
remove_all: bool,
|
||||
instance_suffixes: List[str] | None,
|
||||
) -> List[Moonraker] | None:
|
||||
"""Resolve which instances to remove in headless mode.
|
||||
|
||||
Returns the list of instances to remove, or ``None`` when the caller did
|
||||
not express explicit intent (no ``remove_all`` and no ``instance_suffixes``).
|
||||
A ``None`` return is the "refuse to wipe everything" signal the CLI path
|
||||
relies on. Kept as a single-public-seam helper (no name mangling) so
|
||||
tests can patch it without brittle ``_Class__method`` access.
|
||||
"""
|
||||
if remove_all:
|
||||
return list(self.moonraker_list)
|
||||
if instance_suffixes:
|
||||
wanted = set(instance_suffixes)
|
||||
return [i for i in self.moonraker_list if i.suffix in wanted]
|
||||
return None
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||
|
||||
|
||||
class FakeKlipper:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
name = f"klipper-{suffix}" if suffix else "klipper"
|
||||
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
|
||||
|
||||
|
||||
class FakeMoonraker:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
name = f"moonraker-{suffix}" if suffix else "moonraker"
|
||||
self.service_file_path = Path(f"/etc/systemd/system/{name}.service")
|
||||
self.env_file = Path(f"/tmp/{name}.env")
|
||||
self.port = 7125
|
||||
self.base = type("Base", (), {"sysd_dir": Path("/tmp")})()
|
||||
|
||||
def create(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeKlipperInstanceService:
|
||||
def __init__(self, instances: List[FakeKlipper]) -> None:
|
||||
self._instances = instances
|
||||
|
||||
def load_instances(self) -> None:
|
||||
pass
|
||||
|
||||
def get_all_instances(self) -> List[FakeKlipper]:
|
||||
return self._instances
|
||||
|
||||
|
||||
class FakeMoonrakerInstanceService:
|
||||
def __init__(self, instances: List[FakeMoonraker]) -> None:
|
||||
self._instances = instances
|
||||
self.created: List[str] = []
|
||||
|
||||
def load_instances(self) -> None:
|
||||
pass
|
||||
|
||||
def get_all_instances(self) -> List[FakeMoonraker]:
|
||||
return self._instances
|
||||
|
||||
def create_new_instance(self, suffix: str) -> FakeMoonraker:
|
||||
self.created.append(suffix)
|
||||
return FakeMoonraker(suffix)
|
||||
|
||||
def get_instance_by_suffix(self, suffix: str) -> FakeMoonraker:
|
||||
return FakeMoonraker(suffix)
|
||||
|
||||
def get_instance_port_map(self) -> Dict[str, int]:
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
MoonrakerSetupService, "_MoonrakerSetupService__cls_instance", None
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_instance_services(
|
||||
monkeypatch: pytest.MonkeyPatch, reset_service
|
||||
) -> Dict[str, Any]:
|
||||
state = {"klipper": [], "moonraker": []}
|
||||
|
||||
def make_kis(*args, **kwargs):
|
||||
return FakeKlipperInstanceService(state["klipper"])
|
||||
|
||||
def make_mis(*args, **kwargs):
|
||||
return FakeMoonrakerInstanceService(state["moonraker"])
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(f"{module}.KlipperInstanceService", make_kis)
|
||||
monkeypatch.setattr(f"{module}.MoonrakerInstanceService", make_mis)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
class TestMoonrakerInstall:
|
||||
def test_installs_for_single_klipper_instance(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("")]
|
||||
|
||||
setup_calls: List[Any] = []
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda self, instances, cfg, interactive=True: setup_calls.append((
|
||||
instances,
|
||||
cfg,
|
||||
interactive,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.install()
|
||||
|
||||
assert len(setup_calls) == 1
|
||||
instances, cfg, _interactive = setup_calls[0]
|
||||
assert len(instances) == 1
|
||||
assert instances[0].suffix == ""
|
||||
assert cfg is True
|
||||
|
||||
def test_installs_for_selected_klipper_instance(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
|
||||
|
||||
setup_calls: List[Any] = []
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda self, instances, cfg, interactive=True: setup_calls.append((
|
||||
instances,
|
||||
cfg,
|
||||
interactive,
|
||||
)),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_selection_input", lambda *a, **k: "1")
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.install()
|
||||
|
||||
assert len(setup_calls) == 1
|
||||
assert setup_calls[0][0][0].suffix == "a"
|
||||
|
||||
|
||||
class TestMoonrakerUpdate:
|
||||
def test_update_runs_expected_steps(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
calls: List[str] = []
|
||||
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.stop_all",
|
||||
staticmethod(lambda instances: calls.append("stop")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_python_requirements",
|
||||
lambda *a, **k: calls.append("requirements"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.start_all",
|
||||
staticmethod(lambda instances: calls.append("start")),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.settings.kiauh.backup_before_update = True
|
||||
service.update()
|
||||
|
||||
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
|
||||
|
||||
|
||||
class TestMoonrakerRemove:
|
||||
def test_removes_selected_instance(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
|
||||
fake_instance = FakeMoonraker("")
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._get_instances_to_remove",
|
||||
lambda self: [fake_instance],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.remove",
|
||||
staticmethod(lambda instance: removed["instances"].append(instance)),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
|
||||
monkeypatch.setattr(f"{module}.remove_polkit_rules", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda path: removed["paths"].append(str(path)) or True,
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.remove(
|
||||
remove_service=True, remove_dir=True, remove_env=True, remove_polkit=True
|
||||
)
|
||||
|
||||
assert removed["instances"] == [fake_instance]
|
||||
assert any("moonraker" in p for p in removed["paths"])
|
||||
|
||||
|
||||
class TestMoonrakerInstallHeadless:
|
||||
def test_headless_install_does_not_show_success_dialog(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._install_deps",
|
||||
lambda self, interactive: None,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
monkeypatch.setattr(f"{module}.cmd_sysctl_service", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.cmd_sysctl_manage", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.check_install_dependencies", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_ipv4_addr", lambda: "127.0.0.1")
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_dialog",
|
||||
lambda *a, **k: pytest.fail("should not show dialog in headless install"),
|
||||
)
|
||||
errors: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_error",
|
||||
lambda msg, *a, **k: errors.append(str(msg)),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(interactive=False)
|
||||
|
||||
assert errors == [], f"unexpected errors: {errors}"
|
||||
assert result is True
|
||||
|
||||
def test_installs_with_explicit_klipper_suffixes(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
|
||||
|
||||
setup_calls: List[Any] = []
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda self, instances, cfg, interactive=True: setup_calls.append((
|
||||
instances,
|
||||
cfg,
|
||||
interactive,
|
||||
)),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(klipper_suffixes=["a", "b"], interactive=False)
|
||||
|
||||
assert result is True
|
||||
assert len(setup_calls) == 1
|
||||
instances, cfg, interactive = setup_calls[0]
|
||||
assert [i.suffix for i in instances] == ["a", "b"]
|
||||
assert cfg is False
|
||||
assert interactive is False
|
||||
|
||||
def test_installs_for_all_klipper_instances_when_non_interactive(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("a"), FakeKlipper("b")]
|
||||
|
||||
setup_calls: List[Any] = []
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda self, instances, cfg, interactive=True: setup_calls.append((
|
||||
instances,
|
||||
cfg,
|
||||
interactive,
|
||||
)),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(interactive=False)
|
||||
|
||||
assert result is True
|
||||
assert [i.suffix for i in setup_calls[0][0]] == ["a", "b"]
|
||||
assert setup_calls[0][2] is False
|
||||
|
||||
def test_returns_false_when_klipper_is_missing(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = []
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(interactive=False)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_when_setup_raises(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(interactive=False)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMoonrakerUpdateHeadless:
|
||||
def test_update_runs_without_confirmation(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
calls: List[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
f"{module}.backup_moonraker_dir", lambda: calls.append("backup")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.stop_all",
|
||||
staticmethod(lambda instances: calls.append("stop")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: calls.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_moonraker_packages", lambda: calls.append("packages")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.install_python_requirements",
|
||||
lambda *a, **k: calls.append("requirements"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.start_all",
|
||||
staticmethod(lambda instances: calls.append("start")),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.settings.kiauh.backup_before_update = True
|
||||
service.update(interactive=False)
|
||||
|
||||
assert calls == ["backup", "stop", "pull", "packages", "requirements", "start"]
|
||||
|
||||
def test_update_cancelled_by_user_returns_false(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("")]
|
||||
pulled: List[str] = []
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.update(interactive=True)
|
||||
|
||||
assert result is False
|
||||
assert pulled == []
|
||||
|
||||
|
||||
class TestMoonrakerPolkitBehavior:
|
||||
def test_install_polkit_failure_logs_error_and_continues(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
|
||||
class FakeResult:
|
||||
returncode = 1
|
||||
stderr = "polkit install failed"
|
||||
|
||||
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
|
||||
monkeypatch.setattr(
|
||||
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
|
||||
)
|
||||
|
||||
error_messages: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_error",
|
||||
lambda msg, *a, **k: error_messages.append(str(msg)),
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service._install_polkit()
|
||||
|
||||
assert any("polkit" in m.lower() for m in error_messages)
|
||||
|
||||
def test_install_succeeds_when_polkit_rules_fail(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["klipper"] = [FakeKlipper("")]
|
||||
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._check_requirements",
|
||||
lambda self, kl: True,
|
||||
)
|
||||
|
||||
setup_calls: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._run_setup",
|
||||
lambda self, instances, cfg, interactive=True: setup_calls.append((
|
||||
instances,
|
||||
cfg,
|
||||
interactive,
|
||||
)),
|
||||
)
|
||||
|
||||
class FakeResult:
|
||||
returncode = 1
|
||||
stderr = "polkit install failed"
|
||||
|
||||
monkeypatch.setattr(f"{module}.run", lambda *a, **k: FakeResult())
|
||||
monkeypatch.setattr(
|
||||
f"{module}.check_file_exist", lambda p, follow_symlinks=False: False
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
|
||||
def fake_install_deps(self, interactive: bool = True) -> None:
|
||||
self._install_polkit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._install_deps",
|
||||
fake_install_deps,
|
||||
)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install()
|
||||
|
||||
assert result is True
|
||||
assert len(setup_calls) == 1
|
||||
|
||||
|
||||
class TestMoonrakerRemoveHeadless:
|
||||
def _patch_remove_internals(self, monkeypatch, removed):
|
||||
module = "components.moonraker.services.moonraker_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.remove",
|
||||
staticmethod(lambda instance: removed["instances"].append(instance.suffix)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MoonrakerSetupService._refresh_state",
|
||||
lambda self: None,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.unit_file_exists", lambda *a, **k: False)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_polkit_rules",
|
||||
lambda: removed["paths"].append("polkit") or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda path: removed["paths"].append(str(path)) or True,
|
||||
)
|
||||
|
||||
def test_removes_all_instances_when_explicit_all(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
|
||||
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=True,
|
||||
remove_env=True,
|
||||
remove_polkit=True,
|
||||
remove_all=True,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert set(removed["instances"]) == {"a", "b"}
|
||||
assert "polkit" in removed["paths"]
|
||||
|
||||
def test_without_explicit_intent_removes_nothing(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
# non-interactive remove with no --all / --instance must not destroy any instance and must refuse.
|
||||
patch_instance_services["moonraker"] = [FakeMoonraker("a"), FakeMoonraker("b")]
|
||||
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
result = service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=False,
|
||||
remove_env=False,
|
||||
remove_polkit=False,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert removed["instances"] == []
|
||||
assert removed["paths"] == []
|
||||
|
||||
def test_with_instance_suffix_removes_only_matching(
|
||||
self, patch_instance_services, monkeypatch
|
||||
) -> None:
|
||||
patch_instance_services["moonraker"] = [
|
||||
FakeMoonraker("a"),
|
||||
FakeMoonraker("b"),
|
||||
]
|
||||
|
||||
removed: Dict[str, List[Any]] = {"instances": [], "paths": []}
|
||||
self._patch_remove_internals(monkeypatch, removed)
|
||||
|
||||
service = MoonrakerSetupService()
|
||||
service.remove(
|
||||
remove_service=True,
|
||||
remove_dir=False,
|
||||
remove_env=False,
|
||||
remove_polkit=False,
|
||||
instance_suffixes=["a"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert removed["instances"] == ["a"]
|
||||
@@ -0,0 +1,179 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# 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)
|
||||
self.vendor: str = ""
|
||||
if pathlib.Path("/etc/rpi-issue").is_file():
|
||||
self.vendor = "raspberry-pi"
|
||||
|
||||
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 == "vendor":
|
||||
left_op = self.vendor
|
||||
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 []
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from components.moonraker.utils import utils as moonraker_utils
|
||||
from components.moonraker.utils.utils import (
|
||||
backup_moonraker_db_dir,
|
||||
backup_moonraker_dir,
|
||||
create_example_moonraker_conf,
|
||||
get_moonraker_status,
|
||||
install_moonraker_packages,
|
||||
load_sysdeps_json,
|
||||
remove_polkit_rules,
|
||||
)
|
||||
|
||||
|
||||
class FakeMoonraker:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
self.data_dir = Path(f"/tmp/moonraker{suffix}_data")
|
||||
self.db_dir = self.data_dir.joinpath("database")
|
||||
self.cfg_file = self.data_dir.joinpath("moonraker.conf")
|
||||
self.base = type(
|
||||
"Base",
|
||||
(),
|
||||
{
|
||||
"cfg_dir": self.data_dir,
|
||||
"comms_dir": self.data_dir.joinpath("comms"),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_instance(tmp_path: Path) -> FakeMoonraker:
|
||||
instance = FakeMoonraker("")
|
||||
instance.data_dir = tmp_path / "moonraker_data"
|
||||
instance.cfg_file = instance.data_dir / "moonraker.conf"
|
||||
instance.db_dir = instance.data_dir / "database"
|
||||
instance.base = type(
|
||||
"Base",
|
||||
(),
|
||||
{
|
||||
"cfg_dir": instance.data_dir,
|
||||
"comms_dir": instance.data_dir / "comms",
|
||||
},
|
||||
)()
|
||||
return instance
|
||||
|
||||
|
||||
class TestGetMoonrakerStatus:
|
||||
def test_delegates_to_get_install_status(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
called: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils,
|
||||
"get_install_status",
|
||||
lambda *args: called.append(args) or type("S", (), {"status": 0})(),
|
||||
)
|
||||
|
||||
status = get_moonraker_status()
|
||||
|
||||
assert called
|
||||
assert status.status == 0
|
||||
|
||||
|
||||
class TestInstallMoonrakerPackages:
|
||||
def test_parses_deps_json_when_present(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
deps_file = tmp_path / "moonraker_deps.json"
|
||||
deps_file.write_text(json.dumps({"debian": ["pkg1", "pkg2"]}))
|
||||
install_script = tmp_path / "install_moonraker.sh"
|
||||
install_script.write_text("# dummy")
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
|
||||
|
||||
deps: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
|
||||
)
|
||||
|
||||
class FakeParser:
|
||||
def parse_dependencies(self, data):
|
||||
return ["pkg1", "pkg2"]
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "SysDepsParser", FakeParser)
|
||||
|
||||
install_moonraker_packages()
|
||||
|
||||
assert "pkg1" in deps
|
||||
assert "pkg2" in deps
|
||||
|
||||
def test_falls_back_to_install_script(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
deps_file = tmp_path / "missing.json"
|
||||
install_script = tmp_path / "install_moonraker.sh"
|
||||
install_script.write_text("apt-get install pkg3 pkg4\n")
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
|
||||
|
||||
deps: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils, "check_install_dependencies", lambda p: deps.extend(p)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils,
|
||||
"parse_packages_from_file",
|
||||
lambda p: ["pkg3", "pkg4"],
|
||||
)
|
||||
|
||||
install_moonraker_packages()
|
||||
|
||||
assert "pkg3" in deps
|
||||
|
||||
def test_raises_when_no_deps_found(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
deps_file = tmp_path / "missing.json"
|
||||
install_script = tmp_path / "missing.sh"
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DEPS_JSON_FILE", deps_file)
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_INSTALL_SCRIPT", install_script)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
install_moonraker_packages()
|
||||
|
||||
|
||||
class TestRemovePolkitRules:
|
||||
def test_returns_false_when_moonraker_dir_missing(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "missing")
|
||||
|
||||
assert remove_polkit_rules() is False
|
||||
|
||||
def test_returns_true_on_success(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils,
|
||||
"run",
|
||||
lambda *a, **k: type("R", (), {"returncode": 0})(),
|
||||
)
|
||||
|
||||
assert remove_polkit_rules() is True
|
||||
|
||||
def test_returns_false_on_command_failure(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path)
|
||||
|
||||
def fake_run(*a, **k):
|
||||
raise moonraker_utils.CalledProcessError(1, cmd="clear")
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "run", fake_run)
|
||||
|
||||
assert remove_polkit_rules() is False
|
||||
|
||||
|
||||
class TestCreateExampleMoonrakerConf:
|
||||
def test_skips_when_config_already_exists(
|
||||
self, monkeypatch: pytest.MonkeyPatch, fake_instance: FakeMoonraker
|
||||
) -> None:
|
||||
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fake_instance.cfg_file.write_text("existing")
|
||||
|
||||
create_example_moonraker_conf(fake_instance, {})
|
||||
|
||||
# no changes expected
|
||||
assert fake_instance.cfg_file.read_text() == "existing"
|
||||
|
||||
def test_creates_config_with_default_port(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
fake_instance: FakeMoonraker,
|
||||
) -> None:
|
||||
fake_instance.cfg_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
assets_dir = tmp_path / "assets"
|
||||
assets_dir.mkdir()
|
||||
template = assets_dir / "moonraker.conf"
|
||||
template.write_text(
|
||||
"[server]\nport: %{PORT}%\nklippy_uds_address: %{UDS}%\n"
|
||||
"[authorization]\ntrusted_clients:\n %{CLIENTS}%\n"
|
||||
)
|
||||
monkeypatch.setattr(moonraker_utils, "MODULE_PATH", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils, "get_ipv4_addr", lambda: "192.168.1.10"
|
||||
)
|
||||
|
||||
create_example_moonraker_conf(fake_instance, {})
|
||||
|
||||
content = fake_instance.cfg_file.read_text()
|
||||
assert "192.168.0.0/16" in content
|
||||
|
||||
|
||||
class TestBackupMoonrakerDir:
|
||||
def test_backs_up_repository_and_environment(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
calls: List[Dict[str, Any]] = []
|
||||
|
||||
class FakeBackup:
|
||||
def backup_directory(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_DIR", tmp_path / "moonraker")
|
||||
monkeypatch.setattr(moonraker_utils, "MOONRAKER_ENV_DIR", tmp_path / "env")
|
||||
|
||||
backup_moonraker_dir()
|
||||
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
class TestBackupMoonrakerDbDir:
|
||||
def test_backs_up_db_for_each_instance(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
calls: List[Dict[str, Any]] = []
|
||||
|
||||
class FakeBackup:
|
||||
def backup_directory(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
moonraker_utils, "get_instances", lambda model: [FakeMoonraker("")]
|
||||
)
|
||||
|
||||
backup_moonraker_db_dir()
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_falls_back_to_home_dirs(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
printer_data = tmp_path / "printer_data"
|
||||
printer_data.mkdir()
|
||||
printer_data.joinpath("database").mkdir()
|
||||
|
||||
calls: List[Dict[str, Any]] = []
|
||||
|
||||
class FakeBackup:
|
||||
def backup_directory(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(moonraker_utils, "BackupService", FakeBackup)
|
||||
monkeypatch.setattr(moonraker_utils, "get_instances", lambda model: [])
|
||||
|
||||
backup_moonraker_db_dir()
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
class TestLoadSysdepsJson:
|
||||
def test_loads_valid_json(self, tmp_path: Path) -> None:
|
||||
file = tmp_path / "deps.json"
|
||||
file.write_text('{"debian": ["curl"]}')
|
||||
|
||||
result = load_sysdeps_json(file)
|
||||
|
||||
assert result == {"debian": ["curl"]}
|
||||
|
||||
def test_returns_empty_on_invalid_json(self, tmp_path: Path) -> None:
|
||||
file = tmp_path / "deps.json"
|
||||
file.write_text("not json")
|
||||
|
||||
result = load_sysdeps_json(file)
|
||||
|
||||
assert result == {}
|
||||
@@ -0,0 +1,228 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from subprocess import DEVNULL, PIPE, CalledProcessError, run
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from components.moonraker import (
|
||||
MODULE_PATH,
|
||||
MOONRAKER_DEFAULT_PORT,
|
||||
MOONRAKER_DEPS_JSON_FILE,
|
||||
MOONRAKER_DIR,
|
||||
MOONRAKER_ENV_DIR,
|
||||
MOONRAKER_INSTALL_SCRIPT,
|
||||
)
|
||||
from components.moonraker.moonraker import Moonraker
|
||||
from components.moonraker.utils.sysdeps_parser import SysDepsParser
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from core.logger import Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.simple_config_parser.simple_config_parser import (
|
||||
SimpleConfigParser,
|
||||
)
|
||||
from core.types.component_status import ComponentStatus
|
||||
from utils.common import check_install_dependencies, get_install_status
|
||||
from utils.instance_utils import get_instances
|
||||
from utils.sys_utils import (
|
||||
get_ipv4_addr,
|
||||
parse_packages_from_file,
|
||||
)
|
||||
|
||||
|
||||
def get_moonraker_status() -> ComponentStatus:
|
||||
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(
|
||||
instance: Moonraker,
|
||||
ports_map: Dict[str, int],
|
||||
clients: Optional[List[BaseWebClient]] = None,
|
||||
) -> None:
|
||||
Logger.print_status(f"Creating example moonraker.conf in '{instance.base.cfg_dir}'")
|
||||
if instance.cfg_file.is_file():
|
||||
Logger.print_info(f"'{instance.cfg_file}' already exists.")
|
||||
return
|
||||
|
||||
source = MODULE_PATH.joinpath("assets/moonraker.conf")
|
||||
target = instance.cfg_file
|
||||
try:
|
||||
shutil.copy(source, target)
|
||||
except OSError as e:
|
||||
Logger.print_error(f"Unable to create example moonraker.conf:\n{e}")
|
||||
return
|
||||
|
||||
ports = [
|
||||
ports_map.get(instance)
|
||||
for instance in ports_map
|
||||
if ports_map.get(instance) is not None
|
||||
]
|
||||
if ports_map.get(instance.suffix) is None:
|
||||
# this could be improved to not increment the max value of the ports list and assign it as the port
|
||||
# as it can lead to situation where the port for e.g. instance moonraker-2 becomes 7128 if the port
|
||||
# of moonraker-1 is 7125 and moonraker-3 is 7127 and there are moonraker.conf files for moonraker-1
|
||||
# and moonraker-3 already. though, there does not seem to be a very reliable way of always assigning
|
||||
# the correct port to each instance and the user will likely be required to correct the value manually.
|
||||
port = max(ports) + 1 if ports else MOONRAKER_DEFAULT_PORT
|
||||
else:
|
||||
port = ports_map.get(instance.suffix)
|
||||
|
||||
ports_map[instance.suffix] = port
|
||||
|
||||
ip = get_ipv4_addr().split(".")[:2]
|
||||
ip.extend(["0", "0/16"])
|
||||
uds = instance.base.comms_dir.joinpath("klippy.sock")
|
||||
|
||||
scp = SimpleConfigParser()
|
||||
scp.read_file(target)
|
||||
trusted_clients: List[str] = [
|
||||
f"{'.'.join(ip)}",
|
||||
*scp.getvals("authorization", "trusted_clients"),
|
||||
]
|
||||
|
||||
scp.set_option("server", "port", str(port))
|
||||
scp.set_option("server", "klippy_uds_address", str(uds))
|
||||
scp.set_option("authorization", "trusted_clients", trusted_clients)
|
||||
|
||||
# add existing client and client configs in the update section
|
||||
if clients is not None and len(clients) > 0:
|
||||
for c in clients:
|
||||
# client part
|
||||
c_section = f"update_manager {c.name}"
|
||||
c_options = [
|
||||
("type", "web"),
|
||||
("channel", "stable"),
|
||||
("repo", c.repo_path),
|
||||
("path", c.client_dir),
|
||||
]
|
||||
scp.add_section(section=c_section)
|
||||
for option in c_options:
|
||||
scp.set_option(c_section, option[0], option[1])
|
||||
|
||||
# client config part
|
||||
c_config = c.client_config
|
||||
if c_config.config_dir.exists():
|
||||
c_config_section = f"update_manager {c_config.name}"
|
||||
c_config_options = [
|
||||
("type", "git_repo"),
|
||||
("primary_branch", "master"),
|
||||
("path", c_config.config_dir),
|
||||
("origin", c_config.repo_url),
|
||||
("managed_services", "klipper"),
|
||||
]
|
||||
scp.add_section(section=c_config_section)
|
||||
for option in c_config_options:
|
||||
scp.set_option(c_config_section, option[0], option[1])
|
||||
|
||||
scp.write_file(target)
|
||||
Logger.print_ok(f"Example moonraker.conf created in '{instance.base.cfg_dir}'")
|
||||
|
||||
|
||||
def backup_moonraker_dir() -> None:
|
||||
svc = BackupService()
|
||||
svc.backup_directory(
|
||||
source_path=MOONRAKER_DIR, backup_name="moonraker", target_path="moonraker"
|
||||
)
|
||||
svc.backup_directory(
|
||||
source_path=MOONRAKER_ENV_DIR,
|
||||
backup_name="moonraker-env",
|
||||
target_path="moonraker",
|
||||
)
|
||||
|
||||
|
||||
def backup_moonraker_db_dir() -> None:
|
||||
instances: List[Moonraker] = get_instances(Moonraker)
|
||||
svc = BackupService()
|
||||
|
||||
if not instances:
|
||||
# fallback: search for printer data directories in the user's home directory
|
||||
Logger.print_info("No Moonraker instances found via systemd services.")
|
||||
Logger.print_info(
|
||||
"Attempting to find printer data directories in home directory..."
|
||||
)
|
||||
|
||||
home_dir = Path.home()
|
||||
printer_data_dirs = []
|
||||
|
||||
for pattern in ["printer_data", "printer_*_data"]:
|
||||
for data_dir in home_dir.glob(pattern):
|
||||
if data_dir.is_dir():
|
||||
printer_data_dirs.append(data_dir)
|
||||
|
||||
if not printer_data_dirs:
|
||||
Logger.print_info("Unable to find directory to backup!")
|
||||
Logger.print_info("No printer data directories found in home directory.")
|
||||
return
|
||||
|
||||
for data_dir in printer_data_dirs:
|
||||
svc.backup_directory(
|
||||
source_path=data_dir.joinpath("database"),
|
||||
target_path=data_dir.name,
|
||||
backup_name="database",
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
for instance in instances:
|
||||
svc.backup_directory(
|
||||
source_path=instance.db_dir,
|
||||
target_path=f"{instance.data_dir.name}",
|
||||
backup_name="database",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,23 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict
|
||||
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from components.webui_client.fluidd_data import FluiddData
|
||||
from components.webui_client.mainsail_data import MainsailData
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parent
|
||||
|
||||
# Shared registry of supported web clients
|
||||
CLIENTS: Dict[str, Callable[[], BaseWebClient]] = {
|
||||
"mainsail": MainsailData,
|
||||
"fluidd": FluiddData,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# /etc/nginx/conf.d/common_vars.conf
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
server {
|
||||
listen %PORT%;
|
||||
# uncomment the next line to activate IPv6
|
||||
# listen [::]:%PORT%;
|
||||
|
||||
access_log /var/log/nginx/%NAME%-access.log;
|
||||
error_log /var/log/nginx/%NAME%-error.log;
|
||||
|
||||
# disable this section on smaller hardware like a pi zero
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_comp_level 4;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml;
|
||||
|
||||
# web_path from %NAME% static files
|
||||
root %ROOT_DIR%;
|
||||
|
||||
index index.html;
|
||||
server_name _;
|
||||
|
||||
# disable max upload size checks
|
||||
client_max_body_size 0;
|
||||
|
||||
# disable proxy request buffering
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location /websocket {
|
||||
proxy_pass http://apiserver/websocket;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location ~ ^/(printer|api|access|machine|server)/ {
|
||||
proxy_pass http://apiserver$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Scheme $scheme;
|
||||
}
|
||||
|
||||
location /webcam/ {
|
||||
postpone_output 0;
|
||||
proxy_buffering off;
|
||||
proxy_ignore_headers X-Accel-Buffering;
|
||||
access_log off;
|
||||
error_log off;
|
||||
proxy_pass http://mjpgstreamer1/;
|
||||
}
|
||||
|
||||
location /webcam2/ {
|
||||
postpone_output 0;
|
||||
proxy_buffering off;
|
||||
proxy_ignore_headers X-Accel-Buffering;
|
||||
access_log off;
|
||||
error_log off;
|
||||
proxy_pass http://mjpgstreamer2/;
|
||||
}
|
||||
|
||||
location /webcam3/ {
|
||||
postpone_output 0;
|
||||
proxy_buffering off;
|
||||
proxy_ignore_headers X-Accel-Buffering;
|
||||
access_log off;
|
||||
error_log off;
|
||||
proxy_pass http://mjpgstreamer3/;
|
||||
}
|
||||
|
||||
location /webcam4/ {
|
||||
postpone_output 0;
|
||||
proxy_buffering off;
|
||||
proxy_ignore_headers X-Accel-Buffering;
|
||||
access_log off;
|
||||
error_log off;
|
||||
proxy_pass http://mjpgstreamer4/;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# /etc/nginx/conf.d/upstreams.conf
|
||||
upstream apiserver {
|
||||
ip_hash;
|
||||
server 127.0.0.1:7125;
|
||||
}
|
||||
|
||||
upstream mjpgstreamer1 {
|
||||
ip_hash;
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
upstream mjpgstreamer2 {
|
||||
ip_hash;
|
||||
server 127.0.0.1:8081;
|
||||
}
|
||||
|
||||
upstream mjpgstreamer3 {
|
||||
ip_hash;
|
||||
server 127.0.0.1:8082;
|
||||
}
|
||||
|
||||
upstream mjpgstreamer4 {
|
||||
ip_hash;
|
||||
server 127.0.0.1:8083;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class WebClientType(Enum):
|
||||
MAINSAIL: str = "mainsail"
|
||||
FLUIDD: str = "fluidd"
|
||||
|
||||
|
||||
class WebClientConfigType(Enum):
|
||||
MAINSAIL: str = "mainsail-config"
|
||||
FLUIDD: str = "fluidd-config"
|
||||
|
||||
|
||||
@dataclass()
|
||||
class BaseWebClient(ABC):
|
||||
"""Base class for webclient data"""
|
||||
|
||||
client: WebClientType
|
||||
name: str
|
||||
display_name: str
|
||||
client_dir: Path
|
||||
config_file: Path
|
||||
repo_path: str
|
||||
download_url: str
|
||||
nginx_config: Path
|
||||
nginx_access_log: Path
|
||||
nginx_error_log: Path
|
||||
client_config: BaseWebClientConfig
|
||||
|
||||
|
||||
@dataclass()
|
||||
class BaseWebClientConfig(ABC):
|
||||
"""Base class for webclient config data"""
|
||||
|
||||
client_config: WebClientConfigType
|
||||
name: str
|
||||
display_name: str
|
||||
config_filename: str
|
||||
config_dir: Path
|
||||
repo_url: str
|
||||
config_section: str
|
||||
@@ -0,0 +1,93 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from typing import List
|
||||
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from core.logger import DialogType, Logger
|
||||
|
||||
|
||||
def print_moonraker_not_found_dialog(name: str) -> None:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
"No local Moonraker installation was found!",
|
||||
"\n\n",
|
||||
f"It is possible to install {name} without a local Moonraker installation. "
|
||||
"If you continue, you need to make sure, that Moonraker is installed on "
|
||||
f"another machine in your network. Otherwise {name} will NOT work "
|
||||
"correctly.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def print_client_already_installed_dialog(name: str) -> None:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
f"{name} seems to be already installed!",
|
||||
f"If you continue, your current {name} installation will be overwritten.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def print_client_port_select_dialog(
|
||||
name: str, port: int, ports_in_use: List[int]
|
||||
) -> None:
|
||||
dialog_content: List[str] = [
|
||||
f"Please select the port, {name} should be served on. If your are unsure "
|
||||
f"what to select, hit Enter to apply the suggested value of: {port}",
|
||||
"\n\n",
|
||||
f"In case you need {name} to be served on a specific port, you can set it "
|
||||
f"now. Make sure that the port is not already used by another application "
|
||||
f"on your system!",
|
||||
]
|
||||
|
||||
if ports_in_use:
|
||||
dialog_content.extend(
|
||||
[
|
||||
"\n\n",
|
||||
"The following ports were found to be already in use:",
|
||||
*[f"● {p}" for p in ports_in_use if p != port],
|
||||
]
|
||||
)
|
||||
|
||||
Logger.print_dialog(DialogType.CUSTOM, dialog_content)
|
||||
|
||||
|
||||
def print_install_client_config_dialog(client: BaseWebClient) -> None:
|
||||
name = client.display_name
|
||||
url = client.client_config.repo_url.replace(".git", "")
|
||||
Logger.print_dialog(
|
||||
DialogType.INFO,
|
||||
[
|
||||
f"It is recommended to use special macros in order to have {name} fully "
|
||||
f"functional and working.",
|
||||
"\n\n",
|
||||
f"The recommended macros for {name} can be seen here:",
|
||||
url,
|
||||
"\n\n",
|
||||
"If you already use these macros skip this step. Otherwise you should "
|
||||
"consider to answer with 'Y' to download the recommended macros.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def print_ipv6_warning_dialog() -> None:
|
||||
Logger.print_dialog(
|
||||
DialogType.WARNING,
|
||||
[
|
||||
"It looks like IPv6 is enabled on this system!",
|
||||
"This may cause issues with the installation of NGINX in the following "
|
||||
"steps! It is recommended to disable IPv6 on your system to avoid this issue.",
|
||||
"\n\n",
|
||||
"If you think this warning is a false alarm, and you are sure that "
|
||||
"IPv6 is disabled, you can continue with the installation.",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from json import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, CalledProcessError, run
|
||||
from typing import List, get_args
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.webui_client import MODULE_PATH
|
||||
from components.webui_client.base_data import (
|
||||
BaseWebClient,
|
||||
BaseWebClientConfig,
|
||||
WebClientType,
|
||||
)
|
||||
from components.webui_client.client_dialogs import print_client_port_select_dialog
|
||||
from components.webui_client.fluidd_data import FluiddData
|
||||
from components.webui_client.mainsail_data import MainsailData
|
||||
from core.constants import (
|
||||
NGINX_CONFD,
|
||||
NGINX_SITES_AVAILABLE,
|
||||
NGINX_SITES_ENABLED,
|
||||
)
|
||||
from core.logger import Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.settings.kiauh_settings import KiauhSettings, WebUiSettings
|
||||
from core.simple_config_parser.simple_config_parser import (
|
||||
SimpleConfigParser,
|
||||
)
|
||||
from core.types.color import Color
|
||||
from core.types.component_status import ComponentStatus
|
||||
from utils.common import get_install_status
|
||||
from utils.fs_utils import create_symlink, remove_file
|
||||
from utils.git_utils import (
|
||||
get_latest_remote_tag,
|
||||
get_latest_unstable_tag,
|
||||
)
|
||||
from utils.input_utils import get_number_input
|
||||
from utils.instance_utils import get_instances
|
||||
|
||||
|
||||
def get_client_status(
|
||||
client: BaseWebClient, fetch_remote: bool = False
|
||||
) -> ComponentStatus:
|
||||
files = [
|
||||
NGINX_SITES_AVAILABLE.joinpath(client.name),
|
||||
NGINX_CONFD.joinpath("upstreams.conf"),
|
||||
NGINX_CONFD.joinpath("common_vars.conf"),
|
||||
]
|
||||
comp_status: ComponentStatus = get_install_status(client.client_dir, files=files)
|
||||
|
||||
# if the client dir does not exist, set the status to not
|
||||
# installed even if the other files are present
|
||||
if not client.client_dir.exists():
|
||||
comp_status.status = 0
|
||||
|
||||
comp_status.local = get_local_client_version(client)
|
||||
comp_status.remote = get_remote_client_version(client) if fetch_remote else None
|
||||
return comp_status
|
||||
|
||||
|
||||
def get_client_config_status(client: BaseWebClient) -> ComponentStatus:
|
||||
return get_install_status(client.client_config.config_dir)
|
||||
|
||||
|
||||
def get_current_client_config() -> str:
|
||||
mainsail, fluidd = MainsailData(), FluiddData()
|
||||
clients: List[BaseWebClient] = [mainsail, fluidd]
|
||||
installed = [c for c in clients if c.client_config.config_dir.exists()]
|
||||
|
||||
if not installed:
|
||||
return str(Color.apply("-", Color.CYAN))
|
||||
elif len(installed) == 1:
|
||||
cfg = installed[0].client_config
|
||||
return str(Color.apply(cfg.display_name, Color.CYAN))
|
||||
|
||||
# at this point, both client config folders exists, so we need to check
|
||||
# which are actually included in the printer.cfg of all klipper instances
|
||||
mainsail_includes, fluidd_includes = [], []
|
||||
klipper_instances: List[Klipper] = get_instances(Klipper)
|
||||
for instance in klipper_instances:
|
||||
scp = SimpleConfigParser()
|
||||
scp.read_file(instance.cfg_file)
|
||||
includes_mainsail = scp.has_section(mainsail.client_config.config_section)
|
||||
includes_fluidd = scp.has_section(fluidd.client_config.config_section)
|
||||
|
||||
if includes_mainsail:
|
||||
mainsail_includes.append(instance)
|
||||
if includes_fluidd:
|
||||
fluidd_includes.append(instance)
|
||||
|
||||
# if both are included in the same file, we have a potential conflict
|
||||
if includes_mainsail and includes_fluidd:
|
||||
return str(Color.apply("Conflict", Color.YELLOW))
|
||||
|
||||
if not mainsail_includes and not fluidd_includes:
|
||||
# there are no includes at all, even though the client config folders exist
|
||||
return str(Color.apply("-", Color.CYAN))
|
||||
elif len(fluidd_includes) > len(mainsail_includes):
|
||||
# there are more instances that include fluidd than mainsail
|
||||
return str(Color.apply(fluidd.client_config.display_name, Color.CYAN))
|
||||
else:
|
||||
# there are the same amount of non-conflicting includes for each config
|
||||
# or more instances include mainsail than fluidd
|
||||
return str(Color.apply(mainsail.client_config.display_name, Color.CYAN))
|
||||
|
||||
|
||||
def enable_mainsail_remotemode() -> None:
|
||||
Logger.print_status("Enable Mainsails remote mode ...")
|
||||
c_json = MainsailData().client_dir.joinpath("config.json")
|
||||
with open(c_json, "r") as f:
|
||||
config_data = json.load(f)
|
||||
|
||||
if config_data["instancesDB"] == "browser" or config_data["instancesDB"] == "json":
|
||||
Logger.print_info("Remote mode already configured. Skipped ...")
|
||||
return
|
||||
|
||||
Logger.print_status("Setting instance storage location to 'browser' ...")
|
||||
config_data["instancesDB"] = "browser"
|
||||
|
||||
with open(c_json, "w") as f:
|
||||
json.dump(config_data, f, indent=4)
|
||||
Logger.print_ok("Mainsails remote mode enabled!")
|
||||
|
||||
|
||||
def symlink_webui_nginx_log(
|
||||
client: BaseWebClient, klipper_instances: List[Klipper]
|
||||
) -> None:
|
||||
Logger.print_status("Link NGINX logs into log directory ...")
|
||||
access_log = client.nginx_access_log
|
||||
error_log = client.nginx_error_log
|
||||
|
||||
for instance in klipper_instances:
|
||||
desti_access = instance.base.log_dir.joinpath(access_log.name)
|
||||
if not desti_access.exists():
|
||||
desti_access.symlink_to(access_log)
|
||||
|
||||
desti_error = instance.base.log_dir.joinpath(error_log.name)
|
||||
if not desti_error.exists():
|
||||
desti_error.symlink_to(error_log)
|
||||
|
||||
|
||||
def get_local_client_version(client: BaseWebClient) -> str | None:
|
||||
relinfo_file = client.client_dir.joinpath("release_info.json")
|
||||
version_file = client.client_dir.joinpath(".version")
|
||||
|
||||
if not client.client_dir.exists():
|
||||
return None
|
||||
|
||||
# try to get version from release_info.json first
|
||||
if relinfo_file.is_file():
|
||||
try:
|
||||
if relinfo_file.stat().st_size == 0:
|
||||
raise JSONDecodeError("Empty file", "", 0)
|
||||
with open(relinfo_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
raw_version = data.get("version")
|
||||
if raw_version is not None:
|
||||
parsed = str(raw_version).strip()
|
||||
if parsed:
|
||||
return parsed
|
||||
except (JSONDecodeError, OSError):
|
||||
Logger.print_error("Invalid 'release_info.json'")
|
||||
|
||||
# fallback to .version file
|
||||
if version_file.is_file():
|
||||
try:
|
||||
with open(version_file, "r") as f:
|
||||
line = f.readline().strip()
|
||||
return line or None
|
||||
except OSError:
|
||||
Logger.print_error("Unable to read '.version'")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_remote_client_version(client: BaseWebClient) -> str | None:
|
||||
try:
|
||||
if (tag := get_latest_remote_tag(client.repo_path)) != "":
|
||||
return str(tag)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def backup_client_data(client: BaseWebClient) -> None:
|
||||
version = ""
|
||||
src = client.client_dir
|
||||
if src.joinpath(".version").exists():
|
||||
with open(src.joinpath(".version"), "r") as v:
|
||||
version = v.readlines()[0]
|
||||
|
||||
svc = BackupService()
|
||||
target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}")
|
||||
svc.backup_directory(
|
||||
source_path=client.client_dir,
|
||||
target_path=target_path,
|
||||
backup_name=client.name,
|
||||
)
|
||||
svc.backup_file(
|
||||
source_path=client.config_file,
|
||||
target_path=target_path,
|
||||
)
|
||||
|
||||
|
||||
def backup_client_config_data(client: BaseWebClient) -> None:
|
||||
version = ""
|
||||
src = client.client_dir
|
||||
if src.joinpath(".version").exists():
|
||||
with open(src.joinpath(".version"), "r") as v:
|
||||
version = v.readlines()[0]
|
||||
|
||||
svc = BackupService()
|
||||
target_path = svc.backup_root.joinpath(f"{client.client_dir.name}_{version}")
|
||||
svc.backup_directory(
|
||||
source_path=client.client_config.config_dir,
|
||||
target_path=target_path,
|
||||
backup_name=client.client_config.name,
|
||||
)
|
||||
|
||||
|
||||
def get_existing_clients() -> List[BaseWebClient]:
|
||||
clients = list(get_args(WebClientType))
|
||||
installed_clients: List[BaseWebClient] = []
|
||||
for client in clients:
|
||||
if client.client_dir.exists():
|
||||
installed_clients.append(client)
|
||||
|
||||
return installed_clients
|
||||
|
||||
|
||||
def detect_client_cfg_conflict(curr_client: BaseWebClient) -> bool:
|
||||
"""
|
||||
Check if any other client configs are present on the system.
|
||||
It is usually not harmful, but chances are they can conflict each other.
|
||||
Multiple client configs are, at least, redundant to have them installed
|
||||
:param curr_client: The client name to check for the conflict
|
||||
:return: True, if other client configs were found, else False
|
||||
"""
|
||||
|
||||
mainsail_cfg_status: ComponentStatus = get_client_config_status(MainsailData())
|
||||
fluidd_cfg_status: ComponentStatus = get_client_config_status(FluiddData())
|
||||
|
||||
if curr_client.client == WebClientType.MAINSAIL and fluidd_cfg_status.status == 2:
|
||||
return True
|
||||
if curr_client.client == WebClientType.FLUIDD and mainsail_cfg_status.status == 2:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_download_url(base_url: str, client: BaseWebClient) -> str:
|
||||
settings = KiauhSettings()
|
||||
use_unstable = settings.get(client.name, "unstable_releases")
|
||||
stable_url = f"{base_url}/latest/download/{client.name}.zip"
|
||||
|
||||
if not use_unstable:
|
||||
return stable_url
|
||||
|
||||
try:
|
||||
unstable_tag = get_latest_unstable_tag(client.repo_path)
|
||||
if unstable_tag == "":
|
||||
raise Exception
|
||||
return f"{base_url}/download/{unstable_tag}/{client.name}.zip"
|
||||
except Exception:
|
||||
return stable_url
|
||||
|
||||
|
||||
#################################################
|
||||
## NGINX RELATED FUNCTIONS
|
||||
#################################################
|
||||
|
||||
|
||||
def copy_upstream_nginx_cfg() -> None:
|
||||
"""
|
||||
Creates an upstream.conf in /etc/nginx/conf.d
|
||||
:return: None
|
||||
"""
|
||||
source = MODULE_PATH.joinpath("assets/upstreams.conf")
|
||||
target = NGINX_CONFD.joinpath("upstreams.conf")
|
||||
try:
|
||||
command = ["sudo", "cp", source, target]
|
||||
run(command, stderr=PIPE, check=True)
|
||||
except CalledProcessError as e:
|
||||
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
|
||||
Logger.print_error(log)
|
||||
raise
|
||||
|
||||
|
||||
def copy_common_vars_nginx_cfg() -> None:
|
||||
"""
|
||||
Creates a common_vars.conf in /etc/nginx/conf.d
|
||||
:return: None
|
||||
"""
|
||||
source = MODULE_PATH.joinpath("assets/common_vars.conf")
|
||||
target = NGINX_CONFD.joinpath("common_vars.conf")
|
||||
try:
|
||||
command = ["sudo", "cp", source, target]
|
||||
run(command, stderr=PIPE, check=True)
|
||||
except CalledProcessError as e:
|
||||
log = f"Unable to create upstreams.conf: {e.stderr.decode()}"
|
||||
Logger.print_error(log)
|
||||
raise
|
||||
|
||||
|
||||
def generate_nginx_cfg_from_template(name: str, template_src: Path, **kwargs) -> None:
|
||||
"""
|
||||
Creates an NGINX config from a template file and
|
||||
replaces all placeholders passed as kwargs. A placeholder must be defined
|
||||
in the template file as %{placeholder}%.
|
||||
:param name: name of the config to create
|
||||
:param template_src: the path to the template file
|
||||
:return: None
|
||||
"""
|
||||
tmp = Path.home().joinpath(f"{name}.tmp")
|
||||
shutil.copy(template_src, tmp)
|
||||
with open(tmp, "r+") as f:
|
||||
content = f.read()
|
||||
|
||||
for key, value in kwargs.items():
|
||||
content = content.replace(f"%{key}%", str(value))
|
||||
|
||||
f.seek(0)
|
||||
f.write(content)
|
||||
f.truncate()
|
||||
|
||||
target = NGINX_SITES_AVAILABLE.joinpath(name)
|
||||
try:
|
||||
command = ["sudo", "mv", tmp, target]
|
||||
run(command, stderr=PIPE, check=True)
|
||||
except CalledProcessError as e:
|
||||
log = f"Unable to create '{target}': {e.stderr.decode()}"
|
||||
Logger.print_error(log)
|
||||
raise
|
||||
|
||||
|
||||
def create_nginx_cfg(
|
||||
display_name: str,
|
||||
cfg_name: str,
|
||||
template_src: Path,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
from utils.sys_utils import set_nginx_permissions
|
||||
|
||||
try:
|
||||
Logger.print_status(f"Creating NGINX config for {display_name} ...")
|
||||
|
||||
source = NGINX_SITES_AVAILABLE.joinpath(cfg_name)
|
||||
target = NGINX_SITES_ENABLED.joinpath(cfg_name)
|
||||
remove_file(Path("/etc/nginx/sites-enabled/default"), True)
|
||||
generate_nginx_cfg_from_template(cfg_name, template_src=template_src, **kwargs)
|
||||
create_symlink(source, target, True)
|
||||
set_nginx_permissions()
|
||||
|
||||
Logger.print_ok(f"NGINX config for {display_name} successfully created.")
|
||||
except Exception:
|
||||
Logger.print_error(f"Creating NGINX config for {display_name} failed!")
|
||||
raise
|
||||
|
||||
|
||||
def get_nginx_config_list() -> List[Path]:
|
||||
"""
|
||||
Get a list of all NGINX config files in /etc/nginx/sites-enabled
|
||||
:return: List of NGINX config files
|
||||
"""
|
||||
configs: List[Path] = []
|
||||
for config in NGINX_SITES_ENABLED.iterdir():
|
||||
if not config.is_file():
|
||||
continue
|
||||
configs.append(config)
|
||||
return configs
|
||||
|
||||
|
||||
def get_nginx_listen_port(config: Path) -> int | None:
|
||||
"""
|
||||
Get the listen port from an NGINX config file
|
||||
:param config: The NGINX config file to read the port from
|
||||
:return: The listen port as int or None if not found/parsable
|
||||
"""
|
||||
|
||||
# noinspection HttpUrlsUsage
|
||||
pattern = r"default_server|http://|https://|[;\[\]]"
|
||||
port = ""
|
||||
with open(config, "r") as cfg:
|
||||
for line in cfg.readlines():
|
||||
line = re.sub(pattern, "", line.strip())
|
||||
if line.startswith("listen"):
|
||||
if ":" not in line:
|
||||
port = line.split()[-1]
|
||||
else:
|
||||
port = line.split(":")[-1]
|
||||
try:
|
||||
return int(port)
|
||||
except ValueError:
|
||||
Logger.print_error(
|
||||
f"Unable to parse listen port {port} from {config.name}!"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def read_ports_from_nginx_configs() -> List[int]:
|
||||
"""
|
||||
Helper function to iterate over all NGINX configs
|
||||
and read all ports defined for listen
|
||||
:return: A sorted list of listen ports
|
||||
"""
|
||||
if not NGINX_SITES_ENABLED.exists():
|
||||
return []
|
||||
|
||||
port_list: List[int] = []
|
||||
for config in get_nginx_config_list():
|
||||
port = get_nginx_listen_port(config)
|
||||
if port is not None:
|
||||
port_list.append(port)
|
||||
|
||||
return sorted(port_list, key=lambda x: int(x))
|
||||
|
||||
|
||||
def get_client_port_selection(
|
||||
client: BaseWebClient,
|
||||
settings: KiauhSettings,
|
||||
reconfigure=False,
|
||||
) -> int:
|
||||
default_port: int = int(settings.get(client.name, "port"))
|
||||
ports_in_use: List[int] = read_ports_from_nginx_configs()
|
||||
next_free_port: int = get_next_free_port(ports_in_use)
|
||||
|
||||
port: int = (
|
||||
next_free_port
|
||||
if not reconfigure and default_port in ports_in_use
|
||||
else default_port
|
||||
)
|
||||
|
||||
print_client_port_select_dialog(client.display_name, port, ports_in_use)
|
||||
|
||||
while True:
|
||||
_type = "Reconfigure" if reconfigure else "Configure"
|
||||
question = f"{_type} {client.display_name} for port"
|
||||
port_input: int | None = get_number_input(question, min_value=80, default=port)
|
||||
|
||||
if port_input and port_input not in ports_in_use:
|
||||
client_settings: WebUiSettings = settings[client.name]
|
||||
client_settings.port = port_input
|
||||
settings.save()
|
||||
|
||||
return port_input
|
||||
|
||||
Logger.print_error("This port is already in use. Please select another one.")
|
||||
|
||||
|
||||
def get_next_free_port(ports_in_use: List[int]) -> int:
|
||||
valid_ports = set(range(80, 7125))
|
||||
used_ports = set(map(int, ports_in_use))
|
||||
|
||||
return min(valid_ports - used_ports)
|
||||
|
||||
|
||||
def set_listen_port(client: BaseWebClient, curr_port: int, new_port: int) -> None:
|
||||
"""
|
||||
Set the port the client should listen on in the NGINX config
|
||||
:param curr_port: The current port the client listens on
|
||||
:param new_port: The new port to set
|
||||
:param client: The client to set the port for
|
||||
:return: None
|
||||
"""
|
||||
config = NGINX_SITES_AVAILABLE.joinpath(client.name)
|
||||
with open(config, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "listen" in line:
|
||||
lines[i] = line.replace(str(curr_port), str(new_port))
|
||||
|
||||
with open(config, "w") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def create_client_config_symlink(
|
||||
client_config: BaseWebClientConfig, klipper_instances: List[Klipper]
|
||||
) -> None:
|
||||
"""Symlink the client config file into every Klipper instance's config dir."""
|
||||
for instance in klipper_instances:
|
||||
Logger.print_status(f"Create symlink for {client_config.config_filename} ...")
|
||||
source = Path(client_config.config_dir, client_config.config_filename)
|
||||
target = instance.base.cfg_dir
|
||||
Logger.print_status(f"Linking {source} to {target}")
|
||||
try:
|
||||
create_symlink(source, target)
|
||||
except Exception:
|
||||
Logger.print_error("Creating symlink failed!")
|
||||
@@ -0,0 +1,55 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from components.webui_client.base_data import (
|
||||
BaseWebClient,
|
||||
BaseWebClientConfig,
|
||||
WebClientConfigType,
|
||||
WebClientType,
|
||||
)
|
||||
from core.constants import NGINX_SITES_AVAILABLE
|
||||
|
||||
|
||||
@dataclass()
|
||||
class FluiddConfigWeb(BaseWebClientConfig):
|
||||
client_config: WebClientConfigType = WebClientConfigType.FLUIDD
|
||||
name: str = client_config.value
|
||||
display_name: str = name.title()
|
||||
config_dir: Path = Path.home().joinpath("fluidd-config")
|
||||
config_filename: str = "fluidd.cfg"
|
||||
config_section: str = f"include {config_filename}"
|
||||
repo_url: str = "https://github.com/fluidd-core/fluidd-config.git"
|
||||
|
||||
|
||||
@dataclass()
|
||||
class FluiddData(BaseWebClient):
|
||||
BASE_DL_URL = "https://github.com/fluidd-core/fluidd/releases"
|
||||
|
||||
client: WebClientType = WebClientType.FLUIDD
|
||||
name: str = client.value
|
||||
display_name: str = name.capitalize()
|
||||
client_dir: Path = Path.home().joinpath("fluidd")
|
||||
config_file: Path = client_dir.joinpath("config.json")
|
||||
repo_path: str = "fluidd-core/fluidd"
|
||||
nginx_config: Path = NGINX_SITES_AVAILABLE.joinpath("fluidd")
|
||||
nginx_access_log: Path = Path("/var/log/nginx/fluidd-access.log")
|
||||
nginx_error_log: Path = Path("/var/log/nginx/fluidd-error.log")
|
||||
client_config: BaseWebClientConfig = None
|
||||
download_url: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
from components.webui_client.client_utils import get_download_url
|
||||
|
||||
self.client_config = FluiddConfigWeb()
|
||||
self.download_url = get_download_url(self.BASE_DL_URL, self)
|
||||
@@ -0,0 +1,55 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from components.webui_client.base_data import (
|
||||
BaseWebClient,
|
||||
BaseWebClientConfig,
|
||||
WebClientConfigType,
|
||||
WebClientType,
|
||||
)
|
||||
from core.constants import NGINX_SITES_AVAILABLE
|
||||
|
||||
|
||||
@dataclass()
|
||||
class MainsailConfigWeb(BaseWebClientConfig):
|
||||
client_config: WebClientConfigType = WebClientConfigType.MAINSAIL
|
||||
name: str = client_config.value
|
||||
display_name: str = name.title()
|
||||
config_dir: Path = Path.home().joinpath("mainsail-config")
|
||||
config_filename: str = "mainsail.cfg"
|
||||
config_section: str = f"include {config_filename}"
|
||||
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
|
||||
|
||||
|
||||
@dataclass()
|
||||
class MainsailData(BaseWebClient):
|
||||
BASE_DL_URL: str = "https://github.com/mainsail-crew/mainsail/releases"
|
||||
|
||||
client: WebClientType = WebClientType.MAINSAIL
|
||||
name: str = WebClientType.MAINSAIL.value
|
||||
display_name: str = name.capitalize()
|
||||
client_dir: Path = Path.home().joinpath("mainsail")
|
||||
config_file: Path = client_dir.joinpath("config.json")
|
||||
repo_path: str = "mainsail-crew/mainsail"
|
||||
nginx_config: Path = NGINX_SITES_AVAILABLE.joinpath("mainsail")
|
||||
nginx_access_log: Path = Path("/var/log/nginx/mainsail-access.log")
|
||||
nginx_error_log: Path = Path("/var/log/nginx/mainsail-error.log")
|
||||
client_config: BaseWebClientConfig = None
|
||||
download_url: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
from components.webui_client.client_utils import get_download_url
|
||||
|
||||
self.client_config = MainsailConfigWeb()
|
||||
self.download_url = get_download_url(self.BASE_DL_URL, self)
|
||||
@@ -0,0 +1,109 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from components.webui_client.client_utils import (
|
||||
get_client_port_selection,
|
||||
get_nginx_listen_port,
|
||||
set_listen_port,
|
||||
)
|
||||
from components.webui_client.services.web_client_setup_service import (
|
||||
WebClientSetupService,
|
||||
)
|
||||
from core.logger import Logger
|
||||
from core.menus import Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.services.message_service import Message
|
||||
from core.settings.kiauh_settings import KiauhSettings, WebUiSettings
|
||||
from core.types.color import Color
|
||||
from utils.sys_utils import cmd_sysctl_service, get_ipv4_addr
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
class ClientInstallMenu(BaseMenu):
|
||||
def __init__(
|
||||
self, client: BaseWebClient, previous_menu: Type[BaseMenu] | None = None
|
||||
):
|
||||
super().__init__()
|
||||
self.title = f"Installation Menu > {client.display_name}"
|
||||
self.title_color = Color.GREEN
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.client: BaseWebClient = client
|
||||
self.settings = KiauhSettings()
|
||||
self.client_settings: WebUiSettings = self.settings[client.name]
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from core.menus.install_menu import InstallMenu
|
||||
|
||||
self.previous_menu = previous_menu if previous_menu is not None else InstallMenu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"1": Option(method=self.reinstall_client),
|
||||
"2": Option(method=self.change_listen_port),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
client_name = self.client.display_name
|
||||
port = f"(Current: {Color.apply(self._get_current_port(), Color.GREEN)})"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) Reinstall {client_name:16} ║
|
||||
║ 2) Reconfigure Listen Port {port:<34} ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def reinstall_client(self, **kwargs) -> None:
|
||||
WebClientSetupService(self.client.name).install(
|
||||
reinstall=True, interactive=True
|
||||
)
|
||||
|
||||
def change_listen_port(self, **kwargs) -> None:
|
||||
curr_port = self._get_current_port()
|
||||
new_port = get_client_port_selection(
|
||||
self.client,
|
||||
self.settings,
|
||||
reconfigure=True,
|
||||
)
|
||||
|
||||
cmd_sysctl_service("nginx", "stop")
|
||||
set_listen_port(self.client, curr_port, new_port)
|
||||
|
||||
Logger.print_status("Saving new port configuration ...")
|
||||
self.client_settings.port = new_port
|
||||
self.settings.save()
|
||||
Logger.print_ok("Port configuration saved!")
|
||||
|
||||
cmd_sysctl_service("nginx", "start")
|
||||
|
||||
# noinspection HttpUrlsUsage
|
||||
message = Message(
|
||||
title="Port reconfiguration complete!",
|
||||
text=[
|
||||
f"Open {self.client.display_name} now on: "
|
||||
f"http://{get_ipv4_addr()}:{new_port}",
|
||||
],
|
||||
color=Color.GREEN,
|
||||
)
|
||||
self.message_service.set_message(message)
|
||||
|
||||
def _get_current_port(self) -> int:
|
||||
curr_port: int | None = get_nginx_listen_port(self.client.nginx_config)
|
||||
if curr_port is None:
|
||||
# if the port is not found in the config file we use
|
||||
# the default port from the kiauh settings as fallback
|
||||
return int(self.client_settings.port)
|
||||
return curr_port
|
||||
@@ -0,0 +1,115 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Type
|
||||
|
||||
from components.webui_client.base_data import BaseWebClient
|
||||
from components.webui_client.services.web_client_setup_service import (
|
||||
WebClientSetupService,
|
||||
)
|
||||
from core.menus import Option
|
||||
from core.menus.base_menu import BaseMenu
|
||||
from core.types.color import Color
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
class ClientRemoveMenu(BaseMenu):
|
||||
def __init__(
|
||||
self, client: BaseWebClient, previous_menu: Type[BaseMenu] | None = None
|
||||
):
|
||||
super().__init__()
|
||||
self.title = f"Remove {client.display_name}"
|
||||
self.title_color = Color.RED
|
||||
self.previous_menu: Type[BaseMenu] | None = previous_menu
|
||||
self.client: BaseWebClient = client
|
||||
self.remove_client: bool = False
|
||||
self.remove_client_cfg: bool = False
|
||||
self.backup_config_json: bool = False
|
||||
self.select_state: bool = False
|
||||
|
||||
def set_previous_menu(self, previous_menu: Type[BaseMenu] | None) -> None:
|
||||
from core.menus.remove_menu import RemoveMenu
|
||||
|
||||
self.previous_menu = previous_menu if previous_menu is not None else RemoveMenu
|
||||
|
||||
def set_options(self) -> None:
|
||||
self.options = {
|
||||
"a": Option(method=self.toggle_all),
|
||||
"1": Option(method=self.toggle_rm_client),
|
||||
"2": Option(method=self.toggle_rm_client_config),
|
||||
"3": Option(method=self.toggle_backup_config_json),
|
||||
"c": Option(method=self.run_removal_process),
|
||||
}
|
||||
|
||||
def print_menu(self) -> None:
|
||||
client_name = self.client.display_name
|
||||
client_config = self.client.client_config
|
||||
client_config_name = client_config.display_name
|
||||
|
||||
checked = f"[{Color.apply('x', Color.CYAN)}]"
|
||||
unchecked = "[ ]"
|
||||
o1 = checked if self.remove_client else unchecked
|
||||
o2 = checked if self.remove_client_cfg else unchecked
|
||||
o3 = checked if self.backup_config_json else unchecked
|
||||
sel_state = f"{'Select' if not self.select_state else 'Deselect'} everything"
|
||||
menu = textwrap.dedent(
|
||||
f"""
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ Enter a number and hit enter to select / deselect ║
|
||||
║ the specific option for removal. ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ a) {sel_state:49} ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ 1) {o1} Remove {client_name:16} ║
|
||||
║ 2) {o2} Remove {client_config_name:24} ║
|
||||
║ 3) {o3} Backup config.json ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
║ C) Continue ║
|
||||
╟───────────────────────────────────────────────────────╢
|
||||
"""
|
||||
)[1:]
|
||||
print(menu, end="")
|
||||
|
||||
def toggle_all(self, **kwargs) -> None:
|
||||
self.select_state = not self.select_state
|
||||
self.remove_client = self.select_state
|
||||
self.remove_client_cfg = self.select_state
|
||||
self.backup_config_json = self.select_state
|
||||
|
||||
def toggle_rm_client(self, **kwargs) -> None:
|
||||
self.remove_client = not self.remove_client
|
||||
|
||||
def toggle_rm_client_config(self, **kwargs) -> None:
|
||||
self.remove_client_cfg = not self.remove_client_cfg
|
||||
|
||||
def toggle_backup_config_json(self, **kwargs) -> None:
|
||||
self.backup_config_json = not self.backup_config_json
|
||||
|
||||
def run_removal_process(self, **kwargs) -> None:
|
||||
if (
|
||||
not self.remove_client
|
||||
and not self.remove_client_cfg
|
||||
and not self.backup_config_json
|
||||
):
|
||||
print(Color.apply("Nothing selected ...", Color.RED))
|
||||
return
|
||||
|
||||
WebClientSetupService(self.client.name).remove(
|
||||
remove_client=self.remove_client,
|
||||
remove_client_cfg=self.remove_client_cfg,
|
||||
backup_config=self.backup_config_json,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
self.remove_client = False
|
||||
self.remove_client_cfg = False
|
||||
self.backup_config_json = False
|
||||
self.select_state = False
|
||||
@@ -0,0 +1,105 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import components.webui_client.menus.client_install_menu as cim_module
|
||||
import pytest
|
||||
from components.webui_client.menus.client_install_menu import ClientInstallMenu
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, name: str = "mainsail") -> None:
|
||||
self.name = name
|
||||
self.display_name = name.capitalize()
|
||||
self.nginx_config = Path("/tmp/nginx/mainsail")
|
||||
|
||||
|
||||
def _build_menu(monkeypatch: pytest.MonkeyPatch, current_port: int | None = 80) -> ClientInstallMenu:
|
||||
# Neutralise singletons / IO from BaseMenu and KiauhSettings.
|
||||
monkeypatch.setattr(cim_module, "KiauhSettings", lambda: _FakeSettings())
|
||||
monkeypatch.setattr(cim_module, "get_nginx_listen_port", lambda cfg: current_port)
|
||||
return ClientInstallMenu(_FakeClient())
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
def __init__(self) -> None:
|
||||
self._section = _FakeSection()
|
||||
self.mainsail = self._section
|
||||
self.fluidd = self._section
|
||||
|
||||
def save(self) -> None:
|
||||
self._section.saved = True
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._section
|
||||
|
||||
|
||||
class _FakeSection:
|
||||
port = 80
|
||||
saved = False
|
||||
|
||||
|
||||
class TestClientInstallMenu:
|
||||
def test_options_cover_reinstall_and_port_change(self, monkeypatch) -> None:
|
||||
menu = _build_menu(monkeypatch)
|
||||
# BaseMenu may append a "back" footer option depending on the menu's
|
||||
# footer type; the two install-specific entries must always be present.
|
||||
assert {"1", "2"}.issubset(menu.options.keys())
|
||||
|
||||
def test_set_previous_menu_defaults_to_install_menu(self, monkeypatch) -> None:
|
||||
menu = _build_menu(monkeypatch)
|
||||
menu.set_previous_menu(None)
|
||||
from core.menus.install_menu import InstallMenu
|
||||
|
||||
assert menu.previous_menu is InstallMenu
|
||||
|
||||
def test_get_current_port_uses_nginx_value(self, monkeypatch) -> None:
|
||||
menu = _build_menu(monkeypatch, current_port=8080)
|
||||
assert menu._get_current_port() == 8080
|
||||
|
||||
def test_get_current_port_falls_back_to_settings(self, monkeypatch) -> None:
|
||||
menu = _build_menu(monkeypatch, current_port=None)
|
||||
# FakeSettings._FakeSection.port == 80
|
||||
assert menu._get_current_port() == 80
|
||||
|
||||
def test_reinstall_delegates_to_web_client_setup_service(self, monkeypatch) -> None:
|
||||
menu = _build_menu(monkeypatch)
|
||||
calls: List[Any] = []
|
||||
|
||||
class _FakeService:
|
||||
def install(self, **kwargs) -> bool:
|
||||
calls.append(kwargs)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(cim_module, "WebClientSetupService", lambda name: _FakeService())
|
||||
menu.reinstall_client()
|
||||
|
||||
assert calls
|
||||
assert calls[0]["reinstall"] is True
|
||||
assert calls[0]["interactive"] is True
|
||||
|
||||
def test_change_listen_port_persists_and_restarts_nginx(self, monkeypatch, tmp_path) -> None:
|
||||
menu = _build_menu(monkeypatch)
|
||||
captured: dict = {}
|
||||
|
||||
monkeypatch.setattr(cim_module, "get_client_port_selection", lambda *a, **k: 9090)
|
||||
monkeypatch.setattr(cim_module, "cmd_sysctl_service", lambda svc, action: captured.setdefault("nginx", []).append(action))
|
||||
monkeypatch.setattr(cim_module, "set_listen_port", lambda client, c, n: captured.setdefault("set_port", (c, n)))
|
||||
monkeypatch.setattr(cim_module, "get_ipv4_addr", lambda: "127.0.0.1")
|
||||
# Inject a fake message service to avoid the real MessageService.
|
||||
menu.message_service = type("MS", (), {"set_message": lambda self, m: captured.setdefault("msg", m)})()
|
||||
|
||||
menu.change_listen_port()
|
||||
|
||||
assert captured["nginx"] == ["stop", "start"]
|
||||
assert captured["set_port"] == (80, 9090)
|
||||
assert menu.client_settings.port == 9090
|
||||
@@ -0,0 +1,63 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from components.webui_client.base_data import WebClientType
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClientConfig:
|
||||
name: str = "mainsail-config"
|
||||
display_name: str = "Mainsail-Config"
|
||||
config_filename: str = "mainsail.cfg"
|
||||
config_section: str = "include mainsail.cfg"
|
||||
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
|
||||
config_dir: Path = Path("/tmp/mainsail-config")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeWebClient:
|
||||
name: str = "mainsail"
|
||||
display_name: str = "Mainsail"
|
||||
client: WebClientType = WebClientType.MAINSAIL
|
||||
client_dir: Path = Path("/tmp/mainsail")
|
||||
config_file: Path = Path("/tmp/mainsail/config.json")
|
||||
repo_path: str = "mainsail-crew/mainsail"
|
||||
nginx_config: Path = Path("/tmp/nginx/mainsail")
|
||||
nginx_access_log: Path = Path("/tmp/log/mainsail-access.log")
|
||||
nginx_error_log: Path = Path("/tmp/log/mainsail-error.log")
|
||||
download_url: str = "https://example.com/mainsail.zip"
|
||||
client_config: Any = field(default_factory=FakeClientConfig)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FakeWebClient:
|
||||
return FakeWebClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
|
||||
KiauhSettings._KiauhSettings__instance = None
|
||||
KiauhSettings._KiauhSettings__initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(reset_settings) -> Any:
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
|
||||
return KiauhSettings()
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from components.webui_client.services.web_client_config_setup_service import (
|
||||
WebClientConfigSetupService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bind_client(client, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Make the service construct the per-test FakeWebClient instead of the real data class."""
|
||||
monkeypatch.setattr(
|
||||
WebClientConfigSetupService,
|
||||
"CLIENTS",
|
||||
{"mainsail": lambda: client, "fluidd": lambda: client},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]:
|
||||
calls: Dict[str, List[Any]] = {
|
||||
"download": [],
|
||||
"symlink": [],
|
||||
"backup_printer": [],
|
||||
"add_section": [],
|
||||
"add_section_at_top": [],
|
||||
"restart": [],
|
||||
}
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: False)
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_clone_wrapper",
|
||||
lambda repo, target: calls["download"].append((repo, str(target))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.create_client_config_symlink",
|
||||
lambda cfg, kl: calls["symlink"].append((cfg.name, kl)),
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_printer_config_dir(self) -> None:
|
||||
calls["backup_printer"].append(True)
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.add_config_section",
|
||||
lambda **kwargs: calls["add_section"].append(kwargs["section"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.add_config_section_at_top",
|
||||
lambda section, instances: calls["add_section_at_top"].append(section),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.restart_all",
|
||||
staticmethod(lambda instances: calls["restart"].append(len(instances))),
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
class TestWebClientConfigSetupServiceConstruction:
|
||||
def test_accepts_known_clients(self) -> None:
|
||||
for name in ("mainsail", "fluidd"):
|
||||
svc = WebClientConfigSetupService(name)
|
||||
assert svc.name == name
|
||||
|
||||
def test_rejects_unknown_client(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
WebClientConfigSetupService("unknown")
|
||||
|
||||
def test_clients_mapping_is_the_single_shared_source(self) -> None:
|
||||
# there must be exactly one CLIENTS dict, imported from
|
||||
# components.webui_client by both web-client services.
|
||||
from components import webui_client
|
||||
from components.webui_client.services.web_client_setup_service import (
|
||||
WebClientSetupService,
|
||||
)
|
||||
|
||||
assert webui_client.CLIENTS is WebClientConfigSetupService.CLIENTS
|
||||
assert webui_client.CLIENTS is WebClientSetupService.CLIENTS
|
||||
assert set(WebClientConfigSetupService.CLIENTS.keys()) == {"mainsail", "fluidd"}
|
||||
|
||||
|
||||
class TestInstallClientConfig:
|
||||
def test_installs_when_clean(
|
||||
self, bind_client, patched_install_deps, tmp_path: Path
|
||||
) -> None:
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
result = WebClientConfigSetupService("mainsail").install()
|
||||
|
||||
assert result is True
|
||||
assert patched_install_deps["download"]
|
||||
assert patched_install_deps["symlink"]
|
||||
assert "update_manager mainsail-config" in patched_install_deps["add_section"]
|
||||
|
||||
def test_skips_when_conflict_detected(
|
||||
self, bind_client, patched_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(f"{module}.detect_client_cfg_conflict", lambda c: True)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").install()
|
||||
|
||||
assert result is True
|
||||
assert patched_install_deps["download"] == []
|
||||
|
||||
def test_interactive_reinstall_after_confirm(
|
||||
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
removed: List[Path] = []
|
||||
monkeypatch.setattr(f"{module}.shutil.rmtree", lambda p: removed.append(p))
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: True)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").install(interactive=True)
|
||||
|
||||
assert result is True
|
||||
assert removed == [bind_client.client_config.config_dir]
|
||||
assert patched_install_deps["download"]
|
||||
|
||||
def test_interactive_decline_reinstall_skips(
|
||||
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.shutil.rmtree", lambda p: pytest.fail("no rmtree")
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.get_confirm", lambda *a, **k: False)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").install(interactive=True)
|
||||
|
||||
assert result is True
|
||||
assert patched_install_deps["download"] == []
|
||||
|
||||
def test_non_interactive_existing_dir_skips(
|
||||
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
|
||||
)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").install(interactive=False)
|
||||
|
||||
assert result is True
|
||||
assert patched_install_deps["download"] == []
|
||||
|
||||
def test_install_failure_returns_false(
|
||||
self, bind_client, patched_install_deps, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_clone_wrapper",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").install()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestUpdateClientConfig:
|
||||
def test_update_skips_when_dir_missing(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
pulled: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
|
||||
)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").update()
|
||||
|
||||
assert result is True
|
||||
assert pulled == []
|
||||
|
||||
def test_update_pulls_when_dir_exists(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
pulled: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper", lambda *a, **k: pulled.append("pull")
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").update()
|
||||
|
||||
assert result is True
|
||||
assert pulled == ["pull"]
|
||||
|
||||
def test_update_failure_returns_false(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.git_pull_wrapper",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").update()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_update_non_interactive_omits_restart_hint(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
|
||||
printed: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg))
|
||||
)
|
||||
|
||||
WebClientConfigSetupService("mainsail").update(interactive=False)
|
||||
|
||||
assert not any("Restart Klipper" in m for m in printed)
|
||||
|
||||
def test_update_interactive_shows_restart_hint(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
bind_client.client_config.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(f"{module}.git_pull_wrapper", lambda *a, **k: None)
|
||||
monkeypatch.setattr(f"{module}.backup_client_config_data", lambda c: None)
|
||||
printed: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_info", lambda msg, *a, **k: printed.append(str(msg))
|
||||
)
|
||||
|
||||
WebClientConfigSetupService("mainsail").update(interactive=True)
|
||||
|
||||
assert any("Restart Klipper" in m for m in printed)
|
||||
|
||||
|
||||
class TestRemoveClientConfig:
|
||||
def test_remove_signature_rejects_unused_interactive_parameter(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: True)
|
||||
monkeypatch.setattr(f"{module}.remove_config_section", lambda s, i: i)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_printer_cfg(self) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
WebClientConfigSetupService("mainsail").remove(interactive=True)
|
||||
|
||||
def test_remove_runs_dir_and_section_cleanup(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
removed: List[str] = []
|
||||
sections: List[str] = []
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda p: removed.append(str(p)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_config_section",
|
||||
lambda section, instances: sections.append(section) or instances,
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_printer_cfg(self) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
|
||||
)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").remove()
|
||||
|
||||
assert result is True
|
||||
assert any("mainsail-config" in p for p in removed)
|
||||
|
||||
def test_remove_failure_returns_false(self, bind_client, monkeypatch) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda p: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_printer_cfg(self) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
|
||||
)
|
||||
|
||||
result = WebClientConfigSetupService("mainsail").remove()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestRemoveConfig:
|
||||
"""The config-removal operation mutates the filesystem and returns the
|
||||
completion message. Its name must reflect that it does the removal, not
|
||||
merely build a message."""
|
||||
|
||||
def test_old_build_removal_message_name_no_longer_exists(self) -> None:
|
||||
assert not hasattr(WebClientConfigSetupService, "build_removal_message")
|
||||
|
||||
def test_remove_config_performs_destructive_removal_and_returns_message(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
removed: List[str] = []
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda p: removed.append(str(p)) or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_config_section",
|
||||
lambda section, instances: instances,
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_printer_cfg(self) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
|
||||
message = WebClientConfigSetupService("mainsail").remove_config(
|
||||
kl_instances=[], mr_instances=[], backup_config=False
|
||||
)
|
||||
|
||||
assert removed # destructive removal actually ran
|
||||
assert message.text # completion message populated
|
||||
assert any("config" in line.lower() for line in message.text)
|
||||
|
||||
def test_remove_config_nothing_to_remove_message(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_config_setup_service"
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(f"{module}.run_remove_routines", lambda p: False)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_config_section",
|
||||
lambda section, instances: instances,
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_printer_cfg(self) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
|
||||
message = WebClientConfigSetupService("mainsail").remove_config(
|
||||
kl_instances=[], mr_instances=[]
|
||||
)
|
||||
|
||||
assert "Nothing to remove." in message.text
|
||||
@@ -0,0 +1,530 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
from components.webui_client.base_data import WebClientType
|
||||
from components.webui_client.services.web_client_setup_service import (
|
||||
WebClientSetupService,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bind_client(client, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
WebClientSetupService, "CLIENTS", {"mainsail": lambda: client, "fluidd": lambda: client}
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
class FakeInstance:
|
||||
def __init__(self, suffix: str = "") -> None:
|
||||
self.suffix = suffix
|
||||
self.service_file_path = Path(f"service-{suffix}.service")
|
||||
self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_install_deps(monkeypatch: pytest.MonkeyPatch) -> Dict[str, List[Any]]:
|
||||
calls: Dict[str, List[Any]] = {
|
||||
"download_client": [],
|
||||
"enable_remotemode": [],
|
||||
"backup_printer": [],
|
||||
"add_config_section": [],
|
||||
"restart_all": [],
|
||||
"install_client_config": [],
|
||||
"copy_upstream": [],
|
||||
"copy_common_vars": [],
|
||||
"create_nginx_cfg": [],
|
||||
"symlink_logs": [],
|
||||
"restart_nginx": [],
|
||||
}
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(f"{module}.check_install_dependencies", lambda packages: None)
|
||||
monkeypatch.setattr(
|
||||
f"{module}._download_client",
|
||||
lambda client: calls["download_client"].append(client.name),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.enable_mainsail_remotemode",
|
||||
lambda: calls["enable_remotemode"].append(True),
|
||||
)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_printer_config_dir(self) -> None:
|
||||
calls["backup_printer"].append(True)
|
||||
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
calls["backup_printer"].append("moonraker_conf")
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.add_config_section",
|
||||
lambda **kwargs: calls["add_config_section"].append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.InstanceManager.restart_all",
|
||||
staticmethod(lambda instances: calls["restart_all"].append(len(instances))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.WebClientConfigSetupService",
|
||||
lambda name: type(
|
||||
"FakeCfgSvc",
|
||||
(),
|
||||
{
|
||||
"install": lambda self, cfg_backup=True, interactive=True: (
|
||||
calls["install_client_config"].append((name, cfg_backup, interactive))
|
||||
or True
|
||||
)
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.copy_upstream_nginx_cfg", lambda: calls["copy_upstream"].append(True)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.copy_common_vars_nginx_cfg",
|
||||
lambda: calls["copy_common_vars"].append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.create_nginx_cfg",
|
||||
lambda **kwargs: calls["create_nginx_cfg"].append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.symlink_webui_nginx_log",
|
||||
lambda client, instances: calls["symlink_logs"].append(
|
||||
(client.name, len(instances))
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.cmd_sysctl_service",
|
||||
lambda service, action: calls["restart_nginx"].append((service, action)),
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
class TestWebClientSetupServiceConstruction:
|
||||
@pytest.mark.parametrize("name", ["mainsail", "fluidd"])
|
||||
def test_accepts_known_clients(self, name: str) -> None:
|
||||
svc = WebClientSetupService(name)
|
||||
assert svc.name == name
|
||||
|
||||
def test_rejects_unknown_client(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
WebClientSetupService("unknown")
|
||||
|
||||
|
||||
class TestInstallClient:
|
||||
def test_interactive_install_runs_all_steps(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
|
||||
lambda c, s, reconfigure=False: 80,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_confirm",
|
||||
lambda *a, **k: True,
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install()
|
||||
|
||||
assert result is True
|
||||
assert patch_install_deps["download_client"] == ["mainsail"]
|
||||
assert patch_install_deps["create_nginx_cfg"]
|
||||
assert patch_install_deps["restart_nginx"] == [("nginx", "restart")]
|
||||
|
||||
def test_headless_install_uses_explicit_port_and_cfg(
|
||||
self, bind_client, patch_install_deps, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
|
||||
lambda *a, **k: pytest.fail("should not select port interactively"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_instances",
|
||||
lambda model: [FakeInstance()] if model.__name__ == "Klipper" else [],
|
||||
)
|
||||
bind_client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
interactive=False, port=8080, install_client_cfg=True, continue_without_moonraker=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert patch_install_deps["download_client"] == ["mainsail"]
|
||||
assert patch_install_deps["install_client_config"] == [("mainsail", False, False)]
|
||||
nginx_call = patch_install_deps["create_nginx_cfg"][0]
|
||||
assert nginx_call["PORT"] == 8080
|
||||
|
||||
def test_reinstall_uses_default_port_without_prompting(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_instances",
|
||||
lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
|
||||
lambda *a, **k: pytest.fail("should not prompt for port during reinstall"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt during reinstall"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install(reinstall=True, interactive=True)
|
||||
|
||||
assert result is True
|
||||
nginx_call = patch_install_deps["create_nginx_cfg"][0]
|
||||
assert nginx_call["PORT"] == 80
|
||||
|
||||
def test_reinstall_explicit_port_overrides_default(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_instances",
|
||||
lambda model: [FakeInstance()] if model.__name__ == "Moonraker" else [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_client_port_selection",
|
||||
lambda *a, **k: pytest.fail("should not prompt when port is explicit"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt during reinstall"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
reinstall=True, interactive=True, port=9090
|
||||
)
|
||||
|
||||
assert result is True
|
||||
nginx_call = patch_install_deps["create_nginx_cfg"][0]
|
||||
assert nginx_call["PORT"] == 9090
|
||||
|
||||
def test_headless_install_without_moonraker_returns_false(
|
||||
self, bind_client, patch_install_deps
|
||||
) -> None:
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
interactive=False, continue_without_moonraker=False
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert patch_install_deps["download_client"] == []
|
||||
|
||||
def test_install_failure_returns_false(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service._download_client",
|
||||
lambda client: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
interactive=False, continue_without_moonraker=True
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_headless_install_failure_does_not_show_error_dialog(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}._download_client",
|
||||
lambda client: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_dialog",
|
||||
lambda *a, **k: pytest.fail("should not show error dialog in headless mode"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
interactive=False, continue_without_moonraker=True
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_headless_install_does_not_show_completion_dialog(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.get_confirm",
|
||||
lambda *a, **k: pytest.fail("should not prompt in headless mode"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_dialog",
|
||||
lambda *a, **k: pytest.fail("should not show dialog in headless mode"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").install(
|
||||
interactive=False, continue_without_moonraker=True
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_interactive_install_shows_completion_dialog(
|
||||
self, bind_client, patch_install_deps, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.get_confirm", lambda *a, **k: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.get_client_port_selection",
|
||||
lambda c, s, reconfigure=False: 80,
|
||||
)
|
||||
dialog_calls: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_dialog",
|
||||
lambda *a, **k: dialog_calls.append(k),
|
||||
)
|
||||
|
||||
WebClientSetupService("mainsail").install(interactive=True)
|
||||
|
||||
assert dialog_calls
|
||||
|
||||
|
||||
class TestUpdateClient:
|
||||
def test_update_downloads_and_restores_config(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service._download_client",
|
||||
lambda c: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service.shutil.copy",
|
||||
lambda src, dst: None,
|
||||
)
|
||||
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
|
||||
bind_client.config_file.write_text("{}")
|
||||
|
||||
result = WebClientSetupService("mainsail").update()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_update_missing_dir_returns_true(
|
||||
self, bind_client, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
bind_client.client_dir = tmp_path / "does-not-exist"
|
||||
pulled: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service._download_client",
|
||||
lambda c: pulled.append("download"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").update()
|
||||
|
||||
assert result is True
|
||||
assert pulled == []
|
||||
|
||||
def test_update_failure_returns_false(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
|
||||
bind_client.config_file.write_text("{}")
|
||||
monkeypatch.setattr(
|
||||
"components.webui_client.services.web_client_setup_service._download_client",
|
||||
lambda c: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").update()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_update_accepts_interactive_parameter(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
|
||||
bind_client.config_file.write_text("{}")
|
||||
monkeypatch.setattr(f"{module}._download_client", lambda c: None)
|
||||
monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None)
|
||||
|
||||
result = WebClientSetupService("mainsail").update(interactive=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_update_headless_does_not_show_dialog(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
bind_client.client_dir.mkdir(parents=True, exist_ok=True)
|
||||
bind_client.config_file.write_text("{}")
|
||||
monkeypatch.setattr(f"{module}._download_client", lambda c: None)
|
||||
monkeypatch.setattr(f"{module}.shutil.copy", lambda s, d: None)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.Logger.print_dialog",
|
||||
lambda *a, **k: pytest.fail("should not show dialog in headless update"),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").update(interactive=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestRemoveClientHelpers:
|
||||
"""Directly exercise the small removal helper methods so the destructive
|
||||
remove path is covered beyond the integrated ``remove()`` test."""
|
||||
|
||||
def test_remove_client_dir_returns_run_remove_result(self, bind_client, monkeypatch) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines", lambda p: True
|
||||
)
|
||||
svc = WebClientSetupService("mainsail")
|
||||
assert svc._remove_client_dir() is True
|
||||
|
||||
def test_remove_client_nginx_config_delegates_to_sudo(self, bind_client, monkeypatch) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
removed: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_with_sudo", lambda files: removed.append(files) or True
|
||||
)
|
||||
svc = WebClientSetupService("mainsail")
|
||||
assert svc._remove_client_nginx_config("mainsail") is True
|
||||
assert removed # files passed through
|
||||
|
||||
def test_remove_client_nginx_logs_appends_per_instance_paths(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
passed: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_with_sudo", lambda files: passed.append(files) or True
|
||||
)
|
||||
|
||||
class FakeKlipperInstance:
|
||||
def __init__(self, suffix: str) -> None:
|
||||
self.suffix = suffix
|
||||
self.base = type("Base", (), {"log_dir": Path(f"/tmp/log-{suffix}")})()
|
||||
|
||||
svc = WebClientSetupService("mainsail")
|
||||
result = svc._remove_client_nginx_logs(
|
||||
svc.client, [FakeKlipperInstance("a"), FakeKlipperInstance("b")]
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# 2 base log files + 2 per instance * 2 = 6 files total
|
||||
assert len(passed[0]) == 6
|
||||
|
||||
|
||||
class TestRemoteModeLogic:
|
||||
@pytest.mark.parametrize(
|
||||
"client_name, instance_count, expected",
|
||||
[
|
||||
("mainsail", 0, True),
|
||||
("mainsail", 1, False),
|
||||
("mainsail", 2, True),
|
||||
("fluidd", 0, False),
|
||||
("fluidd", 2, False),
|
||||
],
|
||||
)
|
||||
def test_should_enable_remote_mode(
|
||||
self, client_name: str, instance_count: int, expected: bool
|
||||
) -> None:
|
||||
svc = WebClientSetupService(client_name)
|
||||
mr_instances = [FakeInstance(str(i)) for i in range(instance_count)]
|
||||
|
||||
result = svc._should_enable_remote_mode(mr_instances)
|
||||
|
||||
assert result is expected
|
||||
|
||||
def test_should_enable_remote_mode_rejects_non_mainsail(
|
||||
self) -> None:
|
||||
svc = WebClientSetupService("mainsail")
|
||||
svc.client = type("NotMainsail", (), {"client": WebClientType.FLUIDD})()
|
||||
|
||||
assert svc._should_enable_remote_mode([]) is False
|
||||
|
||||
|
||||
class TestRemoveClient:
|
||||
def test_remove_client_and_config(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
removed_dir: List[str] = []
|
||||
sections: List[str] = []
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda p: removed_dir.append(str(p)) or True,
|
||||
)
|
||||
monkeypatch.setattr(f"{module}.remove_with_sudo", lambda files: True)
|
||||
|
||||
class FakeBackup:
|
||||
def backup_moonraker_conf(self) -> None:
|
||||
pass
|
||||
|
||||
def backup_file(self, **kwargs) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(f"{module}.BackupService", FakeBackup)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.remove_config_section",
|
||||
lambda section, instances: sections.append(section) or instances,
|
||||
)
|
||||
build_called: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
f"{module}.WebClientConfigSetupService",
|
||||
lambda name: type(
|
||||
"FakeCfgSvc",
|
||||
(),
|
||||
{
|
||||
"remove_config": lambda self, kl_instances, mr_instances, backup_config=True, svc=None: (
|
||||
build_called.append(name) or type(
|
||||
"Msg", (), {"color": 2, "text": ["x", "config removed"]}
|
||||
)()
|
||||
)
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").remove(
|
||||
remove_client=True, remove_client_cfg=True, backup_config=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert build_called == ["mainsail"]
|
||||
assert "update_manager mainsail" in sections
|
||||
|
||||
def test_remove_failure_returns_false(
|
||||
self, bind_client, monkeypatch
|
||||
) -> None:
|
||||
module = "components.webui_client.services.web_client_setup_service"
|
||||
monkeypatch.setattr(f"{module}.get_instances", lambda model: [])
|
||||
monkeypatch.setattr(
|
||||
f"{module}.run_remove_routines",
|
||||
lambda p: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{module}.MessageService",
|
||||
lambda: type("MS", (), {"set_message": lambda self, m: None})(),
|
||||
)
|
||||
|
||||
result = WebClientSetupService("mainsail").remove(
|
||||
remove_client=True, remove_client_cfg=False, backup_config=False
|
||||
)
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,250 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import traceback
|
||||
from typing import List
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.moonraker.moonraker import Moonraker
|
||||
from components.webui_client import CLIENTS
|
||||
from components.webui_client.base_data import BaseWebClient, BaseWebClientConfig
|
||||
from components.webui_client.client_dialogs import print_client_already_installed_dialog
|
||||
from components.webui_client.client_utils import (
|
||||
backup_client_config_data,
|
||||
create_client_config_symlink,
|
||||
detect_client_cfg_conflict,
|
||||
)
|
||||
from core.instance_manager.instance_manager import InstanceManager
|
||||
from core.logger import Logger
|
||||
from core.services.backup_service import BackupService
|
||||
from core.services.message_service import Message, MessageService
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
from core.types.color import Color
|
||||
from utils.config_utils import (
|
||||
add_config_section,
|
||||
add_config_section_at_top,
|
||||
remove_config_section,
|
||||
)
|
||||
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
|
||||
from utils.instance_utils import get_instances
|
||||
|
||||
|
||||
class WebClientConfigSetupService:
|
||||
"""Headless-capable service for installing, updating and removing web client configs."""
|
||||
|
||||
CLIENTS = CLIENTS
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
if name not in self.CLIENTS:
|
||||
raise ValueError(f"Unknown web client: {name}")
|
||||
self.name = name
|
||||
self.client: BaseWebClient = self.CLIENTS[name]()
|
||||
self.settings = KiauhSettings()
|
||||
|
||||
def install(
|
||||
self,
|
||||
cfg_backup: bool = True,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Install the client config for this service's client.
|
||||
|
||||
Returns ``True`` on success or when the install is legitimately skipped
|
||||
(conflict or already installed), and ``False`` when installation fails.
|
||||
"""
|
||||
client_config: BaseWebClientConfig = self.client.client_config
|
||||
display_name = client_config.display_name
|
||||
|
||||
if detect_client_cfg_conflict(self.client):
|
||||
Logger.print_info("Another Client-Config is already installed! Skipped ...")
|
||||
return True
|
||||
|
||||
if client_config.config_dir.exists():
|
||||
if interactive:
|
||||
print_client_already_installed_dialog(display_name)
|
||||
if get_confirm(f"Re-install {display_name}?", allow_go_back=True):
|
||||
shutil.rmtree(client_config.config_dir)
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
Logger.print_info(
|
||||
f"{display_name} is already installed; "
|
||||
"skipping non-interactive install."
|
||||
)
|
||||
return True
|
||||
|
||||
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||
kl_instances: List[Klipper] = get_instances(Klipper)
|
||||
|
||||
try:
|
||||
self.__download_client_config(client_config)
|
||||
create_client_config_symlink(client_config, kl_instances)
|
||||
|
||||
if cfg_backup:
|
||||
BackupService().backup_printer_config_dir()
|
||||
|
||||
add_config_section(
|
||||
section=f"update_manager {client_config.name}",
|
||||
instances=mr_instances,
|
||||
options=[
|
||||
("type", "git_repo"),
|
||||
("primary_branch", "master"),
|
||||
("path", str(client_config.config_dir)),
|
||||
("origin", str(client_config.repo_url)),
|
||||
("managed_services", "klipper"),
|
||||
],
|
||||
)
|
||||
add_config_section_at_top(client_config.config_section, kl_instances)
|
||||
InstanceManager.restart_all(kl_instances)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error(f"{display_name} installation failed!")
|
||||
return False
|
||||
|
||||
Logger.print_ok(f"{display_name} installation complete!", start="\n")
|
||||
return True
|
||||
|
||||
def update(self, interactive: bool = True) -> bool:
|
||||
"""Update the client config. Honors ``interactive`` to gate the
|
||||
post-update "Restart Klipper" hint.
|
||||
"""
|
||||
client_config: BaseWebClientConfig = self.client.client_config
|
||||
|
||||
Logger.print_status(f"Updating {client_config.display_name} ...")
|
||||
|
||||
if not client_config.config_dir.exists():
|
||||
Logger.print_info(
|
||||
f"Unable to update {client_config.display_name}. "
|
||||
"Directory does not exist! Skipping ..."
|
||||
)
|
||||
return True
|
||||
|
||||
if self.settings.kiauh.backup_before_update:
|
||||
backup_client_config_data(self.client)
|
||||
|
||||
try:
|
||||
git_pull_wrapper(client_config.config_dir)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error(f"Updating {client_config.display_name} failed!")
|
||||
return False
|
||||
|
||||
Logger.print_ok(f"Successfully updated {client_config.display_name}.")
|
||||
if interactive:
|
||||
Logger.print_info("Restart Klipper to reload the configuration!")
|
||||
return True
|
||||
|
||||
def remove(
|
||||
self,
|
||||
backup_config: bool = True,
|
||||
) -> bool:
|
||||
"""Remove the client config dir, symlinks and config sections.
|
||||
|
||||
Returns ``True`` on success and ``False`` if removal failed.
|
||||
"""
|
||||
client_config: BaseWebClientConfig = self.client.client_config
|
||||
try:
|
||||
message = self.remove_config(
|
||||
kl_instances=get_instances(Klipper),
|
||||
mr_instances=get_instances(Moonraker),
|
||||
backup_config=backup_config,
|
||||
)
|
||||
MessageService().set_message(message)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error(f"Error while removing {client_config.display_name}!")
|
||||
return False
|
||||
return True
|
||||
|
||||
def remove_config(
|
||||
self,
|
||||
kl_instances: List[Klipper],
|
||||
mr_instances: List[Moonraker],
|
||||
backup_config: bool = True,
|
||||
svc: BackupService | None = None,
|
||||
) -> Message:
|
||||
"""Remove the client config dir, its symlinks and config sections.
|
||||
|
||||
This method performs the actual (destructive) removal work and returns
|
||||
the resulting completion ``Message``. It is named ``remove_config``
|
||||
(not ``build_*``) so the call site obviously mutates the filesystem.
|
||||
``WebClientSetupService.remove`` merges this message into the combined
|
||||
client-removal message without double-setting it.
|
||||
"""
|
||||
client_config: BaseWebClientConfig = self.client.client_config
|
||||
completion_msg = Message(
|
||||
title=f"{client_config.display_name} Removal Process completed",
|
||||
color=Color.GREEN,
|
||||
)
|
||||
Logger.print_status(f"Removing {client_config.display_name} ...")
|
||||
if run_remove_routines(client_config.config_dir):
|
||||
completion_msg.text.append(f"● {client_config.display_name} removed")
|
||||
|
||||
if svc is None:
|
||||
svc = BackupService()
|
||||
|
||||
svc.backup_moonraker_conf()
|
||||
self.__remove_moonraker_config_section(
|
||||
completion_msg, client_config, mr_instances
|
||||
)
|
||||
svc.backup_printer_cfg()
|
||||
self.__remove_printer_config_section(
|
||||
completion_msg, client_config, kl_instances
|
||||
)
|
||||
|
||||
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 __download_client_config(self, client_config: BaseWebClientConfig) -> None:
|
||||
Logger.print_status(f"Downloading {client_config.display_name} ...")
|
||||
git_clone_wrapper(client_config.repo_url, client_config.config_dir)
|
||||
|
||||
@staticmethod
|
||||
def __update_msg(instances: list, message: Message, text: str) -> Message:
|
||||
if not instances:
|
||||
return message
|
||||
instance_names = [i.service_file_path.stem for i in instances]
|
||||
message.text.append(f"● {text}: {', '.join(instance_names)}")
|
||||
return message
|
||||
|
||||
def __remove_printer_config_section(
|
||||
self,
|
||||
message: Message,
|
||||
client_config: BaseWebClientConfig,
|
||||
kl_instances: List[Klipper],
|
||||
) -> None:
|
||||
kl_section = client_config.config_section
|
||||
handled = remove_config_section(kl_section, kl_instances)
|
||||
self.__update_msg(
|
||||
handled,
|
||||
message,
|
||||
f"Klipper config section '{kl_section}' removed for instance",
|
||||
)
|
||||
|
||||
def __remove_moonraker_config_section(
|
||||
self,
|
||||
message: Message,
|
||||
client_config: BaseWebClientConfig,
|
||||
mr_instances: List[Moonraker],
|
||||
) -> None:
|
||||
mr_section = f"update_manager {client_config.name}"
|
||||
handled = remove_config_section(mr_section, mr_instances)
|
||||
self.__update_msg(
|
||||
handled,
|
||||
message,
|
||||
f"Moonraker config section '{mr_section}' removed for instance",
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from components.klipper.klipper import Klipper
|
||||
from components.moonraker.moonraker import Moonraker
|
||||
from components.webui_client import CLIENTS, MODULE_PATH
|
||||
from components.webui_client.base_data import BaseWebClient, WebClientType
|
||||
from components.webui_client.client_dialogs import (
|
||||
print_install_client_config_dialog,
|
||||
print_moonraker_not_found_dialog,
|
||||
)
|
||||
from components.webui_client.client_utils import (
|
||||
copy_common_vars_nginx_cfg,
|
||||
copy_upstream_nginx_cfg,
|
||||
create_nginx_cfg,
|
||||
detect_client_cfg_conflict,
|
||||
enable_mainsail_remotemode,
|
||||
get_client_port_selection,
|
||||
symlink_webui_nginx_log,
|
||||
)
|
||||
from components.webui_client.services.web_client_config_setup_service import (
|
||||
WebClientConfigSetupService,
|
||||
)
|
||||
from core.constants import NGINX_SITES_AVAILABLE, NGINX_SITES_ENABLED
|
||||
from core.instance_manager.instance_manager import InstanceManager
|
||||
from core.logger import DialogType, Logger
|
||||
from core.services.backup_service import BackupService
|
||||
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.config_utils import add_config_section, remove_config_section
|
||||
from utils.fs_utils import remove_with_sudo, run_remove_routines, unzip
|
||||
from utils.input_utils import get_confirm
|
||||
from utils.instance_utils import get_instances
|
||||
from utils.sys_utils import cmd_sysctl_service, download_file, get_ipv4_addr
|
||||
|
||||
|
||||
class WebClientSetupService:
|
||||
"""Headless-capable service for installing, updating and removing web clients."""
|
||||
|
||||
CLIENTS = CLIENTS
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
if name not in self.CLIENTS:
|
||||
raise ValueError(f"Unknown web client: {name}")
|
||||
self.name = name
|
||||
self.client: BaseWebClient = self.CLIENTS[name]()
|
||||
self.settings = KiauhSettings()
|
||||
|
||||
def install(
|
||||
self,
|
||||
reinstall: bool = False,
|
||||
interactive: bool = True,
|
||||
port: int | None = None,
|
||||
install_client_cfg: bool | None = None,
|
||||
continue_without_moonraker: bool = False,
|
||||
) -> bool:
|
||||
"""Install the web client.
|
||||
|
||||
When called from the TUI, choices are prompted interactively. The CLI
|
||||
passes explicit values and ``interactive=False``.
|
||||
|
||||
Returns ``True`` on success and ``False`` when the installation could
|
||||
not be completed.
|
||||
"""
|
||||
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||
|
||||
enable_remotemode = False
|
||||
if not mr_instances:
|
||||
if interactive:
|
||||
print_moonraker_not_found_dialog(self.client.display_name)
|
||||
if not get_confirm(
|
||||
f"Continue {self.client.display_name} installation?"
|
||||
):
|
||||
return False
|
||||
elif not continue_without_moonraker:
|
||||
Logger.print_info(
|
||||
f"Moonraker not installed; skipping {self.client.display_name} installation."
|
||||
)
|
||||
return False
|
||||
|
||||
enable_remotemode = self._should_enable_remote_mode(mr_instances)
|
||||
|
||||
kl_instances: List[Klipper] = get_instances(Klipper)
|
||||
install_cfg = False
|
||||
client_config = self.client.client_config
|
||||
if (
|
||||
kl_instances
|
||||
and not client_config.config_dir.exists()
|
||||
and not detect_client_cfg_conflict(self.client)
|
||||
):
|
||||
if interactive:
|
||||
print_install_client_config_dialog(self.client)
|
||||
question = f"Download the recommended {client_config.display_name}?"
|
||||
install_cfg = get_confirm(question, allow_go_back=False)
|
||||
else:
|
||||
install_cfg = bool(install_client_cfg)
|
||||
|
||||
default_port: int = int(self.settings.get(self.client.name, "port"))
|
||||
if port is not None:
|
||||
resolved_port = port
|
||||
elif interactive and not reinstall:
|
||||
resolved_port = get_client_port_selection(self.client, self.settings)
|
||||
else:
|
||||
resolved_port = default_port
|
||||
|
||||
check_install_dependencies({"nginx"})
|
||||
|
||||
try:
|
||||
_download_client(self.client)
|
||||
if enable_remotemode and self.client.client == WebClientType.MAINSAIL:
|
||||
enable_mainsail_remotemode()
|
||||
|
||||
BackupService().backup_printer_config_dir()
|
||||
add_config_section(
|
||||
section=f"update_manager {self.client.name}",
|
||||
instances=mr_instances,
|
||||
options=[
|
||||
("persistent_files", ["config.json"]),
|
||||
("type", "web"),
|
||||
("channel", "stable"),
|
||||
("repo", str(self.client.repo_path)),
|
||||
("path", str(self.client.client_dir)),
|
||||
],
|
||||
)
|
||||
InstanceManager.restart_all(mr_instances)
|
||||
|
||||
if install_cfg and kl_instances:
|
||||
WebClientConfigSetupService(self.name).install(
|
||||
cfg_backup=False, interactive=interactive
|
||||
)
|
||||
|
||||
copy_upstream_nginx_cfg()
|
||||
copy_common_vars_nginx_cfg()
|
||||
create_nginx_cfg(
|
||||
display_name=self.client.display_name,
|
||||
cfg_name=self.client.name,
|
||||
template_src=MODULE_PATH.joinpath("assets/nginx_cfg"),
|
||||
PORT=resolved_port,
|
||||
ROOT_DIR=self.client.client_dir,
|
||||
NAME=self.client.name,
|
||||
)
|
||||
|
||||
if kl_instances:
|
||||
symlink_webui_nginx_log(self.client, kl_instances)
|
||||
cmd_sysctl_service("nginx", "restart")
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
if interactive:
|
||||
Logger.print_dialog(
|
||||
DialogType.ERROR,
|
||||
center_content=True,
|
||||
content=[f"{self.client.display_name} installation failed!"],
|
||||
)
|
||||
return False
|
||||
|
||||
webui_url: str = f"http://{get_ipv4_addr()}{'' if resolved_port == 80 else f':{resolved_port}'}"
|
||||
if interactive:
|
||||
Logger.print_dialog(
|
||||
DialogType.CUSTOM,
|
||||
custom_title=f"{self.client.display_name} installation complete!",
|
||||
custom_color=Color.GREEN,
|
||||
center_content=True,
|
||||
content=[f"Open {self.client.display_name} now on: {webui_url}"],
|
||||
)
|
||||
else:
|
||||
Logger.print_info(
|
||||
f"Installation of {self.client.display_name} complete! URL: {webui_url}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def _should_enable_remote_mode(self, mr_instances: List[Moonraker]) -> bool:
|
||||
"""Return whether Mainsail remote mode should be enabled.
|
||||
|
||||
Remote mode is required when Mainsail is installed without a local
|
||||
Moonraker instance or when more than one Moonraker instance exists.
|
||||
"""
|
||||
return self.client.client == WebClientType.MAINSAIL and (
|
||||
not mr_instances or len(mr_instances) > 1
|
||||
)
|
||||
|
||||
def update(self, interactive: bool = True) -> bool:
|
||||
"""Update the web client. Returns ``True`` on success, ``False`` on failure."""
|
||||
Logger.print_status(f"Updating {self.client.display_name} ...")
|
||||
if not self.client.client_dir.exists():
|
||||
Logger.print_info(
|
||||
f"Unable to update {self.client.display_name}. "
|
||||
"Directory does not exist! Skipping ..."
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".json") as tmp_file:
|
||||
Logger.print_status(
|
||||
f"Creating temporary backup of {self.client.config_file} "
|
||||
f"as {tmp_file.name} ..."
|
||||
)
|
||||
shutil.copy(self.client.config_file, tmp_file.name)
|
||||
_download_client(self.client)
|
||||
shutil.copy(tmp_file.name, self.client.config_file)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error(f"Updating {self.client.display_name} failed!")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def remove(
|
||||
self,
|
||||
remove_client: bool = False,
|
||||
remove_client_cfg: bool = False,
|
||||
backup_config: bool = True,
|
||||
interactive: bool = True,
|
||||
) -> bool:
|
||||
"""Remove the web client and (optionally) its config.
|
||||
|
||||
Returns ``True`` on success and ``False`` if removal failed.
|
||||
"""
|
||||
try:
|
||||
message = self._build_removal_message(
|
||||
remove_client=remove_client,
|
||||
remove_client_cfg=remove_client_cfg,
|
||||
backup_config=backup_config,
|
||||
interactive=interactive,
|
||||
)
|
||||
MessageService().set_message(message)
|
||||
except Exception:
|
||||
Logger.print_error(traceback.format_exc())
|
||||
Logger.print_error(f"Error while removing {self.client.display_name}!")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _build_removal_message(
|
||||
self,
|
||||
remove_client: bool,
|
||||
remove_client_cfg: bool,
|
||||
backup_config: bool,
|
||||
interactive: bool,
|
||||
) -> Message:
|
||||
completion_msg = Message(
|
||||
title=f"{self.client.display_name} Removal Process completed",
|
||||
color=Color.GREEN,
|
||||
)
|
||||
mr_instances: List[Moonraker] = get_instances(Moonraker)
|
||||
kl_instances: List[Klipper] = get_instances(Klipper)
|
||||
svc = BackupService()
|
||||
|
||||
if backup_config:
|
||||
version = ""
|
||||
src = self.client.client_dir
|
||||
if src.joinpath(".version").exists():
|
||||
with open(src.joinpath(".version"), "r") as v:
|
||||
version = v.readlines()[0]
|
||||
|
||||
target_path = svc.backup_root.joinpath(
|
||||
f"{self.client.client_dir.name}_{version}"
|
||||
)
|
||||
success = svc.backup_file(
|
||||
source_path=self.client.config_file,
|
||||
target_path=target_path,
|
||||
)
|
||||
if success:
|
||||
completion_msg.text.append(
|
||||
f"● {self.client.config_file.name} backup created"
|
||||
)
|
||||
|
||||
if remove_client:
|
||||
if self._remove_client_dir():
|
||||
completion_msg.text.append(f"● {self.client.display_name} removed")
|
||||
if self._remove_client_nginx_config(self.client.name):
|
||||
completion_msg.text.append("● NGINX config removed")
|
||||
if self._remove_client_nginx_logs(self.client, kl_instances):
|
||||
completion_msg.text.append("● NGINX logs removed")
|
||||
|
||||
svc.backup_moonraker_conf()
|
||||
section = f"update_manager {self.client.name}"
|
||||
handled_instances = remove_config_section(section, mr_instances)
|
||||
if handled_instances:
|
||||
names = [i.service_file_path.stem for i in handled_instances]
|
||||
completion_msg.text.append(
|
||||
f"● Moonraker config section '{section}' removed for "
|
||||
f"instance: {', '.join(names)}"
|
||||
)
|
||||
|
||||
if remove_client_cfg:
|
||||
cfg_svc = WebClientConfigSetupService(self.name)
|
||||
cfg_message = cfg_svc.remove_config(
|
||||
kl_instances=kl_instances,
|
||||
mr_instances=mr_instances,
|
||||
backup_config=backup_config,
|
||||
svc=svc,
|
||||
)
|
||||
if cfg_message.color == Color.GREEN:
|
||||
completion_msg.text.extend(cfg_message.text[1:])
|
||||
|
||||
if not completion_msg.text:
|
||||
completion_msg.color = Color.YELLOW
|
||||
completion_msg.centered = True
|
||||
completion_msg.text.append("Nothing to remove.")
|
||||
else:
|
||||
completion_msg.text.insert(0, "The following actions were performed:")
|
||||
|
||||
return completion_msg
|
||||
|
||||
def _remove_client_dir(self) -> bool:
|
||||
Logger.print_status(f"Removing {self.client.display_name} ...")
|
||||
return bool(run_remove_routines(self.client.client_dir))
|
||||
|
||||
def _remove_client_nginx_config(self, name: str) -> bool:
|
||||
Logger.print_status(f"Removing NGINX config for {name.capitalize()} ...")
|
||||
return bool(
|
||||
remove_with_sudo([
|
||||
NGINX_SITES_AVAILABLE.joinpath(name),
|
||||
NGINX_SITES_ENABLED.joinpath(name),
|
||||
])
|
||||
)
|
||||
|
||||
def _remove_client_nginx_logs(
|
||||
self, client: BaseWebClient, instances: List[Klipper]
|
||||
) -> bool:
|
||||
Logger.print_status(f"Removing NGINX logs for {client.display_name} ...")
|
||||
files = [client.nginx_access_log, client.nginx_error_log]
|
||||
if instances:
|
||||
for instance in instances:
|
||||
files.append(
|
||||
instance.base.log_dir.joinpath(client.nginx_access_log.name)
|
||||
)
|
||||
files.append(
|
||||
instance.base.log_dir.joinpath(client.nginx_error_log.name)
|
||||
)
|
||||
return bool(remove_with_sudo(files))
|
||||
|
||||
|
||||
def _download_client(client: BaseWebClient) -> None:
|
||||
zipfile = f"{client.name.lower()}.zip"
|
||||
target = Path().home().joinpath(zipfile)
|
||||
try:
|
||||
Logger.print_status(
|
||||
f"Downloading {client.display_name} from {client.download_url} ..."
|
||||
)
|
||||
download_file(client.download_url, target, True)
|
||||
Logger.print_ok("Download complete!")
|
||||
|
||||
Logger.print_status(f"Extracting {zipfile} ...")
|
||||
unzip(target, client.client_dir)
|
||||
target.unlink(missing_ok=True)
|
||||
Logger.print_ok("OK!")
|
||||
except Exception:
|
||||
Logger.print_error(f"Downloading {client.display_name} failed!")
|
||||
raise
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from components.webui_client.base_data import WebClientType
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeClientConfig:
|
||||
name: str = "mainsail-config"
|
||||
display_name: str = "Mainsail-Config"
|
||||
config_dir: Path = Path("/tmp/mainsail-config")
|
||||
config_filename: str = "mainsail.cfg"
|
||||
config_section: str = "include mainsail.cfg"
|
||||
repo_url: str = "https://github.com/mainsail-crew/mainsail-config.git"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeWebClient:
|
||||
name: str = "mainsail"
|
||||
display_name: str = "Mainsail"
|
||||
client: WebClientType = WebClientType.MAINSAIL
|
||||
client_dir: Path = Path("/tmp/mainsail")
|
||||
config_file: Path = Path("/tmp/mainsail/config.json")
|
||||
repo_path: str = "mainsail-crew/mainsail"
|
||||
nginx_config: Path = Path("/tmp/nginx/mainsail")
|
||||
nginx_access_log: Path = Path("/tmp/log/mainsail-access.log")
|
||||
nginx_error_log: Path = Path("/tmp/log/mainsail-error.log")
|
||||
download_url: str = "https://example.com/mainsail.zip"
|
||||
client_config: Any = field(default_factory=FakeClientConfig)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FakeWebClient:
|
||||
return FakeWebClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(monkeypatch: pytest.MonkeyPatch) -> Any:
|
||||
from core.settings.kiauh_settings import KiauhSettings
|
||||
|
||||
KiauhSettings._KiauhSettings__instance = None
|
||||
KiauhSettings._KiauhSettings__initialized = False
|
||||
return KiauhSettings()
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
from components.webui_client import client_utils
|
||||
from components.webui_client.base_data import WebClientType
|
||||
from components.webui_client.client_utils import (
|
||||
backup_client_config_data,
|
||||
backup_client_data,
|
||||
create_client_config_symlink,
|
||||
detect_client_cfg_conflict,
|
||||
get_client_status,
|
||||
get_current_client_config,
|
||||
get_download_url,
|
||||
get_local_client_version,
|
||||
get_next_free_port,
|
||||
get_nginx_listen_port,
|
||||
get_remote_client_version,
|
||||
read_ports_from_nginx_configs,
|
||||
set_listen_port,
|
||||
)
|
||||
|
||||
|
||||
class TestGetLocalClientVersion:
|
||||
def test_returns_none_when_client_dir_missing(self, client) -> None:
|
||||
client.client_dir = Path("/does/not/exist")
|
||||
assert get_local_client_version(client) is None
|
||||
|
||||
def test_reads_release_info_json(self, client, tmp_path: Path) -> None:
|
||||
client.client_dir = tmp_path
|
||||
release = tmp_path / "release_info.json"
|
||||
release.write_text('{"version": "v2.0.0"}')
|
||||
|
||||
assert get_local_client_version(client) == "v2.0.0"
|
||||
|
||||
def test_falls_back_to_version_file(self, client, tmp_path: Path) -> None:
|
||||
client.client_dir = tmp_path
|
||||
(tmp_path / ".version").write_text("v1.2.3\n")
|
||||
|
||||
assert get_local_client_version(client) == "v1.2.3"
|
||||
|
||||
def test_returns_none_for_empty_version_file(self, client, tmp_path: Path) -> None:
|
||||
client.client_dir = tmp_path
|
||||
(tmp_path / ".version").write_text("")
|
||||
|
||||
assert get_local_client_version(client) is None
|
||||
|
||||
|
||||
class TestGetRemoteClientVersion:
|
||||
def test_returns_tag_when_available(self, monkeypatch, client) -> None:
|
||||
monkeypatch.setattr(
|
||||
client_utils, "get_latest_remote_tag", lambda repo: "v3.0.0"
|
||||
)
|
||||
assert get_remote_client_version(client) == "v3.0.0"
|
||||
|
||||
def test_returns_none_when_tag_empty(self, monkeypatch, client) -> None:
|
||||
monkeypatch.setattr(client_utils, "get_latest_remote_tag", lambda repo: "")
|
||||
assert get_remote_client_version(client) is None
|
||||
|
||||
def test_returns_none_on_error(self, monkeypatch, client) -> None:
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"get_latest_remote_tag",
|
||||
lambda repo: (_ for _ in ()).throw(RuntimeError("network")),
|
||||
)
|
||||
assert get_remote_client_version(client) is None
|
||||
|
||||
|
||||
class TestGetDownloadUrl:
|
||||
def test_returns_stable_url_when_not_unstable(self, monkeypatch, client) -> None:
|
||||
class FakeSettings:
|
||||
def get(self, name, key):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings)
|
||||
url = get_download_url("https://example.com/repo", client)
|
||||
assert "latest/download" in url
|
||||
|
||||
def test_returns_unstable_url_when_available(self, monkeypatch, client) -> None:
|
||||
class FakeSettings:
|
||||
def get(self, name, key):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(client_utils, "KiauhSettings", FakeSettings)
|
||||
monkeypatch.setattr(
|
||||
client_utils, "get_latest_unstable_tag", lambda repo: "v9.9.9"
|
||||
)
|
||||
url = get_download_url("https://example.com/repo", client)
|
||||
assert "v9.9.9" in url
|
||||
|
||||
|
||||
class TestDetectClientCfgConflict:
|
||||
def test_mainsail_conflicts_with_fluidd_installed(
|
||||
self, monkeypatch, client
|
||||
) -> None:
|
||||
def fake_status(c):
|
||||
code = 2 if c.client == WebClientType.FLUIDD else 0
|
||||
return type("S", (), {"status": code})()
|
||||
|
||||
monkeypatch.setattr(client_utils, "get_client_config_status", fake_status)
|
||||
client.client = WebClientType.MAINSAIL
|
||||
assert detect_client_cfg_conflict(client) is True
|
||||
|
||||
def test_fluidd_conflicts_with_mainsail_installed(
|
||||
self, monkeypatch, client
|
||||
) -> None:
|
||||
def fake_status(c):
|
||||
code = 2 if c.client == WebClientType.MAINSAIL else 0
|
||||
return type("S", (), {"status": code})()
|
||||
|
||||
monkeypatch.setattr(client_utils, "get_client_config_status", fake_status)
|
||||
client.client = WebClientType.FLUIDD
|
||||
assert detect_client_cfg_conflict(client) is True
|
||||
|
||||
|
||||
class TestGetNextFreePort:
|
||||
def test_returns_lowest_unused_port(self) -> None:
|
||||
assert get_next_free_port([80, 81]) == 82
|
||||
|
||||
def test_starts_at_80(self) -> None:
|
||||
assert get_next_free_port([]) == 80
|
||||
|
||||
|
||||
class TestNginxPortParsing:
|
||||
def test_parses_plain_listen_port(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "site"
|
||||
cfg.write_text("server {\n listen 8080;\n}\n")
|
||||
assert get_nginx_listen_port(cfg) == 8080
|
||||
|
||||
def test_parses_listen_port_with_host(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "site"
|
||||
cfg.write_text("server {\n listen 127.0.0.1:9090;\n}\n")
|
||||
assert get_nginx_listen_port(cfg) == 9090
|
||||
|
||||
def test_returns_none_when_no_listen(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "site"
|
||||
cfg.write_text("server {\n}\n")
|
||||
assert get_nginx_listen_port(cfg) is None
|
||||
|
||||
def test_reads_all_configs_in_enabled_dir(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
sites = tmp_path / "sites-enabled"
|
||||
sites.mkdir()
|
||||
(sites / "a").write_text("listen 1000;")
|
||||
(sites / "b").write_text("listen 2000;")
|
||||
monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", sites)
|
||||
|
||||
ports = read_ports_from_nginx_configs()
|
||||
assert ports == [1000, 2000]
|
||||
|
||||
def test_returns_empty_when_enabled_dir_missing(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr(client_utils, "NGINX_SITES_ENABLED", Path("/missing"))
|
||||
assert read_ports_from_nginx_configs() == []
|
||||
|
||||
|
||||
class TestSetListenPort:
|
||||
def test_replaces_port_in_config(
|
||||
self, client, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
client.name = "mainsail"
|
||||
monkeypatch.setattr(client_utils, "NGINX_SITES_AVAILABLE", tmp_path)
|
||||
cfg = tmp_path / "mainsail"
|
||||
cfg.write_text("server {\n listen 80;\n}\n")
|
||||
|
||||
set_listen_port(client, 80, 8080)
|
||||
|
||||
assert "listen 8080" in cfg.read_text()
|
||||
|
||||
|
||||
class TestCreateClientConfigSymlink:
|
||||
def test_creates_symlink_per_instance(
|
||||
self, monkeypatch, client, tmp_path: Path
|
||||
) -> None:
|
||||
client.client_config.config_dir = tmp_path / "cfg"
|
||||
client.client_config.config_filename = "mainsail.cfg"
|
||||
called: List[Any] = []
|
||||
monkeypatch.setattr(
|
||||
client_utils, "create_symlink", lambda s, t: called.append((s, t))
|
||||
)
|
||||
|
||||
class FakeInstance:
|
||||
base = type("Base", (), {"cfg_dir": tmp_path / "printer"})()
|
||||
|
||||
create_client_config_symlink(client.client_config, [FakeInstance()])
|
||||
|
||||
assert len(called) == 1
|
||||
|
||||
def test_symlink_failure_logs_error_and_continues(
|
||||
self, monkeypatch, client, tmp_path: Path
|
||||
) -> None:
|
||||
client.client_config.config_dir = tmp_path / "cfg"
|
||||
client.client_config.config_filename = "mainsail.cfg"
|
||||
|
||||
attempt: List[Any] = []
|
||||
|
||||
def flaky_create_symlink(source, target) -> None:
|
||||
attempt.append(target)
|
||||
if len(attempt) == 1:
|
||||
raise RuntimeError("permission denied")
|
||||
|
||||
monkeypatch.setattr(client_utils, "create_symlink", flaky_create_symlink)
|
||||
errors: List[str] = []
|
||||
monkeypatch.setattr(
|
||||
client_utils.Logger,
|
||||
"print_error",
|
||||
lambda msg, *a, **k: errors.append(str(msg)),
|
||||
)
|
||||
|
||||
class FakeInstance:
|
||||
def __init__(self, cfg: Path) -> None:
|
||||
self.base = type("Base", (), {"cfg_dir": cfg})()
|
||||
|
||||
create_client_config_symlink(
|
||||
client.client_config,
|
||||
[FakeInstance(tmp_path / "a"), FakeInstance(tmp_path / "b")],
|
||||
)
|
||||
|
||||
assert len(attempt) == 2 # failure did not abort the loop
|
||||
assert any("symlink" in m.lower() for m in errors)
|
||||
|
||||
|
||||
class TestBackupClientData:
|
||||
def test_backs_up_client_dir_and_config_file(
|
||||
self, monkeypatch, client, tmp_path: Path
|
||||
) -> None:
|
||||
client.client_dir = tmp_path / "mainsail"
|
||||
client.client_dir.mkdir()
|
||||
(client.client_dir / ".version").write_text("v1\n")
|
||||
client.config_file = client.client_dir / "config.json"
|
||||
client.config_file.write_text("{}")
|
||||
|
||||
calls: List[str] = []
|
||||
|
||||
class FakeBackup:
|
||||
backup_root = tmp_path / "backups"
|
||||
|
||||
def backup_directory(self, **kwargs):
|
||||
calls.append("dir")
|
||||
|
||||
def backup_file(self, **kwargs):
|
||||
calls.append("file")
|
||||
|
||||
monkeypatch.setattr(client_utils, "BackupService", FakeBackup)
|
||||
backup_client_data(client)
|
||||
|
||||
assert "dir" in calls
|
||||
assert "file" in calls
|
||||
|
||||
|
||||
class TestBackupClientConfigData:
|
||||
def test_backs_up_config_dir(self, monkeypatch, client, tmp_path: Path) -> None:
|
||||
client.client_dir = tmp_path / "mainsail"
|
||||
client.client_dir.mkdir()
|
||||
(client.client_dir / ".version").write_text("v1\n")
|
||||
client.client_config.config_dir = tmp_path / "mainsail-config"
|
||||
|
||||
calls: List[str] = []
|
||||
|
||||
class FakeBackup:
|
||||
backup_root = tmp_path / "backups"
|
||||
|
||||
def backup_directory(self, **kwargs):
|
||||
calls.append("dir")
|
||||
|
||||
monkeypatch.setattr(client_utils, "BackupService", FakeBackup)
|
||||
backup_client_config_data(client)
|
||||
|
||||
assert "dir" in calls
|
||||
|
||||
|
||||
class TestGetClientStatus:
|
||||
def test_sets_status_not_installed_when_dir_missing(
|
||||
self, monkeypatch, client, tmp_path: Path
|
||||
) -> None:
|
||||
client.client_dir = tmp_path / "missing"
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"get_install_status",
|
||||
lambda *args, **kwargs: type(
|
||||
"S", (), {"status": 2, "local": None, "remote": None}
|
||||
)(),
|
||||
)
|
||||
|
||||
status = get_client_status(client)
|
||||
assert status.status == 0
|
||||
|
||||
|
||||
class TestGetCurrentClientConfig:
|
||||
def test_returns_dash_when_no_config_dirs(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"MainsailData",
|
||||
lambda: type(
|
||||
"M",
|
||||
(),
|
||||
{
|
||||
"client_config": type(
|
||||
"C", (), {"config_dir": Path("/no/mainsail")}
|
||||
)()
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"FluiddData",
|
||||
lambda: type(
|
||||
"F",
|
||||
(),
|
||||
{"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()},
|
||||
)(),
|
||||
)
|
||||
|
||||
result = get_current_client_config()
|
||||
assert "-" in result
|
||||
|
||||
def test_returns_single_installed_name(self, monkeypatch, tmp_path: Path) -> None:
|
||||
cfg_dir = tmp_path / "mainsail-config"
|
||||
cfg_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"MainsailData",
|
||||
lambda: type(
|
||||
"M",
|
||||
(),
|
||||
{
|
||||
"client_config": type(
|
||||
"C",
|
||||
(),
|
||||
{"config_dir": cfg_dir, "display_name": "Mainsail-Config"},
|
||||
)()
|
||||
},
|
||||
)(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
client_utils,
|
||||
"FluiddData",
|
||||
lambda: type(
|
||||
"F",
|
||||
(),
|
||||
{"client_config": type("C", (), {"config_dir": Path("/no/fluidd")})()},
|
||||
)(),
|
||||
)
|
||||
|
||||
result = get_current_client_config()
|
||||
assert "Mainsail-Config" in result
|
||||
@@ -0,0 +1,25 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence_logger(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress logger output so tests produce clean, assertion-focused output."""
|
||||
for name in (
|
||||
"print_info",
|
||||
"print_ok",
|
||||
"print_warn",
|
||||
"print_error",
|
||||
"print_status",
|
||||
"print_dialog",
|
||||
):
|
||||
monkeypatch.setattr(f"core.logger.Logger.{name}", lambda *a, **k: None)
|
||||
@@ -0,0 +1,182 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Protocol, Sequence, cast, runtime_checkable
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Singleton backends #
|
||||
# --------------------------------------------------------------------------- #
|
||||
# There is exactly ONE ``command_runner`` and ONE ``filesystem`` global in the
|
||||
# whole project, owned by this module. They are assigned (with explicit type
|
||||
# annotations) at the bottom of this file, AFTER the default implementations
|
||||
# are defined. ``utils.fs_utils`` and ``utils.sys_utils`` delegate to these
|
||||
# singletons via the wrapper functions below, so tests patch a single place —
|
||||
# ``core.backends.command_runner`` / ``core.backends.filesystem`` — instead of
|
||||
# per-module duplicates
|
||||
|
||||
|
||||
def run(cmd: str | List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command through the shared command runner."""
|
||||
return command_runner.run(cmd, **kwargs)
|
||||
|
||||
|
||||
def check_output(cmd: str | List[str], **kwargs: Any) -> str | bytes:
|
||||
"""Run a command and return its output through the shared command runner."""
|
||||
return command_runner.check_output(cmd, **kwargs)
|
||||
|
||||
|
||||
def call(cmd: str | List[str], **kwargs: Any) -> int:
|
||||
"""Run a command and return its exit code through the shared command runner."""
|
||||
return command_runner.call(cmd, **kwargs)
|
||||
|
||||
|
||||
def popen(cmd: str | List[str], **kwargs: Any) -> subprocess.Popen:
|
||||
"""Start a process through the shared command runner."""
|
||||
return command_runner.popen(cmd, **kwargs)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CommandRunner(Protocol):
|
||||
"""Pluggable backend for executing system commands."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> subprocess.CompletedProcess: ...
|
||||
|
||||
def check_output(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> str | bytes: ...
|
||||
|
||||
def call(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> int: ...
|
||||
|
||||
def popen(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> subprocess.Popen: ...
|
||||
|
||||
|
||||
class SubprocessRunner:
|
||||
"""Default command runner backed by the standard subprocess module."""
|
||||
|
||||
def run(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, **kwargs)
|
||||
|
||||
def check_output(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> str | bytes:
|
||||
return cast("str | bytes", subprocess.check_output(cmd, **kwargs))
|
||||
|
||||
def call(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> int:
|
||||
return subprocess.call(cmd, **kwargs)
|
||||
|
||||
def popen(
|
||||
self,
|
||||
cmd: str | Sequence[str],
|
||||
**kwargs: Any,
|
||||
) -> subprocess.Popen:
|
||||
return subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FilesystemBackend(Protocol):
|
||||
"""Pluggable backend for filesystem operations."""
|
||||
|
||||
def exists(self, path: Path) -> bool: ...
|
||||
|
||||
def is_dir(self, path: Path) -> bool: ...
|
||||
|
||||
def is_file(self, path: Path) -> bool: ...
|
||||
|
||||
def is_symlink(self, path: Path) -> bool: ...
|
||||
|
||||
def mkdir(
|
||||
self, path: Path, *, parents: bool = False, exist_ok: bool = False
|
||||
) -> None: ...
|
||||
|
||||
def unlink(self, path: Path) -> None: ...
|
||||
|
||||
def rmtree(self, path: Path) -> None: ...
|
||||
|
||||
def read_text(self, path: Path) -> str: ...
|
||||
|
||||
def write_text(self, path: Path, content: str) -> None: ...
|
||||
|
||||
def copy(self, source: Path, target: Path) -> None: ...
|
||||
|
||||
def home(self) -> Path: ...
|
||||
|
||||
|
||||
class LocalFilesystemBackend:
|
||||
"""Default filesystem backend backed by the local filesystem."""
|
||||
|
||||
def exists(self, path: Path) -> bool:
|
||||
return path.exists()
|
||||
|
||||
def is_dir(self, path: Path) -> bool:
|
||||
return path.is_dir()
|
||||
|
||||
def is_file(self, path: Path) -> bool:
|
||||
return path.is_file()
|
||||
|
||||
def is_symlink(self, path: Path) -> bool:
|
||||
return path.is_symlink()
|
||||
|
||||
def mkdir(
|
||||
self, path: Path, *, parents: bool = False, exist_ok: bool = False
|
||||
) -> None:
|
||||
path.mkdir(parents=parents, exist_ok=exist_ok)
|
||||
|
||||
def unlink(self, path: Path) -> None:
|
||||
path.unlink()
|
||||
|
||||
def rmtree(self, path: Path) -> None:
|
||||
shutil.rmtree(path)
|
||||
|
||||
def read_text(self, path: Path) -> str:
|
||||
return path.read_text()
|
||||
|
||||
def write_text(self, path: Path, content: str) -> None:
|
||||
path.write_text(content)
|
||||
|
||||
def copy(self, source: Path, target: Path) -> None:
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
|
||||
def home(self) -> Path:
|
||||
return Path.home()
|
||||
|
||||
|
||||
command_runner: CommandRunner = SubprocessRunner()
|
||||
filesystem: FilesystemBackend = LocalFilesystemBackend()
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from core import backends
|
||||
from core.backends import LocalFilesystemBackend, SubprocessRunner
|
||||
from tests.helpers.fake_backends import FakeCommandRunner, FakeFilesystemBackend
|
||||
from utils import fs_utils, sys_utils
|
||||
|
||||
|
||||
class TestSubprocessRunner:
|
||||
def test_run_executes_command(self) -> None:
|
||||
runner = SubprocessRunner()
|
||||
result = runner.run(["true"])
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_output_returns_stdout(self) -> None:
|
||||
runner = SubprocessRunner()
|
||||
output = runner.check_output(["echo", "hello"], text=True)
|
||||
assert "hello" in output
|
||||
|
||||
|
||||
class TestCommandRunnerInjection:
|
||||
def test_sys_utils_uses_injected_runner(self, monkeypatch) -> None:
|
||||
fake = FakeCommandRunner({
|
||||
("some", "cmd"): subprocess.CompletedProcess(["some", "cmd"], 0, "", "")
|
||||
})
|
||||
monkeypatch.setattr(backends, "command_runner", fake)
|
||||
|
||||
sys_utils.run(["some", "cmd"], check=True)
|
||||
|
||||
assert fake.calls[0][0] == ["some", "cmd"]
|
||||
assert fake.calls[0][1].get("check") is True
|
||||
|
||||
def test_cmd_sysctl_service_records_command(self, monkeypatch) -> None:
|
||||
expected_cmd = ["sudo", "systemctl", "start", "klipper.service"]
|
||||
fake = FakeCommandRunner({
|
||||
tuple(expected_cmd): subprocess.CompletedProcess(expected_cmd, 0, "", "")
|
||||
})
|
||||
monkeypatch.setattr(backends, "command_runner", fake)
|
||||
|
||||
sys_utils.cmd_sysctl_service("klipper.service", "start")
|
||||
|
||||
assert fake.calls[0][0] == expected_cmd
|
||||
|
||||
@pytest.mark.parametrize("module", [sys_utils, fs_utils])
|
||||
def test_single_shared_command_runner_registry(self, monkeypatch, module) -> None:
|
||||
# there is only ONE ``command_runner`` global to patch.
|
||||
# Patching ``core.backends.command_runner`` must affect every wrapper
|
||||
# (sys_utils.run, fs_utils.run, enum helpers) — no per-module duplicates.
|
||||
fake = FakeCommandRunner({
|
||||
("shared", "cmd"): subprocess.CompletedProcess(["shared", "cmd"], 0, "", "")
|
||||
})
|
||||
monkeypatch.setattr(backends, "command_runner", fake)
|
||||
|
||||
module.run(["shared", "cmd"], check=True)
|
||||
|
||||
assert fake.calls[0][0] == ["shared", "cmd"]
|
||||
|
||||
|
||||
class TestLocalFilesystemBackend:
|
||||
def test_write_and_read_text(self, tmp_path: Path) -> None:
|
||||
fs = LocalFilesystemBackend()
|
||||
target = tmp_path / "test.txt"
|
||||
fs.write_text(target, "hello")
|
||||
assert fs.read_text(target) == "hello"
|
||||
|
||||
def test_mkdir_and_exists(self, tmp_path: Path) -> None:
|
||||
fs = LocalFilesystemBackend()
|
||||
target = tmp_path / "new_dir"
|
||||
assert not fs.exists(target)
|
||||
fs.mkdir(target)
|
||||
assert fs.exists(target)
|
||||
assert fs.is_dir(target)
|
||||
|
||||
|
||||
class TestFilesystemBackendInjection:
|
||||
def test_create_folders_uses_injected_fs(self, monkeypatch) -> None:
|
||||
fake = FakeFilesystemBackend()
|
||||
monkeypatch.setattr(backends, "filesystem", fake)
|
||||
|
||||
fs_utils.create_folders([Path("/tmp/a"), Path("/tmp/b")])
|
||||
|
||||
assert fake.exists(Path("/tmp/a"))
|
||||
assert fake.exists(Path("/tmp/b"))
|
||||
|
||||
def test_run_remove_routines_uses_injected_fs(self, monkeypatch) -> None:
|
||||
fake = FakeFilesystemBackend()
|
||||
fake.add_file(Path("/tmp/file.txt"), "x")
|
||||
monkeypatch.setattr(backends, "filesystem", fake)
|
||||
|
||||
assert fs_utils.run_remove_routines(Path("/tmp/file.txt")) is True
|
||||
assert not fake.exists(Path("/tmp/file.txt"))
|
||||
|
||||
def test_run_remove_routines_skips_missing_file(self, monkeypatch) -> None:
|
||||
fake = FakeFilesystemBackend()
|
||||
monkeypatch.setattr(backends, "filesystem", fake)
|
||||
|
||||
assert fs_utils.run_remove_routines(Path("/tmp/missing")) is False
|
||||
@@ -0,0 +1,365 @@
|
||||
# ======================================================================= #
|
||||
# Copyright (C) 2020 - 2026 Dominik Willner <th33xitus@gmail.com> #
|
||||
# #
|
||||
# This file is part of KIAUH - Klipper Installation And Update Helper #
|
||||
# https://github.com/dw-0/kiauh #
|
||||
# #
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license #
|
||||
# ======================================================================= #
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Callable, Dict, List, Tuple
|
||||
|
||||
from components.klipper.services.klipper_setup_service import KlipperSetupService
|
||||
from components.moonraker.services.moonraker_setup_service import MoonrakerSetupService
|
||||
from components.webui_client.services.web_client_config_setup_service import (
|
||||
WebClientConfigSetupService,
|
||||
)
|
||||
from components.webui_client.services.web_client_setup_service import (
|
||||
WebClientSetupService,
|
||||
)
|
||||
|
||||
# A dispatcher receives the parsed argparse namespace and the parser (so it can
|
||||
# raise ``parser.error`` for invalid input) and returns the CLI exit code.
|
||||
Dispatcher = Callable[[argparse.Namespace, argparse.ArgumentParser], int]
|
||||
|
||||
|
||||
def _add_klipper_install(sub: argparse._SubParsersAction) -> None:
|
||||
p = sub.add_parser("klipper", help="Install Klipper")
|
||||
p.add_argument(
|
||||
"--count", type=int, default=None, help="Number of instances to install"
|
||||
)
|
||||
p.add_argument(
|
||||
"--name", action="append", default=[], help="Custom instance name(s)"
|
||||
)
|
||||
p.add_argument(
|
||||
"--create-example-cfg", action="store_true", help="Create example printer.cfg"
|
||||
)
|
||||
p.add_argument(
|
||||
"--match-moonraker",
|
||||
action="store_true",
|
||||
help="Match Klipper instance count to existing Moonraker instances",
|
||||
)
|
||||
|
||||
|
||||
def _add_moonraker_install(sub: argparse._SubParsersAction) -> None:
|
||||
p = sub.add_parser("moonraker", help="Install Moonraker")
|
||||
p.add_argument(
|
||||
"--klipper-suffix",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Klipper suffix to set up Moonraker for (can be repeated)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--create-example-cfg",
|
||||
action="store_true",
|
||||
help="Create example moonraker.conf",
|
||||
)
|
||||
|
||||
|
||||
def _add_web_client_install(sub: argparse._SubParsersAction) -> None:
|
||||
for name in ("mainsail", "fluidd"):
|
||||
p = sub.add_parser(name, help=f"Install {name.capitalize()}")
|
||||
p.add_argument("--port", type=int, default=None, help="Listen port")
|
||||
p.add_argument(
|
||||
"--install-config",
|
||||
action="store_true",
|
||||
help="Install the recommended client config",
|
||||
)
|
||||
p.add_argument(
|
||||
"--continue-without-moonraker",
|
||||
action="store_true",
|
||||
help="Allow installation even if Moonraker is not installed",
|
||||
)
|
||||
|
||||
sub.add_parser("mainsail-config", help="Install the Mainsail client config")
|
||||
sub.add_parser("fluidd-config", help="Install the Fluidd client config")
|
||||
|
||||
|
||||
def _add_klipper_remove(sub: argparse._SubParsersAction) -> None:
|
||||
p = sub.add_parser("klipper", help="Remove Klipper")
|
||||
p.add_argument("--service", action="store_true", help="Remove Klipper services")
|
||||
p.add_argument("--dir", action="store_true", help="Remove Klipper local repository")
|
||||
p.add_argument(
|
||||
"--env", action="store_true", help="Remove Klipper Python environment"
|
||||
)
|
||||
p.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove every installed Klipper instance (destructive)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--instance",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Klipper instance suffix to remove (repeatable)",
|
||||
)
|
||||
|
||||
|
||||
def _add_moonraker_remove(sub: argparse._SubParsersAction) -> None:
|
||||
p = sub.add_parser("moonraker", help="Remove Moonraker")
|
||||
p.add_argument("--service", action="store_true", help="Remove Moonraker services")
|
||||
p.add_argument(
|
||||
"--dir", action="store_true", help="Remove Moonraker local repository"
|
||||
)
|
||||
p.add_argument(
|
||||
"--env", action="store_true", help="Remove Moonraker Python environment"
|
||||
)
|
||||
p.add_argument(
|
||||
"--polkit", action="store_true", help="Remove Moonraker policykit rules"
|
||||
)
|
||||
p.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove every installed Moonraker instance (destructive)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--instance",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Moonraker instance suffix to remove (repeatable)",
|
||||
)
|
||||
|
||||
|
||||
def _add_web_client_remove(sub: argparse._SubParsersAction) -> None:
|
||||
for name in ("mainsail", "fluidd"):
|
||||
p = sub.add_parser(name, help=f"Remove {name.capitalize()}")
|
||||
p.add_argument("--client", action="store_true", help="Remove the web client")
|
||||
p.add_argument("--config", action="store_true", help="Remove the client config")
|
||||
p.add_argument("--no-backup", action="store_true", help="Skip config backup")
|
||||
|
||||
|
||||
def _add_klipper_update(sub: argparse._SubParsersAction) -> None:
|
||||
p = sub.add_parser("klipper", help="Update Klipper")
|
||||
p.add_argument("--backup", action="store_true", help="Backup before updating")
|
||||
|
||||
|
||||
def _add_moonraker_update(sub: argparse._SubParsersAction) -> None:
|
||||
sub.add_parser("moonraker", help="Update Moonraker")
|
||||
|
||||
|
||||
def _add_web_client_update(sub: argparse._SubParsersAction) -> None:
|
||||
for name in ("mainsail", "fluidd"):
|
||||
sub.add_parser(name, help=f"Update {name.capitalize()}")
|
||||
sub.add_parser("mainsail-config", help="Update the Mainsail client config")
|
||||
sub.add_parser("fluidd-config", help="Update the Fluidd client config")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="kiauh")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
install = subparsers.add_parser("install", help="Install a component")
|
||||
install_sub = install.add_subparsers(dest="component", required=True)
|
||||
_add_klipper_install(install_sub)
|
||||
_add_moonraker_install(install_sub)
|
||||
_add_web_client_install(install_sub)
|
||||
|
||||
remove = subparsers.add_parser("remove", help="Remove a component")
|
||||
remove_sub = remove.add_subparsers(dest="component", required=True)
|
||||
_add_klipper_remove(remove_sub)
|
||||
_add_moonraker_remove(remove_sub)
|
||||
_add_web_client_remove(remove_sub)
|
||||
|
||||
update = subparsers.add_parser("update", help="Update a component")
|
||||
update_sub = update.add_subparsers(dest="component", required=True)
|
||||
_add_klipper_update(update_sub)
|
||||
_add_moonraker_update(update_sub)
|
||||
_add_web_client_update(update_sub)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Command handlers: one callable per (command, component) pair. #
|
||||
# Adding a new component is a matter of registering a handler here instead of #
|
||||
# extending the previous long if/elif chain. #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _install_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
if args.count is not None and args.name and args.count != len(args.name):
|
||||
parser.error("--count must match the number of --name values")
|
||||
|
||||
service = KlipperSetupService()
|
||||
custom_names = {i: name for i, name in enumerate(args.name)} if args.name else None
|
||||
result = service.install(
|
||||
count=args.count,
|
||||
custom_names=custom_names,
|
||||
create_example_cfg=args.create_example_cfg,
|
||||
match_moonraker=args.match_moonraker,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _remove_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
if not (args.service or args.dir or args.env):
|
||||
parser.error(
|
||||
"specify at least one of --service, --dir, --env for 'remove klipper'"
|
||||
)
|
||||
if args.service and not (args.all or args.instance):
|
||||
# refuse to silently wipe every Klipper instance.
|
||||
parser.error(
|
||||
"removing Klipper services is destructive; pass --all or "
|
||||
"--instance <suffix> (repeatable) to select what to remove"
|
||||
)
|
||||
service = KlipperSetupService()
|
||||
result = service.remove(
|
||||
remove_service=args.service,
|
||||
remove_dir=args.dir,
|
||||
remove_env=args.env,
|
||||
remove_all=args.all,
|
||||
instance_suffixes=args.instance or None,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _update_klipper(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
service = KlipperSetupService()
|
||||
if args.backup:
|
||||
service.settings.kiauh.backup_before_update = True
|
||||
result = service.update(interactive=False)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _install_moonraker(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
service = MoonrakerSetupService()
|
||||
result = service.install(
|
||||
klipper_suffixes=args.klipper_suffix or None,
|
||||
create_example_cfg=args.create_example_cfg,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _remove_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
if not (args.service or args.dir or args.env or args.polkit):
|
||||
parser.error(
|
||||
"specify at least one of --service, --dir, --env, --polkit "
|
||||
"for 'remove moonraker'"
|
||||
)
|
||||
if args.service and not (args.all or args.instance):
|
||||
# refuse to silently wipe every Moonraker instance.
|
||||
parser.error(
|
||||
"removing Moonraker services is destructive; pass --all or "
|
||||
"--instance <suffix> (repeatable) to select what to remove"
|
||||
)
|
||||
service = MoonrakerSetupService()
|
||||
result = service.remove(
|
||||
remove_service=args.service,
|
||||
remove_dir=args.dir,
|
||||
remove_env=args.env,
|
||||
remove_polkit=args.polkit,
|
||||
remove_all=args.all,
|
||||
instance_suffixes=args.instance or None,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _update_moonraker(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
service = MoonrakerSetupService()
|
||||
result = service.update(interactive=False)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _install_web_client(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
service = WebClientSetupService(args.component)
|
||||
result = service.install(
|
||||
port=args.port,
|
||||
install_client_cfg=args.install_config,
|
||||
continue_without_moonraker=args.continue_without_moonraker,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _install_web_client_config(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
client_name = args.component.replace("-config", "")
|
||||
result = WebClientConfigSetupService(client_name).install(interactive=False)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _remove_web_client(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
if not (args.client or args.config):
|
||||
parser.error(
|
||||
f"specify at least one of --client, --config for 'remove {args.component}'"
|
||||
)
|
||||
service = WebClientSetupService(args.component)
|
||||
result = service.remove(
|
||||
remove_client=args.client,
|
||||
remove_client_cfg=args.config,
|
||||
backup_config=not args.no_backup,
|
||||
interactive=False,
|
||||
)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _update_web_client(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
result = WebClientSetupService(args.component).update()
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _update_web_client_config(
|
||||
args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
) -> int:
|
||||
client_name = args.component.replace("-config", "")
|
||||
result = WebClientConfigSetupService(client_name).update(interactive=False)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
# Dispatch registry: (command, component) -> handler. Keeping this as a module
|
||||
# constant (not a closure) keeps ``run_cli`` trivial and lets tests assert which
|
||||
# combinations are actually supported.
|
||||
DISPATCH: Dict[Tuple[str, str], Dispatcher] = {
|
||||
("install", "klipper"): _install_klipper,
|
||||
("remove", "klipper"): _remove_klipper,
|
||||
("update", "klipper"): _update_klipper,
|
||||
("install", "moonraker"): _install_moonraker,
|
||||
("remove", "moonraker"): _remove_moonraker,
|
||||
("update", "moonraker"): _update_moonraker,
|
||||
("install", "mainsail"): _install_web_client,
|
||||
("install", "fluidd"): _install_web_client,
|
||||
("remove", "mainsail"): _remove_web_client,
|
||||
("remove", "fluidd"): _remove_web_client,
|
||||
("update", "mainsail"): _update_web_client,
|
||||
("update", "fluidd"): _update_web_client,
|
||||
("install", "mainsail-config"): _install_web_client_config,
|
||||
("install", "fluidd-config"): _install_web_client_config,
|
||||
("update", "mainsail-config"): _update_web_client_config,
|
||||
("update", "fluidd-config"): _update_web_client_config,
|
||||
}
|
||||
|
||||
|
||||
def run_cli(argv: List[str] | None = None) -> int:
|
||||
"""Run a headless CLI command.
|
||||
|
||||
Returns 0 on success, -1 if no command was provided (-> fall back to TUI),
|
||||
and a positive exit code when a command reports failure.
|
||||
"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.command:
|
||||
return -1
|
||||
|
||||
handler = DISPATCH.get((args.command, args.component))
|
||||
if handler is None:
|
||||
parser.error(f"Unsupported command: {args.command} {args.component}")
|
||||
return handler(args, parser)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sys.exit(run_cli())
|
||||
@@ -0,0 +1,517 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
import core.cli as cli_module
|
||||
import pytest
|
||||
from core.cli import run_cli
|
||||
|
||||
|
||||
class FakeKlipperService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
self.results: Dict[str, bool] = {}
|
||||
self.settings = type(
|
||||
"Settings",
|
||||
(),
|
||||
{
|
||||
"kiauh": type(
|
||||
"KiauhSettingsSection", (), {"backup_before_update": False}
|
||||
)()
|
||||
},
|
||||
)()
|
||||
|
||||
def install(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "install", "kwargs": kwargs})
|
||||
return self.results.get("install", True)
|
||||
|
||||
def remove(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "remove", "kwargs": kwargs})
|
||||
return self.results.get("remove", True)
|
||||
|
||||
def update(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "update", "kwargs": kwargs})
|
||||
return self.results.get("update", True)
|
||||
|
||||
|
||||
class FakeMoonrakerService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Dict[str, Any]] = []
|
||||
self.results: Dict[str, bool] = {}
|
||||
|
||||
def install(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "install", "kwargs": kwargs})
|
||||
return self.results.get("install", True)
|
||||
|
||||
def remove(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "remove", "kwargs": kwargs})
|
||||
return self.results.get("remove", True)
|
||||
|
||||
def update(self, **kwargs: Any) -> bool:
|
||||
self.calls.append({"method": "update", "kwargs": kwargs})
|
||||
return self.results.get("update", True)
|
||||
|
||||
|
||||
class FakeWebClientService:
|
||||
def __init__(self) -> None:
|
||||
self.install_calls: List[Dict[str, Any]] = []
|
||||
self.remove_calls: List[Dict[str, Any]] = []
|
||||
self.update_calls: List[str] = []
|
||||
self.results: Dict[str, bool] = {}
|
||||
|
||||
def install(self, **kwargs: Any) -> bool:
|
||||
self.install_calls.append(kwargs)
|
||||
return self.results.get("install", True)
|
||||
|
||||
def remove(self, **kwargs: Any) -> bool:
|
||||
self.remove_calls.append(kwargs)
|
||||
return self.results.get("remove", True)
|
||||
|
||||
def update(self) -> bool:
|
||||
self.update_calls.append("update")
|
||||
return self.results.get("update", True)
|
||||
|
||||
|
||||
class FakeWebClientConfigService:
|
||||
def __init__(self) -> None:
|
||||
self.install_calls: List[Dict[str, Any]] = []
|
||||
self.update_calls: List[Dict[str, Any]] = []
|
||||
self.results: Dict[str, bool] = {}
|
||||
|
||||
def install(self, **kwargs: Any) -> bool:
|
||||
self.install_calls.append(kwargs)
|
||||
return self.results.get("install", True)
|
||||
|
||||
def update(self, **kwargs: Any) -> bool:
|
||||
self.update_calls.append(kwargs)
|
||||
return self.results.get("update", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_service(monkeypatch: pytest.MonkeyPatch) -> FakeKlipperService:
|
||||
fake = FakeKlipperService()
|
||||
monkeypatch.setattr("core.cli.KlipperSetupService", lambda: fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_moonraker_service(monkeypatch: pytest.MonkeyPatch) -> FakeMoonrakerService:
|
||||
fake = FakeMoonrakerService()
|
||||
monkeypatch.setattr("core.cli.MoonrakerSetupService", lambda: fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_web_client_service(monkeypatch: pytest.MonkeyPatch) -> FakeWebClientService:
|
||||
fake = FakeWebClientService()
|
||||
monkeypatch.setattr("core.cli.WebClientSetupService", lambda name: fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_web_client_config_service(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> FakeWebClientConfigService:
|
||||
fake = FakeWebClientConfigService()
|
||||
monkeypatch.setattr("core.cli.WebClientConfigSetupService", lambda name: fake)
|
||||
return fake
|
||||
|
||||
|
||||
class TestCliDispatch:
|
||||
def test_no_args_returns_tui_signal(self) -> None:
|
||||
assert run_cli([]) == -1
|
||||
|
||||
def test_install_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||
rc = run_cli(["install", "klipper", "--count", "2"])
|
||||
assert rc == 0
|
||||
assert fake_service.calls == [
|
||||
{
|
||||
"method": "install",
|
||||
"kwargs": {
|
||||
"count": 2,
|
||||
"custom_names": None,
|
||||
"create_example_cfg": False,
|
||||
"match_moonraker": False,
|
||||
"interactive": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_install_klipper_default_count_is_none(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
rc = run_cli(["install", "klipper"])
|
||||
assert rc == 0
|
||||
assert fake_service.calls[0]["kwargs"]["count"] is None
|
||||
|
||||
def test_install_klipper_with_names(self, fake_service: FakeKlipperService) -> None:
|
||||
rc = run_cli(["install", "klipper", "--name", "a", "--name", "b"])
|
||||
assert rc == 0
|
||||
assert fake_service.calls[0]["kwargs"]["custom_names"] == {0: "a", 1: "b"}
|
||||
assert fake_service.calls[0]["kwargs"]["count"] is None
|
||||
|
||||
def test_install_klipper_count_and_name_mismatch_rejected(self) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli([
|
||||
"install",
|
||||
"klipper",
|
||||
"--count",
|
||||
"3",
|
||||
"--name",
|
||||
"a",
|
||||
"--name",
|
||||
"b",
|
||||
])
|
||||
|
||||
def test_install_klipper_with_flags(self, fake_service: FakeKlipperService) -> None:
|
||||
rc = run_cli([
|
||||
"install",
|
||||
"klipper",
|
||||
"--create-example-cfg",
|
||||
"--match-moonraker",
|
||||
])
|
||||
assert rc == 0
|
||||
kwargs = fake_service.calls[0]["kwargs"]
|
||||
assert kwargs["create_example_cfg"] is True
|
||||
assert kwargs["match_moonraker"] is True
|
||||
assert kwargs["interactive"] is False
|
||||
|
||||
def test_install_klipper_failure_returns_nonzero(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
fake_service.results["install"] = False
|
||||
assert run_cli(["install", "klipper"]) == 1
|
||||
|
||||
def test_remove_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||
rc = run_cli(["remove", "klipper", "--service", "--all", "--dir", "--env"])
|
||||
assert rc == 0
|
||||
assert fake_service.calls == [
|
||||
{
|
||||
"method": "remove",
|
||||
"kwargs": {
|
||||
"remove_service": True,
|
||||
"interactive": False,
|
||||
"remove_dir": True,
|
||||
"remove_env": True,
|
||||
"remove_all": True,
|
||||
"instance_suffixes": None,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_remove_klipper_failure_returns_nonzero(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
fake_service.results["remove"] = False
|
||||
assert run_cli(["remove", "klipper", "--service", "--all"]) == 1
|
||||
|
||||
def test_remove_klipper_no_flags_is_rejected(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
# a remove with no removal flags must not silently succeed
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli(["remove", "klipper"])
|
||||
assert fake_service.calls == []
|
||||
|
||||
def test_remove_klipper_service_without_explicit_intent_is_rejected(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
# `--service` alone must NOT silently wipe all instances.
|
||||
# The user must pass `--all` (or `--instance <suffix>`).
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli(["remove", "klipper", "--service"])
|
||||
assert fake_service.calls == []
|
||||
|
||||
def test_remove_klipper_with_instance_suffix(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
rc = run_cli([
|
||||
"remove",
|
||||
"klipper",
|
||||
"--service",
|
||||
"--instance",
|
||||
"a",
|
||||
"--instance",
|
||||
"b",
|
||||
])
|
||||
assert rc == 0
|
||||
assert fake_service.calls[0]["kwargs"]["instance_suffixes"] == ["a", "b"]
|
||||
assert fake_service.calls[0]["kwargs"]["remove_all"] is False
|
||||
|
||||
def test_update_klipper(self, fake_service: FakeKlipperService) -> None:
|
||||
rc = run_cli(["update", "klipper"])
|
||||
assert rc == 0
|
||||
assert fake_service.calls == [
|
||||
{"method": "update", "kwargs": {"interactive": False}}
|
||||
]
|
||||
|
||||
def test_update_klipper_with_backup_flag(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
rc = run_cli(["update", "klipper", "--backup"])
|
||||
assert rc == 0
|
||||
assert fake_service.settings.kiauh.backup_before_update is True
|
||||
|
||||
def test_update_klipper_failure_returns_nonzero(
|
||||
self, fake_service: FakeKlipperService
|
||||
) -> None:
|
||||
fake_service.results["update"] = False
|
||||
assert run_cli(["update", "klipper"]) == 1
|
||||
|
||||
|
||||
class TestMoonrakerCliDispatch:
|
||||
def test_install_moonraker_default(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
rc = run_cli(["install", "moonraker"])
|
||||
assert rc == 0
|
||||
assert fake_moonraker_service.calls == [
|
||||
{
|
||||
"method": "install",
|
||||
"kwargs": {
|
||||
"klipper_suffixes": None,
|
||||
"create_example_cfg": False,
|
||||
"interactive": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_install_moonraker_with_suffixes(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
rc = run_cli([
|
||||
"install",
|
||||
"moonraker",
|
||||
"--klipper-suffix",
|
||||
"a",
|
||||
"--klipper-suffix",
|
||||
"b",
|
||||
])
|
||||
assert rc == 0
|
||||
assert fake_moonraker_service.calls[0]["kwargs"]["klipper_suffixes"] == [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
def test_install_moonraker_failure_returns_nonzero(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
fake_moonraker_service.results["install"] = False
|
||||
assert run_cli(["install", "moonraker"]) == 1
|
||||
|
||||
def test_remove_moonraker(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
rc = run_cli([
|
||||
"remove",
|
||||
"moonraker",
|
||||
"--service",
|
||||
"--all",
|
||||
"--dir",
|
||||
"--env",
|
||||
"--polkit",
|
||||
])
|
||||
assert rc == 0
|
||||
assert fake_moonraker_service.calls == [
|
||||
{
|
||||
"method": "remove",
|
||||
"kwargs": {
|
||||
"remove_service": True,
|
||||
"remove_dir": True,
|
||||
"remove_env": True,
|
||||
"remove_polkit": True,
|
||||
"interactive": False,
|
||||
"remove_all": True,
|
||||
"instance_suffixes": None,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_remove_moonraker_service_without_explicit_intent_is_rejected(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
# `--service` alone must NOT silently wipe all instances.
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli(["remove", "moonraker", "--service"])
|
||||
assert fake_moonraker_service.calls == []
|
||||
|
||||
def test_update_moonraker(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
rc = run_cli(["update", "moonraker"])
|
||||
assert rc == 0
|
||||
assert fake_moonraker_service.calls == [
|
||||
{"method": "update", "kwargs": {"interactive": False}}
|
||||
]
|
||||
|
||||
def test_remove_moonraker_no_flags_is_rejected(
|
||||
self, fake_moonraker_service: FakeMoonrakerService
|
||||
) -> None:
|
||||
# a remove with no removal flags must not silently succeed
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli(["remove", "moonraker"])
|
||||
assert fake_moonraker_service.calls == []
|
||||
|
||||
|
||||
class TestWebClientCliDispatch:
|
||||
def test_install_mainsail(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
rc = run_cli([
|
||||
"install",
|
||||
"mainsail",
|
||||
"--port",
|
||||
"8080",
|
||||
"--install-config",
|
||||
"--continue-without-moonraker",
|
||||
])
|
||||
assert rc == 0
|
||||
assert fake_web_client_service.install_calls == [
|
||||
{
|
||||
"port": 8080,
|
||||
"install_client_cfg": True,
|
||||
"continue_without_moonraker": True,
|
||||
"interactive": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_install_fluidd_default(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
rc = run_cli(["install", "fluidd"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_service.install_calls == [
|
||||
{
|
||||
"port": None,
|
||||
"install_client_cfg": False,
|
||||
"continue_without_moonraker": False,
|
||||
"interactive": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_install_client_config_runs_non_interactively(
|
||||
self, fake_web_client_config_service: FakeWebClientConfigService
|
||||
) -> None:
|
||||
rc = run_cli(["install", "mainsail-config"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_config_service.install_calls == [{"interactive": False}]
|
||||
|
||||
def test_install_web_client_failure_returns_nonzero(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
fake_web_client_service.results["install"] = False
|
||||
assert run_cli(["install", "mainsail"]) == 1
|
||||
|
||||
def test_remove_mainsail_no_flags_is_rejected(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
# a remove with no removal flags must not silently succeed
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli(["remove", "mainsail"])
|
||||
assert fake_web_client_service.remove_calls == []
|
||||
|
||||
def test_remove_mainsail_with_client_and_config(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
rc = run_cli(["remove", "mainsail", "--client", "--config"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_service.remove_calls == [
|
||||
{
|
||||
"remove_client": True,
|
||||
"remove_client_cfg": True,
|
||||
"backup_config": True,
|
||||
"interactive": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_remove_fluidd_no_backup(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
rc = run_cli(["remove", "fluidd", "--client", "--no-backup"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_service.remove_calls == [
|
||||
{
|
||||
"remove_client": True,
|
||||
"remove_client_cfg": False,
|
||||
"backup_config": False,
|
||||
"interactive": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_mainsail(
|
||||
self, fake_web_client_service: FakeWebClientService
|
||||
) -> None:
|
||||
rc = run_cli(["update", "mainsail"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_service.update_calls == ["update"]
|
||||
|
||||
def test_update_fluidd_config_runs_non_interactively(
|
||||
self, fake_web_client_config_service: FakeWebClientConfigService
|
||||
) -> None:
|
||||
rc = run_cli(["update", "fluidd-config"])
|
||||
assert rc == 0
|
||||
assert fake_web_client_config_service.update_calls == [{"interactive": False}]
|
||||
|
||||
|
||||
class TestDispatchRegistry:
|
||||
"""``run_cli`` must use a dispatch registry instead of a long
|
||||
if/elif chain, and the registry must cover every (command, component) pair
|
||||
the argument parser can produce."""
|
||||
|
||||
_EXPECTED: Set[tuple] = {
|
||||
("install", "klipper"),
|
||||
("remove", "klipper"),
|
||||
("update", "klipper"),
|
||||
("install", "moonraker"),
|
||||
("remove", "moonraker"),
|
||||
("update", "moonraker"),
|
||||
("install", "mainsail"),
|
||||
("install", "fluidd"),
|
||||
("remove", "mainsail"),
|
||||
("remove", "fluidd"),
|
||||
("update", "mainsail"),
|
||||
("update", "fluidd"),
|
||||
("install", "mainsail-config"),
|
||||
("install", "fluidd-config"),
|
||||
("update", "mainsail-config"),
|
||||
("update", "fluidd-config"),
|
||||
}
|
||||
|
||||
def test_dispatch_registry_exists_and_covers_every_pair(self) -> None:
|
||||
dispatch = getattr(cli_module, "DISPATCH", None)
|
||||
assert dispatch is not None, "run_cli must expose a DISPATCH registry"
|
||||
assert set(dispatch.keys()) == self._EXPECTED
|
||||
for handler in dispatch.values():
|
||||
assert callable(handler)
|
||||
|
||||
def test_subparser_helpers_are_typed_not_any(self) -> None:
|
||||
# the ``_add_*`` helpers must accept ``argparse._SubParsersAction``, not ``Any``.
|
||||
import inspect
|
||||
|
||||
for name in dir(cli_module):
|
||||
if not name.startswith("_add_"):
|
||||
continue
|
||||
func = getattr(cli_module, name)
|
||||
if not inspect.isfunction(func):
|
||||
continue
|
||||
hints = inspect.signature(func).parameters.get("sub")
|
||||
assert hints is not None
|
||||
assert hints.annotation is not Any, f"{name} must not type ``sub`` as Any"
|
||||
assert "SubParsersAction" in str(hints.annotation), (
|
||||
f"{name} must type ``sub`` as an argparse SubParsersAction"
|
||||
)
|
||||
|
||||
|
||||
class TestPackaging:
|
||||
def test_pyproject_metadata_allows_editable_dev_install(self) -> None:
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
import subprocess as sp
|
||||
|
||||
result = sp.run(
|
||||
["python", "-m", "pip", "install", "--dry-run", "-e", ".[dev]"],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user