Compare commits

..

7 Commits

Author SHA1 Message Date
dw-0
78af5a1ae5 Merge 39f0bd8b0a into f2691f33d3 2024-03-31 17:29:53 +02:00
Christian Würthner
f2691f33d3 feat: add OctoApp support (#454)
* Add OctoApp support

* Update scripts/octoapp.sh

Co-authored-by: dw-0 <domwil1091+github@gmail.com>

* Remove duplicate clone function

* Update Readme link

* Use "OctoApp for Klipper" in install menu

---------

Co-authored-by: dw-0 <domwil1091+github@gmail.com>
2024-03-31 17:15:47 +02:00
dw-0
39f0bd8b0a refactor: menu refactoring
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-03-31 00:33:00 +01:00
dw-0
dc87d30770 feat: first implementation of firmware flashing via usb and regular flash command
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-03-30 14:33:11 +01:00
dw-0
aaf5216275 refactor: remove unnecessary call to get_logfile_list
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-03-29 21:22:27 +01:00
dw-0
ebdfadac07 feat: allow custom input label text in menus
Signed-off-by: Dominik Willner <th33xitus@gmail.com>
2024-03-29 20:35:00 +01:00
dw-0
cac73cc58d Merge branch 'master' into kiauh-v6-dev 2024-03-27 20:44:31 +01:00
28 changed files with 1156 additions and 247 deletions

View File

@@ -154,18 +154,20 @@ prompt and confirm by hitting ENTER.
<tr> <tr>
<th><h3><a href="https://github.com/Clon1998/mobileraker_companion">Mobileraker's Companion</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> <th><h3><a href="https://octoeverywhere.com/?source=kiauh_readme">OctoEverywhere For Klipper</a></h3></th>
<th><h3><a href="https://github.com/crysxd/OctoPrint-OctoApp">OctoApp For Klipper</a></h3></th>
<th><h3></h3></th> <th><h3></h3></th>
</tr> </tr>
<tr> <tr>
<th><a href="https://github.com/Clon1998/mobileraker_companion"><img src="https://raw.githubusercontent.com/Clon1998/mobileraker/master/assets/icon/mr_appicon.png" alt="OctoEverywhere 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="OctoEverywhere Logo" height="64"></a></th>
<th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th> <th><a href="https://octoeverywhere.com/?source=kiauh_readme"><img src="https://octoeverywhere.com/img/logo.svg" alt="OctoEverywhere Logo" height="64"></a></th>
<th></th> <th><a href="https://octoapp.eu/?source=kiauh_readme"><img src="https://octoapp.eu/octoapp.webp" alt="OctoApp Logo" height="64"></a></th>
</tr> </tr>
<tr> <tr>
<th>by <a href="https://github.com/Clon1998">Patrick Schmidt</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> <th>by <a href="https://github.com/QuinnDamerell">Quinn Damerell</a></th>
<th>by <a href="https://github.com/crysxd">Christian Würthner</a></th>
<th></th> <th></th>
</tr> </tr>

View File

@@ -10,7 +10,7 @@
import textwrap import textwrap
from components.klipper import klipper_remove from components.klipper import klipper_remove
from core.menus import BACK_HELP_FOOTER from core.menus import FooterType
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
@@ -18,18 +18,18 @@ from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
class KlipperRemoveMenu(BaseMenu): class KlipperRemoveMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=False, self.header = False
options={ self.options = {
"0": self.toggle_all, "0": self.toggle_all,
"1": self.toggle_remove_klipper_service, "1": self.toggle_remove_klipper_service,
"2": self.toggle_remove_klipper_dir, "2": self.toggle_remove_klipper_dir,
"3": self.toggle_remove_klipper_env, "3": self.toggle_remove_klipper_env,
"4": self.toggle_delete_klipper_logs, "4": self.toggle_delete_klipper_logs,
"c": self.run_removal_process, "c": self.run_removal_process,
}, }
footer_type=BACK_HELP_FOOTER, self.footer_type = FooterType.BACK_HELP
)
self.remove_klipper_service = False self.remove_klipper_service = False
self.remove_klipper_dir = False self.remove_klipper_dir = False
self.remove_klipper_env = False self.remove_klipper_env = False

View File

@@ -0,0 +1,48 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from dataclasses import field, dataclass
from enum import Enum
from typing import Union, 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"
UART = "UART"
@dataclass
class FlashOptions:
_instance = None
flash_method: Union[FlashMethod, None] = None
flash_command: Union[FlashCommand, None] = None
connection_type: Union[ConnectionType, None] = None
mcu_list: List[str] = field(default_factory=list)
selected_mcu: str = ""
selected_board: str = ""
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):
cls._instance = None

View File

