From 33873870aaf6da528304f150f3ae1e7d73e7d6d2 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 6 Jul 2023 02:02:10 +0800 Subject: [PATCH 01/10] Add GUI with pysimplegui And bundled into single binary for Windows with pyinstaller. Signed-off-by: Daniel Schaefer --- .github/workflows/ci.yml | 15 ++ .gitignore | 7 + qmk_gui.py | 331 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 4 files changed, 355 insertions(+) create mode 100644 qmk_gui.py create mode 100644 requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dcf8f5..2e2da07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,3 +107,18 @@ jobs: - name: Run cargo clippy run: cargo clippy -- -D warnings + + build-gui: + name: Build Windows + runs-on: windows-2022 + steps: + - uses: actions/checkout@v3 + + - name: Create Executable + uses: Martin005/pyinstaller-action@main + with: + python_ver: '3.11' + spec: qmk_gui.py + requirements: 'requirements.txt' + upload_exe_with_name: 'qmk_gui.py' + options: --onefile, --name "qmk_gui", --windowed diff --git a/.gitignore b/.gitignore index 2f7896d..b699590 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ target/ + +venv/ + +# pyinstaller +qmk_gui.spec +build/ +dist/ diff --git a/qmk_gui.py b/qmk_gui.py new file mode 100644 index 0000000..4cccdc7 --- /dev/null +++ b/qmk_gui.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +import PySimpleGUI as sg +import hid + +# TODO: +# - Clear EEPROM +# - Save settings +# - Get current values +# - Set sliders to current values +# - Show connected devices +# - Get firmware version + +FWK_VID = 0x32AC + +QMK_INTERFACE = 0x01 +RAW_HID_BUFFER_SIZE = 32 + +RAW_USAGE_PAGE = 0xFF60 +CONSOLE_USAGE_PAGE = 0xFF31 +# Generic Desktop +G_DESK_USAGE_PAGE = 0x01 +CONSUMER_USAGE_PAGE = 0x0C + +GET_PROTOCOL_VERSION = 0x01 # always 0x01 +GET_KEYBOARD_VALUE = 0x02 +SET_KEYBOARD_VALUE = 0x03 +# DynamicKeymapGetKeycode = 0x04 +# DynamicKeymapSetKeycode = 0x05 +# DynamicKeymapReset = 0x06 +CUSTOM_SET_VALUE = 0x07 +CUSTOM_GET_VALUE = 0x08 +CUSTOM_SAVE = 0x09 +EEPROM_RESET = 0x0A +BOOTLOADER_JUMP = 0x0B + +CHANNEL_CUSTOM = 0 +CHANNEL_BACKLIGHT = 1 +CHANNEL_RGB_LIGHT = 2 +CHANNEL_RGB_MATRIX = 3 +CHANNEL_AUDIO = 4 + +BACKLIGHT_VALUE_BRIGHTNESS = 1 +BACKLIGHT_VALUE_EFFECT = 2 + +RGB_MATRIX_VALUE_BRIGHTNESS = 1 +RGB_MATRIX_VALUE_EFFECT = 2 +RGB_MATRIX_VALUE_EFFECT_SPEED = 3 +RGB_MATRIX_VALUE_COLOR = 4 + +RED_HUE = 0 +YELLOW_HUE = 43 +GREEN_HUE = 85 +CYAN_HUE = 125 +BLUE_HUE = 170 +PURPLE_HUE = 213 + +RGB_EFFECTS = [ + "Off", + "SOLID_COLOR", + "ALPHAS_MODS", + "GRADIENT_UP_DOWN", + "GRADIENT_LEFT_RIGHT", + "BREATHING", + "BAND_SAT", + "BAND_VAL", + "BAND_PINWHEEL_SAT", + "BAND_PINWHEEL_VAL", + "BAND_SPIRAL_SAT", + "BAND_SPIRAL_VAL", + "CYCLE_ALL", + "CYCLE_LEFT_RIGHT", + "CYCLE_UP_DOWN", + "CYCLE_OUT_IN", + "CYCLE_OUT_IN_DUAL", + "RAINBOW_MOVING_CHEVRON", + "CYCLE_PINWHEEL", + "CYCLE_SPIRAL", + "DUAL_BEACON", + "RAINBOW_BEACON", + "RAINBOW_PINWHEELS", + "RAINDROPS", + "JELLYBEAN_RAINDROPS", + "HUE_BREATHING", + "HUE_PENDULUM", + "HUE_WAVE", + "PIXEL_FRACTAL", + "PIXEL_FLOW", + "PIXEL_RAIN", + "TYPING_HEATMAP", + "DIGITAL_RAIN", + "SOLID_REACTIVE_SIMPLE", + "SOLID_REACTIVE", + "SOLID_REACTIVE_WIDE", + "SOLID_REACTIVE_MULTIWIDE", + "SOLID_REACTIVE_CROSS", + "SOLID_REACTIVE_MULTICROSS", + "SOLID_REACTIVE_NEXUS", + "SOLID_REACTIVE_MULTINEXUS", + "SPLASH", + "MULTISPLASH", + "SOLID_SPLASH", + "SOLID_MULTISPLASH", +] + +def main(devices): + layout = [ + [sg.Text("Keyboard")], + + [sg.Text("Bootloader")], + [sg.Button("Bootloader", k='-BOOTLOADER-')], + + [sg.Text("Single-Zone Brightness")], + # TODO: Get default from device + [sg.Slider((0, 255), orientation='h', default_value=120, + k='-BRIGHTNESS-', enable_events=True)], + #[sg.Button("Enable Breathing", k='-ENABLE-BREATHING-')], + #[sg.Button("Disable Breathing", k='-DISABLE-BREATHING-')], + + [sg.Text("RGB Brightness")], + # TODO: Get default from device + [sg.Slider((0, 255), orientation='h', default_value=120, + k='-RGB-BRIGHTNESS-', enable_events=True)], + + [sg.Text("RGB Color")], + [ + sg.Button("Red", k='-RED-'), + sg.Button("Green", k='-GREEN-'), + sg.Button("Blue", k='-BLUE-'), + sg.Button("White", k='-WHITE-'), + sg.Button("Off", k='-OFF-'), + ], + + [sg.Text("RGB Effect")], + [sg.Combo(RGB_EFFECTS, k='-RGB-EFFECT-', enable_events=True)], + + [sg.Text("Save Settings")], + [sg.Button("Save", k='-SAVE-'), sg.Button("Clear EEPROM", k='-CLEAR-EEPROM-')], + + [sg.Button("Quit")] + ] + window = sg.Window("QMK Keyboard Control", layout) + + while True: + event, values = window.read() + #print('Event', event) + #print('Values', values) + + for dev in devices: + if event == "-BOOTLOADER-": + bootloader_jump(dev) + + if event == '-BRIGHTNESS-': + brightness(dev, int(values['-BRIGHTNESS-'])) + + if event == '-RGB-BRIGHTNESS-': + rgb_brightness(dev, int(values['-RGB-BRIGHTNESS-'])) + + if event == '-RGB-EFFECT-': + effect = RGB_EFFECTS.index(values['-RGB-EFFECT-']) + set_rgb_u8(dev, RGB_MATRIX_VALUE_EFFECT, effect) + # TODO: Get effect + + if event == '-RED-': + set_rgb_color(dev, RED_HUE, 255) + if event == '-GREEN-': + set_rgb_color(dev, GREEN_HUE, 255) + if event == '-BLUE-': + set_rgb_color(dev, BLUE_HUE, 255) + if event == '-WHITE-': + set_rgb_color(dev, None, 0) + if event == '-OFF-': + window['-RGB-BRIGHTNESS-'].Update(0) + rgb_brightness(dev, 0) + + if event == '-SAVE-': + save(dev) + + if event == '-CLEAR-EEPROM-': + eeprom_reset(dev) + + if event == "Quit" or event == sg.WIN_CLOSED: + break + + window.close() + + +def find_devs(show, verbose): + if verbose: + show = True + + devices = [] + for device_dict in hid.enumerate(): + vid = device_dict["vendor_id"] + if vid != FWK_VID: + continue + + if device_dict['usage_page'] not in [RAW_USAGE_PAGE, CONSOLE_USAGE_PAGE]: + continue + + pid = device_dict["product_id"] + product = device_dict["product_string"] + manufacturer = device_dict["manufacturer_string"] + sn = device_dict['serial_number'] + interface = device_dict['interface_number'] + path = device_dict['path'] + + fw_ver = device_dict["release_number"] + fw_ver_major = (fw_ver & 0xFF00) >> 8 + fw_ver_minor = (fw_ver & 0x00F0) >> 4 + fw_ver_patch = (fw_ver & 0x000F) + + if device_dict['usage_page'] == RAW_USAGE_PAGE or verbose: + if show: + print(f"Manufacturer: {manufacturer}") + print(f"Product: {product}") + print(f"FW Version: {fw_ver_major}.{fw_ver_minor}.{fw_ver_patch}") + print(f"Serial No: {sn}") + + if verbose: + print(f"VID/PID: {vid:02X}:{pid:02X}") + print(f"Interface: {interface}") + # TODO: print Usage Page + print("") + + if interface == QMK_INTERFACE: + devices.append(device_dict) + + return devices + + +def send_message(dev, message_id, msg, out_len): + data = [0xFE] * RAW_HID_BUFFER_SIZE + data[0] = 0x00 # NULL report ID + data[1] = message_id + + if msg: + if len(msg) > RAW_HID_BUFFER_SIZE-2: + print("Message too big. BUG. Please report") + sys.exit(1) + for i, x in enumerate(msg): + data[2+i] = x + + try: + # TODO: Do this somewhere outside + h = hid.device() + h.open_path(dev['path']) + #h.set_nonblocking(0) + h.write(data) + + if out_len == 0: + return None + + out_data = h.read(out_len) + return out_data + except IOError as ex: + print(ex) + sys.exit(1) + +def set_keyboard_value(dev, value, number): + msg = [value, number] + send_message(dev, SET_KEYBOARD_VALUE, msg, 0) + +def set_rgb_u8(dev, value, value_data): + msg = [CHANNEL_RGB_MATRIX, value, value_data] + send_message(dev, CUSTOM_SET_VALUE, msg, 0) + +def get_rgb_u8(dev, value): + msg = [CHANNEL_RGB_MATRIX, value] + output = send_message(dev, CUSTOM_SET_VALUE, msg, 3) + print("output", output) + return output[2] + +def get_backlight(dev, value, value_data): + msg = [CHANNEL_BACKLIGHT, value] + output = send_message(dev, CUSTOM_SET_VALUE, msg, 3) + print(output[2]) + return output[2] + +def set_backlight(dev, value, value_data): + msg = [CHANNEL_BACKLIGHT, value, value_data] + send_message(dev, CUSTOM_SET_VALUE, msg, 0) + +def save(dev): + save_rgb(dev) + save_backlight(dev) + +def save_rgb(dev): + msg = [CHANNEL_RGB_MATRIX] + send_message(dev, CUSTOM_SAVE, msg, 0) + +def save_backlight(dev): + msg = [CHANNEL_BACKLIGHT] + send_message(dev, CUSTOM_SAVE, msg, 0) + +def eeprom_reset(dev): + send_message(dev, EEPROM_RESET, None, 0) + + +def bootloader_jump(dev): + send_message(dev, BOOTLOADER_JUMP, None, 0) + + +def rgb_brightness(dev, brightness): + set_rgb_u8(dev, RGB_MATRIX_VALUE_BRIGHTNESS, brightness) + #brightness = get_rgb_u8(dev, RGB_MATRIX_VALUE_BRIGHTNESS) + #print(f"New Brightness: {brightness}") + + +def brightness(dev, brightness): + set_backlight(dev, BACKLIGHT_VALUE_BRIGHTNESS, brightness) + #brightness = get_backlight(dev, BACKLIGHT_VALUE_BRIGHTNESS) + #print(f"New Brightness: {brightness}") + + +def set_rgb_color(dev, hue, saturation): + if not hue: + # TODO: Just choose current hue + hue = 0 + msg = [CHANNEL_RGB_MATRIX, RGB_MATRIX_VALUE_COLOR, hue, saturation] + send_message(dev, CUSTOM_SET_VALUE, msg, 0) + + +if __name__ == "__main__": + devices = find_devs(show=False, verbose=False) + + #for device in devices: + # if device['product_string'] == 'Laptop 16 Keyboard Module - ANSI': + # continue + # bootloader_jump(device) + + main(devices) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c8e4248 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +hidapi==0.14.0 +PySimpleGUI==4.60.5 From 26bcfe378fd1bd489eb4c662b476859c84b09b0b Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 6 Jul 2023 05:31:01 +0800 Subject: [PATCH 02/10] qmk_gui: Exe extension Signed-off-by: Daniel Schaefer --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e2da07..2af68b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,5 +120,5 @@ jobs: python_ver: '3.11' spec: qmk_gui.py requirements: 'requirements.txt' - upload_exe_with_name: 'qmk_gui.py' + upload_exe_with_name: 'qmk_gui.exe' options: --onefile, --name "qmk_gui", --windowed From 4ab545621e50bb37c76450c77a307b60ba1ac7a7 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 6 Jul 2023 12:15:29 +0800 Subject: [PATCH 03/10] qmk_gui: Display version Signed-off-by: Daniel Schaefer --- README.md | 6 ++++++ qmk_gui.py | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 59f7e4b..398487c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ It will soon be superceded by QMK XAP, but that isn't ready yet. Tested to work on Windows and Linux, without any drivers or admin privileges. +###### GUI + +There is also an easy to use GUI tool that does not require commandline interaction. +On Linux install Python requirements via `python3 -m install -r requirements.txt` and run `qmk_gui.py`. +On Windows download the `qmk_gui.exe` and run it. + ## Running Download the latest binary from the [releases page](https://github.com/FrameworkComputer/qmk_hid/releases). diff --git a/qmk_gui.py b/qmk_gui.py index 4cccdc7..baa6c3f 100644 --- a/qmk_gui.py +++ b/qmk_gui.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import sys + import PySimpleGUI as sg import hid @@ -102,9 +104,24 @@ "SOLID_MULTISPLASH", ] +def format_fw_ver(fw_ver): + fw_ver_major = (fw_ver & 0xFF00) >> 8 + fw_ver_minor = (fw_ver & 0x00F0) >> 4 + fw_ver_patch = (fw_ver & 0x000F) + return f"{fw_ver_major}.{fw_ver_minor}.{fw_ver_patch}" + def main(devices): + device_info = "" + for dev in devices: + device_info += "{}\n Serial No: {}\n FW Version: {}\n".format( + dev['product_string'], + dev['serial_number'], + format_fw_ver(dev['release_number']) + ) + layout = [ - [sg.Text("Keyboard")], + [sg.Text("Detected Devices")], + [sg.Text(device_info)], [sg.Text("Bootloader")], [sg.Button("Bootloader", k='-BOOTLOADER-')], @@ -205,15 +222,12 @@ def find_devs(show, verbose): path = device_dict['path'] fw_ver = device_dict["release_number"] - fw_ver_major = (fw_ver & 0xFF00) >> 8 - fw_ver_minor = (fw_ver & 0x00F0) >> 4 - fw_ver_patch = (fw_ver & 0x000F) if device_dict['usage_page'] == RAW_USAGE_PAGE or verbose: if show: print(f"Manufacturer: {manufacturer}") print(f"Product: {product}") - print(f"FW Version: {fw_ver_major}.{fw_ver_minor}.{fw_ver_patch}") + print("FW Version: {}".format(format_fw_ver(fw_ver))) print(f"Serial No: {sn}") if verbose: From 2966dc26f89b1716c59b1ce73c4091e41c65aaad Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 6 Jul 2023 12:15:51 +0800 Subject: [PATCH 04/10] qmk_gui: Executable on Linux Signed-off-by: Daniel Schaefer --- qmk_gui.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 qmk_gui.py diff --git a/qmk_gui.py b/qmk_gui.py old mode 100644 new mode 100755 From b7349363a8d6d5631147aea201d2b2f3b32e8232 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 6 Jul 2023 12:35:56 +0800 Subject: [PATCH 05/10] qmk_gui: Fix on Linux Signed-off-by: Daniel Schaefer --- qmk_gui.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/qmk_gui.py b/qmk_gui.py index baa6c3f..96a57ba 100755 --- a/qmk_gui.py +++ b/qmk_gui.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import os import sys import PySimpleGUI as sg @@ -208,12 +209,6 @@ def find_devs(show, verbose): devices = [] for device_dict in hid.enumerate(): vid = device_dict["vendor_id"] - if vid != FWK_VID: - continue - - if device_dict['usage_page'] not in [RAW_USAGE_PAGE, CONSOLE_USAGE_PAGE]: - continue - pid = device_dict["product_id"] product = device_dict["product_string"] manufacturer = device_dict["manufacturer_string"] @@ -221,9 +216,24 @@ def find_devs(show, verbose): interface = device_dict['interface_number'] path = device_dict['path'] + if vid != FWK_VID: + if verbose: + print("Vendor ID not matching") + continue + + if interface != QMK_INTERFACE: + if verbose: + print("Interface not matching") + continue + # For some reason on Linux it'll always show usage_page==0 + if os.name == 'nt' and device_dict['usage_page'] not in [RAW_USAGE_PAGE, CONSOLE_USAGE_PAGE]: + if verbose: + print("Usage Page not matching") + continue + fw_ver = device_dict["release_number"] - if device_dict['usage_page'] == RAW_USAGE_PAGE or verbose: + if (os.name == 'nt' and device_dict['usage_page'] == RAW_USAGE_PAGE) or verbose: if show: print(f"Manufacturer: {manufacturer}") print(f"Product: {product}") @@ -236,8 +246,7 @@ def find_devs(show, verbose): # TODO: print Usage Page print("") - if interface == QMK_INTERFACE: - devices.append(device_dict) + devices.append(device_dict) return devices @@ -337,9 +346,4 @@ def set_rgb_color(dev, hue, saturation): if __name__ == "__main__": devices = find_devs(show=False, verbose=False) - #for device in devices: - # if device['product_string'] == 'Laptop 16 Keyboard Module - ANSI': - # continue - # bootloader_jump(device) - main(devices) From aabb29bbdb93358f350102acbe711d91b63f734e Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Sun, 9 Jul 2023 16:05:26 +0800 Subject: [PATCH 06/10] qmk_gui: Allow selecting of device and updating firmware Signed-off-by: Daniel Schaefer --- qmk_gui.py | 158 ++++++++++++++++++++-- uf2conv.py | 389 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 uf2conv.py diff --git a/qmk_gui.py b/qmk_gui.py index 96a57ba..f15c8ff 100755 --- a/qmk_gui.py +++ b/qmk_gui.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 import os import sys +import time import PySimpleGUI as sg import hid +import uf2conv + # TODO: # - Clear EEPROM # - Save settings @@ -112,21 +115,42 @@ def format_fw_ver(fw_ver): return f"{fw_ver_major}.{fw_ver_minor}.{fw_ver_patch}" def main(devices): - device_info = "" + device_checkboxes = [] for dev in devices: - device_info += "{}\n Serial No: {}\n FW Version: {}\n".format( + device_info = "{}\nSerial No: {}\nFW Version: {}\n".format( dev['product_string'], dev['serial_number'], format_fw_ver(dev['release_number']) ) + checkbox = sg.Checkbox(device_info, default=True, key='-CHECKBOX-{}-'.format(dev['path']), enable_events=True) + device_checkboxes.append([checkbox]) + + releases = find_releases() + versions = sorted(list(releases.keys()), reverse=True) + + # Only in the pyinstaller bundle are the FW update binaries included + if is_pyinstaller(): + bundled_update = [ + [sg.Text("Update Version")], + [sg.Text("Version"), sg.Push(), sg.Combo(versions, k='-VERSION-', enable_events=True, default_value=versions[0])], + [sg.Text("Type"), sg.Push(), sg.Combo(list(releases[versions[0]]), k='-TYPE-', enable_events=True)], + [sg.Text("Make sure the firmware is compatible with\nALL selected devices!")], + [sg.Button("Flash", k='-FLASH-', disabled=True)], + [sg.HorizontalSeparator()], + ] + else: + bundled_update = [] + layout = [ [sg.Text("Detected Devices")], - [sg.Text(device_info)], + ] + device_checkboxes + [ + [sg.HorizontalSeparator()], [sg.Text("Bootloader")], [sg.Button("Bootloader", k='-BOOTLOADER-')], - + [sg.HorizontalSeparator()], + ] + bundled_update + [ [sg.Text("Single-Zone Brightness")], # TODO: Get default from device [sg.Slider((0, 255), orientation='h', default_value=120, @@ -151,21 +175,48 @@ def main(devices): [sg.Text("RGB Effect")], [sg.Combo(RGB_EFFECTS, k='-RGB-EFFECT-', enable_events=True)], + [sg.HorizontalSeparator()], [sg.Text("Save Settings")], [sg.Button("Save", k='-SAVE-'), sg.Button("Clear EEPROM", k='-CLEAR-EEPROM-')], - - [sg.Button("Quit")] ] window = sg.Window("QMK Keyboard Control", layout) + selected_devices = [] + while True: event, values = window.read() #print('Event', event) #print('Values', values) - for dev in devices: + selected_devices = [ + dev for dev in devices if + values and values['-CHECKBOX-{}-'.format(dev['path'])] + ] + # print("Selected {} devices".format(len(selected_devices))) + + # Updating firmware + if event == "-FLASH-" and len(selected_devices) != 1: + sg.Popup('To flash select exactly 1 device.') + continue + if event == "-VERSION-": + # After selecting a version, we can list the types of firmware available for this version + types = list(releases[values['-VERSION-']]) + window['-TYPE-'].update(value=types[0], values=types) + if event == "-TYPE-": + # Once the user has selected a type, the exact firmware file is known and can be flashed + window['-FLASH-'].update(disabled=False) + if event == "-FLASH-": + ver = values['-VERSION-'] + t = values['-TYPE-'] + # print("Flashing", releases[ver][t]) + flash_firmware(dev, releases[ver][t]) + restart_hint() + + # Run commands on all selected devices + for dev in selected_devices: if event == "-BOOTLOADER-": bootloader_jump(dev) + restart_hint() if event == '-BRIGHTNESS-': brightness(dev, int(values['-BRIGHTNESS-'])) @@ -196,12 +247,62 @@ def main(devices): if event == '-CLEAR-EEPROM-': eeprom_reset(dev) - if event == "Quit" or event == sg.WIN_CLOSED: + if event == sg.WIN_CLOSED: break window.close() +def is_pyinstaller(): + return getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') + + +def resource_path(): + """ Get absolute path to resource, works for dev and for PyInstaller""" + try: + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = sys._MEIPASS + except Exception: + base_path = os.path.abspath(".") + + return base_path + + +# Example return value +# { +# '0.1.7': { +# 'ansi': 'framework_ansi_default_v0.1.7.uf2', +# 'gridpad': 'framework_gridpad_default_v0.1.7.uf2' +# }, +# '0.1.8': { +# 'ansi': 'framework_ansi_default.uf2', +# 'gridpad': 'framework_gridpad_default.uf2', +# } +# } +def find_releases(): + from os import listdir + from os.path import isfile, join + import re + + res_path = resource_path() + versions = listdir(os.path.join(res_path, "releases")) + releases = {} + for version in versions: + path = join(res_path, "releases", version) + releases[version] = {} + for filename in listdir(path): + if not isfile(join(path, filename)): + continue + type_search = re.search('framework_(.*)_default.*\.uf2', filename) + if not type_search: + print(f"Filename '{filename}' not matching patten!") + sys.exit(1) + continue + fw_type = type_search.group(1) + releases[version][fw_type] = os.path.join(res_path, "releases", version, filename) + return releases + + def find_devs(show, verbose): if verbose: show = True @@ -343,7 +444,48 @@ def set_rgb_color(dev, hue, saturation): send_message(dev, CUSTOM_SET_VALUE, msg, 0) +def restart_hint(): + sg.Popup('After updating a device, \nrestart the application\nto reload the connections.') + + +def flash_firmware(dev, fw_path): + print(f"Flashing {fw_path}") + + # First jump to bootloader + drives = uf2conv.list_drives() + if not drives: + print("jump to bootloader") + bootloader_jump(dev) + + timeout = 10 # 5s + while not drives: + if timeout == 0: + print("Failed to find device in bootloader") + # TODO: Handle return value + return False + # Wait for it to appear + time.sleep(0.5) + timeout -= 1 + drives = uf2conv.get_drives() + + + if len(drives) == 0: + print("No drive to deploy.") + return False + + # Firmware is pretty small, can just fit it all into memory + with open(fw_path, 'rb') as f: + fw_buf = f.read() + + for d in drives: + print("Flashing {} ({})".format(d, uf2conv.board_id(d))) + uf2conv.write_file(d + "/NEW.UF2", fw_buf) + + print("Flashing finished") + + if __name__ == "__main__": devices = find_devs(show=False, verbose=False) + print("Found {} devices".format(len(devices))) main(devices) diff --git a/uf2conv.py b/uf2conv.py new file mode 100644 index 0000000..5c8fbc5 --- /dev/null +++ b/uf2conv.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# yapf: disable +import sys +import struct +import subprocess +import re +import os +import os.path +import argparse +import json + +# Don't even need -b. hex has this embedded +# > ./util/uf2conv.py .build/framework_ansi_default.hex -o ansi.uf2 -b 0x10000000 -f rp2040 --convert --blocks-reserved 1 +# Converted to 222 blocks +# Converted to uf2, output size: 113664, start address: 0x10000000 +# Wrote 113664 bytes to ansi.uf2 +# # 113664 / 512 = 222 +# +# > ./util/uf2conv.py serial.bin -o serial.uf2 -b 0x100ff000 -f rp2040 --convert --blocks-offset 222 +# Converted to 1 blocks +# Converted to uf2, output size: 512, start address: 0x100ff000 +# Wrote 512 bytes to serial.uf2 + + + +UF2_MAGIC_START0 = 0x0A324655 # "UF2\n" +UF2_MAGIC_START1 = 0x9E5D5157 # Randomly selected +UF2_MAGIC_END = 0x0AB16F30 # Ditto + +INFO_FILE = "/INFO_UF2.TXT" + +appstartaddr = 0x2000 +familyid = 0x0 + + +def is_uf2(buf): + w = struct.unpack(" 476: + assert False, "Invalid UF2 data size at " + ptr + newaddr = hd[3] + if (hd[2] & 0x2000) and (currfamilyid == None): + currfamilyid = hd[7] + if curraddr == None or ((hd[2] & 0x2000) and hd[7] != currfamilyid): + currfamilyid = hd[7] + curraddr = newaddr + if familyid == 0x0 or familyid == hd[7]: + appstartaddr = newaddr + print(f" flags: 0x{hd[2]:02x}") + print(f" addr: 0x{hd[3]:02x}") + print(f" len: {hd[4]}") + print(f" block no: {hd[5]}") + print(f" blocks: {hd[6]}") + print(f" size/famid: {hd[7]}") + print() + padding = newaddr - curraddr + if padding < 0: + assert False, "Block out of order at " + ptr + if padding > 10*1024*1024: + assert False, "More than 10M of padding needed at " + ptr + if padding % 4 != 0: + assert False, "Non-word padding size at " + ptr + while padding > 0: + padding -= 4 + outp.append(b"\x00\x00\x00\x00") + if familyid == 0x0 or ((hd[2] & 0x2000) and familyid == hd[7]): + outp.append(block[32 : 32 + datalen]) + curraddr = newaddr + datalen + if hd[2] & 0x2000: + if hd[7] in families_found.keys(): + if families_found[hd[7]] > newaddr: + families_found[hd[7]] = newaddr + else: + families_found[hd[7]] = newaddr + if prev_flag == None: + prev_flag = hd[2] + if prev_flag != hd[2]: + all_flags_same = False + if blockno == (numblocks - 1): + print("--- UF2 File Header Info ---") + families = load_families() + for family_hex in families_found.keys(): + family_short_name = "" + for name, value in families.items(): + if value == family_hex: + family_short_name = name + print("Family ID is {:s}, hex value is 0x{:08x}".format(family_short_name,family_hex)) + print("Target Address is 0x{:08x}".format(families_found[family_hex])) + if all_flags_same: + print("All block flag values consistent, 0x{:04x}".format(hd[2])) + else: + print("Flags were not all the same") + print("----------------------------") + if len(families_found) > 1 and familyid == 0x0: + outp = [] + appstartaddr = 0x0 + return b"".join(outp) + +def convert_to_carray(file_content): + outp = "const unsigned long bindata_len = %d;\n" % len(file_content) + outp += "const unsigned char bindata[] __attribute__((aligned(16))) = {" + for i in range(len(file_content)): + if i % 16 == 0: + outp += "\n" + outp += "0x%02x, " % file_content[i] + outp += "\n};\n" + return bytes(outp, "utf-8") + +def convert_to_uf2(file_content, blocks_reserved=0, blocks_offset=0): + global familyid + datapadding = b"" + while len(datapadding) < 512 - 256 - 32 - 4: + datapadding += b"\x00\x00\x00\x00" + numblocks = (len(file_content) + 255) // 256 + outp = [] + for blockno in range(numblocks): + ptr = 256 * blockno + chunk = file_content[ptr:ptr + 256] + flags = 0x0 + if familyid: + flags |= 0x2000 + hd = struct.pack(b"= 3 and words[1] == "2" and words[2] == "FAT": + drives.append(words[0]) + else: + rootpath = "/media" + if sys.platform == "darwin": + rootpath = "/Volumes" + elif sys.platform == "linux": + tmp = rootpath + "/" + os.environ["USER"] + if os.path.isdir(tmp): + rootpath = tmp + tmp = "/run" + rootpath + "/" + os.environ["USER"] + if os.path.isdir(tmp): + rootpath = tmp + for d in os.listdir(rootpath): + drives.append(os.path.join(rootpath, d)) + + + def has_info(d): + try: + return os.path.isfile(d + INFO_FILE) + except: + return False + + return list(filter(has_info, drives)) + + +def board_id(path): + with open(path + INFO_FILE, mode='r') as file: + file_content = file.read() + return re.search("Board-ID: ([^\r\n]*)", file_content).group(1) + + +def list_drives(): + for d in get_drives(): + print(d, board_id(d)) + + +def write_file(name, buf): + with open(name, "wb") as f: + f.write(buf) + print("Wrote %d bytes to %s" % (len(buf), name)) + + +def load_families(): + # The expectation is that the `uf2families.json` file is in the same + # directory as this script. Make a path that works using `__file__` + # which contains the full path to this script. + filename = "uf2families.json" + pathname = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename) + with open(pathname) as f: + raw_families = json.load(f) + + families = {} + for family in raw_families: + families[family["short_name"]] = int(family["id"], 0) + + return families + + +def main(): + global appstartaddr, familyid + def error(msg): + print(msg, file=sys.stderr) + sys.exit(1) + parser = argparse.ArgumentParser(description='Convert to UF2 or flash directly.') + parser.add_argument('input', metavar='INPUT', type=str, nargs='?', + help='input file (HEX, BIN or UF2)') + parser.add_argument('-b' , '--base', dest='base', type=str, + default="0x2000", + help='set base address of application for BIN format (default: 0x2000)') + parser.add_argument('-o' , '--output', metavar="FILE", dest='output', type=str, + help='write output to named file; defaults to "flash.uf2" or "flash.bin" where sensible') + parser.add_argument('-d' , '--device', dest="device_path", + help='select a device path to flash') + parser.add_argument('-l' , '--list', action='store_true', + help='list connected devices') + parser.add_argument('-c' , '--convert', action='store_true', + help='do not flash, just convert') + parser.add_argument('-D' , '--deploy', action='store_true', + help='just flash, do not convert') + parser.add_argument('-f' , '--family', dest='family', type=str, + default="0x0", + help='specify familyID - number or name (default: 0x0)') + parser.add_argument('--blocks-offset', dest='blocks_offset', type=str, + default="0x0", + help='TODO') + parser.add_argument('--blocks-reserved', dest='blocks_reserved', type=str, + default="0x0", + help='TODO') + parser.add_argument('-C' , '--carray', action='store_true', + help='convert binary file to a C array, not UF2') + parser.add_argument('-i', '--info', action='store_true', + help='display header information from UF2, do not convert') + args = parser.parse_args() + appstartaddr = int(args.base, 0) + blocks_offset = int(args.blocks_offset, 0) + blocks_reserved = int(args.blocks_reserved, 0) + + families = load_families() + + if args.family.upper() in families: + familyid = families[args.family.upper()] + else: + try: + familyid = int(args.family, 0) + except ValueError: + error("Family ID needs to be a number or one of: " + ", ".join(families.keys())) + + if args.list: + list_drives() + else: + if not args.input: + error("Need input file") + with open(args.input, mode='rb') as f: + inpbuf = f.read() + from_uf2 = is_uf2(inpbuf) + ext = "uf2" + if args.deploy: + outbuf = inpbuf + elif from_uf2 and not args.info: + outbuf = convert_from_uf2(inpbuf) + ext = "bin" + elif from_uf2 and args.info: + outbuf = "" + convert_from_uf2(inpbuf) + + elif is_hex(inpbuf): + outbuf = convert_from_hex_to_uf2(inpbuf.decode("utf-8"), blocks_reserved, blocks_offset) + elif args.carray: + outbuf = convert_to_carray(inpbuf) + ext = "h" + else: + outbuf = convert_to_uf2(inpbuf, blocks_reserved, blocks_offset) + if not args.deploy and not args.info: + print("Converted to %s, output size: %d, start address: 0x%x" % + (ext, len(outbuf), appstartaddr)) + if args.convert or ext != "uf2": + drives = [] + if args.output == None: + args.output = "flash." + ext + else: + drives = get_drives() + + if args.output: + write_file(args.output, outbuf) + else: + if len(drives) == 0: + error("No drive to deploy.") + if outbuf: + for d in drives: + print("Flashing %s (%s)" % (d, board_id(d))) + write_file(d + "/NEW.UF2", outbuf) + + +if __name__ == "__main__": + main() From 4c2b554d37e77ab53cbe565be2a79645e63ff3e2 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Sun, 9 Jul 2023 16:46:05 +0800 Subject: [PATCH 07/10] qmk_gui: Bundle releases Signed-off-by: Daniel Schaefer --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af68b6..3793de2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,16 +109,38 @@ jobs: run: cargo clippy -- -D warnings build-gui: - name: Build Windows + name: Build GUI runs-on: windows-2022 steps: - uses: actions/checkout@v3 + - name: Download releases to bundle + run: | + mkdir releases + mkdir releases\0.1.8 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_ansi_default.uf2 -OutFile releases\0.1.8\framework_ansi_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_iso_default.uf2 -OutFile releases\0.1.8\framework_iso_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_jis_default.uf2 -OutFile releases\0.1.8\framework_jis_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_numpad_default.uf2 -OutFile releases\0.1.8\framework_numpad_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_gridpad_default.uf2 -OutFile releases\0.1.8\framework_gridpad_default.uf2 + mkdir releases\0.1.7 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.7/framework_ansi_default_v0.1.7.uf2 -OutFile releases\0.1.7\framework_ansi_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.7/framework_iso_default_v0.1.7.uf2 -OutFile releases\0.1.7\framework_iso_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.7/framework_jis_default_v0.1.7.uf2 -OutFile releases\0.1.7\framework_jis_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.7/framework_numpad_default_v0.1.7.uf2 -OutFile releases\0.1.7\framework_numpad_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.7/framework_gridpad_default_v0.1.7.uf2 -OutFile releases\0.1.7\framework_gridpad_default.uf2 + mkdir releases\0.1.6 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.6/framework_ansi_default.uf2 -OutFile releases\0.1.6\framework_ansi_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.6/framework_iso_default.uf2 -OutFile releases\0.1.6\framework_iso_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.6/framework_jis_default.uf2 -OutFile releases\0.1.6\framework_jis_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.6/framework_numpad_default.uf2 -OutFile releases\0.1.6\framework_numpad_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.6/framework_gridpad_default.uf2 -OutFile releases\0.1.6\framework_gridpad_default.uf2 + - name: Create Executable - uses: Martin005/pyinstaller-action@main + uses: JohnAZoidberg/pyinstaller-action@dont-clean with: python_ver: '3.11' spec: qmk_gui.py requirements: 'requirements.txt' upload_exe_with_name: 'qmk_gui.exe' - options: --onefile, --name "qmk_gui", --windowed + options: --onefile, --name "qmk_gui", --windowed, --add-data "releases;releases" From 6e9da985bbb96455c88e7d229e80f3f09c5b04f9 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Sun, 9 Jul 2023 17:19:11 +0800 Subject: [PATCH 08/10] qmk_gui: Prevent using device after restart Need to restart the program to reenumerate them. Signed-off-by: Daniel Schaefer --- qmk_gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qmk_gui.py b/qmk_gui.py index f15c8ff..69e7f5e 100755 --- a/qmk_gui.py +++ b/qmk_gui.py @@ -211,11 +211,13 @@ def main(devices): # print("Flashing", releases[ver][t]) flash_firmware(dev, releases[ver][t]) restart_hint() + window['-CHECKBOX-{}-'.format(dev['path'])].update(False, disabled=True) # Run commands on all selected devices for dev in selected_devices: if event == "-BOOTLOADER-": bootloader_jump(dev) + window['-CHECKBOX-{}-'.format(dev['path'])].update(False, disabled=True) restart_hint() if event == '-BRIGHTNESS-': From 4b23caf90df0476026bc03faff4866b44f5cb63d Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Tue, 11 Jul 2023 03:52:51 +0800 Subject: [PATCH 09/10] qmk_gui: Bundle firmware v0.1.9 Signed-off-by: Daniel Schaefer --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3793de2..5bed72a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,12 @@ jobs: - name: Download releases to bundle run: | mkdir releases + mkdir releases\0.1.9 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_ansi_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_ansi_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_iso_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_iso_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_jis_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_jis_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_numpad_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_numpad_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_gridpad_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_gridpad_default.uf2 mkdir releases\0.1.8 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_ansi_default.uf2 -OutFile releases\0.1.8\framework_ansi_default.uf2 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_iso_default.uf2 -OutFile releases\0.1.8\framework_iso_default.uf2 From c867dd22c08b4c2b9aad5b44786d2d8500b84715 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Tue, 11 Jul 2023 04:48:32 +0800 Subject: [PATCH 10/10] qmk_gui: gridpad is macropad since v0.1.9 Signed-off-by: Daniel Schaefer --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bed72a..dada644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_iso_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_iso_default.uf2 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_jis_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_jis_default.uf2 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_numpad_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_numpad_default.uf2 - Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_gridpad_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_gridpad_default.uf2 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.9/framework_macropad_default_v0.1.9.uf2 -OutFile releases\0.1.9\framework_macropad_default.uf2 mkdir releases\0.1.8 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_ansi_default.uf2 -OutFile releases\0.1.8\framework_ansi_default.uf2 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/qmk_firmware/releases/download/v0.1.8/framework_iso_default.uf2 -OutFile releases\0.1.8\framework_iso_default.uf2