diff --git a/APP/binaries/firmware.uf2 b/APP/binaries/firmware.uf2 new file mode 100644 index 0000000..188dd00 Binary files /dev/null and b/APP/binaries/firmware.uf2 differ diff --git a/APP/binaries/flux_arduino.ino.bin b/APP/binaries/flux_arduino.ino.bin deleted file mode 100644 index 67dd41b..0000000 Binary files a/APP/binaries/flux_arduino.ino.bin and /dev/null differ diff --git a/APP/firmware_updater.py b/APP/firmware_updater.py index 88ddae1..aed072d 100644 --- a/APP/firmware_updater.py +++ b/APP/firmware_updater.py @@ -8,40 +8,232 @@ import logging import traceback import pathlib +import threading +import os +import shutil +import traceback from fluxpad_interface import Fluxpad -BOSSAC_PATH = (pathlib.Path(__file__).parent / "tools" / "bossac.exe").resolve() +# BOSSAC_PATH = (pathlib.Path(__file__).parent / "tools" / "bossac.exe").resolve() + +BUFFER_SIZE = 128 * 1024 + +class SameFileError(OSError): + """Raised when source and destination are the same file.""" + + +def copy_with_callback( + src, dest, callback=None, buffer_size=BUFFER_SIZE +): + """ Copy file with a callback. + callback, if provided, must be a callable and will be + called after ever buffer_size bytes are copied. + Args: + src: source file, must exist + dest: destination path; if an existing directory, + file will be copied to the directory; + if it is not a directory, assumed to be destination filename + callback: callable to call after every buffer_size bytes are copied + callback will called as callback(bytes_copied since last callback, total bytes copied, total bytes in source file) + buffer_size: how many bytes to copy before each call to the callback, default = 4Mb + Returns: + Full path to destination file + Raises: + FileNotFoundError if src doesn't exist + SameFileError if src and dest are the same file + Note: Does not copy extended attributes, resource forks or other metadata. + """ + + srcfile = pathlib.Path(src) + destpath = pathlib.Path(dest) + + if not srcfile.is_file(): + raise FileNotFoundError(f"src file `{src}` doesn't exist") + + destfile = destpath / srcfile.name if destpath.is_dir() else destpath + + if destfile.exists() and srcfile.samefile(destfile): + raise SameFileError( + f"source file `{src}` and destinaton file `{dest}` are the same file." + ) + + if callback is not None and not callable(callback): + raise ValueError("callback is not callable") + + size = os.stat(src).st_size + with open(srcfile, "rb") as fsrc: + with open(destfile, "wb") as fdest: + _copyfileobj( + fsrc, fdest, callback=callback, total=size, length=buffer_size + ) + return str(destfile) + + +def _copyfileobj(fsrc, fdest, callback, total, length): + """ copy from fsrc to fdest + Args: + fsrc: filehandle to source file + fdest: filehandle to destination file + callback: callable callback that will be called after every length bytes copied + total: total bytes in source file (will be passed to callback) + length: how many bytes to copy at once (between calls to callback) + """ + copied = 0 + while True: + buf = fsrc.read(length) + if not buf: + break + fdest.write(buf) + copied += len(buf) + if callback is not None: + callback(len(buf), copied, total) + +class FirmwareUploadProgress: + # Object to hold upload progress info + + def __init__(self): + self.lock = threading.RLock() + self.progress_percent = 0.0 + self.current_step = "None" + self.is_done = False + self.error_string = "" + self.update_event = threading.Event() + + +def upload_firmware_threaded(port: serial.Serial, bin_path: pathlib.Path): + progress = FirmwareUploadProgress() + fw_upload_thread = threading.Thread(target=_upload_firmware, args=(progress, port, bin_path), name="fwupdatethread", daemon=True) + fw_upload_thread.start() + return progress + +def _upload_firmware(progress: FirmwareUploadProgress, port: serial.Serial, bin_path: pathlib.Path): + + def copy_progress_callback(buf_size, copied, total): + with progress.lock: + progress.progress_percent = 30 + 70 * copied / total + progress.update_event.set() + print(progress.progress_percent) + + try: + with progress.lock: + progress.current_step = "Resetting to bootloader" + progress.progress_percent = 10 + progress.update_event.set() + _reset_port(port) + + with progress.lock: + progress.current_step = "Waiting for enumeration" + progress.progress_percent = 20 + progress.update_event.set() + rpi_drive = _listen_for_rpi_drive() + + with progress.lock: + progress.current_step = "Uploading firmware" + progress.progress_percent = 30 + progress.update_event.set() + + dest_file = rpi_drive / bin_path.name + copy_with_callback(bin_path, dest_file, copy_progress_callback) + + with progress.lock: + progress.current_step = "Done" + progress.is_done = True + progress.progress_percent = 100 + progress.update_event.set() + + except Exception: + with progress.lock: + progress.error_string = traceback.format_exc() + progress.is_done = True + progress.update_event.set() + + +def _listen_for_rpi_drive(timeout_s: float = 5, period_s: float = 0.3): + + start_time_s = time.monotonic() + while start_time_s - time.monotonic() < timeout_s : + for drive in _list_available_drives(): + print(drive) + if _has_info_uf2_file(drive): + return drive + + time.sleep(period_s) + + +def _list_available_drives(): + """ + Detects and returns a list of available drives on the system. + This function finds available drives on Unix/Linux/MacOS in common mount points like '/media' and '/mnt', + and on Windows, by iterating through drive letters from 'A' to 'Z'. + Returns: + list of pathlib.Path objects: A list of pathlib.Path objects representing available drives. + """ + drives = [] + if os.name == 'posix': # Unix/Linux/MacOS + # On Unix-based systems, drives are typically mounted in /media or /mnt + mounts = ['/media', '/mnt'] + for mount in mounts: + mount_path = pathlib.Path(mount) + if mount_path.is_dir(): + drives.extend([entry for entry in mount_path.iterdir() if entry.is_dir() and not entry.name.startswith('.')]) + elif os.name == 'nt': # Windows + # On Windows, you can list drives by iterating from 'A' to 'Z' + drives = [pathlib.Path(f'{chr(d)}:') for d in range(65, 91) if pathlib.Path(f'{chr(d)}:').is_dir()] + return drives + + +def _has_info_uf2_file(directory_path: pathlib.Path): + """ + Checks if the given pathlib directory contains a file named 'INFO_UF2.TXT'. + Parameters: + directory_path (pathlib.Path): The path to the directory to be checked. + Returns: + bool: True if 'INFO_UF2.TXT' file exists in the directory, False otherwise. + """ + info_uf2_file_path = directory_path / 'INFO_UF2.TXT' + return info_uf2_file_path.is_file() + +def _reset_port(port: serial.Serial): + """Resets given port by opening and closing port at 1200 baud""" + port.close() + time.sleep(0.1) + # assert not port.is_open + port.baudrate = 1200 + port.open() + time.sleep(0.5) + port.close() + time.sleep(0.2) + class FirmwareUpdateFrame(ttk.Labelframe): - def __init__(self, master, *args, **kwargs): + def __init__(self, master, firmware_dir: pathlib.Path, *args, **kwargs): super().__init__(master, *args, **kwargs) # Firmware Update elements - self.update_frame = ttk.Labelframe(self, text="Firmware Update") - self.update_frame.grid(row=1, column=0, padx=5, pady=5, sticky="W") + # self.update_frame = ttk.Labelframe(self, text="Firmware Update") + self.configure(text="Firmware Update") + # self.update_frame.grid(row=1, column=0, padx=5, pady=5, sticky="W") + + self.label_progress = ttk.Label(self, text="FW Update Progress") + self.label_progress.grid(row=1, column=0, padx=5, pady=5, sticky="W") + + self.progressbar_update = ttk.Progressbar(self, mode="determinate", orient="horizontal", length=200, maximum=100) + self.progressbar_update.grid(row=2, column=0, padx=5, pady=5, sticky="W") + self.btn_update = ttk.Button( - self.update_frame, text="Update", command=self.upload_firmware_callback + self, text="Update", command=self.upload_firmware_callback ) self.btn_update["state"] = "disabled" - self.btn_update.grid(row=1, column=0, padx=5, pady=5, sticky="W") - self.fetch_releases_button = ttk.Button( - self.update_frame, - text="Fetch Releases", - # command=self.fetch_firmware_callback, - ) - self.fetch_releases_button.grid(row=0, column=1) - self.firmware_combobox = ttk.Combobox( - self.update_frame, - # textvariable=self.selected_firmware_release, - width=10 - ) - self.firmware_combobox.grid(row=0, column=0) + self.btn_update.grid(row=3, column=0, padx=5, pady=5, sticky="W") self.fluxpad: Optional[Fluxpad] = None + self.stop_listener_callback = None + + self.fw_bin_path = (firmware_dir / "firmware.uf2").resolve() - def set_fluxpad(self, fluxpad: Fluxpad): + def set_fluxpad(self, fluxpad: Optional[Fluxpad]): self.fluxpad = fluxpad def disable_update(self): @@ -50,62 +242,31 @@ def disable_update(self): def enable_update(self): self.btn_update.state(["!disabled"]) + def set_stop_listener_callback(self, callback): + self.stop_listener_callback = callback def upload_firmware_callback(self): - try: + self.stop_listener_callback() assert self.fluxpad is not None - bin_path = pathlib.Path(pathlib.Path(__file__).parent / "binaries" / "flux_arduino.ino.bin").resolve() - upload_firmware(self.fluxpad.port, str(bin_path)) + progress = upload_firmware_threaded(self.fluxpad.port, self.fw_bin_path) + while progress.update_event.wait(timeout=10): + print("waited") + with progress.lock: + self.label_progress.configure(text=progress.current_step) + self.progressbar_update.configure(value=progress.progress_percent) + progress.update_event.clear() + self.progressbar_update.update() + self.label_progress.update() + if progress.is_done: + print("isdone") + break + if progress.error_string: + raise Exception(progress.error_string) + + self.label_progress.configure(text="Done") + self.progressbar_update.configure(value=0) + except Exception: logging.error(f"Failed to upload firmware", exc_info=True) - messagebox.showerror("Exception", f"Failed to load image\n\n{traceback.format_exc()}") - -def upload_firmware(port: serial.Serial, bin_path: str): - - _reset_port(port) - _bossac_upload(port.name, str(BOSSAC_PATH), bin_path) - - -def _reset_port(port: serial.Serial): - """Resets given port by opening and closing port twice within 500ms at 1200 baud""" - port.close() - time.sleep(2) - assert not port.is_open - port.baudrate = 1200 - port.open() - time.sleep(0.05) - port.close() - time.sleep(0.05) - port.open() - time.sleep(0.05) - port.close() - # Wait for reset and enumeration - time.sleep(2) - -def _bossac_upload(port: str, bossac_path: str, bin_path: str): - """Run Bossac to upload given bin file to given serial port""" - - args = ( - f"{bossac_path}", - # f"--info", - "-p", # Select port - f"{port}", - "-U", - "true", - "-i", # INFO - "-e", # Erase - "-w", # Write - "-v", # Verify - f"{bin_path}", - "-R", - ) - print(args) - - popen = subprocess.run( - args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - output = popen.stdout.decode("ASCII") - err = popen.stderr.decode("ASCII") - logging.info(output) - logging.info(err) \ No newline at end of file + messagebox.showerror("Exception", f"Failed to update firmware\n\n{traceback.format_exc()}") diff --git a/APP/fluxapp.py b/APP/fluxapp.py index 3065c38..de7e515 100644 --- a/APP/fluxapp.py +++ b/APP/fluxapp.py @@ -5,11 +5,12 @@ from typing import Union, Optional, Callable, List, Type from collections import deque import logging +import platform +import statistics from tkinter import font import pathlib import time import threading -import statistics from tkinter import messagebox from tkinter import filedialog import math @@ -42,10 +43,12 @@ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): print('running in a PyInstaller bundle') IMAGE_DIR = (pathlib.Path(__file__).parent / "images").resolve() + FIRMWARE_DIR = (pathlib.Path(__file__).parent / "binaries").resolve() BOSSAC_PATH = (pathlib.Path(__file__).parent / "tools" / "bossac.exe").resolve() else: print('running in a normal Python process') IMAGE_DIR = (pathlib.Path(__file__).parent / "images").resolve() + FIRMWARE_DIR = (pathlib.Path(__file__).parent / "binaries").resolve() BOSSAC_PATH = (pathlib.Path(__file__).parent / "tools" / "bossac.exe").resolve() @@ -588,7 +591,7 @@ def on_select_per_key_analog(self, event:tk.Event): logging.debug("Selected per key analog") # Switch back to digital key 1 if currently on digital key 2 - if self.key_select_frame.is_per_key_analog.get() and self.selected_settings_panel == 3: + if self.key_select_frame.is_per_key_analog.get() and self.selected_settings_panel in (3,4): self.on_select_key(tk.Event(), 2) @@ -1096,8 +1099,9 @@ def __init__(self, master, *args, **kwargs): test_label.grid(row=2, column=1, sticky="NEW") # Don't show firmware update for now - self.firmware_update_frame = firmware_updater.FirmwareUpdateFrame(self) - # self.firmware_update_frame.grid(row=3, column=1, sticky="NEW") + self.firmware_update_frame = firmware_updater.FirmwareUpdateFrame(self, FIRMWARE_DIR) + self.firmware_update_frame.grid(row=3, column=1, sticky="NEW") + class CalibrationTopLevel(tk.Toplevel): @@ -1260,7 +1264,6 @@ def on_calibrate(self): self.destroy() - class Application(ttk.Frame): """Top Level application frame""" @@ -1321,6 +1324,9 @@ def __init__(self, master=None): self.frame_utilities.calibration_labelframe.analog_cal_frame_list[2].btn_set_up.configure(command=lambda: self.on_calibrate_button(is_up=True, key_id=4)) self.frame_utilities.calibration_labelframe.analog_cal_frame_list[2].btn_set_down.configure(command=lambda: self.on_calibrate_button(is_up=False, key_id=4)) + # Wire calibration listener callback + self.frame_utilities.firmware_update_frame.set_stop_listener_callback(self.on_fw_upload_button) + # Show menu bar self.master.configure(menu=self.menubar) @@ -1363,7 +1369,7 @@ def _on_disconnected_gui(self): self.save_menu.entryconfigure(1, state=tk.DISABLED) self.load_menu.entryconfigure(1, state=tk.DISABLED) self.frame_utilities.firmware_update_frame.disable_update() - + self.frame_utilities.firmware_update_frame.set_fluxpad(None) def ask_load_from_fluxpad(self): should_load = messagebox.askyesno("Fluxpad Connected", "Load settings from connected FLUXPAD?") @@ -1402,6 +1408,8 @@ def _calibration_worker(self): self.frame_utilities.calibration_labelframe.analog_cal_frame_list[selected_analog_key].update_height(response.height_mm - 2) except fluxpad_interface.serial.SerialException: logging.info(f"Serial exception {self.fluxpad.port.name}") + self.on_calibration_tab = False + break except Exception: logging.error("Exception at calibration worker", exc_info=1) finally: @@ -1426,6 +1434,13 @@ def on_calibrate_button(self, is_up, key_id): self.on_notebook_tab_changed(tk.Event()) self.update() + def on_fw_upload_button(self): + self.on_calibration_tab = False + while self.fluxpad.port.is_open or self.worker_busy: + self.on_calibration_tab = False + logging.debug(f"Waiting for port to close, isopen {self.fluxpad.port.is_open}, busy {self.worker_busy}") + time.sleep(0.1) + def on_notebook_tab_changed(self, event: tk.Event): """"Turn on and off keyboard listener and calibration worker based on which tab is active""" @@ -1540,7 +1555,10 @@ def on_save_to_fluxpad(self): use_sv_ttk.set_theme("light") WIDTH = 500 - HEIGHT = 620 + if platform.system() == "Windows" and platform.release() == "11": + HEIGHT = 660 + else: + HEIGHT = 620 ws = root.winfo_screenwidth() hs = root.winfo_screenheight() diff --git a/APP/fluxapp.spec b/APP/fluxapp.spec index f1b43b4..fbdd66f 100644 --- a/APP/fluxapp.spec +++ b/APP/fluxapp.spec @@ -8,8 +8,7 @@ added_files = [ ( 'Sun-Valley-ttk-theme/sv.tcl', '.' ), ( 'images/*.png', 'images' ), ( 'images/*.ico', 'images' ), - ( 'tools/*.exe', 'tools' ), - ( 'binaries/*.bin', 'binaries' ), + ( 'binaries/*.uf2', 'binaries' ), ] a = Analysis( diff --git a/FW/fluxpad_rp2040_pio/.vscode/settings.json b/FW/fluxpad_rp2040_pio/.vscode/settings.json index a6feff1..d398d7a 100644 --- a/FW/fluxpad_rp2040_pio/.vscode/settings.json +++ b/FW/fluxpad_rp2040_pio/.vscode/settings.json @@ -3,5 +3,9 @@ "editor.formatOnSave": true, "C_Cpp.clang_format_fallbackStyle": "{ BasedOnStyle: LLVM, UseTab: Never, IndentWidth: 4, TabWidth: 4, ColumnLimit: 120}", "editor.indentSize": "tabSize", - "editor.tabSize": 4 + "editor.tabSize": 4, + "files.associations": { + "iostream": "cpp", + "random": "cpp" + } } \ No newline at end of file diff --git a/FW/fluxpad_rp2040_pio/src/flux_arduino.ino b/FW/fluxpad_rp2040_pio/src/flux_arduino.ino index 6ca0484..f754fb6 100644 --- a/FW/fluxpad_rp2040_pio/src/flux_arduino.ino +++ b/FW/fluxpad_rp2040_pio/src/flux_arduino.ino @@ -261,6 +261,9 @@ void typeHIDKey(const KeyMapEntry_t *entry) { switch (entry->keyType) { case KeyType_t::KEYBOARD: // Keyboard.write(KeyboardKeycode(entry->keycode.keyboard)); + keyboard_device.add_key(entry->keycode.keyboard); + keyboard_device.hid_keyboard_service(usb_hid, RID_KEYBOARD); + keyboard_device.remove_key(entry->keycode.keyboard); break; case KeyType_t::CONSUMER: consumer_device.consumer_press_and_release_key(entry->keycode.consumer);