@@ -0,0 +1,77 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
from subprocess import CalledProcessError, check_output, Popen, PIPE, STDOUT
from typing import List
from components.klipper import KLIPPER_DIR
from components.klipper_firmware.flash_options import FlashOptions, FlashCommand
from utils.logger import Logger
from utils.system_utils import log_process
def find_usb_device_by_id() -> List[str]:
try:
command = "find /dev/serial/by-id/* 2>/dev/null"
output = check_output(command, shell=True, text=True)
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:
command = '"find /dev -maxdepth 1 -regextype posix-extended -regex "^\/dev\/tty(AMA0|S0)$" 2>/dev/null"'
output = check_output(command, shell=True, text=True)
return output.splitlines()
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:
command = '"lsusb | grep "DFU" | cut -d " " -f 6 2>/dev/null"'
output = check_output(command, shell=True, text=True)
return output.splitlines()
except CalledProcessError as e:
Logger.print_error("Unable to find a USB DFU device!")
Logger.print_error(e, prefix=False)
return []
def flash_device(flash_options: FlashOptions) -> None:
try:
if not flash_options.selected_mcu:
raise Exception("Missing value for selected_mcu!")
if flash_options.flash_command is FlashCommand.FLASH:
command = [
"make",
flash_options.flash_command.value,
f"FLASH_DEVICE={flash_options.selected_mcu}",
]
process = Popen(
command, cwd=KLIPPER_DIR, stdout=PIPE, stderr=STDOUT, text=True
)
log_process(process)
rc = process.returncode
if rc != 0:
raise Exception(f"Flashing failed with returncode: {rc}")
else:
Logger.print_ok("Flashing successfull!", 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")

View File

@@ -0,0 +1,371 @@
# ======================================================================= #
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= #
import textwrap
from components.klipper_firmware.flash_options import (
FlashOptions,
FlashMethod,
FlashCommand,
ConnectionType,
)
from components.klipper_firmware.flash_utils import (
find_usb_device_by_id,
find_uart_device,
find_usb_dfu_device,
flash_device,
)
from core.menus import FooterType
from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW, COLOR_RED
from utils.input_utils import get_confirm
from utils.logger import Logger
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperFlashMethodMenu(BaseMenu):
def __init__(self):
super().__init__()
self.header = False
self.options = {
"1": self.select_regular,
"2": self.select_sdcard,
"h": KlipperFlashMethodHelpMenu,
}
self.input_label_txt = "Select flash method"
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def print_menu(self) -> None:
header = " [ Flash MCU ] "
color = COLOR_CYAN
count = 62 - len(color) - len(RESET_FORMAT)
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:~^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| Please select the flashing method to flash your MCU. |
| Make sure to only select a method your MCU supports. |
| Not all MCUs support both methods! |
|-------------------------------------------------------|
| |
| 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):
next_menu = KlipperFlashCommandMenu(previous_menu=self)
next_menu.run()
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperFlashCommandMenu(BaseMenu):
def __init__(self, previous_menu: BaseMenu):
super().__init__()
self.header = False
self.options = {
"1": self.select_flash,
"2": self.select_serialflash,
"h": KlipperFlashCommandHelpMenu,
}
self.default_option = self.select_flash
self.input_label_txt = "Select flash command"
self.previous_menu = previous_menu
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def print_menu(self) -> None:
menu = textwrap.dedent(
"""
/=======================================================\\
| |
| Which flash command to use for flashing the MCU? |
| 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):
next_menu = KlipperSelectMcuConnectionMenu(previous_menu=self)
next_menu.run()
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperSelectMcuConnectionMenu(BaseMenu):
def __init__(self, previous_menu: BaseMenu):
super().__init__()
self.header = False
self.options = {
"1": self.select_usb,
"2": self.select_dfu,
"3": self.select_usb_dfu,
"h": KlipperMcuConnectionHelpMenu,
}
self.input_label_txt = "Select connection type"
self.previous_menu = previous_menu
self.footer_type = FooterType.BACK_HELP
self.flash_options = FlashOptions()
def print_menu(self) -> None:
header = "Make sure that the controller board is connected now!"
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| |
| How is the controller board connected to the host? |
| 1) USB |
| 2) UART |
| 3) USB (DFU 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 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()
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.")
else:
self.goto_next_menu()
def goto_next_menu(self, **kwargs):
next_menu = KlipperSelectMcuIdMenu(previous_menu=self)
next_menu.run()
# noinspection PyUnusedLocal
# noinspection PyMethodMayBeStatic
class KlipperSelectMcuIdMenu(BaseMenu):
def __init__(self, previous_menu: BaseMenu):
super().__init__()
self.header = False
self.flash_options = FlashOptions()
self.mcu_list = self.flash_options.mcu_list
options = {f"{index}": self.flash_mcu for index in range(len(self.mcu_list))}
self.options = options
self.input_label_txt = "Select MCU to flash"
self.previous_menu = previous_menu
self.footer_type = FooterType.BACK_HELP
def print_menu(self) -> None:
header = "!!! ATTENTION !!!"
header2 = f"[{COLOR_CYAN}List of available MCUs{RESET_FORMAT}]"
color = COLOR_RED
count = 62 - len(color) - len(RESET_FORMAT)
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| 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" ● MCU #{i}: {COLOR_CYAN}{mcu}{RESET_FORMAT}\n"
print(menu, end="\n")
def flash_mcu(self, **kwargs):
index = int(kwargs.get("opt_index"))
selected_mcu = self.mcu_list[index]
self.flash_options.selected_mcu = selected_mcu
print(f"{COLOR_CYAN}###### You selected:{RESET_FORMAT}")
print(f"● MCU #{index}: {selected_mcu}\n")
if get_confirm("Continue", allow_go_back=True):
Logger.print_status(f"Flashing '{selected_mcu}' ...")
flash_device(self.flash_options)
self.goto_next_menu()
def goto_next_menu(self, **kwargs):
pass
# TODO: navigate back to advanced menu after flashing
# from core.menus.main_menu import MainMenu
# from core.menus.advanced_menu import AdvancedMenu
#
# next_menu = AdvancedMenu()
# next_menu.start()
class KlipperFlashMethodHelpMenu(BaseMenu):
def __init__(self):
super().__init__()
self.header = False
def print_menu(self) -> None:
header = " < ? > Help: Flash MCU < ? > "
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
subheader1 = f"{COLOR_CYAN}Regular flashing method:{RESET_FORMAT}"
subheader2 = f"{COLOR_CYAN}Updating via SD-Card Update:{RESET_FORMAT}"
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:~^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| {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="")
class KlipperFlashCommandHelpMenu(BaseMenu):
def __init__(self):
super().__init__()
self.header = False
def print_menu(self) -> None:
header = " < ? > Help: Flash MCU < ? > "
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
subheader1 = f"{COLOR_CYAN}make flash:{RESET_FORMAT}"
subheader2 = f"{COLOR_CYAN}make serialflash:{RESET_FORMAT}"
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:~^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| {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="")
class KlipperMcuConnectionHelpMenu(BaseMenu):
def __init__(self):
super().__init__()
self.header = False
def print_menu(self) -> None:
header = " < ? > Help: Flash MCU < ? > "
color = COLOR_YELLOW
count = 62 - len(color) - len(RESET_FORMAT)
subheader1 = f"{COLOR_CYAN}USB:{RESET_FORMAT}"
subheader2 = f"{COLOR_CYAN}UART:{RESET_FORMAT}"
menu = textwrap.dedent(
f"""
/=======================================================\\
| {color}{header:~^{count}}{RESET_FORMAT} |
|-------------------------------------------------------|
| {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. |
| |
"""
)[1:]
print(menu, end="")

View File

@@ -11,7 +11,6 @@ import textwrap
from components.log_uploads.log_upload_utils import get_logfile_list from components.log_uploads.log_upload_utils import get_logfile_list
from components.log_uploads.log_upload_utils import upload_logfile from components.log_uploads.log_upload_utils import upload_logfile
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import RESET_FORMAT, COLOR_YELLOW from utils.constants import RESET_FORMAT, COLOR_YELLOW
@@ -19,13 +18,11 @@ from utils.constants import RESET_FORMAT, COLOR_YELLOW
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class LogUploadMenu(BaseMenu): class LogUploadMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__()
self.header = True
self.logfile_list = get_logfile_list() self.logfile_list = get_logfile_list()
options = {f"{index}": self.upload for index in range(len(self.logfile_list))} options = {f"{index}": self.upload for index in range(len(self.logfile_list))}
super().__init__( self.options = options
header=True,
options=options,
footer_type=BACK_FOOTER,
)
def print_menu(self): def print_menu(self):
header = " [ Log Upload ] " header = " [ Log Upload ] "
@@ -41,8 +38,7 @@ class LogUploadMenu(BaseMenu):
""" """
)[1:] )[1:]
logfile_list = get_logfile_list() for logfile in enumerate(self.logfile_list):
for logfile in enumerate(logfile_list):
line = f"{logfile[0]}) {logfile[1].get('display_name')}" line = f"{logfile[0]}) {logfile[1].get('display_name')}"
menu += f"| {line:<54}|\n" menu += f"| {line:<54}|\n"

View File

@@ -10,7 +10,6 @@
import textwrap import textwrap
from components.moonraker import moonraker_remove from components.moonraker import moonraker_remove
from core.menus import BACK_HELP_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
@@ -18,19 +17,18 @@ from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
class MoonrakerRemoveMenu(BaseMenu): class MoonrakerRemoveMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=False, self.header = False
options={ self.options = {
"0": self.toggle_all, "0": self.toggle_all,
"1": self.toggle_remove_moonraker_service, "1": self.toggle_remove_moonraker_service,
"2": self.toggle_remove_moonraker_dir, "2": self.toggle_remove_moonraker_dir,
"3": self.toggle_remove_moonraker_env, "3": self.toggle_remove_moonraker_env,
"4": self.toggle_remove_moonraker_polkit, "4": self.toggle_remove_moonraker_polkit,
"5": self.toggle_delete_moonraker_logs, "5": self.toggle_delete_moonraker_logs,
"c": self.run_removal_process, "c": self.run_removal_process,
}, }
footer_type=BACK_HELP_FOOTER,
)
self.remove_moonraker_service = False self.remove_moonraker_service = False
self.remove_moonraker_dir = False self.remove_moonraker_dir = False
self.remove_moonraker_env = False self.remove_moonraker_env = False

View File

@@ -11,7 +11,6 @@ import textwrap
from typing import Callable, Dict from typing import Callable, Dict
from components.webui_client import client_remove, ClientData from components.webui_client import client_remove, ClientData
from core.menus import BACK_HELP_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
@@ -19,25 +18,23 @@ from utils.constants import RESET_FORMAT, COLOR_RED, COLOR_CYAN
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
class ClientRemoveMenu(BaseMenu): class ClientRemoveMenu(BaseMenu):
def __init__(self, client: ClientData): def __init__(self, client: ClientData):
super().__init__()
self.header = False
self.options = self.get_options(client)
self.client = client self.client = client
self.rm_client = False self.rm_client = False
self.rm_client_config = False self.rm_client_config = False
self.backup_mainsail_config_json = False self.backup_mainsail_config_json = False
super().__init__( def get_options(self, client: ClientData) -> Dict[str, Callable]:
header=False,
options=self.get_options(),
footer_type=BACK_HELP_FOOTER,
)
def get_options(self) -> Dict[str, Callable]:
options = { options = {
"0": self.toggle_all, "0": self.toggle_all,
"1": self.toggle_rm_client, "1": self.toggle_rm_client,
"2": self.toggle_rm_client_config, "2": self.toggle_rm_client_config,
"c": self.run_removal_process, "c": self.run_removal_process,
} }
if self.client.get("name") == "mainsail": if client.get("name") == "mainsail":
options["3"] = self.toggle_backup_mainsail_config_json options["3"] = self.toggle_backup_mainsail_config_json
return options return options

View File

@@ -7,6 +7,17 @@
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
QUIT_FOOTER = "quit" from enum import Enum
BACK_FOOTER = "back"
BACK_HELP_FOOTER = "back_help"
class FooterType(Enum):
QUIT = "QUIT"
BACK = "BACK"
BACK_HELP = "BACK_HELP"
NAVI_OPTIONS = {
FooterType.QUIT: ["q"],
FooterType.BACK: ["b"],
FooterType.BACK_HELP: ["b", "h"],
}

View File

@@ -9,26 +9,25 @@
import textwrap import textwrap
from components.klipper_firmware.menus.klipper_flash_menu import KlipperFlashMenu from components.klipper_firmware.menus.klipper_flash_menu import (
from core.menus import BACK_FOOTER KlipperFlashMethodMenu,
KlipperSelectMcuConnectionMenu,
)
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_YELLOW, RESET_FORMAT from utils.constants import COLOR_YELLOW, RESET_FORMAT
class AdvancedMenu(BaseMenu): class AdvancedMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "1": None,
"1": None, "2": None,
"2": None, "3": None,
"3": None, "4": KlipperFlashMethodMenu,
"4": KlipperFlashMenu, "5": None,
"5": None, "6": KlipperSelectMcuConnectionMenu,
"6": None, }
},
footer_type=BACK_FOOTER,
)
def print_menu(self): def print_menu(self):
header = " [ Advanced Menu ] " header = " [ Advanced Menu ] "

View File

@@ -19,7 +19,6 @@ from components.webui_client.client_utils import (
load_client_data, load_client_data,
backup_client_config_data, backup_client_config_data,
) )
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.common import backup_printer_config_dir from utils.common import backup_printer_config_dir
from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW
@@ -29,21 +28,18 @@ from utils.constants import COLOR_CYAN, RESET_FORMAT, COLOR_YELLOW
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class BackupMenu(BaseMenu): class BackupMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "1": self.backup_klipper,
"1": self.backup_klipper, "2": self.backup_moonraker,
"2": self.backup_moonraker, "3": self.backup_printer_config,
"3": self.backup_printer_config, "4": self.backup_moonraker_db,
"4": self.backup_moonraker_db, "5": self.backup_mainsail,
"5": self.backup_mainsail, "6": self.backup_fluidd,
"6": self.backup_fluidd, "7": self.backup_mainsail_config,
"7": self.backup_mainsail_config, "8": self.backup_fluidd_config,
"8": self.backup_fluidd_config, "9": self.backup_klipperscreen,
"9": self.backup_klipperscreen, }
},
footer_type=BACK_FOOTER,
)
def print_menu(self): def print_menu(self):
header = " [ Backup Menu ] " header = " [ Backup Menu ] "

View File

@@ -7,13 +7,15 @@
# This file may be distributed under the terms of the GNU GPLv3 license # # This file may be distributed under the terms of the GNU GPLv3 license #
# ======================================================================= # # ======================================================================= #
from __future__ import annotations
import subprocess import subprocess
import sys import sys
import textwrap import textwrap
from abc import abstractmethod, ABC from abc import abstractmethod, ABC
from typing import Dict, Any, Literal, Union, Callable from typing import Dict, Union, Callable, Type
from core.menus import QUIT_FOOTER, BACK_FOOTER, BACK_HELP_FOOTER from core.menus import FooterType, NAVI_OPTIONS
from utils.constants import ( from utils.constants import (
COLOR_GREEN, COLOR_GREEN,
COLOR_YELLOW, COLOR_YELLOW,
@@ -90,62 +92,86 @@ def print_back_help_footer():
print(footer, end="") print(footer, end="")
class BaseMenu(ABC): Option = Union[Callable, Type["BaseMenu"], "BaseMenu"]
NAVI_OPTIONS = {"quit": ["q"], "back": ["b"], "back_help": ["b", "h"]} Options = Dict[str, Option]
def __init__(
self, class BaseMenu(ABC):
options: Dict[str, Union[Callable, Any]], options: Options = None
options_offset: int = 0, options_offset: int = 0
header: bool = True, default_option: Union[Option, None] = None
footer_type: Literal[ input_label_txt: str = "Perform action"
"QUIT_FOOTER", "BACK_FOOTER", "BACK_HELP_FOOTER" header: bool = True
] = QUIT_FOOTER, previous_menu: Union[Type[BaseMenu], BaseMenu] = None
): footer_type: FooterType = FooterType.BACK
self.previous_menu = None
self.options = options def __init__(self):
self.options_offset = options_offset if type(self) is BaseMenu:
self.header = header raise NotImplementedError("BaseMenu cannot be instantiated directly.")
self.footer_type = footer_type
@abstractmethod @abstractmethod
def print_menu(self) -> None: def print_menu(self) -> None:
raise NotImplementedError("Subclasses must implement the print_menu method") raise NotImplementedError("Subclasses must implement the print_menu method")
def print_footer(self) -> None: def print_footer(self) -> None:
footer_type_map = { if self.footer_type is FooterType.QUIT:
QUIT_FOOTER: print_quit_footer, print_quit_footer()
BACK_FOOTER: print_back_footer, elif self.footer_type is FooterType.BACK:
BACK_HELP_FOOTER: print_back_help_footer, print_back_footer()
} elif self.footer_type is FooterType.BACK_HELP:
footer_function = footer_type_map.get(self.footer_type, print_quit_footer) print_back_help_footer()
footer_function() else:
raise NotImplementedError("Method for printing footer not implemented.")
def display(self) -> None: def display_menu(self) -> None:
# clear() # clear()
if self.header: if self.header:
print_header() print_header()
self.print_menu() self.print_menu()
self.print_footer() self.print_footer()
def handle_user_input(self) -> str: def validate_user_input(self, usr_input: str) -> Union[Option, str, None]:
"""
Validate the user input and either return an Option, a string or None
:param usr_input: The user input in form of a string
:return: Option, str or None
"""
usr_input = usr_input.lower()
option = self.options.get(usr_input, None)
# check if usr_input contains a character used for basic navigation, e.g. b, h or q
# and if the current menu has the appropriate footer to allow for that action
is_valid_navigation = self.footer_type in NAVI_OPTIONS
user_navigated = usr_input in NAVI_OPTIONS[self.footer_type]
if is_valid_navigation and user_navigated:
return usr_input
# if usr_input is None or an empty string, we execute the menues default option if specified
if option is None or option == "" and self.default_option is not None:
return self.default_option
# user selected a regular option
if option is not None:
return option
return None
def handle_user_input(self) -> Union[Option, str]:
"""Handle the user input, return the validated input or print an error."""
while True: while True:
choice = input(f"{COLOR_CYAN}###### Perform action: {RESET_FORMAT}").lower() print(f"{COLOR_CYAN}###### {self.input_label_txt}: {RESET_FORMAT}", end="")
option = self.options.get(choice, None) usr_input = input().lower()
validated_input = self.validate_user_input(usr_input)
has_navi_option = self.footer_type in self.NAVI_OPTIONS if validated_input is not None:
user_navigated = choice in self.NAVI_OPTIONS[self.footer_type] return validated_input
if has_navi_option and user_navigated:
return choice
if option is not None:
return choice
else: else:
Logger.print_error("Invalid input!", False) Logger.print_error("Invalid input!", False)
def start(self) -> None: def run(self) -> None:
"""Start the menu lifecycle. When this function returns, the lifecycle of the menu ends."""
while True: while True:
self.display() self.display_menu()
choice = self.handle_user_input() choice = self.handle_user_input()
if choice == "q": if choice == "q":
@@ -156,21 +182,16 @@ class BaseMenu(ABC):
else: else:
self.execute_option(choice) self.execute_option(choice)
def execute_option(self, choice: str) -> None: def execute_option(self, option: Option) -> None:
option = self.options.get(choice, None) if option is None:
raise NotImplementedError(f"No implementation for {option}")
if isinstance(option, type) and issubclass(option, BaseMenu): if isinstance(option, type) and issubclass(option, BaseMenu):
self.navigate_to_menu(option, True) self.navigate_to_menu(option, True)
elif isinstance(option, BaseMenu): elif isinstance(option, BaseMenu):
self.navigate_to_menu(option, False) self.navigate_to_menu(option, False)
elif callable(option): elif callable(option):
option(opt_index=choice) option()
elif option is None:
raise NotImplementedError(f"No implementation for option {choice}")
else:
raise TypeError(
f"Type {type(option)} of option {choice} not of type BaseMenu or Method"
)
def navigate_to_menu(self, menu, instantiate: bool) -> None: def navigate_to_menu(self, menu, instantiate: bool) -> None:
""" """
@@ -182,4 +203,4 @@ class BaseMenu(ABC):
""" """
menu = menu() if instantiate else menu menu = menu() if instantiate else menu
menu.previous_menu = self menu.previous_menu = self
menu.start() menu.run()

View File

@@ -12,11 +12,10 @@ import inspect
import json import json
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from typing import List, Dict from typing import List
from core.base_extension import BaseExtension from core.base_extension import BaseExtension
from core.menus import BACK_FOOTER from core.menus.base_menu import BaseMenu, Options
from core.menus.base_menu import BaseMenu
from utils.constants import RESET_FORMAT, COLOR_CYAN, COLOR_YELLOW from utils.constants import RESET_FORMAT, COLOR_CYAN, COLOR_YELLOW
@@ -24,12 +23,11 @@ from utils.constants import RESET_FORMAT, COLOR_CYAN, COLOR_YELLOW
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class ExtensionsMenu(BaseMenu): class ExtensionsMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__()
self.header = False
self.options: Options = self.get_options()
self.extensions = self.discover_extensions() self.extensions = self.discover_extensions()
super().__init__(
header=False,
options=self.get_options(),
footer_type=BACK_FOOTER,
)
def discover_extensions(self) -> List[BaseExtension]: def discover_extensions(self) -> List[BaseExtension]:
extensions = [] extensions = []
@@ -58,8 +56,8 @@ class ExtensionsMenu(BaseMenu):
return sorted(extensions, key=lambda ex: ex.metadata.get("index")) return sorted(extensions, key=lambda ex: ex.metadata.get("index"))
def get_options(self) -> Dict[str, BaseMenu]: def get_options(self) -> Options:
options = {} options: Options = {}
for extension in self.extensions: for extension in self.extensions:
index = extension.metadata.get("index") index = extension.metadata.get("index")
options[f"{index}"] = ExtensionSubmenu(extension) options[f"{index}"] = ExtensionSubmenu(extension)
@@ -93,17 +91,16 @@ class ExtensionsMenu(BaseMenu):
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class ExtensionSubmenu(BaseMenu): class ExtensionSubmenu(BaseMenu):
def __init__(self, extension: BaseExtension): def __init__(self, extension: BaseExtension):
super().__init__()
self.header = False
self.options = {
"1": extension.install_extension,
"2": extension.remove_extension,
}
self.extension = extension self.extension = extension
self.extension_name = extension.metadata.get("display_name") self.extension_name = extension.metadata.get("display_name")
self.extension_desc = extension.metadata.get("description") self.extension_desc = extension.metadata.get("description")
super().__init__(
header=False,
options={
"1": extension.install_extension,
"2": extension.remove_extension,
},
footer_type=BACK_FOOTER,
)
def print_menu(self) -> None: def print_menu(self) -> None:
header = f" [ {self.extension_name} ] " header = f" [ {self.extension_name} ] "

View File

@@ -13,7 +13,7 @@ from components.klipper import klipper_setup
from components.moonraker import moonraker_setup from components.moonraker import moonraker_setup
from components.webui_client import client_setup from components.webui_client import client_setup
from components.webui_client.client_config import client_config_setup from components.webui_client.client_config import client_config_setup
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_GREEN, RESET_FORMAT from utils.constants import COLOR_GREEN, RESET_FORMAT
@@ -22,21 +22,18 @@ from utils.constants import COLOR_GREEN, RESET_FORMAT
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class InstallMenu(BaseMenu): class InstallMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "1": self.install_klipper,
"1": self.install_klipper, "2": self.install_moonraker,
"2": self.install_moonraker, "3": self.install_mainsail,
"3": self.install_mainsail, "4": self.install_fluidd,
"4": self.install_fluidd, "5": self.install_mainsail_config,
"5": self.install_mainsail_config, "6": self.install_fluidd_config,
"6": self.install_fluidd_config, "7": None,
"7": None, "8": None,
"8": None, "9": None,
"9": None, }
},
footer_type=BACK_FOOTER,
)
def print_menu(self): def print_menu(self):
header = " [ Installation Menu ] " header = " [ Installation Menu ] "

View File

@@ -17,7 +17,7 @@ from components.webui_client.client_utils import (
load_client_data, load_client_data,
get_current_client_config, get_current_client_config,
) )
from core.menus import QUIT_FOOTER from core.menus import FooterType
from core.menus.advanced_menu import AdvancedMenu from core.menus.advanced_menu import AdvancedMenu
from core.menus.backup_menu import BackupMenu from core.menus.backup_menu import BackupMenu
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
@@ -39,20 +39,19 @@ from utils.constants import (
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class MainMenu(BaseMenu): class MainMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "0": LogUploadMenu,
"0": LogUploadMenu, "1": InstallMenu,
"1": InstallMenu, "2": UpdateMenu,
"2": UpdateMenu, "3": RemoveMenu,
"3": RemoveMenu, "4": AdvancedMenu,
"4": AdvancedMenu, "5": BackupMenu,
"5": BackupMenu, "e": ExtensionsMenu,
"e": ExtensionsMenu, "s": SettingsMenu,
"s": SettingsMenu, }
}, self.footer_type = FooterType.QUIT
footer_type=QUIT_FOOTER,
)
self.kl_status = "" self.kl_status = ""
self.kl_repo = "" self.kl_repo = ""
self.mr_status = "" self.mr_status = ""

View File

@@ -15,7 +15,6 @@ from components.moonraker.menus.moonraker_remove_menu import (
) )
from components.webui_client.client_utils import load_client_data from components.webui_client.client_utils import load_client_data
from components.webui_client.menus.client_remove_menu import ClientRemoveMenu from components.webui_client.menus.client_remove_menu import ClientRemoveMenu
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import COLOR_RED, RESET_FORMAT from utils.constants import COLOR_RED, RESET_FORMAT
@@ -24,25 +23,22 @@ from utils.constants import COLOR_RED, RESET_FORMAT
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class RemoveMenu(BaseMenu): class RemoveMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "1": KlipperRemoveMenu,
"1": KlipperRemoveMenu, "2": MoonrakerRemoveMenu,
"2": MoonrakerRemoveMenu, "3": ClientRemoveMenu(client=load_client_data("mainsail")),
"3": ClientRemoveMenu(client=load_client_data("mainsail")), "4": ClientRemoveMenu(client=load_client_data("fluidd")),
"4": ClientRemoveMenu(client=load_client_data("fluidd")), "5": None,
"5": None, "6": None,
"6": None, "7": None,
"7": None, "8": None,
"8": None, "9": None,
"9": None, "10": None,
"10": None, "11": None,
"11": None, "12": None,
"12": None, "13": None,
"13": None, }
},
footer_type=BACK_FOOTER,
)
def print_menu(self): def print_menu(self):
header = " [ Remove Menu ] " header = " [ Remove Menu ] "

View File

@@ -13,7 +13,7 @@ from core.menus.base_menu import BaseMenu
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class SettingsMenu(BaseMenu): class SettingsMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__(header=True, options={}) super().__init__()
def print_menu(self): def print_menu(self):
print("self") print("self")

View File

@@ -25,7 +25,6 @@ from components.webui_client.client_utils import (
load_client_data, load_client_data,
get_client_config_status, get_client_config_status,
) )
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from utils.constants import ( from utils.constants import (
COLOR_GREEN, COLOR_GREEN,
@@ -40,23 +39,21 @@ from utils.constants import (
# noinspection PyMethodMayBeStatic # noinspection PyMethodMayBeStatic
class UpdateMenu(BaseMenu): class UpdateMenu(BaseMenu):
def __init__(self): def __init__(self):
super().__init__( super().__init__()
header=True, self.options = {
options={ "0": self.update_all,
"0": self.update_all, "1": self.update_klipper,
"1": self.update_klipper, "2": self.update_moonraker,
"2": self.update_moonraker, "3": self.update_mainsail,
"3": self.update_mainsail, "4": self.update_fluidd,
"4": self.update_fluidd, "5": self.update_mainsail_config,
"5": self.update_mainsail_config, "6": self.update_fluidd_config,
"6": self.update_fluidd_config, "7": self.update_klipperscreen,
"7": self.update_klipperscreen, "8": self.update_mobileraker,
"8": self.update_mobileraker, "9": self.update_crowsnest,
"9": self.update_crowsnest, "10": self.upgrade_system_packages,
"10": self.upgrade_system_packages, }
},
footer_type=BACK_FOOTER,
)
self.kl_local = f"{COLOR_WHITE}{RESET_FORMAT}" self.kl_local = f"{COLOR_WHITE}{RESET_FORMAT}"
self.kl_remote = f"{COLOR_WHITE}{RESET_FORMAT}" self.kl_remote = f"{COLOR_WHITE}{RESET_FORMAT}"
self.mr_local = f"{COLOR_WHITE}{RESET_FORMAT}" self.mr_local = f"{COLOR_WHITE}{RESET_FORMAT}"

View File

@@ -22,7 +22,6 @@ from components.klipper.klipper_dialogs import (
from core.base_extension import BaseExtension from core.base_extension import BaseExtension
from core.instance_manager.base_instance import BaseInstance from core.instance_manager.base_instance import BaseInstance
from core.instance_manager.instance_manager import InstanceManager from core.instance_manager.instance_manager import InstanceManager
from core.menus import BACK_FOOTER
from core.menus.base_menu import BaseMenu from core.menus.base_menu import BaseMenu
from core.repo_manager.repo_manager import RepoManager from core.repo_manager.repo_manager import RepoManager
from utils.constants import COLOR_YELLOW, COLOR_CYAN, RESET_FORMAT from utils.constants import COLOR_YELLOW, COLOR_CYAN, RESET_FORMAT
@@ -44,7 +43,7 @@ class MainsailThemeInstallerExtension(BaseExtension):
def install_extension(self, **kwargs) -> None: def install_extension(self, **kwargs) -> None:
install_menu = MainsailThemeInstallMenu(self.instances) install_menu = MainsailThemeInstallMenu(self.instances)
install_menu.start() install_menu.run()
def remove_extension(self, **kwargs) -> None: def remove_extension(self, **kwargs) -> None:
print_instance_overview( print_instance_overview(
@@ -79,14 +78,13 @@ class MainsailThemeInstallMenu(BaseMenu):
) )
def __init__(self, instances: List[Klipper]): def __init__(self, instances: List[Klipper]):
self.instances = instances super().__init__()
self.header = False
self.themes: List[ThemeData] = self.load_themes() self.themes: List[ThemeData] = self.load_themes()
options = {f"{index}": self.install_theme for index in range(len(self.themes))} options = {f"{index}": self.install_theme for index in range(len(self.themes))}
super().__init__( self.options = options
header=False,
options=options, self.instances = instances
footer_type=BACK_FOOTER,
)
def print_menu(self) -> None: def print_menu(self) -> None:
header = " [ Mainsail Theme Installer ] " header = " [ Mainsail Theme Installer ] "

View File

@@ -13,6 +13,6 @@ from utils.logger import Logger
def main(): def main():
try: try:
MainMenu().start() MainMenu().run()
except KeyboardInterrupt: except KeyboardInterrupt:
Logger.print_ok("\nHappy printing!\n", prefix=False) Logger.print_ok("\nHappy printing!\n", prefix=False)

View File

@@ -10,7 +10,7 @@
import os import os
import shutil import shutil
import socket import socket
import subprocess from subprocess import Popen, PIPE, CalledProcessError, run, DEVNULL
import sys import sys
import time import time
import urllib.error import urllib.error
@@ -19,6 +19,8 @@ import venv
from pathlib import Path from pathlib import Path
from typing import List, Literal from typing import List, Literal
import select
from utils.input_utils import get_confirm from utils.input_utils import get_confirm
from utils.logger import Logger from utils.logger import Logger
from utils.filesystem_utils import check_file_exist from utils.filesystem_utils import check_file_exist
@@ -73,7 +75,7 @@ def create_python_venv(target: Path) -> None:
except OSError as e: except OSError as e:
Logger.print_error(f"Error setting up virtualenv:\n{e}") Logger.print_error(f"Error setting up virtualenv:\n{e}")
raise raise
except subprocess.CalledProcessError as e: except CalledProcessError as e:
Logger.print_error(f"Error setting up virtualenv:\n{e.output.decode()}") Logger.print_error(f"Error setting up virtualenv:\n{e.output.decode()}")
raise raise
else: else:
@@ -103,7 +105,7 @@ def update_python_pip(target: Path) -> None:
raise FileNotFoundError("Error updating pip! Not found.") raise FileNotFoundError("Error updating pip! Not found.")
command = [pip_location, "install", "-U", "pip"] command = [pip_location, "install", "-U", "pip"]
result = subprocess.run(command, stderr=subprocess.PIPE, text=True) result = run(command, stderr=PIPE, text=True)
if result.returncode != 0 or result.stderr: if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False) Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Updating pip failed!") Logger.print_error("Updating pip failed!")
@@ -113,7 +115,7 @@ def update_python_pip(target: Path) -> None:
except FileNotFoundError as e: except FileNotFoundError as e:
Logger.print_error(e) Logger.print_error(e)
raise raise
except subprocess.CalledProcessError as e: except CalledProcessError as e:
Logger.print_error(f"Error updating pip:\n{e.output.decode()}") Logger.print_error(f"Error updating pip:\n{e.output.decode()}")
raise raise
@@ -136,7 +138,7 @@ def install_python_requirements(target: Path, requirements: Path) -> None:
"-r", "-r",
f"{requirements}", f"{requirements}",
] ]
result = subprocess.run(command, stderr=subprocess.PIPE, text=True) result = run(command, stderr=PIPE, text=True)
if result.returncode != 0 or result.stderr: if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False) Logger.print_error(f"{result.stderr}", False)
@@ -144,7 +146,7 @@ def install_python_requirements(target: Path, requirements: Path) -> None:
return return
Logger.print_ok("Installing Python requirements successful!") Logger.print_ok("Installing Python requirements successful!")
except subprocess.CalledProcessError as e: except CalledProcessError as e:
log = f"Error installing Python requirements:\n{e.output.decode()}" log = f"Error installing Python requirements:\n{e.output.decode()}"
Logger.print_error(log) Logger.print_error(log)
raise raise
@@ -180,14 +182,14 @@ def update_system_package_lists(silent: bool, rls_info_change=False) -> None:
if rls_info_change: if rls_info_change:
command.append("--allow-releaseinfo-change") command.append("--allow-releaseinfo-change")
result = subprocess.run(command, stderr=subprocess.PIPE, text=True) result = run(command, stderr=PIPE, text=True)
if result.returncode != 0 or result.stderr: if result.returncode != 0 or result.stderr:
Logger.print_error(f"{result.stderr}", False) Logger.print_error(f"{result.stderr}", False)
Logger.print_error("Updating system package list failed!") Logger.print_error("Updating system package list failed!")
return return
Logger.print_ok("System package list update successful!") Logger.print_ok("System package list update successful!")
except subprocess.CalledProcessError as e: except CalledProcessError as e:
kill(f"Error updating system package list:\n{e.stderr.decode()}") kill(f"Error updating system package list:\n{e.stderr.decode()}")
@@ -200,10 +202,10 @@ def check_package_install(packages: List[str]) -> List[str]:
not_installed = [] not_installed = []
for package in packages: for package in packages:
command = ["dpkg-query", "-f'${Status}'", "--show", package] command = ["dpkg-query", "-f'${Status}'", "--show", package]
result = subprocess.run( result = run(
command, command,
stdout=subprocess.PIPE, stdout=PIPE,
stderr=subprocess.DEVNULL, stderr=DEVNULL,
text=True, text=True,
) )
if "installed" not in result.stdout.strip("'").split(): if "installed" not in result.stdout.strip("'").split():
@@ -224,10 +226,10 @@ def install_system_packages(packages: List[str]) -> None:
command = ["sudo", "apt-get", "install", "-y"] command = ["sudo", "apt-get", "install", "-y"]
for pkg in packages: for pkg in packages:
command.append(pkg) command.append(pkg)
subprocess.run(command, stderr=subprocess.PIPE, check=True) run(command, stderr=PIPE, check=True)
Logger.print_ok("Packages installed successfully.") Logger.print_ok("Packages installed successfully.")
except subprocess.CalledProcessError as e: except CalledProcessError as e:
kill(f"Error installing packages:\n{e.stderr.decode()}") kill(f"Error installing packages:\n{e.stderr.decode()}")
@@ -239,8 +241,8 @@ def mask_system_service(service_name: str) -> None:
""" """
try: try:
command = ["sudo", "systemctl", "mask", service_name] command = ["sudo", "systemctl", "mask", service_name]
subprocess.run(command, stderr=subprocess.PIPE, check=True) run(command, stderr=PIPE, check=True)
except subprocess.CalledProcessError as e: except CalledProcessError as e:
log = f"Unable to mask system service {service_name}: {e.stderr.decode()}" log = f"Unable to mask system service {service_name}: {e.stderr.decode()}"
Logger.print_error(log) Logger.print_error(log)
raise raise
@@ -318,12 +320,12 @@ def set_nginx_permissions() -> None:
:return: None :return: None
""" """
cmd = f"ls -ld {Path.home()} | cut -d' ' -f1" cmd = f"ls -ld {Path.home()} | cut -d' ' -f1"
homedir_perm = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, text=True) homedir_perm = run(cmd, shell=True, stdout=PIPE, text=True)
homedir_perm = homedir_perm.stdout homedir_perm = homedir_perm.stdout
if homedir_perm.count("x") < 3: if homedir_perm.count("x") < 3:
Logger.print_status("Granting NGINX the required permissions ...") Logger.print_status("Granting NGINX the required permissions ...")
subprocess.run(["chmod", "og+x", Path.home()]) run(["chmod", "og+x", Path.home()])
Logger.print_ok("Permissions granted.") Logger.print_ok("Permissions granted.")
@@ -339,9 +341,30 @@ def control_systemd_service(
try: try:
Logger.print_status(f"{action.capitalize()} {name}.service ...") Logger.print_status(f"{action.capitalize()} {name}.service ...")
command = ["sudo", "systemctl", action, f"{name}.service"] command = ["sudo", "systemctl", action, f"{name}.service"]
subprocess.run(command, stderr=subprocess.PIPE, check=True) run(command, stderr=PIPE, check=True)
Logger.print_ok("OK!") Logger.print_ok("OK!")
except subprocess.CalledProcessError as e: except CalledProcessError as e:
log = f"Failed to {action} {name}.service: {e.stderr.decode()}" log = f"Failed to {action} {name}.service: {e.stderr.decode()}"
Logger.print_error(log) Logger.print_error(log)
raise raise
def log_process(process: Popen) -> None:
"""
Helper method to print stdout of a process in near realtime to the console.
:param process: Process to log the output from
:return: None
"""
while True:
reads = [process.stdout.fileno()]
ret = select.select(reads, [], [])
for fd in ret[0]:
if fd == process.stdout.fileno():
line = process.stdout.readline()
if line:
print(line.strip(), flush=True)
else:
break
if process.poll() is not None:
break

View File

@@ -83,4 +83,8 @@ function set_globals() {
MOBILERAKER_DIR="${HOME}/mobileraker_companion" MOBILERAKER_DIR="${HOME}/mobileraker_companion"
MOBILERAKER_REPO="https://github.com/Clon1998/mobileraker_companion.git" MOBILERAKER_REPO="https://github.com/Clon1998/mobileraker_companion.git"
#=============== OCTOAPP ================#
OCTOAPP_ENV="${HOME}/octoapp-env"
OCTOAPP_DIR="${HOME}/octoapp"
OCTOAPP_REPO="https://github.com/crysxd/OctoApp-Plugin.git"
} }

369
scripts/octoapp.sh Normal file
View File

@@ -0,0 +1,369 @@
#!/usr/bin/env bash
#=======================================================================#
# Copyright (C) 2020 - 2024 Dominik Willner <th33xitus@gmail.com> #
# #
# This file is part of KIAUH - Klipper Installation And Update Helper #
# https://github.com/dw-0/kiauh #
# #
# This file may be distributed under the terms of the GNU GPLv3 license #
#=======================================================================#
#
# This file is written and maintained by Christian Würthner from OctoApp
# Please contact me if you need any help!
# hello@octoapp.eu
#
set -e
#===================================================#
#============== Install ============#
#===================================================#
function octoapp_systemd() {
local services
services=$(find "${SYSTEMD}" -maxdepth 1 -regextype posix-extended -regex "${SYSTEMD}/octoapp(-[0-9a-zA-Z]+)?.service")
echo "${services}"
}
function octoapp_setup_dialog() {
status_msg "Initializing OctoApp for Klipper installation ..."
# First, check for moonraker service instances.
local moonraker_count
local moonraker_names
moonraker_count=$(moonraker_systemd | wc -w)
if (( moonraker_count == 0 )); then
### return early if moonraker is not installed
local error="Moonraker not installed! Please install Moonraker first!"
log_error "OctoApp setup started without Moonraker being installed. Aborting setup."
print_error "${error}" && return
elif (( moonraker_count > 1 )); then
# moonraker_names is valid only in case of multi-instance
read -r -a moonraker_names <<< "$(get_multi_instance_names)"
fi
# Next, check for any existing OctoApp services.
local octoapp_services
local existing_octoapp_count
octoapp_services=$(octoapp_systemd)
existing_octoapp_count=$(echo "${octoapp_services}" | wc -w )
# We need to make the moonraker instance count to the OctoApp service count.
local allowed_octoapp_count=$(( moonraker_count - existing_octoapp_count ))
if (( allowed_octoapp_count > 0 )); then
local new_octoapp_count
### Step 1: Ask for the number of OctoApp instances to install
if (( moonraker_count == 1 )); then
ok_msg "Moonraker installation found!\n"
new_octoapp_count=1
elif (( moonraker_count > 1 )); then
top_border
printf "|${green}%-55s${white}|\n" " ${moonraker_count} Moonraker instances found!"
for name in "${moonraker_names[@]}"; do
printf "|${cyan}%-57s${white}|\n" " ● moonraker-${name}"
done
blank_line
if (( existing_octoapp_count > 0 )); then
printf "|${green}%-55s${white}|\n" " ${existing_octoapp_count} OctoApp instances already installed!"
for svc in ${octoapp_services}; do
printf "|${cyan}%-57s${white}|\n" " ● octoapp-$(get_instance_name "${svc}")"
done
fi
blank_line
echo -e "| The setup will apply the same names to OctoApp |"
blank_line
echo -e "| Please select the number of OctoApp instances to |"
echo -e "| install. Usually one OctoApp instance per Moonraker |"
echo -e "| instance is required, but you may not install more |"
echo -e "| OctoApp instances than available Moonraker instances. |"
bottom_border
### ask for amount of instances
local re="^[1-9][0-9]*$"
while [[ ! ${new_octoapp_count} =~ ${re} || ${new_octoapp_count} -gt ${allowed_octoapp_count} ]]; do
read -p "${cyan}###### Number of new OctoApp instances to set up:${white} " -i "${allowed_octoapp_count}" -e new_octoapp_count
### break if input is valid
[[ ${new_octoapp_count} =~ ${re} && ${new_octoapp_count} -le ${allowed_octoapp_count} ]] && break
### conditional error messages
[[ ! ${new_octoapp_count} =~ ${re} ]] && error_msg "Input not a number"
(( new_octoapp_count > allowed_octoapp_count )) && error_msg "Number of OctoApp instances larger than installed Moonraker instances"
done && select_msg "${new_octoapp_count}"
else
log_error "Internal error. moonraker_count of '${moonraker_count}' not equal or grater than one!"
return 1
fi # (( moonraker_count == 1 ))
fi # (( allowed_octoapp_count > 0 ))
# Special case for one moonraker instance with OctoApp already installed.
# If the user selects the install option again, they might be trying to recover the install
# or complete a printer link they didn't finish in the past.
# So in this case, we will allow them to run the install script again, since it's safe to run
# if the service is already installed, it will repair any missing issues.
if (( allowed_octoapp_count == 0 && moonraker_count == 1 )); then
local yn
while true; do
echo "${yellow}OctoApp is already installed.${white}"
echo "It is safe to run the install again to repair any issues or if the printer isn't linked, run the printer linking logic again."
echo ""
local question="Do you want to run the OctoApp recovery or linking logic again?"
read -p "${cyan}###### ${question} (Y/n):${white} " yn
case "${yn}" in
Y|y|Yes|yes|"")
select_msg "Yes"
break;;
N|n|No|no)
select_msg "No"
abort_msg "Exiting OctoApp setup ...\n"
return;;
*)
error_msg "Invalid Input!";;
esac
done
# The user responded yes, allow the install to run again.
allowed_octoapp_count=1
fi
# If there's something to install, do it!
if (( allowed_octoapp_count > 0 )); then
(( new_octoapp_count > 1 )) && status_msg "Installing ${new_octoapp_count} OctoApp instances ..."
(( new_octoapp_count == 1 )) && status_msg "Installing OctoApp ..."
# Ensure the basic system dependencies are installed.
local dep=(git dfu-util virtualenv python3 python3-pip python3-venv)
dependency_check "${dep[@]}"
# Close the repo
clone_octoapp "${OCTOAPP_REPO}"
# Call install with the correct args.
local instance_cfg_dirs
read -r -a instance_cfg_dirs <<< "$(get_instance_folder_path "config")"
echo instance_cfg_dirs[0]
if (( moonraker_count == 1 )); then
"${OCTOAPP_DIR}/install.sh" "${instance_cfg_dirs[0]}/moonraker.conf"
elif (( moonraker_count > 1 )); then
local j=${existing_octoapp_count}
for (( i=1; i <= new_octoapp_count; i++ )); do
"${OCTOAPP_DIR}/install.sh" "${instance_cfg_dirs[${j}]}/moonraker.conf"
j=$(( j + 1 ))
done && unset j
fi # (( moonraker_count == 1 ))
fi # (( allowed_octoapp_count > 0 ))
}
function octoapp_install() {
"${OCTOAPP_DIR}/install.sh" "$@"
}
#===================================================#
#============= Remove ==============#
#===================================================#
function remove_octoapp_systemd() {
[[ -z $(octoapp_systemd) ]] && return
status_msg "Removing OctoApp Systemd Services ..."
for service in $(octoapp_systemd | cut -d"/" -f5); do
status_msg "Removing ${service} ..."
sudo systemctl stop "${service}"
sudo systemctl disable "${service}"
sudo rm -f "${SYSTEMD}/${service}"
ok_msg "Done!"
done
### reloading units
sudo systemctl daemon-reload
sudo systemctl reset-failed
ok_msg "OctoApp Services removed!"
}
function remove_octoapp_logs() {
local files regex="${HOME//\//\\/}\/([A-Za-z0-9_]+)\/logs\/octoapp(-[0-9a-zA-Z]+)?\.log(.*)?"
files=$(find "${HOME}" -maxdepth 3 -regextype posix-extended -regex "${regex}" | sort)
if [[ -n ${files} ]]; then
for file in ${files}; do
status_msg "Removing ${file} ..."
rm -f "${file}"
ok_msg "${file} removed!"
done
fi
}
function remove_octoapp_dir() {
[[ ! -d ${OCTOAPP_DIR} ]] && return
status_msg "Removing OctoApp directory ..."
rm -rf "${OCTOAPP_DIR}"
ok_msg "Directory removed!"
}
function remove_octoapp_config() {
# Remove the system config but not the main config, so the printer id doesn't get lost.
local files regex="${HOME//\//\\/}\/([A-Za-z0-9_]+)\/config\/octoapp-system(-[0-9a-zA-Z]+)?\.cfg(.*)?"
files=$(find "${HOME}" -maxdepth 4 -regextype posix-extended -regex "${regex}" | sort)
if [[ -n ${files} ]]; then
for file in ${files}; do
status_msg "Removing ${file} ..."
rm -f "${file}"
ok_msg "${file} removed!"
done
fi
}
function remove_octoapp_store_dir() {
local files regex="${HOME//\//\\/}\/([A-Za-z0-9_]+)\/octoapp-store"
files=$(find "${HOME}" -maxdepth 2 -type d -regextype posix-extended -regex "${regex}" | sort)
if [[ -n ${files} ]]; then
for file in ${files}; do
status_msg "Removing ${file} ..."
rm -rf "${file}"
ok_msg "${file} removed!"
done
fi
}
function remove_octoapp_env() {
[[ ! -d "${HOME}/octoapp-env" ]] && return
status_msg "Removing octoapp-env directory ..."
rm -rf "${HOME}/octoapp-env"
ok_msg "Directory removed!"
}
function remove_octoapp()
{
remove_octoapp_systemd
remove_octoapp_logs
remove_octoapp_dir
remove_octoapp_env
remove_octoapp_config
remove_octoapp_store_dir
print_confirm "OctoApp was successfully removed!"
return
}
#===================================================#
#============= UPDATE ==============#
#===================================================#
function update_octoapp() {
do_action_service "stop" "octoapp"
if [[ ! -d ${OCTOAPP_DIR} ]]; then
clone_octoapp "${OCTOAPP_REPO}"
else
backup_before_update "octoapp"
status_msg "Updating OctoApp for Klipper ..."
cd "${OCTOAPP_DIR}" && git pull
### read PKGLIST and install possible new dependencies
install_octoapp_dependencies
### install possible new python dependencies
"${OCTOAPP_ENV}"/bin/pip install -r "${OCTOAPP_DIR}/requirements.txt"
fi
ok_msg "Update complete!"
do_action_service "restart" "octoapp"
}
function clone_octoapp() {
local repo=${1}
status_msg "Cloning OctoApp from ${repo} ..."
### force remove existing octoapp dir and clone into fresh octoapp dir
[[ -d ${OCTOAPP_DIR} ]] && rm -rf "${OCTOAPP_DIR}"
cd "${HOME}" || exit 1
if ! git clone "${OCTOAPP_REPO}" "${OCTOAPP_DIR}"; then
print_error "Cloning OctoApp from\n ${repo}\n failed!"
exit 1
fi
}
function install_octoapp_dependencies() {
local packages log_name="OctoApp"
local install_script="${OCTOAPP_DIR}/install.sh"
### read PKGLIST from official install-script
status_msg "Reading dependencies..."
# shellcheck disable=SC2016
packages="$(grep "PKGLIST=" "${install_script}" | cut -d'"' -f2 | sed 's/\${PKGLIST}//g' | tr -d '\n')"
echo "${cyan}${packages}${white}" | tr '[:space:]' '\n'
read -r -a packages <<< "${packages}"
### Update system package lists if stale
update_system_package_lists
### Install required packages
install_system_packages "${log_name}" "packages[@]"
}
#===================================================#
#============= STATUS ==============#
#===================================================#
function get_octoapp_status() {
local status
local service_count
local octoapp_services
octoapp_services=$(octoapp_systemd)
service_count=$(echo "${octoapp_services}" | wc -w )
if (( service_count == 0 )); then
status="Not installed!"
elif [[ ! -d "${OCTOAPP_DIR}" ]]; then
status="Incomplete!"
else
status="Installed!"
fi
echo "${status}"
}
function get_local_octoapp_commit() {
[[ ! -d ${OCTOAPP_DIR} || ! -d "${OCTOAPP_DIR}/.git" ]] && return
local commit
cd "${OCTOAPP_DIR}"
commit="$(git describe HEAD --always --tags | cut -d "-" -f 1,2)"
echo "${commit}"
}
function get_remote_octoapp_commit() {
[[ ! -d ${OCTOAPP_DIR} || ! -d "${OCTOAPP_DIR}/.git" ]] && return
local commit
cd "${OCTOAPP_DIR}" && git fetch origin -q
commit=$(git describe origin/release --always --tags | cut -d "-" -f 1,2)
echo "${commit}"
}
function compare_octoapp_versions() {
local versions local_ver remote_ver
local_ver="$(get_local_octoapp_commit)"
remote_ver="$(get_remote_octoapp_commit)"
if [[ ${local_ver} != "${remote_ver}" ]]; then
versions="${yellow}$(printf " %-14s" "${local_ver}")${white}"
versions+="|${green}$(printf " %-13s" "${remote_ver}")${white}"
# Add us to the update file, so if the user selects "update all" it includes us.
add_to_application_updates "octoapp"
else
versions="${green}$(printf " %-14s" "${local_ver}")${white}"
versions+="|${green}$(printf " %-13s" "${remote_ver}")${white}"
fi
echo "${versions}"
}

View File

@@ -28,9 +28,10 @@ function install_ui() {
echo -e "| 4) [Fluidd] | 9) $(obico_install_title) |" echo -e "| 4) [Fluidd] | 9) $(obico_install_title) |"
echo -e "| | 10) [OctoEverywhere] |" echo -e "| | 10) [OctoEverywhere] |"
echo -e "| | 11) [Mobileraker] |" echo -e "| | 11) [Mobileraker] |"
echo -e "| Touchscreen GUI: | |" echo -e "| Touchscreen GUI: | 12) [OctoApp for Klipper] |"
echo -e "| 5) [KlipperScreen] | Webcam Streamer: |" echo -e "| 5) [KlipperScreen] | |"
echo -e "| | 12) [Crowsnest] |" echo -e "| | Webcam Streamer: |"
echo -e "| | 13) [Crowsnest] |"
back_footer back_footer
} }
@@ -72,6 +73,8 @@ function install_menu() {
11) 11)
do_action "install_mobileraker" "install_ui";; do_action "install_mobileraker" "install_ui";;
12) 12)
do_action "octoapp_setup_dialog" "install_ui";;
13)
do_action "install_crowsnest" "install_ui";; do_action "install_crowsnest" "install_ui";;
B|b) B|b)
clear; main_menu; break;; clear; main_menu; break;;

View File

@@ -28,6 +28,7 @@ function main_ui() {
echo -e "| | Obico: $(print_status "moonraker_obico")|" echo -e "| | Obico: $(print_status "moonraker_obico")|"
echo -e "| | OctoEverywhere: $(print_status "octoeverywhere")|" echo -e "| | OctoEverywhere: $(print_status "octoeverywhere")|"
echo -e "| | Mobileraker: $(print_status "mobileraker")|" echo -e "| | Mobileraker: $(print_status "mobileraker")|"
echo -e "| | OctoApp: $(print_status "octoapp")|"
echo -e "| | |" echo -e "| | |"
echo -e "| | Octoprint: $(print_status "octoprint")|" echo -e "| | Octoprint: $(print_status "octoprint")|"
hr hr

View File

@@ -31,6 +31,7 @@ function remove_ui() {
echo -e "| 7) [KlipperScreen] | 14) [OctoEverywhere] |" echo -e "| 7) [KlipperScreen] | 14) [OctoEverywhere] |"
echo -e "| | 15) [Mobileraker] |" echo -e "| | 15) [Mobileraker] |"
echo -e "| | 16) [NGINX] |" echo -e "| | 16) [NGINX] |"
echo -e "| | 17) [OctoApp] |"
back_footer back_footer
} }
@@ -73,6 +74,8 @@ function remove_menu() {
do_action "remove_mobileraker" "remove_ui";; do_action "remove_mobileraker" "remove_ui";;
16) 16)
do_action "remove_nginx" "remove_ui";; do_action "remove_nginx" "remove_ui";;
17)
do_action "remove_octoapp" "remove_ui";;
B|b) B|b)
clear; main_menu; break;; clear; main_menu; break;;
*) *)

View File

@@ -35,8 +35,9 @@ function update_ui() {
echo -e "| 9) [OctoEverywhere] |$(compare_octoeverywhere_versions)|" echo -e "| 9) [OctoEverywhere] |$(compare_octoeverywhere_versions)|"
echo -e "| 10) [Mobileraker] |$(compare_mobileraker_versions)|" echo -e "| 10) [Mobileraker] |$(compare_mobileraker_versions)|"
echo -e "| 11) [Crowsnest] |$(compare_crowsnest_versions)|" echo -e "| 11) [Crowsnest] |$(compare_crowsnest_versions)|"
echo -e "| 12) [OctoApp] |$(compare_octoapp_versions)|"
echo -e "| |------------------------------|" echo -e "| |------------------------------|"
echo -e "| 12) [System] | $(check_system_updates) |" echo -e "| 13) [System] | $(check_system_updates) |"
back_footer back_footer
} }
@@ -73,6 +74,8 @@ function update_menu() {
11) 11)
do_action "update_crowsnest" "update_ui";; do_action "update_crowsnest" "update_ui";;
12) 12)
do_action "update_octoapp" "update_ui";;
13)
do_action "upgrade_system_packages" "update_ui";; do_action "upgrade_system_packages" "update_ui";;
a) a)
do_action "update_all" "update_ui";; do_action "update_all" "update_ui";;
@@ -128,7 +131,10 @@ function update_all() {
echo -e "| ${cyan}● OctoEverywhere${white} |" echo -e "| ${cyan}● OctoEverywhere${white} |"
[[ "${update_arr[*]}" =~ "mobileraker" ]] && \ [[ "${update_arr[*]}" =~ "mobileraker" ]] && \
echo -e "| ${cyan}● Mobileraker${white} |" echo -e "| ${cyan}● Mobileraker${white} |"
[[ "${update_arr[*]}" =~ "octoapp" ]] && \
echo -e "| ${cyan}● OctoApp${white} |"
[[ "${update_arr[*]}" =~ "system" ]] && \ [[ "${update_arr[*]}" =~ "system" ]] && \
echo -e "| ${cyan}● System${white} |" echo -e "| ${cyan}● System${white} |"