diff --git a/app.py b/app.py new file mode 100644 index 0000000..758514d --- /dev/null +++ b/app.py @@ -0,0 +1,188 @@ +"""pyBlasher CLI app.""" + +import time +from sys import exit + +import serial + +from flash_firmware import flash_image, pulse_nrst +from nor_flash_comm import ( + reset, + read_section, + save_hexdump, +) +from util import find_cp2102n_ports + +SERIAL_PORT = "COM1" + + +def __flash_image(): + print(f"1. Enter a firmware filepath (.bin):") + image_path = input("> ") + if len(image_path) < 5 or image_path[-4:] != ".bin": + image_path += ".bin" + + print(f"2. Opening serial port ({SERIAL_PORT})") + with serial.Serial( + SERIAL_PORT, 115200, parity=serial.PARITY_EVEN, timeout=1 + ) as ser: + time.sleep(1) # Wait for NRSTs to clear from serial port establishment + + print(f"3. Beginning firmware flash") + + try: + flash_image(ser, image_path) + except RuntimeError as e: + if "Sync failed" in str(e): + raise RuntimeError("Ensure BOOT0 is raised, then retry") + + print("\tFirmware update successful") + + +def __nvm_reset(): + print(f"1. Opening serial port ({SERIAL_PORT})") + with serial.Serial(SERIAL_PORT, 115200) as ser: + time.sleep(1) # Wait for NRSTs to clear from serial port establishment + + print(f"2. Beginning NVM reset") + reset(ser) + + print("\tReset NVM") + + +def __nvm_memory_extract(): + print(f"1. Enter a starting address (Hex):") + section_start = input("> ").strip() + try: + section_start = int(section_start, 16) + except TypeError: + raise ValueError("Expected hexadecimal address") + + print(f"2. Enter a read length (recommended 4096):") + section_length = input("> ").strip() + try: + section_length = int(section_length) + except TypeError: + raise ValueError("Expected hexadecimal address") + + print(f"3. Enter a output filepath (recommended .txt):") + output_file_path = input("> ") + if len(output_file_path) < 5 or output_file_path[-4:] != ".txt": + output_file_path += ".txt" + + print(f"4. Opening serial port ({SERIAL_PORT})") + with serial.Serial(SERIAL_PORT, 115200) as ser: + # Pulse NRST before start + pulse_nrst(ser, duration_ms=50) + time.sleep(0.05) + + time.sleep(7) # Wait for NRSTs to clear from serial port establishment + + print(f"5. Beginning NVM read communication") + sector = read_section( + ser, start_addr=section_start, length=section_length + ) + + print(f"6. Beginning hex dump file save") + save_hexdump(sector, start_addr=section_start, filename=output_file_path) + + print(f"\tWrote {len(sector)} bytes to {output_file_path}") + + +def __serial_port_manual_config(): + global SERIAL_PORT + + print(f"Current serial port: {SERIAL_PORT}") + print("Enter a serial port:") + input_serial_port = input("> ").strip().lower() + if input_serial_port.isnumeric(): + SERIAL_PORT = f"COM{input_serial_port}" + else: + SERIAL_PORT = input_serial_port.upper() + print(f"\tSerial port configured to: {SERIAL_PORT}") + + +def __serial_port_auto_config(): + global SERIAL_PORT + + cp_ports = find_cp2102n_ports() + if cp_ports: + print( + f"\tFound CP2102N device(s): " + f"{', '.join([port['device'] for port in cp_ports])}" + ) + SERIAL_PORT = cp_ports[0]["device"] + print(f"\tSerial port configured to: {SERIAL_PORT}") + else: + print("\tNo CP2102N devices found, please add a serial port manually") + + +def header_print(): + print( + "-------------------------------------------------------------------------------\n" + " Momentum pyBlasher (v0.1.0-alpha) \n" + "-------------------------------------------------------------------------------\n" + ) + + +def end_of_command_print(): + print() + + +def main_menu_print(): + print( + " Options: (Not case sensitive)\n" + " 1 = Momentum firmware update\n" + " 2 = NVM reset (wipe memory)\n" + " 3 = NVM sector readout\n" + " 8 = Automatic serial port configuration\n" + " 9 = Manual serial port configuration\n" + " e = Exit\n" + ) + + +def run_cli(): + header_print() + + __serial_port_auto_config() + + end_of_command_print() + + try: + while True: + main_menu_print() + choice = input("> ").strip().lower()[0] + + start = time.time() + + try: + if choice == "1": + __flash_image() + elif choice == "2": + __nvm_reset() + elif choice == "3": + __nvm_memory_extract() + elif choice == "8": + __serial_port_auto_config() + elif choice == "9": + __serial_port_manual_config() + elif choice == "e": + raise KeyboardInterrupt + else: + print(f"Invalid choice: {choice!r}!") + except ValueError as e: + print(f"\tValueError: {e}") + except serial.serialutil.SerialException as e: + print(f"\tSerialException: {e}") + except FileNotFoundError as e: + print(f"\tFileNotFoundError: {e}") + except RuntimeError as e: + print(f"\tRuntimeError: {e}") + + print(f"\tCompleted in {time.time() - start} seconds") + + end_of_command_print() + + except KeyboardInterrupt: + print("\nTerminating program...") + exit(1) diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..dbcc723 Binary files /dev/null and b/assets/icon.png differ diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..d9410e6 --- /dev/null +++ b/constants.py @@ -0,0 +1,5 @@ +"""pyBlasher constants.""" + +# Silicon Labs CP2102N default USB VID/PID. +CP2102N_VID = 0x10C4 +CP2102N_PID = 0xEA60 diff --git a/.github/workflows/pyinstaller.yaml b/docs/pyinstaller.yaml similarity index 81% rename from .github/workflows/pyinstaller.yaml rename to docs/pyinstaller.yaml index 2613350..71d4cb3 100644 --- a/.github/workflows/pyinstaller.yaml +++ b/docs/pyinstaller.yaml @@ -40,6 +40,15 @@ jobs: with: python-version: "3.x" + # Kivy system deps on Linux + - name: Install system libs (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev libsdl2-mixer-dev \ + libgl1-mesa-dev libmtdev-dev libmtdev1 + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..2a3ae72 --- /dev/null +++ b/gui.py @@ -0,0 +1,205 @@ +"""pyBlasher GUI app.""" + +import time + +import serial +from kivy.app import App +from kivy.core.window import Window +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.button import Button +from kivy.uix.filechooser import FileChooserListView +from kivy.uix.label import Label +from kivy.uix.popup import Popup +from kivy.uix.spinner import Spinner +from kivy.uix.textinput import TextInput +from kivy.uix.widget import Widget + +from flash_firmware import flash_image +from util import find_cp2102n_ports + +MSG_NO_PORTS_FOUND = "No ports found" + + +class FirmwareToolUI(BoxLayout): + def __init__(self, **kwargs): + super().__init__( + orientation="vertical", spacing=10, padding=10, **kwargs + ) + # Port selection + self.port_spinner = Spinner( + text="Click to select a port", + size_hint=(1, None), + height=40, + background_normal="", + background_color=(0.1, 0.4, 0.1, 1), + ) + self.add_widget(self.port_spinner) + self.add_widget( + Button( + text="Refresh ports", + size_hint=(1, None), + height=40, + background_normal="", + background_color=(0.1, 0.4, 0.1, 1), + on_press=lambda _: self.refresh_ports(), + ) + ) + + # Spacer + self.add_widget(Widget(size_hint_y=None, height=10)) + + # Firmware file selection + self.bin_label = Label( + text="No .bin selected", size_hint=(1, None), height=30 + ) + self.add_widget(self.bin_label) + self.add_widget( + Button( + text="Drop a .bin file here or click to browse", + size_hint=(1, None), + height=40, + background_normal="", + background_color=(0.8, 0.5, 0.1, 1), + on_press=self.browse_bin, + ) + ) + self.bin_path = None + Window.bind(on_drop_file=self._on_file_drop) + + # Spacer + self.add_widget(Widget(size_hint_y=None, height=10)) + + # Execute & Log + self.add_widget( + Button( + text="Flash Firmware", + size_hint=(1, None), + height=40, + background_normal="", + background_color=(0.8, 0.3, 0.3, 1), + on_press=lambda _: self.execute_flash(), + ) + ) + self.log_view = TextInput( + readonly=True, multiline=True, size_hint=(1, 1) + ) + self.add_widget(self.log_view) + + # Post init actions + self.refresh_ports() + + def log(self, message: str): + self.log_view.text += message + "\n" + + def refresh_ports(self): + found_ports = find_cp2102n_ports() + if found_ports: + self.port_spinner.values = found_ports + else: + self.port_spinner.values = [] + self.port_spinner.text = MSG_NO_PORTS_FOUND + log_text = ( + ",".join(self.port_spinner.values) + if self.port_spinner.values + else MSG_NO_PORTS_FOUND + ) + self.log(f"Ports refreshed: {log_text}") + + def browse_bin(self, _): + chooser = FileChooserListView(filters=["*.bin"]) + popup = Popup( + title="Select .bin file", content=chooser, size_hint=(0.9, 0.9) + ) + chooser.bind(selection=lambda fs, sel: self._select_bin(sel, popup)) + popup.open() + + def _select_bin(self, selection, popup): + if selection: + self.bin_path = selection[0] + self.bin_label.text = self.bin_path + popup.dismiss() + + def _on_file_drop(self, window, file_path, x, y): + path = file_path.decode("utf-8") + if path.endswith(".bin"): + self.bin_path = path + self.bin_label.text = path + self.log(f"Dropped .bin file: {path}") + else: + self.log(f"Ignored dropped file (not .bin): {path}") + + def _confirm_flash_proceed(self, port): + try: + ser = serial.Serial( + port, 115200, parity=serial.PARITY_EVEN, timeout=1 + ) + except Exception as e: + self.log(f"Could not open port {port}: {e}") + return + time.sleep(1) + self.log(f"Starting firmware update on {port} with {self.bin_path}") + try: + flash_image(ser, self.bin_path) + self.log("Firmware update successful.") + except Exception as e: + self.log(f"Error during flash: {e}") + finally: + ser.close() + + def execute_flash(self): + port = self.port_spinner.text + if port == MSG_NO_PORTS_FOUND: + self.log("Select a port!") + return + if not self.bin_path: + self.log("Select a .bin file!") + return + + confirm_layout = BoxLayout( + orientation="vertical", padding=10, spacing=10 + ) + confirm_layout.add_widget( + Label( + text=f"Proceed with flashing\n" + f"{self.bin_path}\n" + f"on port {port}?", + halign="center", + ) + ) + + button_row = BoxLayout(size_hint=(1, None), height=40, spacing=10) + yes_btn = Button(text="Yes", background_color=(0.1, 0.6, 0.1, 1)) + cancel_btn = Button(text="Cancel", background_color=(0.6, 0.1, 0.1, 1)) + button_row.add_widget(yes_btn) + button_row.add_widget(cancel_btn) + + confirm_layout.add_widget(button_row) + + popup = Popup( + title="Confirm Firmware Flash", + content=confirm_layout, + size_hint=(0.8, 0.6), + ) + + yes_btn.bind( + on_press=lambda _: ( + popup.dismiss(), + self._confirm_flash_proceed(port), + ) + ) + cancel_btn.bind(on_press=popup.dismiss) + + popup.open() + + +class PyBlasherApp(App): + def build(self): + Window.size = (600, 450) + Window.minimum_width = 350 + Window.minimum_height = 350 + Window.set_icon("assets/icon.png") + return FirmwareToolUI() + + +def run_gui(): + PyBlasherApp().run() diff --git a/main.py b/main.py index f645f5e..294393a 100644 --- a/main.py +++ b/main.py @@ -1,226 +1,15 @@ """Main pyBlasher application for Momentum.""" import sys -import time -import serial -from serial.tools import list_ports +from app import run_cli -from flash_firmware import flash_image, pulse_nrst -from nor_flash_comm import ( - reset, - read_section, - save_hexdump, -) - -# Silicon Labs CP2102N default USB VID/PID. -CP2102N_VID = 0x10C4 -CP2102N_PID = 0xEA60 - -SERIAL_PORT = "COM1" - - -def __flash_image(): - print(f"1. Enter a firmware filepath (.bin):") - image_path = input("> ") - if len(image_path) < 5 or image_path[-4:] != ".bin": - image_path += ".bin" - - print(f"2. Opening serial port ({SERIAL_PORT})") - with serial.Serial( - SERIAL_PORT, 115200, parity=serial.PARITY_EVEN, timeout=1 - ) as ser: - time.sleep(1) # Wait for NRSTs to clear from serial port establishment - - print(f"3. Beginning firmware flash") - - try: - flash_image(ser, image_path) - except RuntimeError as e: - if "Sync failed" in str(e): - raise RuntimeError("Ensure BOOT0 is raised, then retry") - - print("\tFirmware update successful") - - -def __nvm_reset(): - print(f"1. Opening serial port ({SERIAL_PORT})") - with serial.Serial(SERIAL_PORT, 115200) as ser: - time.sleep(1) # Wait for NRSTs to clear from serial port establishment - - print(f"2. Beginning NVM reset") - reset(ser) - - print("\tReset NVM") - - -def __nvm_memory_extract(): - print(f"1. Enter a starting address (Hex):") - section_start = input("> ").strip() - try: - section_start = int(section_start, 16) - except TypeError: - raise ValueError("Expected hexadecimal address") - - print(f"2. Enter a read length (recommended 4096):") - section_length = input("> ").strip() - try: - section_length = int(section_length) - except TypeError: - raise ValueError("Expected hexadecimal address") - - print(f"3. Enter a output filepath (recommended .txt):") - output_file_path = input("> ") - if len(output_file_path) < 5 or output_file_path[-4:] != ".txt": - output_file_path += ".txt" - - print(f"4. Opening serial port ({SERIAL_PORT})") - with serial.Serial(SERIAL_PORT, 115200) as ser: - # Pulse NRST before start - pulse_nrst(ser, duration_ms=50) - time.sleep(0.05) - - time.sleep(7) # Wait for NRSTs to clear from serial port establishment - - print(f"5. Beginning NVM read communication") - sector = read_section( - ser, start_addr=section_start, length=section_length - ) - - print(f"6. Beginning hex dump file save") - save_hexdump(sector, start_addr=section_start, filename=output_file_path) - - print(f"\tWrote {len(sector)} bytes to {output_file_path}") - - -def __serial_port_manual_config(): - global SERIAL_PORT - - print(f"Current serial port: {SERIAL_PORT}") - print("Enter a serial port:") - input_serial_port = input("> ").strip().lower() - if input_serial_port.isnumeric(): - SERIAL_PORT = f"COM{input_serial_port}" - else: - SERIAL_PORT = input_serial_port.upper() - print(f"\tSerial port configured to: {SERIAL_PORT}") - - -def __find_cp2102n_ports(): - """Scan serial ports and return those matching the CP2102N VID/PID.""" - matches = [] - # Pre-build the hex string once for fallback matching - vid_pid = f"{CP2102N_VID:04X}:{CP2102N_PID:04X}".lower() - - for port in list_ports.comports(): - # Primary check via explicit attributes (pyserial 3.4) - if port.vid == CP2102N_VID and port.pid == CP2102N_PID: - matches.append( - { - "device": port.device, - "description": port.description, - "hwid": port.hwid, - } - ) - - # Fallback: case-insensitive search in the hwid string - elif port.hwid and vid_pid in port.hwid.lower(): - matches.append( - { - "device": port.device, - "description": port.description, - "hwid": port.hwid, - } - ) - - return matches - - -def __serial_port_auto_config(): - global SERIAL_PORT - - cp_ports = __find_cp2102n_ports() - if cp_ports: - print( - f"\tFound CP2102N device(s): " - f"{', '.join([port['device'] for port in cp_ports])}" - ) - SERIAL_PORT = cp_ports[0]["device"] - print(f"\tSerial port configured to: {SERIAL_PORT}") +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] in ("-c", "--cli"): + # Run CLI app + run_cli() else: - print("\tNo CP2102N devices found, please add a serial port manually") - - -def header_print(): - print( - "-------------------------------------------------------------------------------\n" - " Momentum pyBlasher (v0.1.0-alpha) \n" - "-------------------------------------------------------------------------------\n" - ) - - -def end_of_command_print(): - print() - + # Run GUI app + from gui import run_gui -def main_menu_print(): - print( - " Options: (Not case sensitive)\n" - " 1 = Momentum firmware update\n" - " 2 = NVM reset (wipe memory)\n" - " 3 = NVM sector readout\n" - " 8 = Automatic serial port configuration\n" - " 9 = Manual serial port configuration\n" - " e = Exit\n" - ) - - -def main(): - header_print() - - __serial_port_auto_config() - - end_of_command_print() - - try: - while True: - main_menu_print() - choice = input("> ").strip().lower()[0] - - start = time.time() - - try: - if choice == "1": - __flash_image() - elif choice == "2": - __nvm_reset() - elif choice == "3": - __nvm_memory_extract() - elif choice == "8": - __serial_port_auto_config() - elif choice == "9": - __serial_port_manual_config() - elif choice == "e": - raise KeyboardInterrupt - else: - print(f"Invalid choice: {choice!r}!") - except ValueError as e: - print(f"\tValueError: {e}") - except serial.serialutil.SerialException as e: - print(f"\tSerialException: {e}") - except FileNotFoundError as e: - print(f"\tFileNotFoundError: {e}") - except RuntimeError as e: - print(f"\tRuntimeError: {e}") - - print(f"\tCompleted in {time.time() - start} seconds") - - end_of_command_print() - - except KeyboardInterrupt: - print("\nTerminating program...") - sys.exit(1) - - -if __name__ == "__main__": - main() + run_gui() diff --git a/requirements.txt b/requirements.txt index cb8f9a2..2b2238f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ # $ pip install -r requirements.txt +Kivy~=2.3.1 pyserial~=3.5 diff --git a/util.py b/util.py new file mode 100644 index 0000000..8197ffe --- /dev/null +++ b/util.py @@ -0,0 +1,17 @@ +"""pyBlasher utility helper functions.""" + +from serial.tools import list_ports + +from constants import * + + +def find_cp2102n_ports(): + """Scan serial ports and return those matching the CP2102N VID/PID.""" + matches = [] + vid_pid = f"{CP2102N_VID:04X}:{CP2102N_PID:04X}".lower() + for port in list_ports.comports(): + if port.vid == CP2102N_VID and port.pid == CP2102N_PID: + matches.append(port.device) + elif port.hwid and vid_pid in port.hwid.lower(): + matches.append(port.device) + return matches