From e662daab1c1aa60cb8a53ce39d11391a9e729cbd Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 01:39:54 +1100 Subject: [PATCH 01/18] [FirmwareFlasher] Change text from find to select firmware (#183) * increment version number * change Find to Select (Firmware) --- scripts/firmware.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 8609975..7e6ff19 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -30,7 +30,7 @@ KLIPPER_DIR: str = os.path.expanduser("~/klipper") KATAPULT_DIR: str = os.path.expanduser("~/katapult") -FLASHER_VERSION: str = "0.0.1" +FLASHER_VERSION: str = "0.0.2" PAGE_WIDTH: int = 89 # Default global width @@ -1084,7 +1084,7 @@ def menu(self) -> None: menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = { 1: Menu.Item("Find Cartographer Device", self.device_menu), 2: Menu.Item( - "Find CAN Firmware", + "Select CAN Firmware", lambda: self.firmware.firmware_menu(type=FlashMethod.CAN), ), } @@ -1477,7 +1477,7 @@ def menu(self) -> None: menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = { 1: Menu.Item("Find Cartographer Device", self.query_devices), 2: Menu.Item( - "Find USB Firmware", + "Select USB Firmware", lambda: self.firmware.firmware_menu(type=FlashMethod.USB), ), } @@ -1729,7 +1729,7 @@ def menu(self) -> None: menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = { 1: Menu.Item("Find Cartographer Device", self.query_devices), 2: Menu.Item( - "Find DFU Firmware", + "Select DFU Firmware", lambda: self.firmware.firmware_menu(type=FlashMethod.DFU), ), } From 796be49ced6974025e6da9efd593e98ac18906cc Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 01:47:48 +1100 Subject: [PATCH 02/18] fix -f arg not starting at that specific menu (#184) --- scripts/firmware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 7e6ff19..d3e7a4f 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -2104,7 +2104,7 @@ def install(self) -> None: ## TODO ## ## Adjust so users cannot be in certain modes together Utils.make_terminal_bigger() - if args.all: + if args.all or args.flash and not args.all: if args.flash == FlashMethod.CAN: fw.can.menu() elif args.flash == FlashMethod.USB: From f29aef3d3025b89aeefb1c88c8761f62fd24f15b Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 01:49:49 +1100 Subject: [PATCH 03/18] move dfu flash to advanced menu (#185) --- scripts/firmware.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index d3e7a4f..0151058 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -590,11 +590,6 @@ def main_menu(self) -> None: + Utils.colored_text("[For Flashing via USB]", Color.YELLOW), self.usb.menu, ), - 3: Menu.Item( - "DFU " - + Utils.colored_text("[For Flashing with DFU via USB]", Color.YELLOW), - self.dfu.menu, - ), } # Add advanced or basic options @@ -628,6 +623,10 @@ def add_advanced_options( if is_advanced: # Add advanced options menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item( + Utils.colored_text("Flash via DFU", Color.MAGENTA), self.dfu.menu + ) + menu_items[len(menu_items) + 1] = Menu.Separator() menu_items[len(menu_items) + 1] = Menu.Item( Utils.colored_text("Switch Flash Mode", Color.CYAN), self.mode_menu ) From c007bc79f3ccac4ecc8457969aa693827f9d10a4 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 01:56:43 +1100 Subject: [PATCH 04/18] add restart_klipper function (#186) --- scripts/firmware.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/scripts/firmware.py b/scripts/firmware.py index 0151058..746ede2 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -568,6 +568,19 @@ def set_custom_branch(self): print("No custom branch provided.") self.branch_menu() + def restart_klipper(self): + try: + # Execute the restart command + _ = subprocess.run( + ["sudo", "service", "klipper", "restart"], + check=True, + text=True, + capture_output=True, + ) + Utils.success_msg("Service restarted successfully!") + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Failed to restart the service ({e.stderr})") + # Create main menu def main_menu(self) -> None: # Handle advanced mode and flash settings @@ -993,6 +1006,11 @@ def flash_fail(self, message: str): # Show what to do next screen def finished(self): Utils.header() + _ = input( + "Press any key and you may be asked for your password in order to restart klipper" + + "Please make sure youre not printing when you do this." + ) + self.restart_klipper() class Can: From 877c8e0e0bb7e290fe80b08a3fe52c3fdcd8c05c Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 02:19:06 +1100 Subject: [PATCH 05/18] [FirmwareFlasher] Updates Katapult and Checks Origin (#187) * mandatory katapult update + check prior * split KatapultInstaller into smaller functions --- scripts/firmware.py | 410 +++++++++++++++++++++++--------------------- 1 file changed, 215 insertions(+), 195 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 746ede2..8b3778b 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -1014,20 +1014,20 @@ def finished(self): class Can: - def __init__(self, firmware: Firmware, debug: bool = False, ftype: bool = False): + def __init__( + self, + firmware: Firmware, + debug: bool = False, + ftype: bool = False, + ): self.firmware: Firmware = firmware self.validator: Validator = Validator(firmware) - self.katapult_installer: Optional[KatapultInstaller] = None + self.katapult: KatapultInstaller = KatapultInstaller() self.debug: bool = debug self.ftype: bool = ftype self.selected_device: Optional[str] = None self.selected_firmware: Optional[str] = None - def katapult_check(self) -> bool: - if not os.path.exists(KATAPULT_DIR): - return False - return True - def get_bitrate(self, interface: str = "can0"): try: command = f"ip -s -d link show {interface}" @@ -1155,92 +1155,72 @@ def query_devices(self): Utils.page("Querying CAN devices..") detected_uuids: list[str] = [] - if not self.katapult_check(): - Utils.error_msg( - "The Katapult directory doesn't exist or it is not installed.", - ) - if self.katapult_installer is None: - self.katapult_installer = KatapultInstaller(self.device_menu) - - # Define menu items - menu_item: Dict[int, Union[Menu.Item, Menu.Separator]] = { - 1: Menu.Item("Yes", self.katapult_installer.install), - 2: Menu.Item( - Utils.colored_text("No, Back to CAN menu", Color.CYAN), - self.menu, - ), - 3: Menu.Separator(), # Blank separator - 0: Menu.Item("Exit", lambda: exit()), # Add exit option explicitly - } - - # Create and display the menu - menu = Menu("Would you like to install Katapult?", menu_item) - menu.display() - else: - try: - cmd = os.path.expanduser("~/katapult/scripts/flashtool.py") - command = ["python3", cmd, "-i", "can0", "-q"] + if not self.katapult.install(): + Utils.error_msg("Error with Katapult") + self.menu() + return + try: + cmd = os.path.expanduser("~/katapult/scripts/flashtool.py") + command = ["python3", cmd, "-i", "can0", "-q"] - result = subprocess.run( - command, text=True, capture_output=True, check=True - ) + result = subprocess.run(command, text=True, capture_output=True, check=True) - # Parse and display the output - output = result.stdout.strip() - - if "Query Complete" in output: - if "Detected UUID" in output: - print("Available CAN Devices:") - print("=" * 40) - # Extract and display each detected UUID - for line in output.splitlines(): - if "Detected UUID" in line: - # Strip unnecessary parts and keep only the UUID - uuid = ( - line.split(",")[0] - .replace("Detected UUID: ", "") - .strip() - ) - print(uuid) - detected_uuids.append(uuid) - print("=" * 40) - else: - Utils.error_msg("No CAN devices found.") + # Parse and display the output + output = result.stdout.strip() + + if "Query Complete" in output: + if "Detected UUID" in output: + print("Available CAN Devices:") + print("=" * 40) + # Extract and display each detected UUID + for line in output.splitlines(): + if "Detected UUID" in line: + # Strip unnecessary parts and keep only the UUID + uuid = ( + line.split(",")[0] + .replace("Detected UUID: ", "") + .strip() + ) + print(uuid) + detected_uuids.append(uuid) + print("=" * 40) else: - Utils.error_msg("Unexpected output format.") - self.menu() - - except subprocess.CalledProcessError as e: - Utils.error_msg(f"Error querying CAN devices: {e}") - self.menu() - except Exception as e: - Utils.error_msg(f"Unexpected error: {e}") + Utils.error_msg("No CAN devices found.") + else: + Utils.error_msg("Unexpected output format.") self.menu() - finally: - # Define menu items, starting with UUID options - menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} - for index, uuid in enumerate(detected_uuids, start=1): - menu_items[index] = Menu.Item( - f"Select {uuid}", lambda uuid=uuid: self.select_device(uuid) - ) - menu_items[len(menu_items) + 1] = Menu.Separator() - # Add static options after UUID options - menu_items[len(menu_items) + 1] = Menu.Item( - "Check Again", self.query_devices - ) - menu_items[len(menu_items) + 1] = Menu.Separator() - menu_items[len(menu_items) + 1] = Menu.Item("Back", self.device_menu) - menu_items[len(menu_items) + 1] = Menu.Item( - Utils.colored_text("Back to main menu", Color.CYAN), - self.firmware.main_menu, + + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Error querying CAN devices: {e}") + self.menu() + except Exception as e: + Utils.error_msg(f"Unexpected error: {e}") + self.menu() + finally: + # Define menu items, starting with UUID options + menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} + for index, uuid in enumerate(detected_uuids, start=1): + menu_items[index] = Menu.Item( + f"Select {uuid}", lambda uuid=uuid: self.select_device(uuid) ) - menu_items[len(menu_items) + 1] = Menu.Separator() - # Add the Exit option explicitly - menu_items[0] = Menu.Item("Exit", lambda: exit()) + menu_items[len(menu_items) + 1] = Menu.Separator() + # Add static options after UUID options + menu_items[len(menu_items) + 1] = Menu.Item( + "Check Again", self.query_devices + ) + menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item("Back", self.device_menu) + menu_items[len(menu_items) + 1] = Menu.Item( + Utils.colored_text("Back to main menu", Color.CYAN), + self.firmware.main_menu, + ) + menu_items[len(menu_items) + 1] = Menu.Separator() + # Add the Exit option explicitly + menu_items[0] = Menu.Item("Exit", lambda: exit()) - # Create and display the menu - menu = Menu("Options", menu_items) - menu.display() + # Create and display the menu + menu = Menu("Options", menu_items) + menu.display() # find can uuid from klippy.log def search_klippy(self) -> None: @@ -1329,6 +1309,10 @@ def search_klippy(self) -> None: self.menu() def flash_device(self, firmware_file: str, device: str): + if not self.katapult.install(): + Utils.error_msg("Error with Katapult") + self.menu() + return try: self.validator.check_selected_device() self.validator.check_selected_firmware() @@ -1388,17 +1372,12 @@ class Usb: def __init__(self, firmware: Firmware, debug: bool = False, ftype: bool = False): self.firmware: Firmware = firmware self.validator: Validator = Validator(firmware) - self.katapult_installer: Optional[KatapultInstaller] = None + self.katapult: KatapultInstaller = KatapultInstaller() self.debug: bool = debug self.ftype: bool = ftype self.selected_device: Optional[str] = None self.selected_firmware: Optional[str] = None - def katapult_check(self) -> bool: - if not os.path.exists(KATAPULT_DIR): - return False - return True - def select_device(self, device: str): self.selected_device = device # Save the selected device globally self.firmware.set_device(self.selected_device) @@ -1408,81 +1387,62 @@ def query_devices(self): Utils.header() Utils.page("Querying USB devices..") - if not self.katapult_check(): - Utils.error_msg( - "The Katapult directory doesn't exist or it is not installed.", - ) - if self.katapult_installer is None: - self.katapult_installer = KatapultInstaller(self.menu) - - # Define menu items - menu_item: Dict[int, Union[Menu.Item, Menu.Separator]] = { - 1: Menu.Item("Yes", self.katapult_installer.install), - 2: Menu.Item( - Utils.colored_text("No, Back to USB menu", Color.CYAN), - self.menu, - ), - 3: Menu.Separator(), # Blank separator - 0: Menu.Item("Exit", lambda: exit()), # Add exit option explicitly - } - - # Create and display the menu - menu = Menu("Would you like to install Katapult?", menu_item) - menu.display() - else: - detected_devices: List[str] = [] - try: - # List all devices in /dev/serial/by-id/ - base_path = "/dev/serial/by-id/" - if not os.path.exists(base_path): - Utils.error_msg(f"Path '{base_path}' does not exist.") - self.menu() + if not self.katapult.install(): + Utils.error_msg("Error with Katapult") + self.menu() + return + detected_devices: List[str] = [] + try: + # List all devices in /dev/serial/by-id/ + base_path = "/dev/serial/by-id/" + if not os.path.exists(base_path): + Utils.error_msg(f"Path '{base_path}' does not exist.") + self.menu() - for device in os.listdir(base_path): - if "Cartographer" in device or "katapult" in device: - detected_devices.append(device) + for device in os.listdir(base_path): + if "Cartographer" in device or "katapult" in device: + detected_devices.append(device) - if not detected_devices: - Utils.error_msg( - "No devices containing 'Cartographer' or 'katapult' found." - ) - self.menu() + if not detected_devices: + Utils.error_msg( + "No devices containing 'Cartographer' or 'katapult' found." + ) + self.menu() + return - # Display the detected devices - print("Available Cartographer/Katapult Devices:") - print("=" * PAGE_WIDTH) - for device in detected_devices: - print(device) - print("=" * PAGE_WIDTH) + # Display the detected devices + print("Available Cartographer/Katapult Devices:") + print("=" * PAGE_WIDTH) + for device in detected_devices: + print(device) + print("=" * PAGE_WIDTH) - except Exception as e: - Utils.error_msg(f"Unexpected error while querying devices: {e}") - self.menu() + except Exception as e: + Utils.error_msg(f"Unexpected error while querying devices: {e}") + self.menu() - # Define menu items, starting with detected devices - menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} - for index, device in enumerate(detected_devices, start=1): - menu_items[index] = Menu.Item( - f"Select {device}", lambda device=device: self.select_device(device) - ) - menu_items[len(menu_items) + 1] = Menu.Separator() - # Add static options after the device options - menu_items[len(menu_items) + 1] = Menu.Item( - "Check Again", self.query_devices - ) - menu_items[len(menu_items) + 1] = Menu.Separator() - menu_items[len(menu_items) + 1] = Menu.Item("Back", self.menu) - menu_items[len(menu_items) + 1] = Menu.Item( - Utils.colored_text("Back to main menu", Color.CYAN), - self.firmware.main_menu, + # Define menu items, starting with detected devices + menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} + for index, device in enumerate(detected_devices, start=1): + menu_items[index] = Menu.Item( + f"Select {device}", lambda device=device: self.select_device(device) ) - # Add the Exit option explicitly - menu_items[len(menu_items) + 1] = Menu.Separator() - menu_items[0] = Menu.Item("Exit", lambda: exit()) + menu_items[len(menu_items) + 1] = Menu.Separator() + # Add static options after the device options + menu_items[len(menu_items) + 1] = Menu.Item("Check Again", self.query_devices) + menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item("Back", self.menu) + menu_items[len(menu_items) + 1] = Menu.Item( + Utils.colored_text("Back to main menu", Color.CYAN), + self.firmware.main_menu, + ) + # Add the Exit option explicitly + menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[0] = Menu.Item("Exit", lambda: exit()) - # Create and display the menu - menu = Menu("Options", menu_items) - menu.display() + # Create and display the menu + menu = Menu("Options", menu_items) + menu.display() def menu(self) -> None: Utils.header() @@ -1520,6 +1480,10 @@ def menu(self) -> None: menu.display() def flash_device(self, firmware_file: str, device: str): + if not self.katapult.install(): + Utils.error_msg("Error with Katapult") + self.menu() + return try: # Validate selected device and firmware self.validator.check_selected_device() @@ -1950,49 +1914,105 @@ def main(self): class KatapultInstaller: - def __init__(self, device_menu: Callable[[], None]) -> None: - """ - Initialize the installer with a reference to the device menu callback. + def create_directory(self) -> bool: + if not os.path.exists(KATAPULT_DIR): + try: + os.makedirs(KATAPULT_DIR) + if args.debug: + print("Katapult directory created successfully.") + except OSError as e: + Utils.error_msg(f"Failed to create directory: {e}") + return False + return True - :param device_menu: A callable to return to the device menu. - """ - self.device_menu: Callable[[], None] = device_menu + def clone_repository(self) -> bool: + git_dir = os.path.join(KATAPULT_DIR, ".git") + if not os.path.exists(git_dir): + if args.debug: + print( + "Directory exists but is not a Git repository. Cloning the repository..." + ) + try: + _ = subprocess.run( + [ + "git", + "clone", + "https://github.com/arksine/katapult", + KATAPULT_DIR, + ], + check=True, + ) + if args.debug: + print("Repository cloned successfully.") + return True + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Failed to clone repository: {e}") + return False + return True - def install(self) -> None: - """ - Installs Katapult by cloning the repository to the specified directory. - """ + def verify_repository(self) -> bool: try: - # Check if Katapult is already installed - if os.path.exists(KATAPULT_DIR): - Utils.error_msg( - f"Katapult is already installed at {KATAPULT_DIR}.", - ) - return + result = subprocess.run( + ["git", "-C", KATAPULT_DIR, "config", "--get", "remote.origin.url"], + text=True, + capture_output=True, + check=True, + ) + origin_url = result.stdout.strip() + if origin_url != "https://github.com/arksine/katapult": + Utils.error_msg(f"Unexpected repository URL: {origin_url}") + return False + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Failed to verify repository origin: {e}") + return False + return True - # Command to clone the repository - command = [ - "git", - "clone", - "https://github.com/Arksine/katapult.git", - KATAPULT_DIR, - ] + def check_and_update_repository(self) -> bool: + try: + _ = subprocess.run(["git", "-C", KATAPULT_DIR, "fetch"], check=True) + local_commit = subprocess.run( + ["git", "-C", KATAPULT_DIR, "rev-parse", "HEAD"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + remote_commit = subprocess.run( + ["git", "-C", KATAPULT_DIR, "rev-parse", "origin/master"], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + + if local_commit != remote_commit: + if args.debug: + print("The repository is not up to date. Updating...") + _ = subprocess.run(["git", "-C", KATAPULT_DIR, "pull"], check=True) + if args.debug: + print("Repository updated successfully.") + else: + if args.debug: + print("The repository is up to date.") + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Git update failed: {e}") + return False + return True - print("Cloning the Katapult repository...") - _ = subprocess.run(command, check=True, text=True) + def install(self) -> bool: + if not self.create_directory(): + return False - Utils.success_msg( - f"Katapult has been successfully installed in {KATAPULT_DIR}." - ) + if not self.clone_repository(): + return False - except subprocess.CalledProcessError as e: - Utils.error_msg(f"Error cloning Katapult repository: {e}") + if not self.verify_repository(): + return False - except Exception as e: - Utils.error_msg(f"Unexpected error: {e}") + if not self.check_and_update_repository(): + return False - finally: - self.device_menu() + if args.debug: + print("Katapult check passed.") + return True class DfuInstaller: From 6ebc0790e418e1bedb0f6cf8efe958aacb20ab93 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 10 Dec 2024 02:20:58 +1100 Subject: [PATCH 06/18] remove firmware.sh (#181) --- firmware.sh | 1077 --------------------------------------------------- 1 file changed, 1077 deletions(-) delete mode 100755 firmware.sh diff --git a/firmware.sh b/firmware.sh deleted file mode 100755 index 867b5be..0000000 --- a/firmware.sh +++ /dev/null @@ -1,1077 +0,0 @@ -#!/bin/bash - -SCRIPT_VERSION="v1.2.1" - -while getopts s:t:f:b:ht flag; do - case "${flag}" in - s) switch=${OPTARG} ;; # Argument for -s - t) ftype=${OPTARG} ;; # Argument for -t - f) flash=${OPTARG} ;; # Argument for -f - b) branch=${OPTARG} ;; # Argument for -b - h) hightemp=true ;; # Set hightemp to true if -h flag is present - t) # Handle 't' as required, though there may be a conflict with `ht` as written - ;; - *) - echo "Invalid option: -$flag" >&2 - exit 1 - ;; - esac -done -# Define repository URLs -CARTOGRAPHER_KLIPPER_REPO="https://github.com/Cartographer3D/cartographer-klipper.git" -KATAPULT_REPO="https://github.com/Arksine/katapult.git" - -if [[ -n $branch ]]; then - TARBALL_URL="https://api.github.com/repos/Cartographer3D/cartographer-klipper/tarball/$branch" -else - TARBALL_URL="https://api.github.com/repos/Cartographer3D/cartographer-klipper/tarball/master" -fi -TEMP_DIR="/tmp/cartographer-klipper" - -KATAPULT_DIR="$HOME/katapult" - -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[1;36m' -NC='\033[0m' # No Color -### Written by KrauTech (https://github.com/krautech) - -### Written for Cartographer3D - -### Credit to Esoterical (https://github.com/Esoterical) -### I used inspiration and snippet from his debugging script -### Thanks - -if systemctl is-active --quiet "klipper.service"; then - result=$(curl 127.0.0.1:7125/printer/objects/query?print_stats) - if grep -q "'state': 'printing'" <<<$result; then - echo "Printer is NOT IDLE. Please stop or finish whatever youre doing before running this script." - exit - else - if grep -q "'state': 'paused'" <<<$result; then - echo "Printer is NOT IDLE. Please stop or finish whatever youre doing before running this script." - exit - fi - fi -else - sudo service klipper stop -fi -## -# Color Variables -## -red='\r\033[31m' -green='\r\033[32m' -blue='\r\033[1;36m' -yellow='\r\033[1;33m' -clear='\e[0m' -## -# Color Functions -## -ColorRed() { - echo -ne $red$1$clear -} -ColorGreen() { - echo -ne $green$1$clear -} -ColorBlue() { - echo -ne $blue$1$clear -} -ColorYellow() { - echo -ne $yellow$1$clear -} -header() { - clear - printf "${BLUE} - ____ _ _ - / ___| __ _ _ __ | |_ ___ __ _ _ __ __ _ _ __ | |__ ___ _ __ - | | / _ | | '__| | __| / _ \ / _ | | '__| / _ | | '_ \ | '_ \ / _ \ | '__| - | |___ | (_| | | | | |_ | (_) | | (_| | | | | (_| | | |_) | | | | | | __/ | | - \____| \__,_| |_| \__| \___/ \__, | |_| \__,_| | .__/ |_| |_| \___| |_| - |___/ |_| -${NC}" - printf "${RED}Firmware Script ${NC} ${SCRIPT_VERSION}\n" - printf "Created by ${GREEN}KrauTech${NC} ${BLUE}(https://github.com/krautech)${NC}\n" - echo - if [[ $hightemp ]]; then - printf "${BLUE}Flashing High Temp Firmware${NC}\n" - fi - printf "${RED}###################################################################################${NC}\n" - #echo $switch - #echo $ftype -} -header -saved_uuid="" -queryID="" - -disclaimer() { - # Show Disclaimer FUNCTION - echo "******************************************************************" - echo "* Attention *" - echo "******************************************************************" - echo - echo "This script is designed to update your firmware via Katapult/DFU/USB" - echo "" - printf "${RED}USE AT YOUR OWN RISK${NC}" - echo "" - echo "This script is available for review at: " - printf "${BLUE}https://github.com/krautech/scripts/blob/main/cartographer/scripts/release/firmware.sh${NC}\n\n" - echo - - while true; do - read -p "Do you wish to run this program? (yes/no) " yn /dev/null 2>&1 - canCheck=$(ip -s -d link | grep "can0") - if [[ $canCheck != "" ]]; then - findUUID=$(grep -E "\[scanner\]" ~/printer_data/logs/klippy.log -A 3 | grep uuid | tail -1 | awk '{print $3}') - if [[ $findUUID == "" ]]; then - findUUID=$(grep -E "\[cartographer\]" ~/printer_data/logs/klippy.log -A 3 | grep uuid | tail -1 | awk '{print $3}') - if [[ $findUUID != "" ]]; then - checkuuid=$(python3 ~/katapult/scripts/flashtool.py -i can0 -u $findUUID -r | grep -s "Flash Success") - sleep 5 - fi - else - checkuuid=$(python3 ~/katapult/scripts/flashtool.py -i can0 -u $findUUID -r | grep -s "Flash Success") - sleep 5 - fi - # Check for canboot device - canbootCheck=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -v 'Klipper$' | grep -v 'Total.*uuids found' | grep 'CanBoot') - if [[ $canbootCheck != "" ]]; then - # Save CanBoot Device UUID - canbootID=$( - ~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -v 'Klipper$' | grep -v 'Total.*uuids found' | grep 'CanBoot' | awk -F'canbus_uuid=' '{print $2}' | awk -F', ' '{print $1}' - ) - found=1 - fi - # Check for Canbus Katapult device - katapultCheck=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -v 'Klipper$' | grep -v 'Total.*uuids found' | grep 'Katapult') - if [[ $katapultCheck != "" ]]; then - # Save Katapult Device UUID - katapultID=$( - ~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -v 'Klipper$' | grep -v 'Total.*uuids found' | grep 'Katapult' | awk -F'canbus_uuid=' '{print $2}' | awk -F', ' '{print $1}' - ) - found=1 - fi - fi - fi - fi -} - -installPre() { - # Installs all needed files FUNCTION - check_git_installed - check_dfu_util_installed - check_katapult -} -# Function to check if Git is installed -check_git_installed() { - if command -v git >/dev/null 2>&1; then - echo "Git is already installed. Proceeding..." - else - echo "Git is not installed. Installing Git..." - install_git - fi -} -# Function to check if dfu-util is installed -check_dfu_util_installed() { - if command -v dfu-util >/dev/null 2>&1; then - echo "dfu-util is already installed. Proceeding..." - else - echo "dfu-util is not installed. Installing dfu-util..." - install_dfu_util - fi -} -# Function to check if a git repository is already pulled -check_repo_pulled() { - local repo_dir=$1 - local repo_url=$2 - - if [ -d "$repo_dir/.git" ]; then - echo "Repository already exists at $repo_dir. Proceeding..." - else - echo "Repository not found at $repo_dir. Pulling from $repo_url..." - git clone "$repo_url" "$repo_dir" - if [ $? -eq 0 ]; then - echo "Repository cloned successfully." - else - echo "Failed to clone the repository. Please check your internet connection or repo URL." - exit 1 - fi - fi -} - -# Check if katapult is pulled -check_katapult() { - check_repo_pulled "$KATAPULT_DIR" "$KATAPULT_REPO" -} -# Function to install Git -install_git() { - if [ -x "$(command -v apt)" ]; then - sudo apt update - sudo apt install git -y - elif [ -x "$(command -v yum)" ]; then - sudo yum install git -y - elif [ -x "$(command -v dnf)" ]; then - sudo dnf install git -y - elif [ -x "$(command -v pacman)" ]; then - sudo pacman -S git --noconfirm - else - echo "Package manager not supported. Please install Git manually." - exit 1 - fi - echo "Git installed successfully." -} -# Function to install dfu-util -install_dfu_util() { - if [ -x "$(command -v apt)" ]; then - sudo apt update - sudo apt install dfu-util -y - elif [ -x "$(command -v yum)" ]; then - sudo yum install dfu-util -y - elif [ -x "$(command -v dnf)" ]; then - sudo dnf install dfu-util -y - elif [ -x "$(command -v pacman)" ]; then - sudo pacman -S dfu-util --noconfirm - else - echo "Package manager not supported. Please install dfu-util manually." - exit 1 - fi - echo "dfu-util installed successfully." -} -uuidLookup() { - header - canCheck=$(ip -s -d link | grep "can0") - if [[ $canCheck != "" ]]; then - python3 ~/katapult/scripts/flashtool.py -q | grep -v 'Klipper$' - else - echo "CANBUS is not configured on this host" - fi - read -p "Press enter to go back" - -} -checkUUID() { - # Checks Users UUID and Put Device into Katapult Mode - header - canCheck=$(ip -s -d link | grep "can0") - if [[ $canCheck != "" ]]; then - echo "This is only needed if youre using CANBUS" - echo - echo "Please enter your cartographer UUID" - echo "found usually in your printer.cfg under [cartographer] or [scanner]" - echo - echo "To go back: b" - echo - echo -n "UUID: " - read -p "" -e uuid - - # If user entered a valid UUID - if ! [[ $uuid == "b" ]]; then - cd ~/katapult - git pull >/dev/null 2>&1 - # Check If UUID is valid and puts device into Katapult Mode - check2uuid=$(python3 ~/katapult/scripts/flashtool.py -i can0 -u $uuid -r | grep -s "Flash Success") - sleep 5 - if [[ $check2uuid == "Flash Success" ]]; then - # Check for canboot device - canboot2Check=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -m 1 "CanBoot") - sleep 5 - if [[ $canboot2Check != "" ]]; then - # Save CanBoot Device UUID - canboot2ID=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -m 1 -oP "canbus_uuid=\K.*" | sed -e 's/, Application: CanBoot//g') - found=1 - findqueryUUID=$uuid - queryID=$canboot2ID - if [[ $queryID == *"Klipper"* ]]; then - queryID="" - fi - fi - # Check for Canbus Katapult device - katapult2Check=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -m 1 "Katapult") - sleep 5 - if [[ $katapult2Check != "" ]]; then - # Save Katapult Device UUID - katapult2ID=$(~/klippy-env/bin/python ~/klipper/scripts/canbus_query.py can0 | grep -m 1 -oP "canbus_uuid=\K.*" | sed -e 's/, Application: Katapult//g') - found=1 - findqueryUUID=$uuid - queryID=$katapult2ID - if [[ $queryID == *"Klipper"* ]]; then - queryID="" - fi - fi - if [[ $found == 1 ]]; then - printf "${uuid} UUID Check: ${GREEN}Success & Entered Katapult Mode${NC}\n" - read -p "Press enter to check for flashable device" - uuid="" - initialChecks - ##echo "DEBUG CHECK UUID:"$checkuuid - else - echo "UUID Check Failed: Device not found." - read -p "Press enter to go back" - uuid="" - fi - else - echo "UUID Check Failed, Device couldnt enter Katapult mode." - read -p "Press enter to go back" - uuid="" - fi - fi - else - echo "CANBUS is not configured on this host device." - read -p "Press enter to go back" - fi -} -whichFlavor() { - if [[ $ftype == "katapult" ]]; then - flashFirmware 2 $1 - else - flashFirmware 1 $1 - # # Tap vs non tap - # header; - # echo "Unsure what to do? Ask us on discord (https://discord.gg/yzazQMEGS2)" - # echo - # echo -ne "\n - # $(ColorGreen '1)') 5.0.0" - # echo -ne "\n - # $(ColorRed '6)') Back" - # echo -ne "\n - # $(ColorBlue 'Choose an option:') " - # read a - # COLUMNS=12 - # case $a in - # 1) flashFirmware 1 $1; menu ;; - # 2) flashFirmware 2 $1; menu ;; - # 6) menu ;; - # *) echo -e $red"Wrong option."$clear;; - # esac - fi -} -########################### -# Helper function to sort firmware files -sort_firmware_files() { - awk ' - BEGIN { - order["1m"]=1; - order["500k"]=2; - order["250k"]=3; - order["usb"]=4; - } - { - for (key in order) { - if (index($0, key)) { - print order[key] ":" $0; - next; - } - } - print 5 ":" $0; - } - ' | sort -t: -k1,1n | cut -d: -f2 -} - -flashFirmware() { - # List Firmware for Found Device FUNCTION - header - options=() - echo "Pick which firmware you want to install, if unsure ask on discord (https://discord.gg/yzazQMEGS2)" - echo - # Check if canbootID or katapultID are set - if [[ -n $canbootID || -n $katapultID ]] && [[ $2 == 1 ]]; then - # Retrieve CANBus bitrate - bitrate=$(ip -s -d link show can0 | grep -oP 'bitrate\s\K\w+') - printf "Your Host CANBus is configured at ${RED}Bitrate: $bitrate${NC}\n" - printf "${BLUE}Flashing ${canbootID}${katapultID} via ${GREEN}CANBUS - KATAPULT${NC}\n\n" - - # Update repository - cd "$CARTOGRAPHER_KLIPPER_DIR" || exit - - # Determine flashing type - if [[ $ftype == "katapult" ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/katapult-deployer/" || exit - - # Set search parameters based on switch - if [[ $switch == "canbus" ]]; then - exclude_pattern="*usb*" - elif [[ $switch == "usb" ]]; then - include_pattern="*usb*" - else - include_pattern="*" - fi - - # Find and sort firmware files - mapfile -t options < <( - find . -maxdepth 1 -type f ! -name "*.txt" \( \ - ${include_pattern:+-name "$include_pattern"} \ - ${exclude_pattern:+! -name "$exclude_pattern"} \ - \) -printf "%f\n" | sort_firmware_files - ) - - else - # Set directory based on parameter - if [[ $1 == 1 ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/survey" || exit - else - cd "$CARTOGRAPHER_KLIPPER_DIR/cartographer-klipper/firmware/v2-v3/" || exit - fi - - # Set search pattern based on bitrate - search_pattern="*${bitrate}*" - - # Find and sort firmware files - # mapfile -t options < <( - # find . -maxdepth 1 -type f ! -name "*.md" -name "$search_pattern" -printf "%f\n" | sort_firmware_files - # ) - - archive_dir="$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/survey" # Corrected path - if [[ -d $archive_dir ]]; then - # Get the folder names sorted from largest to smallest version - folders=($(ls -d "$archive_dir"/*/ | sort -rV)) - - for folder in "${folders[@]}"; do - if [[ -d "$folder" ]]; then - folder_name=$(basename "$folder") - - # Check if $hightemp is true - if [[ $hightemp ]]; then - # Process only if "HT" subdirectory exists - if [[ -d "$folder/HT" ]]; then - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/HT/$(basename "$file")") - fi - done - fi - else - # Process all folders regardless of "HT" subdirectory - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/$(basename "$file")") - fi - done - fi - fi - done - fi - fi - - # Add "Back" option - options+=("Back") - - # Display select menu - COLUMNS=12 - PS3="Please select a firmware to flash: " - select opt in "${options[@]}"; do - case $opt in - *.bin) - flashing "$opt" "$1" "klippy" - break - ;; - "Back") - menu - break - ;; - *) - echo "Invalid selection. Please try again." - ;; - esac - done - fi - options=() - if [[ -n $queryID ]] && [[ $2 == 4 ]] && [[ $usbID == "" ]]; then - # Retrieve CANBus bitrate - bitrate=$(ip -s -d link show can0 | grep -oP 'bitrate\s\K\w+') - printf "Your Host CANBus is configured at ${RED}Bitrate: $bitrate${NC}\n" - printf "${BLUE}Flashing ${queryID} via ${GREEN}CANBUS - KATAPULT${NC}\n\n" - - # Update repository - cd "$CARTOGRAPHER_KLIPPER_DIR" || exit - - # Determine flashing type - if [[ $ftype == "katapult" ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/katapult-deployer" || exit - - # Set search parameters based on switch - if [[ $switch == "canbus" ]]; then - exclude_pattern="*usb*" - elif [[ $switch == "usb" ]]; then - include_pattern="*usb*" - else - include_pattern="*" - fi - - # Find and sort firmware files - mapfile -t options < <( - find . -maxdepth 1 -type f ! -name "*.txt" \( \ - ${include_pattern:+-name "$include_pattern"} \ - ${exclude_pattern:+! -name "$exclude_pattern"} \ - \) -printf "%f\n" | sort_firmware_files - ) - - else - # Set directory based on parameter - if [[ $1 == 1 ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/survey" || exit - else - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3" || exit - fi - - # Set search pattern based on bitrate - search_pattern="*${bitrate}*" - - # # Find and sort firmware files - # mapfile -t options < <( - # find . -maxdepth 1 -type f ! -name "*.md" -name "$search_pattern" -printf "%f\n" | sort_firmware_files - # ) - - archive_dir="$CARTOGRAPHER_KLIPPER_DIR/cartographer-klipper/firmware/v2-v3/survey" # Corrected path - if [[ -d $archive_dir ]]; then - # Get the folder names sorted from largest to smallest version - folders=($(ls -d "$archive_dir"/*/ | sort -rV)) - - for folder in "${folders[@]}"; do - if [[ -d "$folder" ]]; then - folder_name=$(basename "$folder") - - # Check if $hightemp is true - if [[ $hightemp ]]; then - # Process only if "HT" subdirectory exists - if [[ -d "$folder/HT" ]]; then - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/HT/$(basename "$file")") - fi - done - fi - else - # Process all folders regardless of "HT" subdirectory - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/$(basename "$file")") - fi - done - fi - fi - done - fi - fi - - # Add "Back" option - options+=("Back") - - # Display select menu - COLUMNS=12 - PS3="Please select a firmware to flash: " - select opt in "${options[@]}"; do - case $opt in - *.bin) - flashing "$opt" "$1" "query" - break - ;; - "Back") - menu - break - ;; - *) - echo "Invalid selection. Please try again." - ;; - esac - done - fi - options=() - # If found device is DFU - if [[ $dfuID != "" ]] && [[ $2 == 2 ]]; then - printf "${BLUE}Flashing via ${GREEN}DFU${NC}\n\n" - search_pattern="Full_Survey_*" - - archive_dir="$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/combined-firmware" # Corrected path - if [[ -d $archive_dir ]]; then - # Get the folder names sorted from largest to smallest version - folders=($(ls -d "$archive_dir"/*/ | sort -rV)) - - for folder in "${folders[@]}"; do - if [[ -d "$folder" ]]; then - folder_name=$(basename "$folder") - - # Check if $hightemp is true - if [[ $hightemp ]]; then - # Process only if "HT" subdirectory exists - if [[ -d "$folder/HT" ]]; then - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/HT/$(basename "$file")") - fi - done - fi - else - # Process all folders regardless of "HT" subdirectory - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/$(basename "$file")") - fi - done - fi - fi - done - fi - - # Add "Back" option - options+=("Back") - - #done < <(find $DIRECTORY -maxdepth 1 -type f \( -name 'katapult_and_carto_can_1m_beta.bin' \) -print0) - COLUMNS=12 - PS3="Please select a firmware to flash: " - select opt in "${options[@]}"; do - case $opt in - *.bin) - flashing "$opt" "$1" "dfu" - break - ;; - "Back") - menu - break - ;; - *) - echo "Invalid selection. Please try again." - ;; - esac - done - fi - options=() - # If found device is USB - if [[ -n $usbID ]] && [[ $2 == 3 ]]; then - printf "${BLUE}Flashing via ${GREEN}USB - KATAPULT${NC}\n\n" - - # Update repository - - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/katapult-deployer" || exit - - if [[ $ftype == "katapult" ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/katapult-deployer" || exit - - # Set search parameters based on switch - if [[ $switch == "canbus" ]]; then - exclude_pattern="*USB*" - elif [[ $switch == "usb" ]]; then - include_pattern="*USB*" - else - include_pattern="*" - fi - - # Find and sort firmware files - mapfile -t options < <( - find . -maxdepth 1 -type f ! -name "*.txt" \( \ - ${include_pattern:+-name "$include_pattern"} \ - ${exclude_pattern:+! -name "$exclude_pattern"} \ - \) -printf "%f\n" | sort_firmware_files - ) - - else - # Set directory based on parameter - if [[ $1 == 1 ]]; then - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/survey" || exit - else - cd "$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/" || exit - fi - - # Set search pattern based on USB - search_pattern="*USB*" - - # Find and sort firmware files - # mapfile -t options < <( - # find . -maxdepth 1 -type f ! -name "*.md" -name "$search_pattern" -printf "%f\n" | sort_firmware_files - # ) - - archive_dir="$CARTOGRAPHER_KLIPPER_DIR/firmware/v2-v3/survey" # Corrected path - if [[ -d $archive_dir ]]; then - # Get the folder names sorted from largest to smallest version - folders=($(ls -d "$archive_dir"/*/ | sort -rV)) - - for folder in "${folders[@]}"; do - if [[ -d "$folder" ]]; then - folder_name=$(basename "$folder") - - # Check if $hightemp is true - if [[ $hightemp ]]; then - # Process only if "HT" subdirectory exists - if [[ -d "$folder/HT" ]]; then - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/HT/$(basename "$file")") - fi - done - fi - else - # Process all folders regardless of "HT" subdirectory - for file in "$folder"/$search_pattern; do - if [[ -f "$file" ]]; then - options+=("${folder_name}/$(basename "$file")") - fi - done - fi - fi - done - fi - fi - - # Add "Back" option - options+=("Back") - - # Display select menu - COLUMNS=12 - PS3="Please select a firmware to flash: " - select opt in "${options[@]}"; do - case $opt in - *.bin) - flashing "$opt" "$1" "usb" - break - ;; - "Back") - menu - break - ;; - *) - echo "Invalid selection. Please try again." - ;; - esac - done - fi - read -p "Press enter to continue" -} - -flashing() { - # Flash Device FUNCTION - header - survey=$2 - firmwareFile=$(echo "$1" | sed 's|^./||') - folder_name=$(echo "$firmwareFile" | cut -d'/' -f1) - reference_version="5.0.0" - # Compare the folder name (version) to the reference version - if [[ $(printf "%s\n" "$folder_name" "$reference_version" | sort -V | head -n 1) != "$folder_name" ]]; then - echo - echo - printf "${RED}WARNING:${NC} For Firmware Newer Than 5.0.0\n" - printf "${GREEN}channel: stable${NC} needs to be set under ${BLUE}[update_manager cartographer]${NC} in your ${BLUE}moonraker.conf${NC}" - echo - echo "Please also, make sure you recalibrate." - echo - printf "Changelogs are available at ${BLUE}https://github.com/Cartographer3D/cartographer-klipper/releases${NC}\n\n" - printf "${RED}###################################################################################${NC}\n" - while true; do - read -p "Do you wish to continue flashing? (yes/no) " yn Date: Wed, 11 Dec 2024 00:55:31 +1100 Subject: [PATCH 07/18] add dfu firmware 5.1.0 (#189) --- .../Full_Survey_Cartographer_CAN_1M_5_1_0.bin | Bin 0 -> 29980 bytes ...Survey_Cartographer_CrealityK1_USB_5_1_0.bin | Bin 0 -> 30624 bytes .../Full_Survey_Cartographer_USB_5_1_0.bin | Bin 0 -> 30624 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CAN_1M_5_1_0.bin create mode 100644 firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CrealityK1_USB_5_1_0.bin create mode 100644 firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_USB_5_1_0.bin diff --git a/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CAN_1M_5_1_0.bin b/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CAN_1M_5_1_0.bin new file mode 100644 index 0000000000000000000000000000000000000000..6d6a762748a7e5ffcffd3036c4f669467a42a4ef GIT binary patch literal 29980 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≦#_fIoC&hh-URpFVx@*aS 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 TSCIP^M_^A12qMHeivj-&F~&Y4 literal 0 HcmV?d00001 diff --git a/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CrealityK1_USB_5_1_0.bin b/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_CrealityK1_USB_5_1_0.bin new file mode 100644 index 0000000000000000000000000000000000000000..b2cf07f61457ef5c58aa363c8ea6de244adbfe5a GIT binary patch literal 30624 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 literal 0 HcmV?d00001 diff --git a/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_USB_5_1_0.bin b/firmware/v2-v3/combined-firmware/5.1.0/Full_Survey_Cartographer_USB_5_1_0.bin new file mode 100644 index 0000000000000000000000000000000000000000..5d0a5889f863951278ca1d41755002d3cea202ed GIT binary patch literal 30624 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 literal 0 HcmV?d00001 From 731fc3e444938bd7e68fcfbdd23d05ae38f22c74 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Wed, 11 Dec 2024 22:10:57 +1100 Subject: [PATCH 08/18] change menu calls to returns (#193) --- scripts/firmware.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 8b3778b..3d8e018 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -1188,14 +1188,14 @@ def query_devices(self): Utils.error_msg("No CAN devices found.") else: Utils.error_msg("Unexpected output format.") - self.menu() + return except subprocess.CalledProcessError as e: Utils.error_msg(f"Error querying CAN devices: {e}") - self.menu() + return except Exception as e: Utils.error_msg(f"Unexpected error: {e}") - self.menu() + return finally: # Define menu items, starting with UUID options menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} @@ -1389,7 +1389,6 @@ def query_devices(self): if not self.katapult.install(): Utils.error_msg("Error with Katapult") - self.menu() return detected_devices: List[str] = [] try: @@ -1397,7 +1396,7 @@ def query_devices(self): base_path = "/dev/serial/by-id/" if not os.path.exists(base_path): Utils.error_msg(f"Path '{base_path}' does not exist.") - self.menu() + return for device in os.listdir(base_path): if "Cartographer" in device or "katapult" in device: @@ -1407,7 +1406,6 @@ def query_devices(self): Utils.error_msg( "No devices containing 'Cartographer' or 'katapult' found." ) - self.menu() return # Display the detected devices @@ -1419,7 +1417,7 @@ def query_devices(self): except Exception as e: Utils.error_msg(f"Unexpected error while querying devices: {e}") - self.menu() + return # Define menu items, starting with detected devices menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} From 3f646cc55d7f2397041931e9c2638ee477d7d2d8 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Wed, 11 Dec 2024 22:44:03 +1100 Subject: [PATCH 09/18] [FirmwareFlasher] Adds logging (#191) * adds logging * remove clear handlers * fix color to error message * removed logging_debug * adds INFO only filter --- scripts/firmware.py | 209 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 163 insertions(+), 46 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 3d8e018..06e231c 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -9,6 +9,8 @@ import fnmatch import platform import time +import logging +from logging.handlers import RotatingFileHandler from enum import Enum from time import sleep @@ -102,6 +104,46 @@ class FirmwareFile(NamedTuple): class Utils: + @staticmethod + def configure_logging(): + # Get the root logger + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) # Capture all logs (DEBUG and above) + + # Create a console handler (only active for INFO level messages) + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) # Set to DEBUG to allow filtering + + # Add a custom filter to only allow INFO messages + class InfoOnlyFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return record.levelno == logging.INFO + + console_handler.addFilter(InfoOnlyFilter()) # Apply the filter + + console_formatter = logging.Formatter( + "%(message)s" + ) # Simple format for console + console_handler.setFormatter(console_formatter) + + # Add console handler to the logger + logger.addHandler(console_handler) + + # Create a rotating file handler (always active) + file_handler = RotatingFileHandler( + "firmware.log", + maxBytes=5 * 1024 * 1024, + backupCount=3, # 5 MB per log + ) + file_handler.setLevel(logging.DEBUG) # Log everything to the file + file_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" + ) # Detailed format for file + file_handler.setFormatter(file_formatter) + + # Add file handler to the logger + logger.addHandler(file_handler) + @staticmethod def make_terminal_bigger(width: int = 110, height: int = 40): system = platform.system() @@ -163,12 +205,16 @@ def colored_text(text: str, color: Color) -> str: @staticmethod def error_msg(message: str) -> None: - print(Utils.colored_text("Error:", Color.RED), message) + colored_message = Utils.colored_text(f"Error: {message}", Color.RED) + logging.error(message) + print(colored_message) _ = input(Utils.colored_text("\nPress Enter to continue...", Color.YELLOW)) @staticmethod def success_msg(message: str) -> None: - print(Utils.colored_text("Success:", Color.GREEN), message) + colored_message = Utils.colored_text(f"Success: {message}", Color.GREEN) + logging.debug(message) # Log the colored message + print(colored_message) _ = input(Utils.colored_text("\nPress Enter to continue...", Color.YELLOW)) @staticmethod @@ -381,9 +427,11 @@ def __init__( self.validator: Validator = Validator(self) # Initialize the Validator def set_device(self, device: str): + logging.debug(f"Device Set: {device}") self.selected_device = device def set_firmware(self, firmware: str): + logging.debug(f"Firmware Set: {firmware}") self.selected_firmware = firmware def get_device(self) -> Optional[str]: @@ -427,7 +475,7 @@ def find_firmware_files( high_temp: bool = False, ) -> List[FirmwareFile]: if not os.path.isdir(base_dir): - print(f"Base directory does not exist: {base_dir}") + logging.info(f"Base directory does not exist: {base_dir}") return [] firmware_files: List[FirmwareFile] = [] @@ -469,6 +517,8 @@ def find_firmware_files( firmware_files.append( FirmwareFile(subdirectory=subdirectory, filename=file) ) + logging_msg = f"Firmware Found: {subdirectory}/{file}" + logging.debug(logging_msg) return sorted( firmware_files, key=lambda f: f.subdirectory @@ -476,13 +526,13 @@ def find_firmware_files( def select_latest(self, firmware_files: List[FirmwareFile], type: FlashMethod): if not firmware_files: - print("No firmware files found.") + logging.info("No firmware files found.") return # Extract unique subdirectory names subdirectories: Set[str] = {file[0] for file in firmware_files} if not subdirectories: - print("No valid subdirectories found.") + logging.info("No valid subdirectories found.") return latest_subdirectory: str = max( @@ -505,7 +555,7 @@ def select_latest(self, firmware_files: List[FirmwareFile], type: FlashMethod): self.select_firmware(firmware_path, type) self.main_menu() else: - print("No firmware files found in the latest subdirectory.") + logging.info("No firmware files found in the latest subdirectory.") def set_advanced(self): global is_advanced @@ -554,6 +604,7 @@ def set_mode(self, mode: str): def set_branch(self, branch: str): if branch: + logging.debug(f"Branch Changed to : {branch}") self.branch = args.branch = branch else: Utils.error_msg("You didnt specify a branch to use.") @@ -565,7 +616,7 @@ def set_custom_branch(self): if custom_branch: self.set_branch(custom_branch) else: - print("No custom branch provided.") + logging.info("No custom branch provided.") self.branch_menu() def restart_klipper(self): @@ -840,7 +891,7 @@ def display_firmware_menu( menu = Menu("Select Firmware", menu_items) menu.display() else: - print("No firmware files found.") + logging.info("No firmware files found.") def select_firmware(self, firmware: str, type: FlashMethod): self.set_firmware(firmware) @@ -927,6 +978,8 @@ def confirm(self, type: FlashMethod): self.validator.check_selected_device() # Display selected firmware and device + logging.debug(f"Device to Flash: {self.selected_device}") + logging.debug(f"Firmware to Flash: {self.selected_firmware}") print( Utils.colored_text("Device to Flash:", Color.MAGENTA), self.selected_device @@ -1036,6 +1089,7 @@ def get_bitrate(self, interface: str = "can0"): ).read() # Use subprocess for better control in production bitrate_match = re.search(r"bitrate\s(\d+)", result) if bitrate_match: + logging.debug(f"Bitrate: {bitrate_match.group(1)}") return bitrate_match.group(1) else: return None @@ -1181,7 +1235,7 @@ def query_devices(self): .replace("Detected UUID: ", "") .strip() ) - print(uuid) + logging.info(uuid) detected_uuids.append(uuid) print("=" * 40) else: @@ -1339,7 +1393,9 @@ def flash_device(self, firmware_file: str, device: str): # Print stdout as it happens if process.stdout is not None: for line in process.stdout: - print(line.strip()) + line = line.strip() + logging.debug(line) # Log stdout + print(line) # Wait for the process to complete _ = process.wait() @@ -1442,6 +1498,54 @@ def query_devices(self): menu = Menu("Options", menu_items) menu.display() + def enter_katapult_bootloader(self, device: str): + try: + device_path = f"/dev/serial/by-id/{device}" + bootloader_cmd = [ + os.path.expanduser("~/klippy-env/bin/python"), + "-c", + f"import flash_usb as u; u.enter_bootloader('{device_path}')", + ] + + # Run the command and capture its output + result = subprocess.run( + bootloader_cmd, + text=True, + capture_output=True, # Captures both stdout and stderr + check=True, + cwd=os.path.expanduser("~/klipper/scripts"), + ) + + # Log stdout + if result.stdout: + for line in result.stdout.splitlines(): + logging.debug(line) # Log each line to DEBUG + + # Log stderr + if result.stderr: + for line in result.stderr.splitlines(): + logging.debug(line) # Log each line to ERROR + + logging.info( + f"Bootloader command completed successfully for device {device}." + ) + + except subprocess.CalledProcessError as e: + logging.error( + f"Bootloader command failed for device {device}. Return code: {e.returncode}" + ) + if e.stdout: + for line in e.stdout.splitlines(): + logging.debug(line) # Log stdout from the exception + if e.stderr: + for line in e.stderr.splitlines(): + logging.error(line) # Log stderr from the exception + + except Exception as e: + logging.exception( + f"Unexpected error occurred while entering bootloader for device {device}: {e}" + ) + def menu(self) -> None: Utils.header() self.firmware.display_device() @@ -1496,21 +1600,7 @@ def flash_device(self, firmware_file: str, device: str): Utils.error_msg("Your device is not a valid Cartographer device.") self.menu() - # Prepend device path for Cartographer - device = f"/dev/serial/by-id/{device}" - - # Enter bootloader for the device - bootloader_cmd = [ - os.path.expanduser("~/klippy-env/bin/python"), - "-c", - f"import flash_usb as u; u.enter_bootloader('{device}')", - ] - _ = subprocess.run( - bootloader_cmd, - text=True, - check=True, - cwd=os.path.expanduser("~/klipper/scripts"), - ) + self.enter_katapult_bootloader(device) sleep(5) # Perform ls to find Katapult device @@ -1550,7 +1640,9 @@ def flash_device(self, firmware_file: str, device: str): # Print stdout as it happens if process.stdout is not None: for line in process.stdout: - print(line.strip()) + line = line.strip() + logging.debug(line) # Log stdout + print(line) # Wait for the process to complete _ = process.wait() @@ -1593,7 +1685,7 @@ def check_dfu_util(self) -> bool: if shutil.which("dfu-util"): return True else: - print("dfu-util is not installed. Please install it and try again.") + logging.info("dfu-util is not installed. Please install it and try again.") return False def dfu_loop(self) -> List[str]: @@ -1618,19 +1710,19 @@ def dfu_loop(self) -> List[str]: 5 ] # Assuming field 6 contains the ID detected_devices.append(device_id) # Add to the list - print(f"Detected DFU device: {device_id}") + logging.info(f"Detected DFU device: {device_id}") if detected_devices: return detected_devices # Exit both loops immediately - print("No DFU devices found. Retrying in 1 second...") + logging.info("No DFU devices found. Retrying in 1 second...") time.sleep(1) # Wait 1 second before retrying - print("No DFU devices found within the timeout period.") + logging.info("No DFU devices found within the timeout period.") except KeyboardInterrupt: print("\nQuery canceled by user.") return [] except Exception as e: - print(f"Error while querying devices: {e}") + logging.error(f"Error while querying devices: {e}") return [] # Return detected devices to avoid further processing if none found @@ -1770,7 +1862,9 @@ def flash_device(self, firmware_file: str, device: str): # Print stdout as it happens if process.stdout is not None: for line in process.stdout: - print(line.strip()) + line = line.strip() + logging.debug(line) # Log stdout + print(line) # Wait for the process to complete _ = process.wait() @@ -1824,27 +1918,27 @@ def __init__(self, firmware: Firmware, branch: str = "master", debug: bool = Fal def temp_dir_exists(self) -> Optional[str]: if self.debug: - print(f"Checking temporary directory: {self.temp_dir}") + logging.info(f"Checking temporary directory: {self.temp_dir}") if os.path.exists(self.temp_dir): if self.debug: - print(f"Directory exists: {self.temp_dir}") + logging.info(f"Directory exists: {self.temp_dir}") subdirs = [ os.path.join(self.temp_dir, d) for d in os.listdir(self.temp_dir) if os.path.isdir(os.path.join(self.temp_dir, d)) ] if self.debug: - print(f"Subdirectories found: {subdirs}") + logging.info(f"Subdirectories found: {subdirs}") if subdirs: return subdirs[0] if self.debug: - print("No subdirectories found.") + logging.info("No subdirectories found.") return None def clean_temp_dir(self): if os.path.exists(self.temp_dir): if self.debug: - print(f"Cleaning temporary directory: {self.temp_dir}") + logging.info(f"Cleaning temporary directory: {self.temp_dir}") shutil.rmtree(self.temp_dir) os.makedirs(self.temp_dir, exist_ok=True) @@ -1853,7 +1947,7 @@ def download_and_extract(self): # Define the path for the downloaded tarball tarball_path = os.path.join(self.temp_dir, "firmware.tar.gz") - print("Downloading tarball...") + logging.info("Downloading tarball...") # Use curl to save the tarball to a file with open(os.devnull, "w") as devnull: curl_command = [ @@ -1870,7 +1964,7 @@ def download_and_extract(self): check=True, ) - print("Extracting tarball...") + logging.info("Extracting tarball...") # Extract the tarball into the temporary directory with open(os.devnull, "w") as devnull: tar_command = ["tar", "-xz", "-C", self.temp_dir, "-f", tarball_path] @@ -1917,7 +2011,9 @@ def create_directory(self) -> bool: try: os.makedirs(KATAPULT_DIR) if args.debug: - print("Katapult directory created successfully.") + logging.info("Katapult directory created successfully.") + else: + logging.debug("Katapult directory created successfully.") except OSError as e: Utils.error_msg(f"Failed to create directory: {e}") return False @@ -1927,7 +2023,11 @@ def clone_repository(self) -> bool: git_dir = os.path.join(KATAPULT_DIR, ".git") if not os.path.exists(git_dir): if args.debug: - print( + logging.info( + "Directory exists but is not a Git repository. Cloning the repository..." + ) + else: + logging.debug( "Directory exists but is not a Git repository. Cloning the repository..." ) try: @@ -1941,7 +2041,9 @@ def clone_repository(self) -> bool: check=True, ) if args.debug: - print("Repository cloned successfully.") + logging.info("Repository cloned successfully.") + else: + logging.debug("Repository cloned successfully.") return True except subprocess.CalledProcessError as e: Utils.error_msg(f"Failed to clone repository: {e}") @@ -1983,13 +2085,19 @@ def check_and_update_repository(self) -> bool: if local_commit != remote_commit: if args.debug: - print("The repository is not up to date. Updating...") + logging.info("The repository is not up to date. Updating...") + else: + logging.debug("The repository is not up to date. Updating...") _ = subprocess.run(["git", "-C", KATAPULT_DIR, "pull"], check=True) if args.debug: - print("Repository updated successfully.") + logging.info("Repository updated successfully.") + else: + logging.debug("Repository updated successfully.") else: if args.debug: - print("The repository is up to date.") + logging.info("The repository is up to date.") + else: + logging.debug("The repository is up to date.") except subprocess.CalledProcessError as e: Utils.error_msg(f"Git update failed: {e}") return False @@ -2009,7 +2117,9 @@ def install(self) -> bool: return False if args.debug: - print("Katapult check passed.") + logging.info("Katapult check passed.") + else: + logging.debug("Katapult check passed.") return True @@ -2115,6 +2225,13 @@ def install(self) -> None: ) try: args = parser.parse_args(namespace=FirmwareNamespace()) + + Utils.configure_logging() + logging.debug( + "###################################################################################################" + ) + logging.info("Starting firmware flasher...") + logging.debug(f"Arguments: {vars(args)}") # Post-processing arguments # Ensure `args.flash` is a FlashMethod or None if isinstance(args.flash, str): # In case of any external assignment From ddd7dc729419837a6236084a5891b0f88464787a Mon Sep 17 00:00:00 2001 From: KrauTech Date: Wed, 11 Dec 2024 23:30:05 +1100 Subject: [PATCH 10/18] [FirmwareFlasher] Query DFU Device Inf Loop + Anykey Exit (#192) * dfu loop without timeout * removed extra func --- scripts/firmware.py | 66 ++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 06e231c..1082886 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -9,6 +9,7 @@ import fnmatch import platform import time +import sys import logging from logging.handlers import RotatingFileHandler @@ -255,6 +256,26 @@ def show_mode(mode: str): mode = mode.center(PAGE_WIDTH) print(Utils.colored_text(mode, Color.RED)) + @staticmethod + def is_key_pressed(timeout: int = 1) -> bool: + if os.name == "nt": # Windows + import msvcrt + + start_time = time.time() + while time.time() - start_time < timeout: + if msvcrt.kbhit(): + _ = msvcrt.getch() # Consume the key press + return True + return False + else: # Unix-based systems + import select + + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if ready: + _ = sys.stdin.read(1) # Consume the key press + return True + return False + class Menu: title: str @@ -1689,44 +1710,39 @@ def check_dfu_util(self) -> bool: return False def dfu_loop(self) -> List[str]: - start_time = time.time() - timeout = 30 # Timeout in seconds - detected_devices: List[str] = [] + print("Press any key to stop...\n") try: - while time.time() - start_time < timeout: + while True: # Run the `lsusb` command result = subprocess.run( ["lsusb"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) lines = result.stdout.splitlines() - # Parse all lines containing "DFU Mode" + # Check for DFU Mode in the output for line in lines: if "DFU Mode" in line: - # Extract the device ID (the 6th field in `lsusb` output) - device_id: str = line.split()[ - 5 - ] # Assuming field 6 contains the ID - detected_devices.append(device_id) # Add to the list - logging.info(f"Detected DFU device: {device_id}") - if detected_devices: - return detected_devices # Exit both loops immediately - - logging.info("No DFU devices found. Retrying in 1 second...") - time.sleep(1) # Wait 1 second before retrying - - logging.info("No DFU devices found within the timeout period.") + device_id = line.split()[5] # Extract device ID (6th field) + detected_devices.append(device_id) + logging.info(f"DFU device found: {device_id}") + _ = input("Press any key to return to the main menu.") + return detected_devices # Exit the loop and return devices + + logging.info( + "DFU device not found, checking again... Press any key to return to the main menu." + ) + + # Check for key press with a timeout of 2 seconds + if Utils.is_key_pressed(timeout=2): + return detected_devices + except KeyboardInterrupt: - print("\nQuery canceled by user.") - return [] + return detected_devices except Exception as e: - logging.error(f"Error while querying devices: {e}") - return [] - - # Return detected devices to avoid further processing if none found - return detected_devices + logging.error(f"Error: {e}") + return detected_devices def query_devices(self): Utils.header() From bffe161c87b447c48b8aec517962713df6f2eedd Mon Sep 17 00:00:00 2001 From: KrauTech Date: Fri, 13 Dec 2024 22:29:47 +1100 Subject: [PATCH 11/18] [FirmwareFlasher] Adds Configuration file & Edit Menu + Minor Text Changes (#194) * initial commit * Initial Commit * Add header * Updated * Add reset to defaults * fix KATAPULT_DIR * just a shit ton of changes, whoops * fixes menu return on firmware select --- scripts/firmware.cfg | 4 + scripts/firmware.py | 319 +++++++++++++++++++++++++++++++++---------- 2 files changed, 248 insertions(+), 75 deletions(-) create mode 100644 scripts/firmware.cfg diff --git a/scripts/firmware.cfg b/scripts/firmware.cfg new file mode 100644 index 0000000..6f5cb42 --- /dev/null +++ b/scripts/firmware.cfg @@ -0,0 +1,4 @@ +KLIPPY_LOG = "~/printer_data/logs/klippy.log" +KATAPULT = "~/katapult" +KLIPPER = "~/klipper" +KLIPPY_ENV = "~/klippy_env" diff --git a/scripts/firmware.py b/scripts/firmware.py index 1082886..65e4b2d 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -25,15 +25,10 @@ Union, Tuple, Set, + ClassVar, ) -HOME_PATH = os.path.expanduser("~") -CONFIG_DIR: str = os.path.expanduser("~/printer_data/config") -KLIPPY_LOG: str = os.path.expanduser("~/printer_data/logs/klippy.log") -KLIPPER_DIR: str = os.path.expanduser("~/klipper") -KATAPULT_DIR: str = os.path.expanduser("~/katapult") - -FLASHER_VERSION: str = "0.0.2" +FLASHER_VERSION: str = "0.0.3" PAGE_WIDTH: int = 89 # Default global width @@ -105,12 +100,57 @@ class FirmwareFile(NamedTuple): class Utils: + CONFIG_FILE: ClassVar[str] = "firmware.cfg" + + # Default Directories + DEFAULT_DIRECTORIES: ClassVar[Dict[str, str]] = { + "KLIPPY_LOG": os.path.expanduser("~/printer_data/logs/klippy.log"), + "KATAPULT_DIR": os.path.expanduser("~/katapult"), + "KLIPPER": os.path.expanduser("~/klipper"), + "KLIPPY_ENV": os.path.expanduser("~/klippy_env"), + } + + @classmethod + def load_config(cls) -> Dict[str, str]: + """ + Load variables from the configuration file if it exists. + Fall back to default values if the file is not found. + + Returns: + Dict[str, str]: A dictionary containing the configuration variables. + """ + config_variables: Dict[str, str] = dict(cls.DEFAULT_DIRECTORIES) + logging.debug(f"Loading Config Variables: {config_variables}") + + # Check if the configuration file exists + if os.path.isfile(cls.CONFIG_FILE): + file_variables: Dict[str, str] = {} + with open(cls.CONFIG_FILE, "r") as file: + # Use exec to evaluate the file content + exec(file.read(), {}, file_variables) + + # Expand user (~) in paths + for key, value in file_variables.items(): + if value.startswith("~"): + file_variables[key] = os.path.expanduser(value) + + # Update the default config with values from the file + config_variables.update(file_variables) + + logging.debug(f"Updated Config Variables: {config_variables}") + return config_variables + @staticmethod def configure_logging(): # Get the root logger logger = logging.getLogger() logger.setLevel(logging.DEBUG) # Capture all logs (DEBUG and above) + # Remove all existing handlers to avoid duplicate logs + + if logger.hasHandlers(): + logger.handlers.clear() + # Create a console handler (only active for INFO level messages) console_handler = logging.StreamHandler() console_handler.setLevel(logging.DEBUG) # Set to DEBUG to allow filtering @@ -276,6 +316,19 @@ def is_key_pressed(timeout: int = 1) -> bool: return True return False + @staticmethod + def restart_klipper(): + try: + # Execute the restart command + _ = subprocess.run( + ["sudo", "service", "klipper", "restart"], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + Utils.error_msg(f"Failed to restart the service ({e.stderr})") + class Menu: title: str @@ -440,13 +493,20 @@ def __init__( self.kseries: bool = kseries self.all: bool = all self.device: Optional[str] = device - self.can = Can(self, debug=self.debug, ftype=self.ftype) - self.usb = Usb(self, debug=self.debug, ftype=self.ftype) + self.config: Dict[str, str] = Utils.load_config() + self.can = Can(self, self.config, debug=self.debug, ftype=self.ftype) + self.usb = Usb(self, self.config, debug=self.debug, ftype=self.ftype) self.dfu = Dfu( self, debug=self.debug, ftype=self.ftype ) # Pass Firmware instance to CAN self.validator: Validator = Validator(self) # Initialize the Validator + self.menu_handlers: Dict[str, Callable[[], None]] = { + FlashMethod.CAN: self.can.menu, + FlashMethod.USB: self.usb.menu, + FlashMethod.DFU: self.dfu.menu, + } + def set_device(self, device: str): logging.debug(f"Device Set: {device}") self.selected_device = device @@ -465,13 +525,8 @@ def handle_initialization(self): """ Handle device initialization based on the flash type and device UUID. """ - handlers: Dict[str, Callable[[], None]] = { - FlashMethod.CAN: self.can.menu, - FlashMethod.USB: self.usb.menu, - FlashMethod.DFU: self.dfu.menu, - } - if self.device and self.flash in handlers: + if self.device and self.flash in self.menu_handlers: # Validate the device if self.validator.validate_device(self.device, self.flash): self.set_device(self.device) @@ -481,7 +536,9 @@ def handle_initialization(self): self.firmware_menu(type=self.flash) # Call the appropriate menu directly from the handlers dictionary - handlers[self.flash]() + self.menu_handlers[self.flash]() + elif self.flash in self.menu_handlers: + self.menu_handlers[self.flash]() else: self.main_menu() @@ -574,7 +631,6 @@ def select_latest(self, firmware_files: List[FirmwareFile], type: FlashMethod): subdirectory, file = latest_firmware_files[0] firmware_path = os.path.join(subdirectory, file) # Construct the full path self.select_firmware(firmware_path, type) - self.main_menu() else: logging.info("No firmware files found in the latest subdirectory.") @@ -640,18 +696,74 @@ def set_custom_branch(self): logging.info("No custom branch provided.") self.branch_menu() - def restart_klipper(self): + def edit_config(self, option: str) -> None: + """ + Display and allow editing of the specified config in the configuration file. + """ + # Check if the config exists in the loaded config + if option not in self.config: + print(f"Config '{option}' is not a recognized configuration key.") + return + + current_value = self.config[option] + print(f"Current value for '{option}': {current_value}") + new_value = input("Enter new value (or press Enter to keep current): ").strip() + + if new_value: + # Update the configuration + self.set_config(option, new_value) + Utils.success_msg(f"Updated '{option}' to '{new_value}'.") + else: + Utils.success_msg(f"'{option}' unchanged.") + self.directory_menu() + + def set_config(self, option: str, value: str) -> None: + """ + Update or add a config value in the configuration file. + """ + # Read current file content or initialize with defaults + if os.path.isfile(Utils.CONFIG_FILE): + with open(Utils.CONFIG_FILE, "r") as file: + lines = file.readlines() + else: + lines = [] + + # Check if the config already exists in the file + for i, line in enumerate(lines): + if line.startswith(f"{option} ="): + # Update the existing line + lines[i] = f'{option} = "{value}"\n' + break + else: + # Add the new option line if not found + lines.append(f'{option} = "{value}"\n') + + # Write updated content back to the file + with open(Utils.CONFIG_FILE, "w") as file: + file.writelines(lines) + + # Update the runtime config + self.config[option] = value + + logging.debug(f"Set {option} to '{value}' in '{Utils.CONFIG_FILE}'.") + + def reset_config(self) -> None: + """ + Reset the configuration file to the default values. + """ try: - # Execute the restart command - _ = subprocess.run( - ["sudo", "service", "klipper", "restart"], - check=True, - text=True, - capture_output=True, - ) - Utils.success_msg("Service restarted successfully!") - except subprocess.CalledProcessError as e: - Utils.error_msg(f"Failed to restart the service ({e.stderr})") + with open(Utils.CONFIG_FILE, "w") as file: + for key, value in Utils.DEFAULT_DIRECTORIES.items(): + _ = file.write(f'{key} = "{value}"\n') + + self.config = dict( + Utils.DEFAULT_DIRECTORIES + ) # Reset runtime config as well + logging.debug(f"Reset configuration to defaults in '{Utils.CONFIG_FILE}'.") + Utils.success_msg("Configuration has been reset to default values.") + except Exception as e: + logging.error(f"Failed to reset configuration: {e}") + Utils.error_msg(f"Error resetting configuration: {e}") # Create main menu def main_menu(self) -> None: @@ -712,6 +824,11 @@ def add_advanced_options( Utils.colored_text("Flash via DFU", Color.MAGENTA), self.dfu.menu ) menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item( + Utils.colored_text("Set Custom Directories", Color.CYAN), + self.directory_menu, + ) + menu_items[len(menu_items) + 1] = Menu.Separator() menu_items[len(menu_items) + 1] = Menu.Item( Utils.colored_text("Switch Flash Mode", Color.CYAN), self.mode_menu ) @@ -802,6 +919,43 @@ def mode_menu(self): menu = Menu("Select a flashing mode", menu_items) menu.display() + def directory_menu(self): + Utils.header() + menu_items: Dict[int, Union[Menu.Item, Menu.Separator]] = {} + + menu_items[len(menu_items) + 1] = Menu.Item( + "Klippy Env", + lambda: self.edit_config("KLIPPY_ENV"), + ) + menu_items[len(menu_items) + 1] = Menu.Item( + "Klippy Logs", + lambda: self.edit_config("KLIPPY_LOG"), + ) + menu_items[len(menu_items) + 1] = Menu.Item( + "Klipper", + lambda: self.edit_config("KLIPPER"), + ) + menu_items[len(menu_items) + 1] = Menu.Item( + "Katapult", + lambda: self.edit_config("KATAPULT_DIR"), + ) + menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item( + "Reset to Defaults", lambda: self.reset_config() + ) + menu_items[len(menu_items) + 1] = Menu.Separator() + menu_items[len(menu_items) + 1] = Menu.Item( + Utils.colored_text("Back to Main Menu", Color.CYAN), + self.main_menu, + ) + menu_items[len(menu_items) + 1] = Menu.Separator() + # Add the "Exit" option last + menu_items[0] = Menu.Item("Exit", lambda: exit()) + + # Create and display the menu + menu = Menu("Which Directory do you want to change?", menu_items) + menu.display() + def branch_menu(self): def display_branch_table(): # Table header @@ -901,7 +1055,9 @@ def display_firmware_menu( "Check Again", lambda: self.firmware_menu(type) ) menu_items[len(menu_items) + 1] = Menu.Separator() - menu_items[len(menu_items) + 1] = Menu.Item("Back", self.can.menu) + handler = self.menu_handlers.get(type) + if handler: + menu_items[len(menu_items) + 1] = Menu.Item("Back", lambda: handler()) menu_items[len(menu_items) + 1] = Menu.Item( Utils.colored_text("Back to main menu", Color.CYAN), self.main_menu ) @@ -916,14 +1072,11 @@ def display_firmware_menu( def select_firmware(self, firmware: str, type: FlashMethod): self.set_firmware(firmware) - menu_handlers: Dict[str, Callable[[], None]] = { - FlashMethod.CAN: self.can.menu, - FlashMethod.USB: self.usb.menu, - FlashMethod.DFU: self.dfu.menu, - } + if args.device and args.flash and self.selected_firmware: + self.confirm(type) # Retrieve the appropriate handler and call it if valid - handler = menu_handlers.get(type) + handler = self.menu_handlers.get(type) if handler: handler() # Call the appropriate menu method else: @@ -933,6 +1086,7 @@ def select_firmware(self, firmware: str, type: FlashMethod): def firmware_menu(self, type: FlashMethod): if not type: raise ValueError("type cannot be None or empty") + # Get the bitrate from CAN interface bitrate = self.can.get_bitrate() @@ -1062,11 +1216,16 @@ def flash_success(self, result: str): Utils.page("Flashed Successfully") if self.debug: print(result) - Utils.success_msg("Firmware flashed successfully to device!") + logging.info("Firmware flashed successfully to device!") # Clean the temporary directory if self.retrieve: self.retrieve.clean_temp_dir() - self.main_menu() # Return to the main menu or any other menu + _ = input( + "Press any key and you may be asked for your password in order to restart klipper\n" + + "Please make sure youre not printing when you do this." + ) + Utils.restart_klipper() + exit() # If flash failed def flash_fail(self, message: str): @@ -1077,20 +1236,12 @@ def flash_fail(self, message: str): self.retrieve.clean_temp_dir() Utils.error_msg(message) - # Show what to do next screen - def finished(self): - Utils.header() - _ = input( - "Press any key and you may be asked for your password in order to restart klipper" - + "Please make sure youre not printing when you do this." - ) - self.restart_klipper() - class Can: def __init__( self, firmware: Firmware, + config: Dict[str, str], debug: bool = False, ftype: bool = False, ): @@ -1101,6 +1252,7 @@ def __init__( self.ftype: bool = ftype self.selected_device: Optional[str] = None self.selected_firmware: Optional[str] = None + self.config: Dict[str, str] = config def get_bitrate(self, interface: str = "can0"): try: @@ -1235,7 +1387,7 @@ def query_devices(self): self.menu() return try: - cmd = os.path.expanduser("~/katapult/scripts/flashtool.py") + cmd = os.path.join(self.config["KATAPULT_DIR"], "scripts", "flashtool.py") command = ["python3", cmd, "-i", "can0", "-q"] result = subprocess.run(command, text=True, capture_output=True, check=True) @@ -1313,7 +1465,7 @@ def search_klippy(self) -> None: scanner_uuids: list[str] = [] # UUIDs with [scanner] above them regular_uuids: list[str] = [] # UUIDs without either tag - with open(KLIPPY_LOG, "r") as log_file: + with open(self.config["KLIPPY_LOG"], "r") as log_file: lines = log_file.readlines() # Parse the log to find UUIDs and their contexts @@ -1374,7 +1526,7 @@ def search_klippy(self) -> None: except FileNotFoundError: Utils.error_msg( - f"KLIPPY log file not found at {KLIPPY_LOG}.", + f"KLIPPY log file not found at {self.config['KLIPPY_LOG']}.", ) self.menu() except Exception as e: @@ -1392,7 +1544,7 @@ def flash_device(self, firmware_file: str, device: str): self.validator.check_selected_device() self.validator.check_selected_firmware() # Prepare the command to execute the flash script - cmd: str = os.path.expanduser("~/katapult/scripts/flash_can.py") + cmd = os.path.join(self.config["KATAPULT_DIR"], "scripts", "flash_can.py") command = [ "python3", cmd, @@ -1446,7 +1598,13 @@ def flash_device(self, firmware_file: str, device: str): class Usb: - def __init__(self, firmware: Firmware, debug: bool = False, ftype: bool = False): + def __init__( + self, + firmware: Firmware, + config: Dict[str, str], + debug: bool = False, + ftype: bool = False, + ): self.firmware: Firmware = firmware self.validator: Validator = Validator(firmware) self.katapult: KatapultInstaller = KatapultInstaller() @@ -1454,6 +1612,7 @@ def __init__(self, firmware: Firmware, debug: bool = False, ftype: bool = False) self.ftype: bool = ftype self.selected_device: Optional[str] = None self.selected_firmware: Optional[str] = None + self.config: Dict[str, str] = config def select_device(self, device: str): self.selected_device = device # Save the selected device globally @@ -1522,8 +1681,9 @@ def query_devices(self): def enter_katapult_bootloader(self, device: str): try: device_path = f"/dev/serial/by-id/{device}" + env: str = os.path.join(self.config["KLIPPY_ENV"], "bin", "python") bootloader_cmd = [ - os.path.expanduser("~/klippy-env/bin/python"), + env, "-c", f"import flash_usb as u; u.enter_bootloader('{device_path}')", ] @@ -1534,7 +1694,7 @@ def enter_katapult_bootloader(self, device: str): text=True, capture_output=True, # Captures both stdout and stderr check=True, - cwd=os.path.expanduser("~/klipper/scripts"), + cwd=os.path.join(self.config["KLIPPER"], "scripts"), ) # Log stdout @@ -1641,7 +1801,7 @@ def flash_device(self, firmware_file: str, device: str): return # Prepare the flash command - cmd: str = os.path.expanduser("~/katapult/scripts/flash_can.py") + cmd = os.path.join(self.config["KATAPULT_DIR"], "scripts", "flash_can.py") command = [ "python3", cmd, @@ -2022,10 +2182,12 @@ def main(self): class KatapultInstaller: + config: Dict[str, str] = Utils.load_config() + def create_directory(self) -> bool: - if not os.path.exists(KATAPULT_DIR): + if not os.path.exists(self.config["KATAPULT_DIR"]): try: - os.makedirs(KATAPULT_DIR) + os.makedirs(self.config["KATAPULT_DIR"]) if args.debug: logging.info("Katapult directory created successfully.") else: @@ -2036,7 +2198,7 @@ def create_directory(self) -> bool: return True def clone_repository(self) -> bool: - git_dir = os.path.join(KATAPULT_DIR, ".git") + git_dir = os.path.join(self.config["KATAPULT_DIR"], ".git") if not os.path.exists(git_dir): if args.debug: logging.info( @@ -2052,7 +2214,7 @@ def clone_repository(self) -> bool: "git", "clone", "https://github.com/arksine/katapult", - KATAPULT_DIR, + self.config["KATAPULT_DIR"], ], check=True, ) @@ -2069,7 +2231,14 @@ def clone_repository(self) -> bool: def verify_repository(self) -> bool: try: result = subprocess.run( - ["git", "-C", KATAPULT_DIR, "config", "--get", "remote.origin.url"], + [ + "git", + "-C", + self.config["KATAPULT_DIR"], + "config", + "--get", + "remote.origin.url", + ], text=True, capture_output=True, check=True, @@ -2085,15 +2254,23 @@ def verify_repository(self) -> bool: def check_and_update_repository(self) -> bool: try: - _ = subprocess.run(["git", "-C", KATAPULT_DIR, "fetch"], check=True) + _ = subprocess.run( + ["git", "-C", self.config["KATAPULT_DIR"], "fetch"], check=True + ) local_commit = subprocess.run( - ["git", "-C", KATAPULT_DIR, "rev-parse", "HEAD"], + ["git", "-C", self.config["KATAPULT_DIR"], "rev-parse", "HEAD"], text=True, capture_output=True, check=True, ).stdout.strip() remote_commit = subprocess.run( - ["git", "-C", KATAPULT_DIR, "rev-parse", "origin/master"], + [ + "git", + "-C", + self.config["KATAPULT_DIR"], + "rev-parse", + "origin/master", + ], text=True, capture_output=True, check=True, @@ -2104,7 +2281,10 @@ def check_and_update_repository(self) -> bool: logging.info("The repository is not up to date. Updating...") else: logging.debug("The repository is not up to date. Updating...") - _ = subprocess.run(["git", "-C", KATAPULT_DIR, "pull"], check=True) + _ = subprocess.run( + ["git", "-C", self.config["KATAPULT_DIR"], "pull"], + check=True, + ) if args.debug: logging.info("Repository updated successfully.") else: @@ -2241,7 +2421,6 @@ def install(self) -> None: ) try: args = parser.parse_args(namespace=FirmwareNamespace()) - Utils.configure_logging() logging.debug( "###################################################################################################" @@ -2272,17 +2451,7 @@ def install(self) -> None: ## TODO ## ## Adjust so users cannot be in certain modes together Utils.make_terminal_bigger() - if args.all or args.flash and not args.all: - if args.flash == FlashMethod.CAN: - fw.can.menu() - elif args.flash == FlashMethod.USB: - fw.usb.menu() - elif args.flash == FlashMethod.DFU: - fw.dfu.menu() - else: - fw.main_menu() - else: - fw.handle_initialization() + fw.handle_initialization() except KeyboardInterrupt: print("\nProcess interrupted by user. Exiting...") exit(0) From 39fd58ddd87c517fd3af39350e1952b58c8e7014 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Sat, 14 Dec 2024 00:03:00 +1100 Subject: [PATCH 12/18] [FirmwareFlasher] Repository would error when case-sensitive - 0.0.4 (#197) * make lowercase * increment --- scripts/firmware.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 65e4b2d..da24330 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -28,7 +28,7 @@ ClassVar, ) -FLASHER_VERSION: str = "0.0.3" +FLASHER_VERSION: str = "0.0.4" PAGE_WIDTH: int = 89 # Default global width @@ -2243,8 +2243,8 @@ def verify_repository(self) -> bool: capture_output=True, check=True, ) - origin_url = result.stdout.strip() - if origin_url != "https://github.com/arksine/katapult": + origin_url = result.stdout.strip().lower() + if origin_url != "https://github.com/arksine/katapult".lower(): Utils.error_msg(f"Unexpected repository URL: {origin_url}") return False except subprocess.CalledProcessError as e: From 4eff2a14f2c34f5bbdd2ddd5b6a17d377f59d669 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Sun, 15 Dec 2024 22:13:42 +1100 Subject: [PATCH 13/18] [FirmwareFlasher] Normalise GIT repo origin (#201) * add firmware.cfg to gitignore * increment to 0.0.6 * normalize katapult repo url to remove .git * removed increment --- .gitignore | 2 ++ scripts/firmware.py | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a81c8ee..b6b93ac 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,5 @@ dmypy.json # Cython debug symbols cython_debug/ + +scripts/firmware.cfg diff --git a/scripts/firmware.py b/scripts/firmware.py index da24330..13f02da 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -2244,7 +2244,11 @@ def verify_repository(self) -> bool: check=True, ) origin_url = result.stdout.strip().lower() - if origin_url != "https://github.com/arksine/katapult".lower(): + # Normalize URLs by removing '.git' at the end if present + normalized_url = origin_url.rstrip(".git") + expected_url = "https://github.com/arksine/katapult".rstrip(".git") + + if normalized_url != expected_url: Utils.error_msg(f"Unexpected repository URL: {origin_url}") return False except subprocess.CalledProcessError as e: From 9d7a17d84bf750cc71e076adff38e20b534e1ee4 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Sun, 15 Dec 2024 22:23:02 +1100 Subject: [PATCH 14/18] [FirmwareFlasher] Add LSUSB (/dev/ Directory to Config Options (#200) * Adds DEV_DIR and increment to 0.0.7 * removed increment --- scripts/firmware.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 13f02da..ccf6f0e 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -108,6 +108,7 @@ class Utils: "KATAPULT_DIR": os.path.expanduser("~/katapult"), "KLIPPER": os.path.expanduser("~/klipper"), "KLIPPY_ENV": os.path.expanduser("~/klippy_env"), + "DEV_DIR": os.path.expanduser("/dev"), } @classmethod @@ -939,6 +940,10 @@ def directory_menu(self): "Katapult", lambda: self.edit_config("KATAPULT_DIR"), ) + menu_items[len(menu_items) + 1] = Menu.Item( + "LSUSB (/Dev)", + lambda: self.edit_config("DEV_DIR"), + ) menu_items[len(menu_items) + 1] = Menu.Separator() menu_items[len(menu_items) + 1] = Menu.Item( "Reset to Defaults", lambda: self.reset_config() @@ -1629,7 +1634,7 @@ def query_devices(self): detected_devices: List[str] = [] try: # List all devices in /dev/serial/by-id/ - base_path = "/dev/serial/by-id/" + base_path = f"{self.config['DEV_DIR']}/serial/by-id/" if not os.path.exists(base_path): Utils.error_msg(f"Path '{base_path}' does not exist.") return @@ -1680,7 +1685,7 @@ def query_devices(self): def enter_katapult_bootloader(self, device: str): try: - device_path = f"/dev/serial/by-id/{device}" + device_path = f"{self.config['DEV_DIR']}/serial/by-id/{device}" env: str = os.path.join(self.config["KLIPPY_ENV"], "bin", "python") bootloader_cmd = [ env, @@ -1774,7 +1779,7 @@ def flash_device(self, firmware_file: str, device: str): # Check if the device is already a Katapult device if "katapult" in device.lower(): - katapult_device = f"/dev/serial/by-id/{device}" + katapult_device = f"{self.config['DEV_DIR']}/serial/by-id/{device}" else: # Validate that the device is a valid Cartographer device if not self.validator.validate_device(device, FlashMethod.USB): @@ -1785,7 +1790,7 @@ def flash_device(self, firmware_file: str, device: str): sleep(5) # Perform ls to find Katapult device - base_path = "/dev/serial/by-id/" + base_path = f"{self.config['DEV_DIR']}/serial/by-id/" katapult_device = None if os.path.exists(base_path): for item in os.listdir(base_path): From 1188afffb382c4cf25f4a9aa491c98dec3e4d9c4 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Mon, 16 Dec 2024 15:10:52 +1100 Subject: [PATCH 15/18] [FirmwareFlasher] Adds Advanced Menu for All Firmware (#198) * Adds menu for All Firmware * removed increment * menu item text change --- scripts/firmware.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index ccf6f0e..198a05b 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -638,9 +638,16 @@ def select_latest(self, firmware_files: List[FirmwareFile], type: FlashMethod): def set_advanced(self): global is_advanced if is_advanced: - is_advanced = args.all = False + is_advanced = False else: - is_advanced = args.all = True + is_advanced = True + self.main_menu() + + def set_all_fw(self): + if self.all: + self.all = args.all = False + else: + self.all = args.all = True self.main_menu() def set_debugging(self): @@ -769,7 +776,7 @@ def reset_config(self) -> None: # Create main menu def main_menu(self) -> None: # Handle advanced mode and flash settings - if is_advanced or self.flash == FlashMethod["DFU"]: + if self.flash == FlashMethod["DFU"]: self.all = True Utils.header() @@ -822,7 +829,9 @@ def add_advanced_options( # Add advanced options menu_items[len(menu_items) + 1] = Menu.Separator() menu_items[len(menu_items) + 1] = Menu.Item( - Utils.colored_text("Flash via DFU", Color.MAGENTA), self.dfu.menu + "DFU Menu " + + Utils.colored_text("[For Flashing via DFU]", Color.YELLOW), + self.can.menu, ) menu_items[len(menu_items) + 1] = Menu.Separator() menu_items[len(menu_items) + 1] = Menu.Item( @@ -838,6 +847,13 @@ def add_advanced_options( ) menu_items[len(menu_items) + 1] = Menu.Separator() + # Debugging toggle + self.add_toggle_item( + menu_items, + "Show All Firmware", + self.all, + self.set_all_fw, + ) # Debugging toggle self.add_toggle_item( menu_items, @@ -1345,6 +1361,7 @@ def menu(self) -> None: "Flash Selected Firmware", lambda: self.firmware.confirm(type=FlashMethod.CAN), ) + menu_items[len(menu_items) + 1] = Menu.Separator() # Add "Back to main menu" after "Flash Selected Firmware" menu_items[len(menu_items) + 1] = Menu.Item( From b3caa3b34897c048849d5ae1def15c0a47db66c9 Mon Sep 17 00:00:00 2001 From: KrauTech Date: Mon, 16 Dec 2024 19:57:37 +1100 Subject: [PATCH 16/18] [Touch][Threshold] Corrects Attempt After Max Retry & STD to MAX (#202) * should fix weird error after final attempt * remove useless else stamement * change weird result to all std_deviation * change back * changed std dev to max dev * increment to v0.0.5 --- scanner.py | 120 +++++++++++++++++++++++--------------------- scripts/firmware.py | 2 +- 2 files changed, 63 insertions(+), 59 deletions(-) diff --git a/scanner.py b/scanner.py index 91d89f5..1c76841 100644 --- a/scanner.py +++ b/scanner.py @@ -461,7 +461,6 @@ def cmd_SCANNER_TOUCH(self, gcmd: GCodeCommand): manual_z_offset = gcmd.get_float( "Z_OFFSET", self.scanner_touch_config["z_offset"], minval=0 ) - # Debugging information self.log_debug_info( vars["verbose"], @@ -561,7 +560,7 @@ def cmd_SCANNER_TOUCH(self, gcmd: GCodeCommand): result = self.start_touch(gcmd, touch_settings, vars["verbose"]) - standard_deviation = result["standard_deviation"] + max_deviation = result["max_deviation"] final_position = result["final_position"] retries = result["retries"] success = result["success"] @@ -577,7 +576,7 @@ def cmd_SCANNER_TOUCH(self, gcmd: GCodeCommand): self.log_debug_info( vars["verbose"], gcmd, - f"Standard Deviation: {standard_deviation:.4f}", + f"Maximum Deviation: {max_deviation:.4f}", ) if calibrate == 1: self._calibrate( @@ -610,6 +609,7 @@ def start_touch(self, gcmd: GCodeCommand, touch_settings, verbose: bool): randomize = touch_settings.randomize original_threshold = self.detect_threshold_z + deviation = None try: self.detect_threshold_z = test_threshold # Set the initial position for the toolhead @@ -623,6 +623,13 @@ def start_touch(self, gcmd: GCodeCommand, touch_settings, verbose: bool): original_position = initial_position[:] while len(samples) < num_samples: + if retries >= max_retries: + self.detect_threshold_z = original_threshold + self.trigger_method = TriggerMethod.SCAN + self._zhop() + raise gcmd.error( + f"Exceeded maximum attempts [{retries}/{int(max_retries)}]" + ) if randomize > 0 and new_retry: # Generate random offsets x_offset = random.uniform(-randomize, randomize) @@ -649,6 +656,7 @@ def start_touch(self, gcmd: GCodeCommand, touch_settings, verbose: bool): ) except self.printer.command_error as e: if self.printer.is_shutdown(): + self.detect_threshold_z = original_threshold self.trigger_method = TriggerMethod.SCAN raise self.printer.command_error( "Touch procedure interrupted due to printer shutdown" @@ -674,12 +682,6 @@ def start_touch(self, gcmd: GCodeCommand, touch_settings, verbose: bool): deviation = round(deviation, 4) if deviation > tolerance: - if retries >= max_retries: - self.trigger_method = TriggerMethod.SCAN - self._zhop() - raise gcmd.error( - f"Exceeded maximum attempts [{retries}/{int(max_retries)}]" - ) self.log_debug_info( verbose, gcmd, @@ -698,42 +700,45 @@ def start_touch(self, gcmd: GCodeCommand, touch_settings, verbose: bool): f"Deviation: {deviation:.4f}\nNew Average: {average:.4f}\nTolerance: {tolerance:.4f}", ) - std_dev = np.std(samples) - gcmd.respond_info( - f"Completed {len(samples)} touches with a standard deviation of {std_dev:.4f}" - ) - position_difference = initial_position[2] - self.toolhead.get_position()[2] - adjusted_difference = initial_position[2] - np.mean(samples) - self.log_debug_info( - verbose, - gcmd, - f"Position Difference: {position_difference:.4f}\nAdjusted Difference: {adjusted_difference:.4f}", - ) + if len(samples) == num_samples and deviation <= tolerance: + gcmd.respond_info( + f"Completed {len(samples)} touches with a max deviation of {deviation:.4f}" + ) + position_difference = ( + initial_position[2] - self.toolhead.get_position()[2] + ) + adjusted_difference = initial_position[2] - np.mean(samples) + self.log_debug_info( + verbose, + gcmd, + f"Position Difference: {position_difference:.4f}\nAdjusted Difference: {adjusted_difference:.4f}", + ) - initial_position[2] = float(adjusted_difference - position_difference) - formatted_position = [f"{coord:.2f}" for coord in initial_position] - self.log_debug_info( - verbose, gcmd, f"Updated Initial Position: {formatted_position}" - ) - if manual_z_offset > 0: - gcmd.respond_info(f"Offsetting by {manual_z_offset:.3f}") - initial_position[2] = initial_position[2] - manual_z_offset - self.toolhead.set_position(initial_position) - self.toolhead.wait_moves() - self.toolhead.flush_step_generation() - self.trigger_method = TriggerMethod.SCAN - self.previous_probe_success = 1 + initial_position[2] = float(adjusted_difference - position_difference) + formatted_position = [f"{coord:.2f}" for coord in initial_position] + self.log_debug_info( + verbose, gcmd, f"Updated Initial Position: {formatted_position}" + ) + if manual_z_offset > 0: + gcmd.respond_info(f"Offsetting by {manual_z_offset:.3f}") + initial_position[2] = initial_position[2] - manual_z_offset + self.toolhead.set_position(initial_position) + self.toolhead.wait_moves() + self.toolhead.flush_step_generation() + self.trigger_method = TriggerMethod.SCAN + self.previous_probe_success = 1 - # Return relevant data - self.detect_threshold_z = original_threshold + # Return relevant data + self.detect_threshold_z = original_threshold return { "samples": samples, - "standard_deviation": std_dev, + "max_deviation": deviation, "final_position": initial_position, "retries": retries, "success": self.previous_probe_success, } except self.printer.command_error: + self.detect_threshold_z = original_threshold self.trigger_method = TriggerMethod.SCAN if hasattr(kinematics, "note_z_not_homed"): kinematics.note_z_not_homed() @@ -791,7 +796,7 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): max_acceptable_retries = round( confirmation_retries * THRESHOLD_ACCEPTANCE_FACTOR ) - max_acceptable_std_dev = vars["target"] + max_acceptable_max_dev = vars["target"] verbose = vars["verbose"] @@ -876,8 +881,8 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): if result["success"]: # Check if this result meets "good" criteria if result["retries"] <= max_acceptable_retries and ( - result["standard_deviation"] is not None - and result["standard_deviation"] <= max_acceptable_std_dev + result["max_deviation"] is not None + and result["max_deviation"] <= max_acceptable_max_dev ): # Increase threshold_max by 3 steps above the current threshold, only if it hasn't been increased before if not has_increased_threshold_max: @@ -898,8 +903,7 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): gcmd, touch_settings, verbose ) if not repeat_result["success"] or ( - repeat_result["standard_deviation"] - > max_acceptable_std_dev + repeat_result["max_deviation"] > max_acceptable_max_dev ): gcmd.respond_info( f"Qualify attempt {attempt + 1} failed for threshold {current_threshold}" @@ -907,15 +911,15 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): consistent_results = False break gcmd.respond_info( - f"Qualify attempt {attempt + 1} successful with std dev: {repeat_result['standard_deviation']:.5f}" + f"Qualify attempt {attempt + 1} successful with max dev: {repeat_result['max_deviation']:.5f}" ) # Save only successful repeat attempts in results result["consistent_results"] = ( consistent_results # Mark if it passed repeatability checks ) - result["standard_deviation"] = ( - repeat_result["standard_deviation"] + result["max_deviation"] = ( + repeat_result["max_deviation"] if consistent_results else None ) @@ -946,13 +950,13 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): return # Exit as there's no best threshold to save if consistent_results: - # Find the best consistent result based on minimum retries and standard deviation + # Find the best consistent result based on minimum retries and max deviation best_result = min( consistent_results, key=lambda x: ( x["retries"], - x["standard_deviation"] - if x["standard_deviation"] is not None + x["max_deviation"] + if x["max_deviation"] is not None else float("inf"), ), ) @@ -964,8 +968,8 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): results, key=lambda x: ( x["retries"], - x["standard_deviation"] - if x["standard_deviation"] is not None + x["max_deviation"] + if x["max_deviation"] is not None else float("inf"), ), ) @@ -976,21 +980,21 @@ def cmd_SCANNER_THRESHOLD_SCAN(self, gcmd: GCodeCommand): self.detect_threshold_z = best_threshold self._save_threshold(best_threshold, vars["speed"]) - # Handle None for standard deviation by using a default message - std_dev_display = ( - f"{best_result['standard_deviation']:.5f}" - if best_result["standard_deviation"] is not None + # Handle None for max deviation by using a default message + max_dev_display = ( + f"{best_result['max_deviation']:.5f}" + if best_result["max_deviation"] is not None else "N/A" ) # Inform the user about the result if optimal_found: gcmd.respond_info( - f"Optimal Threshold Determined: {best_threshold} with standard deviation of {std_dev_display}" + f"Optimal Threshold Determined: {best_threshold} with max deviation of {max_dev_display}" ) else: gcmd.respond_info( - f"No fully optimal threshold found. Best attempt: {best_threshold} with standard deviation of {std_dev_display}" + f"No fully optimal threshold found. Best attempt: {best_threshold} with max deviation of {max_dev_display}" ) gcmd.respond_info( f"You can now {format_macro('SAVE_CONFIG')} to save your threshold." @@ -1098,7 +1102,7 @@ def start_threshold_scan(self, gcmd: GCodeCommand, touch_settings, verbose: bool f"Deviation: {deviation:.4f}\nNew Average: {average:.4f}\nTolerance: {tolerance:.4f}", ) - std_dev = np.std(samples) if samples else None + max_dev = np.std(samples) if samples else None if len(samples) == num_samples: success = True position_difference = ( @@ -1111,7 +1115,7 @@ def start_threshold_scan(self, gcmd: GCodeCommand, touch_settings, verbose: bool f"Position Difference: {position_difference:.4f}\nAdjusted Difference: {adjusted_difference:.4f}", ) else: - std_dev = None + max_dev = None success = False self.toolhead.wait_moves() @@ -1121,7 +1125,7 @@ def start_threshold_scan(self, gcmd: GCodeCommand, touch_settings, verbose: bool # Return relevant data return { "samples": samples, - "standard_deviation": std_dev, + "max_deviation": max_dev, "final_position": initial_position, "retries": retries, "success": success, diff --git a/scripts/firmware.py b/scripts/firmware.py index 198a05b..04c2c43 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -28,7 +28,7 @@ ClassVar, ) -FLASHER_VERSION: str = "0.0.4" +FLASHER_VERSION: str = "0.0.5" PAGE_WIDTH: int = 89 # Default global width From 09259881b8f128e1dca8aee5d9c77b1d999c604f Mon Sep 17 00:00:00 2001 From: KrauTech Date: Tue, 17 Dec 2024 15:42:37 +1100 Subject: [PATCH 17/18] fixes klippy env path (#206) --- scripts/firmware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/firmware.py b/scripts/firmware.py index 04c2c43..6f365f2 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -107,7 +107,7 @@ class Utils: "KLIPPY_LOG": os.path.expanduser("~/printer_data/logs/klippy.log"), "KATAPULT_DIR": os.path.expanduser("~/katapult"), "KLIPPER": os.path.expanduser("~/klipper"), - "KLIPPY_ENV": os.path.expanduser("~/klippy_env"), + "KLIPPY_ENV": os.path.expanduser("~/klippy-env"), "DEV_DIR": os.path.expanduser("/dev"), } From edda8ca7199bae461eefc0858381e1323c5c4e27 Mon Sep 17 00:00:00 2001 From: sbtoonz <67915879+sbtoonz@users.noreply.github.com> Date: Tue, 24 Dec 2024 07:47:09 -0800 Subject: [PATCH 18/18] Feat: dynamic frequency (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clean up this function a little * Feat: Dynamic Freq Adjust Sliding Window Implementation: Utilizes a fixed-size buffer (deque) to maintain the most recent frequency readings, enabling real-time calculation of the rolling average and standard deviation. Dynamic Threshold Calculation: Sets the threshold at the rolling average plus three standard deviations (μ + 3σ), allowing the system to adapt to normal operational fluctuations without manual parameter tuning. Minimum Threshold Establishment: Introduces a baseline threshold set to 20% above the initial frequency reading to ensure stability during initialization and prevent premature anomaly detection. Enhanced Logging: Adds detailed debug logs to track frequency statistics and threshold values, facilitating easier monitoring and troubleshooting. * Update mcu.pyi * Update mcu.pyi --- scanner.py | 76 ++++++++++++++++++++++++++++++++++++++++++------- typings/mcu.pyi | 5 ++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/scanner.py b/scanner.py index 1c76841..dcd90ff 100644 --- a/scanner.py +++ b/scanner.py @@ -30,6 +30,7 @@ import msgproto import numpy as np import pins +from collections import deque from clocksync import SecondarySync from configfile import ConfigWrapper from gcode import GCodeCommand @@ -60,6 +61,10 @@ THRESHOLD_STEP_MULTIPLIER = 10 # Require a qualified threshold to pass at 0.66 of the QUALIFY_SAMPLES THRESHOLD_ACCEPTANCE_FACTOR = 0.66 +SLIDING_WINDOW_SIZE = 50 +SIGMA_MULTIPLIER = 3 +THRESHOLD_MULTIPLIER = 1.2 +SHORTED_COIL_VALUE = 0xFFFFFFF class TriggerMethod(IntEnum): @@ -1714,23 +1719,74 @@ def _is_faulty_coordinate(self, x, y, add_offsets=False): # Streaming mode def _check_hardware(self, sample): + # Validate sample input + if "data" not in sample or "freq" not in sample: + raise self._mcu.error("Sample must contain 'data' and 'freq' keys.") + + # Initialize variables on the first call + if not hasattr(self, "freq_window"): + self.freq_window = deque(maxlen=SLIDING_WINDOW_SIZE) # Sliding window + self.min_threshold = None # Minimum frequency threshold + + # Add the current frequency to the sliding window + freq = sample["freq"] + self.freq_window.append(freq) + + # Calculate statistics from the sliding window + if len(self.freq_window) > 1: + freq_window_array = np.array(self.freq_window) # Convert deque to numpy array + f_avg = np.mean(freq_window_array) + f_std = np.std(freq_window_array) + dynamic_threshold = f_avg + SIGMA_MULTIPLIER * f_std # Dynamic threshold + else: + # Fallback during initialization + f_avg = freq + f_std = 0 + dynamic_threshold = freq * THRESHOLD_MULTIPLIER # Fallback threshold + + + # Ensure a minimum threshold is set + if self.min_threshold is None: + self.min_threshold = freq * THRESHOLD_MULTIPLIER # Initial minimum threshold + + # Final threshold (whichever is greater: dynamic or minimum) + final_threshold = max(dynamic_threshold, self.min_threshold) + + # Debug log for threshold values + logging.debug( + f"Sliding Window Threshold Debug: freq={freq}, f_avg={f_avg}, " + f"f_std={f_std}, dynamic_threshold={dynamic_threshold}, " + f"min_threshold={self.min_threshold}, final_threshold={final_threshold}" + ) + + # Check for hardware issues if not self.hardware_failure: msg = None - if sample["data"] == 0xFFFFFFF: - msg = "coil is shorted or not connected" - elif self.fmin is not None and sample["freq"] > 1.35 * self.fmin: - msg = "coil expected max frequency exceeded" + + if sample["data"] == SHORTED_COIL_VALUE: + msg = "Coil is shorted or not connected." + logging.debug(f"Debug: data={sample['data']} indicates connection issue.") + elif freq > final_threshold: + msg = "Coil expected max frequency exceeded (sliding window)." + logging.debug( + f"Frequency {freq} exceeded final threshold {final_threshold}." + ) + if msg: - msg = "Scanner hardware issue: " + msg - self.hardware_failure = msg - logging.error(msg) + # Log and handle hardware failure + full_msg = f"Scanner hardware issue: {msg}" + self.hardware_failure = full_msg + logging.error(full_msg) + if self._stream_en: - self.printer.invoke_shutdown(msg) + self.printer.invoke_shutdown(full_msg) else: - self.gcode.respond_raw("!! " + msg + "\n") + self.gcode.respond_raw(f"!! {full_msg}\n") elif self._stream_en: + # Handle already detected hardware failure self.printer.invoke_shutdown(self.hardware_failure) + def _enrich_sample_time(self, sample): clock = sample["clock"] = self._mcu.clock32_to_clock64(sample["clock"]) sample["time"] = self._mcu.clock_to_print_time(clock) @@ -3846,4 +3902,4 @@ def load_config_prefix(config: ConfigWrapper): scanner.register_model(name, model) return model else: - raise config.error("Unknown scanner config directive '%s'" % (name[7:],)) + raise config.error("Unknown scanner config directive '%s'" % (name[7:],)) \ No newline at end of file diff --git a/typings/mcu.pyi b/typings/mcu.pyi index b7dde6a..72ff3ea 100644 --- a/typings/mcu.pyi +++ b/typings/mcu.pyi @@ -16,6 +16,9 @@ class MCUStatus(TypedDict): class _CommandQueue: pass +class error(Exception): + pass + class MCU: _mcu_freq: float _clocksync: ClockSync @@ -59,6 +62,8 @@ class MCU: pass def is_fileoutput(self) -> bool: pass + def error(self, message: str) -> string: + pass @overload def get_constant(self, name: str, default: type[sentinel] | str = sentinel) -> str: