From 6daeedbf3833b5e666ac5b3ff0fd30d7385bae59 Mon Sep 17 00:00:00 2001 From: Moon Jam Date: Fri, 25 Oct 2024 03:20:26 +0800 Subject: [PATCH 01/27] python: Use tkinter build GUI instead of PySympleGUI --- python/inputmodule/gui/__init__.py | 422 +++++++++++++---------------- 1 file changed, 188 insertions(+), 234 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index ba0f1e1..542d963 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -1,8 +1,8 @@ import os import threading import sys - -import PySimpleGUI as sg +import tkinter as tk +from tkinter import ttk, messagebox from inputmodule.inputmodule import ( send_command, @@ -28,8 +28,7 @@ image_greyscale, ) - -def update_brightness_slider(window, devices): +def update_brightness_slider(devices): average_brightness = None for dev in devices: if not average_brightness: @@ -38,244 +37,199 @@ def update_brightness_slider(window, devices): br = get_brightness(dev) average_brightness += br if average_brightness: - window["-BRIGHTNESS-"].update(average_brightness / len(devices)) - - -def popup(has_gui, message): - if not has_gui: - return - import PySimpleGUI as sg - - sg.Popup(message, title="Framework Laptop 16 LED Matrix") + brightness_scale.set(average_brightness) +def popup(message): + messagebox.showinfo("Framework Laptop 16 LED Matrix", message) def run_gui(devices): - device_checkboxes = [] + root = tk.Tk() + root.title("LED Matrix Control") + root.geometry("400x900") + + # Configure dark theme + style = ttk.Style() + root.configure(bg="#2b2b2b") + style.configure("TLabelframe", background="#2b2b2b", foreground="white") + style.configure("TLabelframe.Label", background="#2b2b2b", foreground="white") + style.configure("TCheckbutton", background="#2b2b2b", foreground="white") + style.configure("TButton", background="white", foreground="#2b2b2b") + style.configure("TEntry", fieldbackground="#2b2b2b", foreground="white") + style.configure("TCombobox", fieldbackground="#2b2b2b", foreground="white") + style.configure("TScale", background="#2b2b2b", troughcolor="gray") + style.configure("TSpinbox", background="#2b2b2b", foreground="white") + style.map("TButton", background=[("active", "gray"), ("!active", "#2b2b2b")]) + + # Device Checkboxes + detected_devices_frame = ttk.LabelFrame(root, text="Detected Devices", style="TLabelframe") + detected_devices_frame.pack(fill="x", padx=10, pady=5) + + global device_checkboxes + device_checkboxes = {} for dev in devices: version = get_version(dev) device_info = ( f"{dev.name}\nSerial No: {dev.serial_number}\nFW Version:{version}" ) - checkbox = sg.Checkbox( - device_info, default=True, key=f"-CHECKBOX-{dev.name}-", enable_events=True - ) - device_checkboxes.append([checkbox]) - - layout = ( - [ - [sg.Text("Detected Devices")], - ] - + device_checkboxes - + [ - [sg.HorizontalSeparator()], - [sg.Text("Device Control")], - [sg.Button("Bootloader"), sg.Button("Sleep"), sg.Button("Wake")], - [sg.HorizontalSeparator()], - [sg.Text("Brightness")], - # TODO: Get default from device - [ - sg.Slider( - (0, 255), - orientation="h", - default_value=120, - k="-BRIGHTNESS-", - enable_events=True, - ) - ], - [sg.HorizontalSeparator()], - [sg.Text("Animation")], - [sg.Button("Start Animation"), sg.Button("Stop Animation")], - [sg.HorizontalSeparator()], - [sg.Text("Pattern")], - [sg.Combo(PATTERNS, k="-PATTERN-", enable_events=True)], - [sg.HorizontalSeparator()], - [sg.Text("Fill screen X% (could be volume indicator)")], - [ - sg.Slider( - (0, 100), orientation="h", k="-PERCENTAGE-", enable_events=True - ) - ], - [sg.HorizontalSeparator()], - [sg.Text("Countdown Timer")], - [ - sg.Spin([i for i in range(1, 60)], - initial_value=10, k="-COUNTDOWN-"), - sg.Text("Seconds"), - sg.Button("Start", k="-START-COUNTDOWN-"), - sg.Button("Stop", k="-STOP-COUNTDOWN-"), - ], - [sg.HorizontalSeparator()], - [ - sg.Column( - [ - [sg.Text("Black&White Image")], - [sg.Button("Send stripe.gif", k="-SEND-BL-IMAGE-")], - ] - ), - sg.VSeperator(), - sg.Column( - [ - [sg.Text("Greyscale Image")], - [sg.Button("Send greyscale.gif", - k="-SEND-GREY-IMAGE-")], - ] - ), - ], - [sg.HorizontalSeparator()], - [sg.Text("Display Current Time")], - [sg.Button("Start", k="-START-TIME-"), - sg.Button("Stop", k="-STOP-TIME-")], - [sg.HorizontalSeparator()], - [ - sg.Column( - [ - [sg.Text("Custom Text")], - [ - sg.Input(k="-CUSTOM-TEXT-", s=7), - sg.Button("Show", k="SEND-CUSTOM-TEXT"), - ], - ] - ), - sg.VSeperator(), - sg.Column( - [ - [sg.Text("Display Text with Symbols")], - [sg.Button("Send '2 5 degC thunder'", k="-SEND-TEXT-")], - ] - ), - ], - [sg.HorizontalSeparator()], - [sg.Text("PWM Frequency")], - [sg.Combo(PWM_FREQUENCIES, k="-PWM-FREQ-", enable_events=True)], - # TODO - # [sg.Text("Play Snake")], - # [sg.Button("Start Game", k='-PLAY-SNAKE-')], - [sg.HorizontalSeparator()], - [sg.Text("Equalizer")], - [ - sg.Button("Start random equalizer", k="-RANDOM-EQ-"), - sg.Button("Stop", k="-STOP-EQ-"), - ], - # [sg.Button("Panic")] - ] - ) - - window = sg.Window("LED Matrix Control", layout, finalize=True) - selected_devices = [] - - update_brightness_slider(window, devices) - - try: - while True: - event, values = window.read() - # print('Event', event) - # print('Values', values) - - # TODO - for dev in devices: - # print("Dev {} disconnected? {}".format(dev.name, dev.device in DISCONNECTED_DEVS)) - if is_dev_disconnected(dev.device): - window["-CHECKBOX-{}-".format(dev.name)].update( - False, disabled=True - ) - - selected_devices = [ - dev - for dev in devices - if values and values["-CHECKBOX-{}-".format(dev.name)] - ] - # print("Selected {} devices".format(len(selected_devices))) - - if event == sg.WIN_CLOSED: - break - if len(selected_devices) == 1: - dev = selected_devices[0] - if event == "-START-COUNTDOWN-": - print("Starting countdown") - thread = threading.Thread( - target=countdown, - args=( - dev, - int(values["-COUNTDOWN-"]), - ), - daemon=True, - ) - thread.start() - - if event == "-START-TIME-": - thread = threading.Thread( - target=clock, args=(dev,), daemon=True) - thread.start() - - if event == "-PLAY-SNAKE-": - snake() - - if event == "-RANDOM-EQ-": - thread = threading.Thread( - target=random_eq, args=(dev,), daemon=True - ) - thread.start() - else: - if event in [ - "-START-COUNTDOWN-", - "-PLAY-SNAKE-", - "-RANDOM-EQ-", - "-START-TIME-", - ]: - sg.Popup("Select exactly 1 device for this action") - if event in ["-STOP-COUNTDOWN-", "-STOP-EQ-", "-STOP-TIME-"]: - stop_thread() - - for dev in selected_devices: - if event == "Bootloader": - bootloader(dev) - - if event == "-PATTERN-": - pattern(dev, values["-PATTERN-"]) - - if event == "-PWM-FREQ-": - pwm_freq(dev, values["-PWM-FREQ-"]) - - if event == "Start Animation": - animate(dev, True) - - if event == "Stop Animation": - animate(dev, False) - - if event == "-BRIGHTNESS-": - brightness(dev, int(values["-BRIGHTNESS-"])) - - if event == "-PERCENTAGE-": - percentage(dev, int(values["-PERCENTAGE-"])) - - if event == "-SEND-BL-IMAGE-": - path = os.path.join(resource_path(), "res", "stripe.gif") - image_bl(dev, path) - - if event == "-SEND-GREY-IMAGE-": - path = os.path.join( - resource_path(), "res", "greyscale.gif") - image_greyscale(dev, path) - - if event == "-SEND-TEXT-": - show_symbols(dev, ["2", "5", "degC", " ", "thunder"]) - - if event == "SEND-CUSTOM-TEXT": - show_string(dev, values["-CUSTOM-TEXT-"].upper()) - - if event == "Sleep": - send_command(dev, CommandVals.Sleep, [True]) - - if event == "Wake": - send_command(dev, CommandVals.Sleep, [False]) - - window.close() - except Exception as e: - print(e) - raise e - pass - # sg.popup_error_with_traceback(f'An error happened. Here is the info:', e) - + checkbox_var = tk.BooleanVar(value=True) + checkbox = ttk.Checkbutton(detected_devices_frame, text=device_info, variable=checkbox_var, style="TCheckbutton") + checkbox.pack(anchor="w") + device_checkboxes[dev.name] = checkbox_var + + # Device Control Buttons + device_control_frame = ttk.LabelFrame(root, text="Device Control", style="TLabelframe") + device_control_frame.pack(fill="x", padx=10, pady=5) + control_buttons = { + "Bootloader": "bootloader", + "Sleep": "sleep", + "Wake": "wake" + } + for text, action in control_buttons.items(): + ttk.Button(device_control_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) + + # Brightness Slider + brightness_frame = ttk.LabelFrame(root, text="Brightness", style="TLabelframe") + brightness_frame.pack(fill="x", padx=10, pady=5) + global brightness_scale + brightness_scale = tk.Scale(brightness_frame, from_=0, to=255, orient='horizontal', command=lambda value: set_brightness(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") + brightness_scale.set(120) # Default value + brightness_scale.pack(fill="x", padx=5, pady=5) + + # Animation Control + animation_frame = ttk.LabelFrame(root, text="Animation", style="TLabelframe") + animation_frame.pack(fill="x", padx=10, pady=5) + animation_buttons = { + "Start Animation": "start_animation", + "Stop Animation": "stop_animation" + } + for text, action in animation_buttons.items(): + ttk.Button(animation_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) + + # Pattern Combo Box + pattern_frame = ttk.LabelFrame(root, text="Pattern", style="TLabelframe") + pattern_frame.pack(fill="x", padx=10, pady=5) + pattern_combo = ttk.Combobox(pattern_frame, values=PATTERNS, style="TCombobox") + pattern_combo.pack(fill="x", padx=5, pady=5) + pattern_combo.bind("<>", lambda event: set_pattern(devices, pattern_combo.get())) + + # Percentage Slider + percentage_frame = ttk.LabelFrame(root, text="Fill screen X% (could be volume indicator)", style="TLabelframe") + percentage_frame.pack(fill="x", padx=10, pady=5) + percentage_scale = tk.Scale(percentage_frame, from_=0, to=100, orient='horizontal', command=lambda value: set_percentage(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") + percentage_scale.pack(fill="x", padx=5, pady=5) + + # Countdown Timer + countdown_frame = ttk.LabelFrame(root, text="Countdown Timer", style="TLabelframe") + countdown_frame.pack(fill="x", padx=10, pady=5) + countdown_spinbox = tk.Spinbox(countdown_frame, from_=1, to=60, width=5, bg="#2b2b2b", fg="white", textvariable=tk.StringVar(value=10)) + countdown_spinbox.pack(side="left", padx=5, pady=5) + ttk.Label(countdown_frame, text="Seconds", style="TLabel").pack(side="left") + ttk.Button(countdown_frame, text="Start", command=lambda: start_countdown(devices, countdown_spinbox.get()), style="TButton").pack(side="left", padx=5, pady=5) + ttk.Button(countdown_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) + + # Black & White and Greyscale Images in same row + image_frame = ttk.LabelFrame(root, text="Black&White Images / Greyscale Images", style="TLabelframe") + image_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(image_frame, text="Send stripe.gif", command=lambda: send_image(devices, "stripe.gif", image_bl), style="TButton").pack(side="left", padx=5, pady=5) + ttk.Button(image_frame, text="Send greyscale.gif", command=lambda: send_image(devices, "greyscale.gif", image_greyscale), style="TButton").pack(side="left", padx=5, pady=5) + + # Display Current Time + time_frame = ttk.LabelFrame(root, text="Display Current Time", style="TLabelframe") + time_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(time_frame, text="Start", command=lambda: perform_action(devices, "start_time"), style="TButton").pack(side="left", padx=5, pady=5) + ttk.Button(time_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) + + # Custom Text + custom_text_frame = ttk.LabelFrame(root, text="Custom Text", style="TLabelframe") + custom_text_frame.pack(fill="x", padx=10, pady=5) + custom_text_entry = ttk.Entry(custom_text_frame, width=20, style="TEntry") + custom_text_entry.pack(side="left", padx=5, pady=5) + ttk.Button(custom_text_frame, text="Show", command=lambda: show_custom_text(devices, custom_text_entry.get()), style="TButton").pack(side="left", padx=5, pady=5) + + # Display Text with Symbols + symbols_frame = ttk.LabelFrame(root, text="Display Text with Symbols", style="TLabelframe") + symbols_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(symbols_frame, text="Send '2 5 degC thunder'", command=lambda: send_symbols(devices), style="TButton").pack(side="left", padx=5, pady=5) + + # PWM Frequency Combo Box + pwm_freq_frame = ttk.LabelFrame(root, text="PWM Frequency", style="TLabelframe") + pwm_freq_frame.pack(fill="x", padx=10, pady=5) + pwm_freq_combo = ttk.Combobox(pwm_freq_frame, values=PWM_FREQUENCIES, style="TCombobox") + pwm_freq_combo.pack(fill="x", padx=5, pady=5) + pwm_freq_combo.bind("<>", lambda: set_pwm_freq(devices, pwm_freq_combo.get())) + + # Equalizer + equalizer_frame = ttk.LabelFrame(root, text="Equalizer", style="TLabelframe") + equalizer_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(equalizer_frame, text="Start random equalizer", command=lambda: perform_action(devices, "start_eq"), style="TButton").pack(side="left", padx=5, pady=5) + ttk.Button(equalizer_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) + + root.mainloop() + +def perform_action(devices, action): + action_map = { + "bootloader": bootloader, + "sleep": lambda dev: send_command(dev, CommandVals.Sleep, [True]), + "wake": lambda dev: send_command(dev, CommandVals.Sleep, [False]), + "start_animation": lambda dev: animate(dev, True), + "stop_animation": lambda dev: animate(dev, False), + "start_time": lambda dev: threading.Thread(target=clock, args=(dev,), daemon=True).start(), + "start_eq": lambda dev: threading.Thread(target=random_eq, args=(dev,), daemon=True).start() + } + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + if action in action_map: + action_map[action](dev) + +def set_brightness(devices, value): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + brightness(dev, int(value)) + +def set_pattern(devices, pattern_name): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + pattern(dev, pattern_name) + +def set_percentage(devices, value): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + percentage(dev, int(value)) + +def show_custom_text(devices, text): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + show_string(dev, text.upper()) + +def send_image(devices, image_name, image_function): + selected_devices = get_selected_devices(devices) + path = os.path.join(resource_path(), "res", image_name) + if not os.path.exists(path): + popup(f"Image file {image_name} not found.") + return + for dev in selected_devices: + image_function(dev, path) + +def send_symbols(devices): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + show_symbols(dev, ["2", "5", "degC", " ", "thunder"]) + +def start_countdown(devices, countdown_time): + selected_devices = get_selected_devices(devices) + if len(selected_devices) == 1: + dev = selected_devices[0] + threading.Thread(target=countdown, args=(dev, int(countdown_time)), daemon=True).start() + else: + popup("Select exactly 1 device for this action") + +def set_pwm_freq(devices, freq): + selected_devices = get_selected_devices(devices) + for dev in selected_devices: + pwm_freq(dev, freq) + +def get_selected_devices(devices): + return [dev for dev in devices if dev.name in device_checkboxes and device_checkboxes[dev.name].get()] def resource_path(): """Get absolute path to resource, works for dev and for PyInstaller""" From be455f7681174374b4f833a0f28df1617e829cc0 Mon Sep 17 00:00:00 2001 From: Moon Jam Date: Wed, 6 Nov 2024 22:59:47 +0800 Subject: [PATCH 02/27] python: Fix weird btn color in different OS --- python/inputmodule/gui/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 542d963..d5e510c 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -1,6 +1,7 @@ import os import threading import sys +import platform import tkinter as tk from tkinter import ttk, messagebox @@ -53,7 +54,10 @@ def run_gui(devices): style.configure("TLabelframe", background="#2b2b2b", foreground="white") style.configure("TLabelframe.Label", background="#2b2b2b", foreground="white") style.configure("TCheckbutton", background="#2b2b2b", foreground="white") - style.configure("TButton", background="white", foreground="#2b2b2b") + if platform.system() == "Windows": # On Windows, I don't know why background always stays white even if I set it to black + style.configure("TButton", background="white", foreground="#2b2b2b") + else: + style.configure("TButton", background="#2b2b2b", foreground="white") style.configure("TEntry", fieldbackground="#2b2b2b", foreground="white") style.configure("TCombobox", fieldbackground="#2b2b2b", foreground="white") style.configure("TScale", background="#2b2b2b", troughcolor="gray") From c958eaa0946e352edda94e721dc36fe95073741a Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 18 Nov 2024 19:14:43 +0800 Subject: [PATCH 03/27] python: Split UI into tabs Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 34 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index d5e510c..4399cf2 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -46,7 +46,15 @@ def popup(message): def run_gui(devices): root = tk.Tk() root.title("LED Matrix Control") - root.geometry("400x900") + + tabControl = ttk.Notebook(root) + tab1 = ttk.Frame(tabControl) + tab2 = ttk.Frame(tabControl) + tab3 = ttk.Frame(tabControl) + tabControl.add(tab1, text="Home") + tabControl.add(tab2, text="Dynamic Controls") + tabControl.add(tab3, text="Advanced") + tabControl.pack(expand=1, fill="both") # Configure dark theme style = ttk.Style() @@ -81,7 +89,7 @@ def run_gui(devices): device_checkboxes[dev.name] = checkbox_var # Device Control Buttons - device_control_frame = ttk.LabelFrame(root, text="Device Control", style="TLabelframe") + device_control_frame = ttk.LabelFrame(tab1, text="Device Control", style="TLabelframe") device_control_frame.pack(fill="x", padx=10, pady=5) control_buttons = { "Bootloader": "bootloader", @@ -92,7 +100,7 @@ def run_gui(devices): ttk.Button(device_control_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) # Brightness Slider - brightness_frame = ttk.LabelFrame(root, text="Brightness", style="TLabelframe") + brightness_frame = ttk.LabelFrame(tab1, text="Brightness", style="TLabelframe") brightness_frame.pack(fill="x", padx=10, pady=5) global brightness_scale brightness_scale = tk.Scale(brightness_frame, from_=0, to=255, orient='horizontal', command=lambda value: set_brightness(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") @@ -100,7 +108,7 @@ def run_gui(devices): brightness_scale.pack(fill="x", padx=5, pady=5) # Animation Control - animation_frame = ttk.LabelFrame(root, text="Animation", style="TLabelframe") + animation_frame = ttk.LabelFrame(tab1, text="Animation", style="TLabelframe") animation_frame.pack(fill="x", padx=10, pady=5) animation_buttons = { "Start Animation": "start_animation", @@ -110,20 +118,20 @@ def run_gui(devices): ttk.Button(animation_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) # Pattern Combo Box - pattern_frame = ttk.LabelFrame(root, text="Pattern", style="TLabelframe") + pattern_frame = ttk.LabelFrame(tab1, text="Pattern", style="TLabelframe") pattern_frame.pack(fill="x", padx=10, pady=5) pattern_combo = ttk.Combobox(pattern_frame, values=PATTERNS, style="TCombobox") pattern_combo.pack(fill="x", padx=5, pady=5) pattern_combo.bind("<>", lambda event: set_pattern(devices, pattern_combo.get())) # Percentage Slider - percentage_frame = ttk.LabelFrame(root, text="Fill screen X% (could be volume indicator)", style="TLabelframe") + percentage_frame = ttk.LabelFrame(tab1, text="Fill screen X% (could be volume indicator)", style="TLabelframe") percentage_frame.pack(fill="x", padx=10, pady=5) percentage_scale = tk.Scale(percentage_frame, from_=0, to=100, orient='horizontal', command=lambda value: set_percentage(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") percentage_scale.pack(fill="x", padx=5, pady=5) # Countdown Timer - countdown_frame = ttk.LabelFrame(root, text="Countdown Timer", style="TLabelframe") + countdown_frame = ttk.LabelFrame(tab2, text="Countdown Timer", style="TLabelframe") countdown_frame.pack(fill="x", padx=10, pady=5) countdown_spinbox = tk.Spinbox(countdown_frame, from_=1, to=60, width=5, bg="#2b2b2b", fg="white", textvariable=tk.StringVar(value=10)) countdown_spinbox.pack(side="left", padx=5, pady=5) @@ -132,38 +140,38 @@ def run_gui(devices): ttk.Button(countdown_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) # Black & White and Greyscale Images in same row - image_frame = ttk.LabelFrame(root, text="Black&White Images / Greyscale Images", style="TLabelframe") + image_frame = ttk.LabelFrame(tab1, text="Black&White Images / Greyscale Images", style="TLabelframe") image_frame.pack(fill="x", padx=10, pady=5) ttk.Button(image_frame, text="Send stripe.gif", command=lambda: send_image(devices, "stripe.gif", image_bl), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(image_frame, text="Send greyscale.gif", command=lambda: send_image(devices, "greyscale.gif", image_greyscale), style="TButton").pack(side="left", padx=5, pady=5) # Display Current Time - time_frame = ttk.LabelFrame(root, text="Display Current Time", style="TLabelframe") + time_frame = ttk.LabelFrame(tab2, text="Display Current Time", style="TLabelframe") time_frame.pack(fill="x", padx=10, pady=5) ttk.Button(time_frame, text="Start", command=lambda: perform_action(devices, "start_time"), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(time_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) # Custom Text - custom_text_frame = ttk.LabelFrame(root, text="Custom Text", style="TLabelframe") + custom_text_frame = ttk.LabelFrame(tab1, text="Custom Text", style="TLabelframe") custom_text_frame.pack(fill="x", padx=10, pady=5) custom_text_entry = ttk.Entry(custom_text_frame, width=20, style="TEntry") custom_text_entry.pack(side="left", padx=5, pady=5) ttk.Button(custom_text_frame, text="Show", command=lambda: show_custom_text(devices, custom_text_entry.get()), style="TButton").pack(side="left", padx=5, pady=5) # Display Text with Symbols - symbols_frame = ttk.LabelFrame(root, text="Display Text with Symbols", style="TLabelframe") + symbols_frame = ttk.LabelFrame(tab1, text="Display Text with Symbols", style="TLabelframe") symbols_frame.pack(fill="x", padx=10, pady=5) ttk.Button(symbols_frame, text="Send '2 5 degC thunder'", command=lambda: send_symbols(devices), style="TButton").pack(side="left", padx=5, pady=5) # PWM Frequency Combo Box - pwm_freq_frame = ttk.LabelFrame(root, text="PWM Frequency", style="TLabelframe") + pwm_freq_frame = ttk.LabelFrame(tab3, text="PWM Frequency", style="TLabelframe") pwm_freq_frame.pack(fill="x", padx=10, pady=5) pwm_freq_combo = ttk.Combobox(pwm_freq_frame, values=PWM_FREQUENCIES, style="TCombobox") pwm_freq_combo.pack(fill="x", padx=5, pady=5) pwm_freq_combo.bind("<>", lambda: set_pwm_freq(devices, pwm_freq_combo.get())) # Equalizer - equalizer_frame = ttk.LabelFrame(root, text="Equalizer", style="TLabelframe") + equalizer_frame = ttk.LabelFrame(tab2, text="Equalizer", style="TLabelframe") equalizer_frame.pack(fill="x", padx=10, pady=5) ttk.Button(equalizer_frame, text="Start random equalizer", command=lambda: perform_action(devices, "start_eq"), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(equalizer_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) From c4b52f04501d71c24499385405371239abf1feb6 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 18 Nov 2024 19:19:13 +0800 Subject: [PATCH 04/27] python: Remove styling I don't like the styling. And if we do want to change the styling we should use the built-in styling/theming facility instead of building it from scratch. https://tkdocs.com/tutorial/styles.html Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 4399cf2..7465db7 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -56,22 +56,6 @@ def run_gui(devices): tabControl.add(tab3, text="Advanced") tabControl.pack(expand=1, fill="both") - # Configure dark theme - style = ttk.Style() - root.configure(bg="#2b2b2b") - style.configure("TLabelframe", background="#2b2b2b", foreground="white") - style.configure("TLabelframe.Label", background="#2b2b2b", foreground="white") - style.configure("TCheckbutton", background="#2b2b2b", foreground="white") - if platform.system() == "Windows": # On Windows, I don't know why background always stays white even if I set it to black - style.configure("TButton", background="white", foreground="#2b2b2b") - else: - style.configure("TButton", background="#2b2b2b", foreground="white") - style.configure("TEntry", fieldbackground="#2b2b2b", foreground="white") - style.configure("TCombobox", fieldbackground="#2b2b2b", foreground="white") - style.configure("TScale", background="#2b2b2b", troughcolor="gray") - style.configure("TSpinbox", background="#2b2b2b", foreground="white") - style.map("TButton", background=[("active", "gray"), ("!active", "#2b2b2b")]) - # Device Checkboxes detected_devices_frame = ttk.LabelFrame(root, text="Detected Devices", style="TLabelframe") detected_devices_frame.pack(fill="x", padx=10, pady=5) @@ -103,7 +87,7 @@ def run_gui(devices): brightness_frame = ttk.LabelFrame(tab1, text="Brightness", style="TLabelframe") brightness_frame.pack(fill="x", padx=10, pady=5) global brightness_scale - brightness_scale = tk.Scale(brightness_frame, from_=0, to=255, orient='horizontal', command=lambda value: set_brightness(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") + brightness_scale = tk.Scale(brightness_frame, from_=0, to=255, orient='horizontal', command=lambda value: set_brightness(devices, value)) brightness_scale.set(120) # Default value brightness_scale.pack(fill="x", padx=5, pady=5) @@ -120,20 +104,20 @@ def run_gui(devices): # Pattern Combo Box pattern_frame = ttk.LabelFrame(tab1, text="Pattern", style="TLabelframe") pattern_frame.pack(fill="x", padx=10, pady=5) - pattern_combo = ttk.Combobox(pattern_frame, values=PATTERNS, style="TCombobox") + pattern_combo = ttk.Combobox(pattern_frame, values=PATTERNS, style="TCombobox", state="readonly") pattern_combo.pack(fill="x", padx=5, pady=5) pattern_combo.bind("<>", lambda event: set_pattern(devices, pattern_combo.get())) # Percentage Slider percentage_frame = ttk.LabelFrame(tab1, text="Fill screen X% (could be volume indicator)", style="TLabelframe") percentage_frame.pack(fill="x", padx=10, pady=5) - percentage_scale = tk.Scale(percentage_frame, from_=0, to=100, orient='horizontal', command=lambda value: set_percentage(devices, value), bg="#2b2b2b", fg="white", troughcolor="gray", highlightbackground="#2b2b2b") + percentage_scale = tk.Scale(percentage_frame, from_=0, to=100, orient='horizontal', command=lambda value: set_percentage(devices, value)) percentage_scale.pack(fill="x", padx=5, pady=5) # Countdown Timer countdown_frame = ttk.LabelFrame(tab2, text="Countdown Timer", style="TLabelframe") countdown_frame.pack(fill="x", padx=10, pady=5) - countdown_spinbox = tk.Spinbox(countdown_frame, from_=1, to=60, width=5, bg="#2b2b2b", fg="white", textvariable=tk.StringVar(value=10)) + countdown_spinbox = tk.Spinbox(countdown_frame, from_=1, to=60, width=5, textvariable=tk.StringVar(value=10)) countdown_spinbox.pack(side="left", padx=5, pady=5) ttk.Label(countdown_frame, text="Seconds", style="TLabel").pack(side="left") ttk.Button(countdown_frame, text="Start", command=lambda: start_countdown(devices, countdown_spinbox.get()), style="TButton").pack(side="left", padx=5, pady=5) @@ -166,7 +150,7 @@ def run_gui(devices): # PWM Frequency Combo Box pwm_freq_frame = ttk.LabelFrame(tab3, text="PWM Frequency", style="TLabelframe") pwm_freq_frame.pack(fill="x", padx=10, pady=5) - pwm_freq_combo = ttk.Combobox(pwm_freq_frame, values=PWM_FREQUENCIES, style="TCombobox") + pwm_freq_combo = ttk.Combobox(pwm_freq_frame, values=PWM_FREQUENCIES, style="TCombobox", state="readonly") pwm_freq_combo.pack(fill="x", padx=5, pady=5) pwm_freq_combo.bind("<>", lambda: set_pwm_freq(devices, pwm_freq_combo.get())) From 7f3e67c3f18c19d7fcd8551a427a9a550866ae7c Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 18 Nov 2024 19:36:19 +0800 Subject: [PATCH 05/27] python: Move device control to the bottom Users don't usually need it, move it away. Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 7465db7..a5d1abc 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -72,17 +72,6 @@ def run_gui(devices): checkbox.pack(anchor="w") device_checkboxes[dev.name] = checkbox_var - # Device Control Buttons - device_control_frame = ttk.LabelFrame(tab1, text="Device Control", style="TLabelframe") - device_control_frame.pack(fill="x", padx=10, pady=5) - control_buttons = { - "Bootloader": "bootloader", - "Sleep": "sleep", - "Wake": "wake" - } - for text, action in control_buttons.items(): - ttk.Button(device_control_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) - # Brightness Slider brightness_frame = ttk.LabelFrame(tab1, text="Brightness", style="TLabelframe") brightness_frame.pack(fill="x", padx=10, pady=5) @@ -160,6 +149,18 @@ def run_gui(devices): ttk.Button(equalizer_frame, text="Start random equalizer", command=lambda: perform_action(devices, "start_eq"), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(equalizer_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) + # Device Control Buttons + device_control_frame = ttk.LabelFrame(tab1, text="Device Control", style="TLabelframe") + device_control_frame.pack(fill="x", padx=10, pady=5) + control_buttons = { + "Bootloader": "bootloader", + "Sleep": "sleep", + "Wake": "wake" + } + for text, action in control_buttons.items(): + ttk.Button(device_control_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) + + root.mainloop() def perform_action(devices, action): From 4333b1b3337cbf974bd582f5392b01e0ae54a282 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 18:35:14 +0800 Subject: [PATCH 06/27] python: Add ledris game Creates a window and draws on it Signed-off-by: Daniel Schaefer --- python/ledris.py | 220 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 python/ledris.py diff --git a/python/ledris.py b/python/ledris.py new file mode 100644 index 0000000..2d0ebad --- /dev/null +++ b/python/ledris.py @@ -0,0 +1,220 @@ +# Run like +# python3 ledris.py + +import pygame +import random +import time + +# Initialize pygame +pygame.init() + +# Set the screen width and height for a 34 x 9 block Ledris game +block_width = 20 +block_height = 20 +cols = 9 +rows = 34 + +width = cols * block_width +height = rows * block_height + +# Colors +black = (0, 0, 0) +white = (255, 255, 255) + +# Create the screen +screen = pygame.display.set_mode((width, height)) + +# Clock to control the speed of the game +clock = pygame.time.Clock() + +# Ledrimino shapes +shapes = [ + [[1, 1, 1, 1]], # I shape + [[1, 1], [1, 1]], # O shape + [[0, 1, 0], [1, 1, 1]], # T shape + [[1, 1, 0], [0, 1, 1]], # S shape + [[0, 1, 1], [1, 1, 0]], # Z shape + [[1, 1, 1], [1, 0, 0]], # L shape + [[1, 1, 1], [0, 0, 1]] # J shape +] + +# Function to get the current board state +def get_board_state(board, current_shape, current_pos): + temp_board = [row[:] for row in board] + off_x, off_y = current_pos + for y, row in enumerate(current_shape): + for x, cell in enumerate(row): + if cell: + if 0 <= off_y + y < rows and 0 <= off_x + x < cols: + temp_board[off_y + y][off_x + x] = 1 + return temp_board + +# Function to draw the game based on the board state +def draw_board(board, devices): + screen.fill(white) + for y in range(rows): + for x in range(cols): + if board[y][x]: + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(screen, black, rect) + draw_grid() + pygame.display.update() + +# Function to draw a grid +def draw_grid(): + for y in range(rows): + for x in range(cols): + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(screen, black, rect, 1) + +# Function to check if the position is valid +def check_collision(board, shape, offset): + off_x, off_y = offset + for y, row in enumerate(shape): + for x, cell in enumerate(row): + if cell: + if x + off_x < 0 or x + off_x >= cols or y + off_y >= rows: + return True + if y + off_y >= 0 and board[y + off_y][x + off_x]: + return True + return False + +# Function to merge the shape into the board +def merge_shape(board, shape, offset): + off_x, off_y = offset + for y, row in enumerate(shape): + for x, cell in enumerate(row): + if cell: + if 0 <= off_y + y < rows and 0 <= off_x + x < cols: + board[off_y + y][off_x + x] = 1 + +# Function to clear complete rows +def clear_rows(board): + new_board = [row for row in board if any(cell == 0 for cell in row)] + cleared_rows = rows - len(new_board) + while len(new_board) < rows: + new_board.insert(0, [0 for _ in range(cols)]) + return new_board, cleared_rows + +# Function to display the score using blocks +def display_score(board, score): + score_str = str(score) + start_x = cols - len(score_str) * 4 + for i, digit in enumerate(score_str): + if digit.isdigit(): + digit = int(digit) + for y in range(5): + for x in range(3): + if digit_blocks[digit][y][x]: + if y < rows and start_x + i * 4 + x < cols: + board[y][start_x + i * 4 + x] = 1 + +# Digit blocks for representing score +# Each number is represented in a 5x3 block matrix +digit_blocks = [ + [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]], # 0 + [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]], # 1 + [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]], # 2 + [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 3 + [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]], # 4 + [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 5 + [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]], # 6 + [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]], # 7 + [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]], # 8 + [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 9 +] + +# Main game function +def gameLoop(devices): + board = [[0 for _ in range(cols)] for _ in range(rows)] + current_shape = random.choice(shapes) + current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display + game_over = False + fall_time = 0 + fall_speed = 500 # Falling speed in milliseconds + score = 0 + + while not game_over: + # Adjust falling speed based on score + fall_speed = max(100, 500 - (score * 10)) + + # Draw the current board state + board_state = get_board_state(board, current_shape, current_pos) + display_score(board_state, score) + draw_board(board_state, devices) + + # Event handling + for event in pygame.event.get(): + if event.type == pygame.QUIT: + game_over = True + + if event.type == pygame.KEYDOWN: + if event.key in [pygame.K_LEFT, pygame.K_h]: + new_pos = [current_pos[0] - 1, current_pos[1]] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_RIGHT, pygame.K_l]: + new_pos = [current_pos[0] + 1, current_pos[1]] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_DOWN, pygame.K_j]: + new_pos = [current_pos[0], current_pos[1] + 1] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_UP, pygame.K_k]: + rotated_shape = list(zip(*current_shape[::-1])) + if not check_collision(board, rotated_shape, current_pos): + current_shape = rotated_shape + elif event.key == pygame.K_SPACE: # Hard drop + while not check_collision(board, current_shape, [current_pos[0], current_pos[1] + 1]): + current_pos[1] += 1 + + # Automatic falling + fall_time += clock.get_time() + if fall_time >= fall_speed: + fall_time = 0 + new_pos = [current_pos[0], current_pos[1] + 1] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + else: + merge_shape(board, current_shape, current_pos) + board, cleared_rows = clear_rows(board) + score += cleared_rows # Increase score by one for each row cleared + current_shape = random.choice(shapes) + current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display + if check_collision(board, current_shape, current_pos): + game_over = True + + clock.tick(30) + + # Flash the screen twice before waiting for restart + for _ in range(2): + screen.fill(black) + pygame.display.update() + time.sleep(0.3) + screen.fill(white) + pygame.display.update() + time.sleep(0.3) + + # Display final score and wait for restart without clearing the screen + board_state = get_board_state(board, current_shape, current_pos) + display_score(board_state, score) + draw_board(board_state, devices) + + waiting = True + while waiting: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + waiting = False + game_over = True + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_q: + waiting = False + if event.key == pygame.K_r: + board = [[0 for _ in range(cols)] for _ in range(rows)] + gameLoop() + + pygame.quit() + quit() + +gameLoop(devices) From 3d5fca92abf31f2d5f258dd095b4abd1561a13c7 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 18:35:41 +0800 Subject: [PATCH 07/27] python/ledris: Draw on led matrix Draws on all that are connected. Signed-off-by: Daniel Schaefer --- python/ledris.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/python/ledris.py b/python/ledris.py index 2d0ebad..cc6472a 100644 --- a/python/ledris.py +++ b/python/ledris.py @@ -5,6 +5,10 @@ import random import time +from inputmodule import cli +from inputmodule.gui.ledmatrix import show_string +from inputmodule.inputmodule import ledmatrix + # Initialize pygame pygame.init() @@ -49,8 +53,19 @@ def get_board_state(board, current_shape, current_pos): temp_board[off_y + y][off_x + x] = 1 return temp_board +def draw_ledmatrix(board, devices): + for dev in devices: + matrix = [[0 for _ in range(34)] for _ in range(9)] + for y in range(rows): + for x in range(cols): + matrix[x][y] = board[y][x] + ledmatrix.render_matrix(dev, matrix) + #vals = [0 for _ in range(39)] + #send_command(dev, CommandVals.Draw, vals) + # Function to draw the game based on the board state def draw_board(board, devices): + draw_ledmatrix(board, devices) screen.fill(white) for y in range(rows): for x in range(cols): @@ -189,9 +204,14 @@ def gameLoop(devices): # Flash the screen twice before waiting for restart for _ in range(2): + for dev in devices: + ledmatrix.percentage(dev, 0) screen.fill(black) pygame.display.update() time.sleep(0.3) + + for dev in devices: + ledmatrix.percentage(dev, 100) screen.fill(white) pygame.display.update() time.sleep(0.3) @@ -217,4 +237,8 @@ def gameLoop(devices): pygame.quit() quit() -gameLoop(devices) +if __name__ == "__main__": + devices = cli.find_devs() + for dev in devices: + show_string(dev, 'YAY') + gameLoop(devices) From f379c8575146967a418a2e4bb68cdfc18dc65601 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 20:38:27 +0800 Subject: [PATCH 08/27] python/ledris: Allow to be imported Signed-off-by: Daniel Schaefer --- python/ledris.py | 254 ++++++++++++++++++++++++----------------------- 1 file changed, 132 insertions(+), 122 deletions(-) diff --git a/python/ledris.py b/python/ledris.py index cc6472a..469acfe 100644 --- a/python/ledris.py +++ b/python/ledris.py @@ -9,9 +9,6 @@ from inputmodule.gui.ledmatrix import show_string from inputmodule.inputmodule import ledmatrix -# Initialize pygame -pygame.init() - # Set the screen width and height for a 34 x 9 block Ledris game block_width = 20 block_height = 20 @@ -25,12 +22,6 @@ black = (0, 0, 0) white = (255, 255, 255) -# Create the screen -screen = pygame.display.set_mode((width, height)) - -# Clock to control the speed of the game -clock = pygame.time.Clock() - # Ledrimino shapes shapes = [ [[1, 1, 1, 1]], # I shape @@ -63,25 +54,6 @@ def draw_ledmatrix(board, devices): #vals = [0 for _ in range(39)] #send_command(dev, CommandVals.Draw, vals) -# Function to draw the game based on the board state -def draw_board(board, devices): - draw_ledmatrix(board, devices) - screen.fill(white) - for y in range(rows): - for x in range(cols): - if board[y][x]: - rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) - pygame.draw.rect(screen, black, rect) - draw_grid() - pygame.display.update() - -# Function to draw a grid -def draw_grid(): - for y in range(rows): - for x in range(cols): - rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) - pygame.draw.rect(screen, black, rect, 1) - # Function to check if the position is valid def check_collision(board, shape, offset): off_x, off_y = offset @@ -139,106 +111,144 @@ def display_score(board, score): [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 9 ] -# Main game function -def gameLoop(devices): - board = [[0 for _ in range(cols)] for _ in range(rows)] - current_shape = random.choice(shapes) - current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display - game_over = False - fall_time = 0 - fall_speed = 500 # Falling speed in milliseconds - score = 0 - - while not game_over: - # Adjust falling speed based on score - fall_speed = max(100, 500 - (score * 10)) - - # Draw the current board state + +class Ledris: + # Function to draw a grid + def draw_grid(self): + for y in range(rows): + for x in range(cols): + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(self.screen, black, rect, 1) + + # Function to draw the game based on the board state + def draw_board(self, board, devices): + draw_ledmatrix(board, devices) + self.screen.fill(white) + for y in range(rows): + for x in range(cols): + if board[y][x]: + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(self.screen, black, rect) + self.draw_grid() + pygame.display.update() + + # Main game function + def gameLoop(self, devices): + board = [[0 for _ in range(cols)] for _ in range(rows)] + current_shape = random.choice(shapes) + current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display + game_over = False + fall_time = 0 + fall_speed = 500 # Falling speed in milliseconds + score = 0 + + while not game_over: + # Adjust falling speed based on score + fall_speed = max(100, 500 - (score * 10)) + + # Draw the current board state + board_state = get_board_state(board, current_shape, current_pos) + display_score(board_state, score) + self.draw_board(board_state, devices) + + # Event handling + for event in pygame.event.get(): + if event.type == pygame.QUIT: + game_over = True + + if event.type == pygame.KEYDOWN: + if event.key in [pygame.K_LEFT, pygame.K_h]: + new_pos = [current_pos[0] - 1, current_pos[1]] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_RIGHT, pygame.K_l]: + new_pos = [current_pos[0] + 1, current_pos[1]] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_DOWN, pygame.K_j]: + new_pos = [current_pos[0], current_pos[1] + 1] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + elif event.key in [pygame.K_UP, pygame.K_k]: + rotated_shape = list(zip(*current_shape[::-1])) + if not check_collision(board, rotated_shape, current_pos): + current_shape = rotated_shape + elif event.key == pygame.K_SPACE: # Hard drop + while not check_collision(board, current_shape, [current_pos[0], current_pos[1] + 1]): + current_pos[1] += 1 + + # Automatic falling + fall_time += self.clock.get_time() + if fall_time >= fall_speed: + fall_time = 0 + new_pos = [current_pos[0], current_pos[1] + 1] + if not check_collision(board, current_shape, new_pos): + current_pos = new_pos + else: + merge_shape(board, current_shape, current_pos) + board, cleared_rows = clear_rows(board) + score += cleared_rows # Increase score by one for each row cleared + current_shape = random.choice(shapes) + current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display + if check_collision(board, current_shape, current_pos): + game_over = True + + self.clock.tick(30) + + # Flash the screen twice before waiting for restart + for _ in range(2): + for dev in devices: + ledmatrix.percentage(dev, 0) + self.screen.fill(black) + pygame.display.update() + time.sleep(0.3) + + for dev in devices: + ledmatrix.percentage(dev, 100) + self.screen.fill(white) + pygame.display.update() + time.sleep(0.3) + + # Display final score and wait for restart without clearing the screen board_state = get_board_state(board, current_shape, current_pos) display_score(board_state, score) - draw_board(board_state, devices) - - # Event handling - for event in pygame.event.get(): - if event.type == pygame.QUIT: - game_over = True - - if event.type == pygame.KEYDOWN: - if event.key in [pygame.K_LEFT, pygame.K_h]: - new_pos = [current_pos[0] - 1, current_pos[1]] - if not check_collision(board, current_shape, new_pos): - current_pos = new_pos - elif event.key in [pygame.K_RIGHT, pygame.K_l]: - new_pos = [current_pos[0] + 1, current_pos[1]] - if not check_collision(board, current_shape, new_pos): - current_pos = new_pos - elif event.key in [pygame.K_DOWN, pygame.K_j]: - new_pos = [current_pos[0], current_pos[1] + 1] - if not check_collision(board, current_shape, new_pos): - current_pos = new_pos - elif event.key in [pygame.K_UP, pygame.K_k]: - rotated_shape = list(zip(*current_shape[::-1])) - if not check_collision(board, rotated_shape, current_pos): - current_shape = rotated_shape - elif event.key == pygame.K_SPACE: # Hard drop - while not check_collision(board, current_shape, [current_pos[0], current_pos[1] + 1]): - current_pos[1] += 1 - - # Automatic falling - fall_time += clock.get_time() - if fall_time >= fall_speed: - fall_time = 0 - new_pos = [current_pos[0], current_pos[1] + 1] - if not check_collision(board, current_shape, new_pos): - current_pos = new_pos - else: - merge_shape(board, current_shape, current_pos) - board, cleared_rows = clear_rows(board) - score += cleared_rows # Increase score by one for each row cleared - current_shape = random.choice(shapes) - current_pos = [cols // 2 - len(current_shape[0]) // 2, 5] # Start below the score display - if check_collision(board, current_shape, current_pos): + self.draw_board(board_state, devices) + + waiting = True + while waiting: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + waiting = False game_over = True + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_q: + waiting = False + if event.key == pygame.K_r: + board = [[0 for _ in range(cols)] for _ in range(rows)] + gameLoop() - clock.tick(30) + pygame.quit() + quit() - # Flash the screen twice before waiting for restart - for _ in range(2): - for dev in devices: - ledmatrix.percentage(dev, 0) - screen.fill(black) - pygame.display.update() - time.sleep(0.3) + def __init__(self): + # Initialize pygame + pygame.init() - for dev in devices: - ledmatrix.percentage(dev, 100) - screen.fill(white) - pygame.display.update() - time.sleep(0.3) - - # Display final score and wait for restart without clearing the screen - board_state = get_board_state(board, current_shape, current_pos) - display_score(board_state, score) - draw_board(board_state, devices) - - waiting = True - while waiting: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - waiting = False - game_over = True - if event.type == pygame.KEYDOWN: - if event.key == pygame.K_q: - waiting = False - if event.key == pygame.K_r: - board = [[0 for _ in range(cols)] for _ in range(rows)] - gameLoop() + # Create the screen + self.screen = pygame.display.set_mode((width, height)) - pygame.quit() - quit() + # Clock to control the speed of the game + self.clock = pygame.time.Clock() -if __name__ == "__main__": +def main_devices(devices): + ledris = Ledris() + ledris.gameLoop(devices) + +def main(): devices = cli.find_devs() - for dev in devices: - show_string(dev, 'YAY') - gameLoop(devices) + + ledris = Ledris() + ledris.gameLoop(devices) + +if __name__ == "__main__": + main() From 4219c2ae23721a5648cb167325149f82de112da7 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 20:39:43 +0800 Subject: [PATCH 09/27] python: Add ledris in tkinter GUI Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 17 +++++++++++++++-- .../gui/{games.py => games/__init__.py} | 0 python/{ => inputmodule/gui/games}/ledris.py | 0 3 files changed, 15 insertions(+), 2 deletions(-) rename python/inputmodule/gui/{games.py => games/__init__.py} (100%) rename python/{ => inputmodule/gui/games}/ledris.py (100%) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index a5d1abc..af8179c 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -14,6 +14,7 @@ CommandVals, ) from inputmodule.gui.games import snake +from inputmodule.gui.games import ledris from inputmodule.gui.ledmatrix import countdown, random_eq, clock from inputmodule.gui.gui_threading import stop_thread, is_dev_disconnected from inputmodule.inputmodule.ledmatrix import ( @@ -49,9 +50,11 @@ def run_gui(devices): tabControl = ttk.Notebook(root) tab1 = ttk.Frame(tabControl) + tab_games = ttk.Frame(tabControl) tab2 = ttk.Frame(tabControl) tab3 = ttk.Frame(tabControl) tabControl.add(tab1, text="Home") + tabControl.add(tab_games, text="Games") tabControl.add(tab2, text="Dynamic Controls") tabControl.add(tab3, text="Advanced") tabControl.pack(expand=1, fill="both") @@ -103,6 +106,11 @@ def run_gui(devices): percentage_scale = tk.Scale(percentage_frame, from_=0, to=100, orient='horizontal', command=lambda value: set_percentage(devices, value)) percentage_scale.pack(fill="x", padx=5, pady=5) + # Games tab + games_frame = ttk.LabelFrame(tab_games, text="Games", style="TLabelframe") + games_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(games_frame, text="Ledris", command=lambda: perform_action(devices, 'game_ledris'), style="TButton").pack(side="left", padx=5, pady=5) + # Countdown Timer countdown_frame = ttk.LabelFrame(tab2, text="Countdown Timer", style="TLabelframe") countdown_frame.pack(fill="x", padx=10, pady=5) @@ -160,10 +168,15 @@ def run_gui(devices): for text, action in control_buttons.items(): ttk.Button(device_control_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) - root.mainloop() def perform_action(devices, action): + action_map = { + "game_ledris": ledris.main_devices + } + if action in action_map: + threading.Thread(target=action_map[action], args=(devices,), daemon=True).start(), + action_map = { "bootloader": bootloader, "sleep": lambda dev: send_command(dev, CommandVals.Sleep, [True]), @@ -171,7 +184,7 @@ def perform_action(devices, action): "start_animation": lambda dev: animate(dev, True), "stop_animation": lambda dev: animate(dev, False), "start_time": lambda dev: threading.Thread(target=clock, args=(dev,), daemon=True).start(), - "start_eq": lambda dev: threading.Thread(target=random_eq, args=(dev,), daemon=True).start() + "start_eq": lambda dev: threading.Thread(target=random_eq, args=(dev,), daemon=True).start(), } selected_devices = get_selected_devices(devices) for dev in selected_devices: diff --git a/python/inputmodule/gui/games.py b/python/inputmodule/gui/games/__init__.py similarity index 100% rename from python/inputmodule/gui/games.py rename to python/inputmodule/gui/games/__init__.py diff --git a/python/ledris.py b/python/inputmodule/gui/games/ledris.py similarity index 100% rename from python/ledris.py rename to python/inputmodule/gui/games/ledris.py From e24c3c9676e041962ffcb71e87793842645259c1 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 22:30:57 +0800 Subject: [PATCH 10/27] python: Migrate snake to pygame Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 4 +- python/inputmodule/gui/games/__init__.py | 115 ---------- python/inputmodule/gui/games/snake.py | 254 +++++++++++++++++++++++ 3 files changed, 257 insertions(+), 116 deletions(-) create mode 100644 python/inputmodule/gui/games/snake.py diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index af8179c..365cad6 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -109,6 +109,7 @@ def run_gui(devices): # Games tab games_frame = ttk.LabelFrame(tab_games, text="Games", style="TLabelframe") games_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(games_frame, text="Snake", command=lambda: perform_action(devices, 'game_snake'), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(games_frame, text="Ledris", command=lambda: perform_action(devices, 'game_ledris'), style="TButton").pack(side="left", padx=5, pady=5) # Countdown Timer @@ -172,7 +173,8 @@ def run_gui(devices): def perform_action(devices, action): action_map = { - "game_ledris": ledris.main_devices + "game_snake": snake.main_devices, + "game_ledris": ledris.main_devices, } if action in action_map: threading.Thread(target=action_map[action], args=(devices,), daemon=True).start(), diff --git a/python/inputmodule/gui/games/__init__.py b/python/inputmodule/gui/games/__init__.py index 46df99d..c3d01d5 100644 --- a/python/inputmodule/gui/games/__init__.py +++ b/python/inputmodule/gui/games/__init__.py @@ -26,36 +26,6 @@ ARG_2LEFT = 5 ARG_2RIGHT = 6 -# Variables -direction = None -body = [] - - -def opposite_direction(direction): - if direction == keys.RIGHT: - return keys.LEFT - elif direction == keys.LEFT: - return keys.RIGHT - elif direction == keys.UP: - return keys.DOWN - elif direction == keys.DOWN: - return keys.UP - return direction - - -def snake_keyscan(): - global direction - global body - - while True: - current_dir = direction - key = getkey() - if key in [keys.RIGHT, keys.UP, keys.LEFT, keys.DOWN]: - # Don't allow accidental suicide if we have a body - if key == opposite_direction(current_dir) and body: - continue - direction = key - def snake_embedded_keyscan(dev): while True: @@ -76,18 +46,6 @@ def snake_embedded_keyscan(dev): send_command(dev, CommandVals.GameControl, [key_arg]) -def game_over(dev): - global body - while True: - show_string(dev, "GAME ") - time.sleep(0.75) - show_string(dev, "OVER!") - time.sleep(0.75) - score = len(body) - show_string(dev, f"{score:>3} P") - time.sleep(0.75) - - def pong_embedded(dev): # Start game send_command(dev, CommandVals.StartGame, [Game.Pong]) @@ -124,79 +82,6 @@ def snake_embedded(dev): snake_embedded_keyscan(dev) -def snake(dev): - global direction - global body - head = (0, 0) - direction = keys.DOWN - food = (0, 0) - while food == head: - food = (random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1)) - - # Setting - WRAP = False - - thread = threading.Thread(target=snake_keyscan, args=(), daemon=True) - thread.start() - - prev = datetime.now() - while True: - now = datetime.now() - delta = (now - prev) / timedelta(milliseconds=1) - - if delta > 200: - prev = now - else: - continue - - # Update position - (x, y) = head - oldhead = head - if direction == keys.RIGHT: - head = (x + 1, y) - elif direction == keys.LEFT: - head = (x - 1, y) - elif direction == keys.UP: - head = (x, y - 1) - elif direction == keys.DOWN: - head = (x, y + 1) - - # Detect edge condition - (x, y) = head - if head in body: - return game_over(dev) - elif x >= WIDTH or x < 0 or y >= HEIGHT or y < 0: - if WRAP: - if x >= WIDTH: - x = 0 - elif x < 0: - x = WIDTH - 1 - elif y >= HEIGHT: - y = 0 - elif y < 0: - y = HEIGHT - 1 - head = (x, y) - else: - return game_over(dev) - elif head == food: - body.insert(0, oldhead) - while food == head: - food = (random.randint(0, WIDTH - 1), - random.randint(0, HEIGHT - 1)) - elif body: - body.pop() - body.insert(0, oldhead) - - # Draw on screen - matrix = [[0 for _ in range(HEIGHT)] for _ in range(WIDTH)] - matrix[x][y] = 1 - matrix[food[0]][food[1]] = 1 - for bodypart in body: - (x, y) = bodypart - matrix[x][y] = 1 - render_matrix(dev, matrix) - - def wpm_demo(dev): """Capture keypresses and calculate the WPM of the last 10 seconds TODO: I'm not sure my calculation is right.""" diff --git a/python/inputmodule/gui/games/snake.py b/python/inputmodule/gui/games/snake.py new file mode 100644 index 0000000..6568639 --- /dev/null +++ b/python/inputmodule/gui/games/snake.py @@ -0,0 +1,254 @@ +import pygame +import random +import time + +from inputmodule import cli +from inputmodule.inputmodule import ledmatrix + +# Set the screen width and height for a 34 x 9 block game +block_width = 20 +block_height = 20 +COLS = 9 +ROWS = 34 + +WIDTH = COLS * block_width +HEIGHT = ROWS * block_height + +# Colors +black = (0, 0, 0) +white = (255, 255, 255) + +def opposite_direction(direction): + if direction == pygame.K_RIGHT: + return pygame.K_LEFT + elif direction == pygame.K_LEFT: + return pygame.K_RIGHT + elif direction == pygame.K_UP: + return pygame.K_DOWN + elif direction == pygame.K_DOWN: + return pygame.K_UP + return direction + +# Function to get the current board state +def get_board_state(board): + temp_board = [row[:] for row in board] + #off_x, off_y = current_pos + #for y, row in enumerate(current_shape): + # for x, cell in enumerate(row): + # if cell: + # if 0 <= off_y + y < ROWS and 0 <= off_x + x < COLS: + # temp_board[off_y + y][off_x + x] = 1 + return temp_board + +def draw_ledmatrix(board, devices): + for dev in devices: + matrix = [[0 for _ in range(34)] for _ in range(9)] + for y in range(ROWS): + for x in range(COLS): + matrix[x][y] = board[y][x] + ledmatrix.render_matrix(dev, matrix) + #vals = [0 for _ in range(39)] + #send_command(dev, CommandVals.Draw, vals) + +# Function to display the score using blocks +def display_score(board, score): + return + score_str = str(score) + start_x = COLS - len(score_str) * 4 + for i, digit in enumerate(score_str): + if digit.isdigit(): + digit = int(digit) + for y in range(5): + for x in range(3): + if digit_blocks[digit][y][x]: + if y < ROWS and start_x + i * 4 + x < COLS: + board[y][start_x + i * 4 + x] = 1 + +# Digit blocks for representing score +# Each number is represented in a 5x3 block matrix +digit_blocks = [ + [[1, 1, 1], [1, 0, 1], [1, 0, 1], [1, 0, 1], [1, 1, 1]], # 0 + [[0, 1, 0], [1, 1, 0], [0, 1, 0], [0, 1, 0], [1, 1, 1]], # 1 + [[1, 1, 1], [0, 0, 1], [1, 1, 1], [1, 0, 0], [1, 1, 1]], # 2 + [[1, 1, 1], [0, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 3 + [[1, 0, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [0, 0, 1]], # 4 + [[1, 1, 1], [1, 0, 0], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 5 + [[1, 1, 1], [1, 0, 0], [1, 1, 1], [1, 0, 1], [1, 1, 1]], # 6 + [[1, 1, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]], # 7 + [[1, 1, 1], [1, 0, 1], [1, 1, 1], [1, 0, 1], [1, 1, 1]], # 8 + [[1, 1, 1], [1, 0, 1], [1, 1, 1], [0, 0, 1], [1, 1, 1]], # 9 +] + + +class Snake: + # Function to draw a grid + def draw_grid(self): + for y in range(ROWS): + for x in range(COLS): + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(self.screen, black, rect, 1) + + # Function to draw the game based on the board state + def draw_board(self, board, devices): + draw_ledmatrix(board, devices) + self.screen.fill(white) + for y in range(ROWS): + for x in range(COLS): + if board[y][x]: + rect = pygame.Rect(x * block_width, y * block_height, block_width, block_height) + pygame.draw.rect(self.screen, black, rect) + self.draw_grid() + pygame.display.update() + + # Main game function + def gameLoop(self, devices): + board = [[0 for _ in range(COLS)] for _ in range(ROWS)] + + game_over = False + body = [] + score = 0 + head = (0, 0) + direction = pygame.K_DOWN + food = (0, 0) + while food == head: + food = (random.randint(0, COLS - 1), random.randint(0, ROWS - 1)) + move_time = 0 + + # Setting + # Wrap and let the snake come out the other side + WRAP = False + MOVE_PERIOD = 200 + + while not game_over: + # Draw the current board state + board_state = get_board_state(board) + display_score(board_state, score) + self.draw_board(board_state, devices) + + # Event handling + for event in pygame.event.get(): + if event.type == pygame.QUIT: + game_over = True + + if event.type == pygame.KEYDOWN: + if event.key == opposite_direction(direction) and body: + continue + if event.key in [pygame.K_LEFT, pygame.K_h]: + direction = pygame.K_LEFT + elif event.key in [pygame.K_RIGHT, pygame.K_l]: + direction = pygame.K_RIGHT + elif event.key in [pygame.K_DOWN, pygame.K_j]: + direction = pygame.K_DOWN + elif event.key in [pygame.K_UP, pygame.K_k]: + direction = pygame.K_UP + + move_time += self.clock.get_time() + if move_time >= MOVE_PERIOD: + move_time = 0 + + # Update position + (x, y) = head + oldhead = head + if direction == pygame.K_LEFT: + head = (x - 1, y) + elif direction == pygame.K_RIGHT: + head = (x + 1, y) + elif direction == pygame.K_DOWN: + head = (x, y + 1) + elif direction == pygame.K_UP: + head = (x, y - 1) + + # Detect edge condition + (x, y) = head + if head in body: + game_over = True + elif x >= COLS or x < 0 or y >= ROWS or y < 0: + if WRAP: + if x >= COLS: + x = 0 + elif x < 0: + x = COLS - 1 + elif y >= ROWS: + y = 0 + elif y < 0: + y = ROWS - 1 + head = (x, y) + else: + game_over = True + elif head == food: + body.insert(0, oldhead) + while food == head: + food = (random.randint(0, COLS - 1), + random.randint(0, ROWS - 1)) + elif body: + body.pop() + body.insert(0, oldhead) + + # Draw on screen + if not game_over: + board = [[0 for _ in range(COLS)] for _ in range(ROWS)] + board[y][x] = 1 + board[food[1]][food[0]] = 1 + for bodypart in body: + (x, y) = bodypart + board[y][x] = 1 + + self.clock.tick(30) + + # Flash the screen twice before waiting for restart + for _ in range(2): + for dev in devices: + ledmatrix.percentage(dev, 0) + self.screen.fill(black) + pygame.display.update() + time.sleep(0.3) + + for dev in devices: + ledmatrix.percentage(dev, 100) + self.screen.fill(white) + pygame.display.update() + time.sleep(0.3) + + # Display final score and wait for restart without clearing the screen + board_state = get_board_state(board) + display_score(board_state, score) + self.draw_board(board_state, devices) + + waiting = True + while waiting: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + waiting = False + game_over = True + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_q: + waiting = False + if event.key == pygame.K_r: + board = [[0 for _ in range(COLS)] for _ in range(ROWS)] + gameLoop() + + pygame.quit() + quit() + + def __init__(self): + # Initialize pygame + pygame.init() + + # Create the screen + self.screen = pygame.display.set_mode((WIDTH, HEIGHT)) + + # Clock to control the speed of the game + self.clock = pygame.time.Clock() + +def main_devices(devices): + snake = Snake() + snake.gameLoop(devices) + +def main(): + devices = cli.find_devs() + + snake = Snake() + snake.gameLoop(devices) + +if __name__ == "__main__": + main() From 5ddd3820932c7fbd9aeb88953c2a9cb5855dd350 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 22 Nov 2024 22:43:11 +0800 Subject: [PATCH 11/27] python: Add game of life to tkinter gui Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 365cad6..9439f0a 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -12,6 +12,8 @@ get_brightness, bootloader, CommandVals, + Game, + GameControlVal ) from inputmodule.gui.games import snake from inputmodule.gui.games import ledris @@ -107,10 +109,23 @@ def run_gui(devices): percentage_scale.pack(fill="x", padx=5, pady=5) # Games tab - games_frame = ttk.LabelFrame(tab_games, text="Games", style="TLabelframe") + games_frame = ttk.LabelFrame(tab_games, text="Interactive", style="TLabelframe") games_frame.pack(fill="x", padx=10, pady=5) ttk.Button(games_frame, text="Snake", command=lambda: perform_action(devices, 'game_snake'), style="TButton").pack(side="left", padx=5, pady=5) ttk.Button(games_frame, text="Ledris", command=lambda: perform_action(devices, 'game_ledris'), style="TButton").pack(side="left", padx=5, pady=5) + gol_frame = ttk.LabelFrame(tab_games, text="Game of Life", style="TLabelframe") + gol_frame.pack(fill="x", padx=10, pady=5) + animation_buttons = { + "Current Matrix": "gol_current", + "Pattern 1": "gol_pattern1", + "Blinker": "gol_blinker", + "Toad": "gol_toad", + "Beacon": "gol_beacon", + "Glider": "gol_glider", + "Stop": "game_stop", + } + for text, action in animation_buttons.items(): + ttk.Button(gol_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) # Countdown Timer countdown_frame = ttk.LabelFrame(tab2, text="Countdown Timer", style="TLabelframe") @@ -187,6 +202,13 @@ def perform_action(devices, action): "stop_animation": lambda dev: animate(dev, False), "start_time": lambda dev: threading.Thread(target=clock, args=(dev,), daemon=True).start(), "start_eq": lambda dev: threading.Thread(target=random_eq, args=(dev,), daemon=True).start(), + "gol_current": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 0]), + "gol_pattern1": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 1]), + "gol_blinker": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 2]), + "gol_toad": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 3]), + "gol_beacon": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 4]), + "gol_glider": lambda dev: send_command(dev, CommandVals.StartGame, [Game.GameOfLife, 5]), + "game_stop": lambda dev: send_command(dev, CommandVals.GameControl, [GameControlVal.Quit]), } selected_devices = get_selected_devices(devices) for dev in selected_devices: From b02823978b61e571d89c5217e8105e9d050b71d0 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 10:00:22 +0800 Subject: [PATCH 12/27] gh-actions: Update actions to v4 Signed-off-by: Daniel Schaefer --- .github/workflows/firmware.yml | 12 ++++++------ .github/workflows/software.yml | 18 +++++++++--------- .github/workflows/traditional-cargo.yml | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 02e2a2b..cba0a1a 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -24,7 +24,7 @@ jobs: name: Building runs-on: [ubuntu-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show @@ -66,7 +66,7 @@ jobs: cargo make --cwd ledmatrix bin - name: Upload ledmatrix files - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ledmatrix_fw_${{github.sha}} path: | @@ -79,7 +79,7 @@ jobs: target/thumbv6m-none-eabi/release/ledmatrix_evt.uf2 - name: Upload b1display files - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: b1display_fw_${{github.sha}} path: | @@ -87,7 +87,7 @@ jobs: target/thumbv6m-none-eabi/release/b1display.uf2 - name: Upload c1minimal files - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: c1minimal_fw_${{github.sha}} path: | @@ -98,7 +98,7 @@ jobs: name: Linting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show @@ -130,7 +130,7 @@ jobs: name: Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index 448832f..f98b11e 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -29,7 +29,7 @@ jobs: # name: Cross-Build for FreeBSD # runs-on: 'ubuntu-22.04' # steps: - # - uses: actions/checkout@v3 + # - uses: actions/checkout@v4 # - name: Setup Rust toolchain # run: rustup show @@ -41,7 +41,7 @@ jobs: # run: cross build --target=x86_64-unknown-freebsd # - name: Upload FreeBSD App - # uses: actions/upload-artifact@v3 + # uses: actions/upload-artifact@v4 # with: # name: qmk_hid_freebsd # path: target/x86_64-unknown-freebsd/debug/qmk_hid @@ -50,7 +50,7 @@ jobs: name: Build Linux runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | @@ -69,7 +69,7 @@ jobs: run: cargo make --cwd inputmodule-control run -- --help | grep 'RAW HID and VIA commandline' - name: Upload Linux tool - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: inputmodule-control path: target/x86_64-unknown-linux-gnu/release/inputmodule-control @@ -78,7 +78,7 @@ jobs: name: Build Windows runs-on: windows-2022 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show @@ -92,7 +92,7 @@ jobs: run: cargo make --cwd inputmodule-control run -- --help | grep 'RAW HID and VIA commandline' - name: Upload Windows App - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: inputmodule-control.exe path: target/x86_64-pc-windows-msvc/release/inputmodule-control.exe @@ -103,7 +103,7 @@ jobs: name: Build GUI runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Create Executable uses: Martin005/pyinstaller-action@main @@ -118,7 +118,7 @@ jobs: name: Package Python runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | cd python @@ -131,7 +131,7 @@ jobs: name: Lints runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | diff --git a/.github/workflows/traditional-cargo.yml b/.github/workflows/traditional-cargo.yml index 8df7106..231ead0 100644 --- a/.github/workflows/traditional-cargo.yml +++ b/.github/workflows/traditional-cargo.yml @@ -24,7 +24,7 @@ jobs: name: Build firmware runs-on: [ubuntu-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show @@ -41,7 +41,7 @@ jobs: name: Build Linux runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | @@ -61,7 +61,7 @@ jobs: name: Build Windows runs-on: windows-2022 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Rust toolchain run: rustup show @@ -78,7 +78,7 @@ jobs: name: Lint and format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | From 766b2351cb3866594d66c30ca3d00afe603105c3 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 10:00:39 +0800 Subject: [PATCH 13/27] python: Update windows bundle to Python 3.12 Signed-off-by: Daniel Schaefer --- .github/workflows/software.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index f98b11e..8015d01 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -108,7 +108,7 @@ jobs: - name: Create Executable uses: Martin005/pyinstaller-action@main with: - python_ver: '3.11' + python_ver: '3.12' spec: python/inputmodule/cli.py #'src/build.spec' requirements: 'requirements.txt' upload_exe_with_name: 'ledmatrixgui' From d97884dcf8cf9bcdd92a107773f3b27ff3cd459f Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 10:14:37 +0800 Subject: [PATCH 14/27] python: Move requirements.txt to subfolder Signed-off-by: Daniel Schaefer --- .github/workflows/software.yml | 2 +- requirements.txt => python/requirements.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) rename requirements.txt => python/requirements.txt (69%) diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index 8015d01..c1d6764 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -110,7 +110,7 @@ jobs: with: python_ver: '3.12' spec: python/inputmodule/cli.py #'src/build.spec' - requirements: 'requirements.txt' + requirements: 'python/requirements.txt' upload_exe_with_name: 'ledmatrixgui' options: --onefile, --windowed, --add-data 'res;res' diff --git a/requirements.txt b/python/requirements.txt similarity index 69% rename from requirements.txt rename to python/requirements.txt index 1972de4..7b53097 100644 --- a/requirements.txt +++ b/python/requirements.txt @@ -1,4 +1,3 @@ get-key==1.60.0 Pillow==10.0.0 pyserial==3.5 -PySimpleGUI==4.60.5 From 7fb179fa4fd9827fa48fb2f7c9e7074fad060b9b Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 11:16:57 +0800 Subject: [PATCH 15/27] python: Implement firmware update Signed-off-by: Daniel Schaefer --- .github/workflows/software.yml | 6 + python/inputmodule/cli.py | 5 +- python/inputmodule/firmware_update.py | 80 +++++ python/inputmodule/gui/__init__.py | 71 +++- python/inputmodule/inputmodule/__init__.py | 2 +- python/inputmodule/uf2conv.py | 397 +++++++++++++++++++++ 6 files changed, 552 insertions(+), 9 deletions(-) create mode 100644 python/inputmodule/firmware_update.py create mode 100644 python/inputmodule/uf2conv.py diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index c1d6764..327c38e 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -105,6 +105,12 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Download releases to bundle + run: | + mkdir releases + mkdir releases\0.2.0 + Invoke-WebRequest -Uri https://github.com/FrameworkComputer/inputmodule-rs/releases/download/v0.2.0/ledmatrix.uf2 -OutFile releases\0.2.0\ledmatrix.uf2 + - name: Create Executable uses: Martin005/pyinstaller-action@main with: diff --git a/python/inputmodule/cli.py b/python/inputmodule/cli.py index 5f97615..3b9c049 100755 --- a/python/inputmodule/cli.py +++ b/python/inputmodule/cli.py @@ -14,7 +14,7 @@ brightness, get_brightness, CommandVals, - bootloader, + bootloader_jump, GameOfLifeStartParam, GameControlVal, ) @@ -272,7 +272,7 @@ def main_cli(): sys.exit(1) if args.bootloader: - bootloader(dev) + bootloader_jump(dev) elif args.sleep is not None: send_command(dev, CommandVals.Sleep, [args.sleep]) elif args.is_sleeping: @@ -394,6 +394,7 @@ def find_devs(): def print_devs(ports): for port in ports: print(f"{port.device}") + print(f" {port.name}") print(f" VID: 0x{port.vid:04X}") print(f" PID: 0x{port.pid:04X}") print(f" SN: {port.serial_number}") diff --git a/python/inputmodule/firmware_update.py b/python/inputmodule/firmware_update.py new file mode 100644 index 0000000..7d2e752 --- /dev/null +++ b/python/inputmodule/firmware_update.py @@ -0,0 +1,80 @@ +import os +import time + +from inputmodule.inputmodule import bootloader_jump +from inputmodule import uf2conv + +def dev_to_str(dev): + return dev.name + +def flash_firmware(dev, fw_path): + print(f"Flashing {fw_path} onto {dev_to_str(dev)}") + + # 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") + +# 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(res_path, filename_format): + from os import listdir + from os.path import isfile, join + import re + + releases = {} + try: + versions = listdir(os.path.join(res_path, "releases")) + except FileNotFoundError: + return 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(filename_format, 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 diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 9439f0a..9d65306 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -5,12 +5,13 @@ import tkinter as tk from tkinter import ttk, messagebox +from inputmodule import firmware_update from inputmodule.inputmodule import ( send_command, get_version, brightness, get_brightness, - bootloader, + bootloader_jump, CommandVals, Game, GameControlVal @@ -54,10 +55,12 @@ def run_gui(devices): tab1 = ttk.Frame(tabControl) tab_games = ttk.Frame(tabControl) tab2 = ttk.Frame(tabControl) + tab_fw = ttk.Frame(tabControl) tab3 = ttk.Frame(tabControl) tabControl.add(tab1, text="Home") tabControl.add(tab_games, text="Games") tabControl.add(tab2, text="Dynamic Controls") + tabControl.add(tab_fw, text="Firmware Update") tabControl.add(tab3, text="Advanced") tabControl.pack(expand=1, fill="both") @@ -75,7 +78,7 @@ def run_gui(devices): checkbox_var = tk.BooleanVar(value=True) checkbox = ttk.Checkbutton(detected_devices_frame, text=device_info, variable=checkbox_var, style="TCheckbutton") checkbox.pack(anchor="w") - device_checkboxes[dev.name] = checkbox_var + device_checkboxes[dev.name] = (checkbox_var, checkbox) # Brightness Slider brightness_frame = ttk.LabelFrame(tab1, text="Brightness", style="TLabelframe") @@ -160,6 +163,26 @@ def run_gui(devices): symbols_frame.pack(fill="x", padx=10, pady=5) ttk.Button(symbols_frame, text="Send '2 5 degC thunder'", command=lambda: send_symbols(devices), style="TButton").pack(side="left", padx=5, pady=5) + # Firmware Update + bootloader_frame = ttk.LabelFrame(tab_fw, text="Bootloader", style="TLabelframe") + bootloader_frame.pack(fill="x", padx=10, pady=5) + ttk.Button(bootloader_frame, text="Enter Bootloader", command=lambda: perform_action(devices, "bootloader"), style="TButton").pack(side="left", padx=5, pady=5) + + bundled_fw_frame = ttk.LabelFrame(tab_fw, text="Bundled Updates", style="TLabelframe") + bundled_fw_frame.pack(fill="x", padx=10, pady=5) + releases = firmware_update.find_releases(resource_path(), r'(ledmatrix).uf2') + if not releases: + tk.Label(bundled_fw_frame, text="Cannot find firmware updates").pack(side="top", padx=5, pady=5) + else: + versions = sorted(list(releases.keys()), reverse=True) + + #tk.Label(fw_update_frame, text="Ignore user configured keymap").pack(side="top", padx=5, pady=5) + fw_ver_combo = ttk.Combobox(bundled_fw_frame, values=versions, style="TCombobox", state="readonly") + fw_ver_combo.pack(side=tk.LEFT, padx=5, pady=5) + fw_ver_combo.current(0) + flash_btn = ttk.Button(bundled_fw_frame, text="Update", command=lambda: tk_flash_firmware(devices, releases, fw_ver_combo.get(), 'ledmatrix'), style="TButton") + flash_btn.pack(side="left", padx=5, pady=5) + # PWM Frequency Combo Box pwm_freq_frame = ttk.LabelFrame(tab3, text="PWM Frequency", style="TLabelframe") pwm_freq_frame.pack(fill="x", padx=10, pady=5) @@ -177,7 +200,6 @@ def run_gui(devices): device_control_frame = ttk.LabelFrame(tab1, text="Device Control", style="TLabelframe") device_control_frame.pack(fill="x", padx=10, pady=5) control_buttons = { - "Bootloader": "bootloader", "Sleep": "sleep", "Wake": "wake" } @@ -194,8 +216,12 @@ def perform_action(devices, action): if action in action_map: threading.Thread(target=action_map[action], args=(devices,), daemon=True).start(), + if action == "bootloader": + disable_devices(devices) + restart_hint() + action_map = { - "bootloader": bootloader, + "bootloader": bootloader_jump, "sleep": lambda dev: send_command(dev, CommandVals.Sleep, [True]), "wake": lambda dev: send_command(dev, CommandVals.Sleep, [False]), "start_animation": lambda dev: animate(dev, True), @@ -263,7 +289,7 @@ def set_pwm_freq(devices, freq): pwm_freq(dev, freq) def get_selected_devices(devices): - return [dev for dev in devices if dev.name in device_checkboxes and device_checkboxes[dev.name].get()] + return [dev for dev in devices if dev.name in device_checkboxes and device_checkboxes[dev.name][0].get()] def resource_path(): """Get absolute path to resource, works for dev and for PyInstaller""" @@ -271,6 +297,39 @@ def resource_path(): # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: - base_path = os.path.abspath("../../") + base_path = os.path.abspath(".") return base_path + +def info_popup(msg): + parent = tk.Tk() + parent.title("Info") + message = tk.Message(parent, text=msg, width=800) + message.pack(padx=20, pady=20) + parent.mainloop() + +def tk_flash_firmware(devices, releases, version, fw_type): + selected_devices = get_selected_devices(devices) + if len(selected_devices) != 1: + info_popup('To flash select exactly 1 device.') + return + dev = selected_devices[0] + firmware_update.flash_firmware(dev, releases[version][fw_type]) + # Disable device that we just flashed + disable_devices(devices) + restart_hint() + +def restart_hint(): + parent = tk.Tk() + parent.title("Restart Application") + message = tk.Message(parent, text="After updating a device,\n restart the application to reload the connections.", width=800) + message.pack(padx=20, pady=20) + parent.mainloop() + +def disable_devices(devices): + # Disable checkbox of selected devices + for dev in devices: + for name, (checkbox_var, checkbox) in device_checkboxes.items(): + if name == dev.name: + checkbox_var.set(False) + checkbox.config(state=tk.DISABLED) diff --git a/python/inputmodule/inputmodule/__init__.py b/python/inputmodule/inputmodule/__init__.py index 616e726..7fb67f8 100644 --- a/python/inputmodule/inputmodule/__init__.py +++ b/python/inputmodule/inputmodule/__init__.py @@ -90,7 +90,7 @@ class GameControlVal(IntEnum): RESPONSE_SIZE = 32 -def bootloader(dev): +def bootloader_jump(dev): """Reboot into the bootloader to flash new firmware""" send_command(dev, CommandVals.BootloaderReset, [0x00]) diff --git a/python/inputmodule/uf2conv.py b/python/inputmodule/uf2conv.py new file mode 100644 index 0000000..e545b69 --- /dev/null +++ b/python/inputmodule/uf2conv.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 + +# SPDX-License-Identifier: MIT +# Copyright (c) Microsoft Corporation and others +# Taken from: https://github.com/microsoft/uf2/blob/master/utils/uf2conv.py +# And modified, some changes already upstreamed + +# 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 5440e259642e8172ccb6b68ead3787b1772d0da8 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 11:28:45 +0800 Subject: [PATCH 16/27] python: Rearrange game of life buttons in a grid Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 9d65306..ea7d130 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -119,7 +119,7 @@ def run_gui(devices): gol_frame = ttk.LabelFrame(tab_games, text="Game of Life", style="TLabelframe") gol_frame.pack(fill="x", padx=10, pady=5) animation_buttons = { - "Current Matrix": "gol_current", + "Current": "gol_current", "Pattern 1": "gol_pattern1", "Blinker": "gol_blinker", "Toad": "gol_toad", @@ -127,8 +127,15 @@ def run_gui(devices): "Glider": "gol_glider", "Stop": "game_stop", } - for text, action in animation_buttons.items(): - ttk.Button(gol_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton").pack(side="left", padx=5, pady=5) + for (i, (text, action)) in enumerate(animation_buttons.items()): + # Organize in columns of three + row = int(i / 3) + column = i % 3 + if action == "game_stop": + column = 0 + row += 1 + btn = ttk.Button(gol_frame, text=text, command=lambda a=action: perform_action(devices, a), style="TButton") + btn.grid(row=row, column=column) # Countdown Timer countdown_frame = ttk.LabelFrame(tab2, text="Countdown Timer", style="TLabelframe") From 1b17be570e4ca7ad94b7e4fce83bf856dd3bbd8e Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 11:32:07 +0800 Subject: [PATCH 17/27] python: Install pygame Signed-off-by: Daniel Schaefer --- python/pyproject.toml | 5 +++-- python/requirements.txt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 0111e85..d205db7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -25,9 +25,10 @@ classifiers = [ ] dependencies = [ "pyserial", - # Optional for GUI + # Optional for CLI "getkey", - "PySimpleGUI", + # Optional for GUI + "pygame", # Optional for image operations "Pillow", ] diff --git a/python/requirements.txt b/python/requirements.txt index 7b53097..52366a8 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,3 +1,4 @@ get-key==1.60.0 Pillow==10.0.0 pyserial==3.5 +pygame==2.6.1 From 89186a511b379da21968d50f14e1970e1e13d62c Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 11:37:46 +0800 Subject: [PATCH 18/27] python: Fix pyinstaller - Include downloaded releases - Fix exe name Signed-off-by: Daniel Schaefer --- .github/workflows/software.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index 327c38e..bcf1200 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -111,14 +111,16 @@ jobs: mkdir releases\0.2.0 Invoke-WebRequest -Uri https://github.com/FrameworkComputer/inputmodule-rs/releases/download/v0.2.0/ledmatrix.uf2 -OutFile releases\0.2.0\ledmatrix.uf2 + # To run locally, need to make sure to include the pywin32 DLL + # pyinstaller --onefile, --name "python/inputmodule/cli.py", --windowed, --add-data "releases;releases" --path C:\users\skype\appdata\local\packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\localcache\local-packages\Python312\site-packages\pywin32_system32 --add-data 'res;res' -p python/inputmodule python/inputmodule/cli.py - name: Create Executable - uses: Martin005/pyinstaller-action@main + uses: JohnAZoidberg/pyinstaller-action@dont-clean with: python_ver: '3.12' spec: python/inputmodule/cli.py #'src/build.spec' requirements: 'python/requirements.txt' - upload_exe_with_name: 'ledmatrixgui' - options: --onefile, --windowed, --add-data 'res;res' + upload_exe_with_name: 'ledmatrixgui.exe' + options: --onefile, --name "ledmatrixgui", --windowed, --add-data "releases;releases" --add-data 'res;res' -p python/inputmodule package-python: name: Package Python From a6c0258525e70d96fde64c0eaed7cb8d538cb041 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 11:53:12 +0800 Subject: [PATCH 19/27] python: fix popup function changed when removing pysimplegui We removed the function earlier, and then introduced it back, but with different arguments. Signed-off-by: Daniel Schaefer --- python/inputmodule/cli.py | 6 +++--- python/inputmodule/gui/__init__.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/python/inputmodule/cli.py b/python/inputmodule/cli.py index 3b9c049..b65780e 100755 --- a/python/inputmodule/cli.py +++ b/python/inputmodule/cli.py @@ -237,7 +237,7 @@ def main_cli(): if not ports: print("No device found") - gui.popup(args.gui, "No device found") + gui.popup("No device found", gui=args.gui) sys.exit(1) elif args.serial_dev is not None: filtered_devs = [ @@ -250,10 +250,10 @@ def main_cli(): dev = ports[0] elif len(ports) >= 1 and not args.gui: gui.popup( - args.gui, "More than 1 compatibles devices found. Please choose from the commandline with --serial-dev COMX.\nConnected ports:\n- {}".format( "\n- ".join([port.device for port in ports]) ), + gui=args.gui, ) print( "More than 1 compatible device found. Please choose with --serial-dev ..." @@ -268,7 +268,7 @@ def main_cli(): if not args.gui and dev is None: print("No device selected") - gui.popup(args.gui, "No device selected") + gui.popup("No device selected", gui=args.gui) sys.exit(1) if args.bootloader: diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index ea7d130..3592dab 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -44,8 +44,9 @@ def update_brightness_slider(devices): if average_brightness: brightness_scale.set(average_brightness) -def popup(message): - messagebox.showinfo("Framework Laptop 16 LED Matrix", message) +def popup(message, gui=True): + if gui: + messagebox.showinfo("Framework Laptop 16 LED Matrix", message) def run_gui(devices): root = tk.Tk() From 5d4128c689147f1edc0d30afb4f1239b8e2429d4 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 13:36:24 +0800 Subject: [PATCH 20/27] python: readd keyget snake Otherwise the cli snake is broken Signed-off-by: Daniel Schaefer --- python/inputmodule/cli.py | 2 +- python/inputmodule/games.py | 219 ++++++++++++++++++ python/inputmodule/gui/__init__.py | 3 +- python/inputmodule/gui/games/__init__.py | 103 -------- python/inputmodule/gui/pygames/__init__.py | 0 .../gui/{games => pygames}/ledris.py | 0 .../gui/{games => pygames}/snake.py | 0 7 files changed, 221 insertions(+), 106 deletions(-) create mode 100644 python/inputmodule/games.py delete mode 100644 python/inputmodule/gui/games/__init__.py create mode 100644 python/inputmodule/gui/pygames/__init__.py rename python/inputmodule/gui/{games => pygames}/ledris.py (100%) rename python/inputmodule/gui/{games => pygames}/snake.py (100%) diff --git a/python/inputmodule/cli.py b/python/inputmodule/cli.py index b65780e..c12e5f4 100755 --- a/python/inputmodule/cli.py +++ b/python/inputmodule/cli.py @@ -18,7 +18,7 @@ GameOfLifeStartParam, GameControlVal, ) -from inputmodule.gui.games import ( +from inputmodule.games import ( snake, snake_embedded, pong_embedded, diff --git a/python/inputmodule/games.py b/python/inputmodule/games.py new file mode 100644 index 0000000..f6bf1d9 --- /dev/null +++ b/python/inputmodule/games.py @@ -0,0 +1,219 @@ +from getkey import getkey, keys +import random +from datetime import datetime, timedelta +import time +import threading + +from inputmodule.inputmodule import ( + GameControlVal, + send_command, + CommandVals, + Game, +) +from inputmodule.inputmodule.ledmatrix import ( + show_string, + WIDTH, + HEIGHT, + render_matrix, +) + +# Constants +ARG_UP = 0 +ARG_DOWN = 1 +ARG_LEFT = 2 +ARG_RIGHT = 3 +ARG_QUIT = 4 +ARG_2LEFT = 5 +ARG_2RIGHT = 6 + +# Variables +direction = None +body = [] + + +def opposite_direction(direction): + if direction == keys.RIGHT: + return keys.LEFT + elif direction == keys.LEFT: + return keys.RIGHT + elif direction == keys.UP: + return keys.DOWN + elif direction == keys.DOWN: + return keys.UP + return direction + + + +def snake_keyscan(): + global direction + global body + + while True: + current_dir = direction + key = getkey() + if key in [keys.RIGHT, keys.UP, keys.LEFT, keys.DOWN]: + # Don't allow accidental suicide if we have a body + if key == opposite_direction(current_dir) and body: + continue + direction = key + + +def snake_embedded_keyscan(dev): + while True: + key_arg = None + key = getkey() + if key == keys.UP: + key_arg = GameControlVal.Up + elif key == keys.DOWN: + key_arg = GameControlVal.Down + elif key == keys.LEFT: + key_arg = GameControlVal.Left + elif key == keys.RIGHT: + key_arg = GameControlVal.Right + elif key == "q": + # Quit + key_arg = GameControlVal.Quit + if key_arg is not None: + send_command(dev, CommandVals.GameControl, [key_arg]) + + +def game_over(dev): + global body + while True: + show_string(dev, "GAME ") + time.sleep(0.75) + show_string(dev, "OVER!") + time.sleep(0.75) + score = len(body) + show_string(dev, f"{score:>3} P") + time.sleep(0.75) + + +def pong_embedded(dev): + # Start game + send_command(dev, CommandVals.StartGame, [Game.Pong]) + + while True: + key_arg = None + key = getkey() + if key == keys.LEFT: + key_arg = ARG_LEFT + elif key == keys.RIGHT: + key_arg = ARG_RIGHT + elif key == "a": + key_arg = ARG_2LEFT + elif key == "d": + key_arg = ARG_2RIGHT + elif key == "q": + # Quit + key_arg = ARG_QUIT + if key_arg is not None: + send_command(dev, CommandVals.GameControl, [key_arg]) + + +def game_of_life_embedded(dev, arg): + # Start game + # TODO: Add a way to stop it + print("Game", int(arg)) + send_command(dev, CommandVals.StartGame, [Game.GameOfLife, int(arg)]) + + +def snake_embedded(dev): + # Start game + send_command(dev, CommandVals.StartGame, [Game.Snake]) + + snake_embedded_keyscan(dev) + + +def snake(dev): + global direction + global body + head = (0, 0) + direction = keys.DOWN + food = (0, 0) + while food == head: + food = (random.randint(0, WIDTH - 1), random.randint(0, HEIGHT - 1)) + + # Setting + WRAP = False + + thread = threading.Thread(target=snake_keyscan, args=(), daemon=True) + thread.start() + + prev = datetime.now() + while True: + now = datetime.now() + delta = (now - prev) / timedelta(milliseconds=1) + + if delta > 200: + prev = now + else: + continue + + # Update position + (x, y) = head + oldhead = head + if direction == keys.RIGHT: + head = (x + 1, y) + elif direction == keys.LEFT: + head = (x - 1, y) + elif direction == keys.UP: + head = (x, y - 1) + elif direction == keys.DOWN: + head = (x, y + 1) + + # Detect edge condition + (x, y) = head + if head in body: + return game_over(dev) + elif x >= WIDTH or x < 0 or y >= HEIGHT or y < 0: + if WRAP: + if x >= WIDTH: + x = 0 + elif x < 0: + x = WIDTH - 1 + elif y >= HEIGHT: + y = 0 + elif y < 0: + y = HEIGHT - 1 + head = (x, y) + else: + return game_over(dev) + elif head == food: + body.insert(0, oldhead) + while food == head: + food = (random.randint(0, WIDTH - 1), + random.randint(0, HEIGHT - 1)) + elif body: + body.pop() + body.insert(0, oldhead) + + # Draw on screen + matrix = [[0 for _ in range(HEIGHT)] for _ in range(WIDTH)] + matrix[x][y] = 1 + matrix[food[0]][food[1]] = 1 + for bodypart in body: + (x, y) = bodypart + matrix[x][y] = 1 + render_matrix(dev, matrix) + + +def wpm_demo(dev): + """Capture keypresses and calculate the WPM of the last 10 seconds + TODO: I'm not sure my calculation is right.""" + start = datetime.now() + keypresses = [] + while True: + _ = getkey() + + now = datetime.now() + keypresses = [x for x in keypresses if (now - x).total_seconds() < 10] + keypresses.append(now) + # Word is five letters + wpm = (len(keypresses) / 5) * 6 + + total_time = (now - start).total_seconds() + if total_time < 10: + wpm = wpm / (total_time / 10) + + show_string(dev, " " + str(int(wpm))) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 3592dab..0a49555 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -16,8 +16,7 @@ Game, GameControlVal ) -from inputmodule.gui.games import snake -from inputmodule.gui.games import ledris +from inputmodule.gui.pygames import snake, ledris from inputmodule.gui.ledmatrix import countdown, random_eq, clock from inputmodule.gui.gui_threading import stop_thread, is_dev_disconnected from inputmodule.inputmodule.ledmatrix import ( diff --git a/python/inputmodule/gui/games/__init__.py b/python/inputmodule/gui/games/__init__.py deleted file mode 100644 index c3d01d5..0000000 --- a/python/inputmodule/gui/games/__init__.py +++ /dev/null @@ -1,103 +0,0 @@ -from getkey import getkey, keys -import random -from datetime import datetime, timedelta -import time -import threading - -from inputmodule.inputmodule import ( - GameControlVal, - send_command, - CommandVals, - Game, -) -from inputmodule.inputmodule.ledmatrix import ( - show_string, - WIDTH, - HEIGHT, - render_matrix, -) - -# Constants -ARG_UP = 0 -ARG_DOWN = 1 -ARG_LEFT = 2 -ARG_RIGHT = 3 -ARG_QUIT = 4 -ARG_2LEFT = 5 -ARG_2RIGHT = 6 - - -def snake_embedded_keyscan(dev): - while True: - key_arg = None - key = getkey() - if key == keys.UP: - key_arg = GameControlVal.Up - elif key == keys.DOWN: - key_arg = GameControlVal.Down - elif key == keys.LEFT: - key_arg = GameControlVal.Left - elif key == keys.RIGHT: - key_arg = GameControlVal.Right - elif key == "q": - # Quit - key_arg = GameControlVal.Quit - if key_arg is not None: - send_command(dev, CommandVals.GameControl, [key_arg]) - - -def pong_embedded(dev): - # Start game - send_command(dev, CommandVals.StartGame, [Game.Pong]) - - while True: - key_arg = None - key = getkey() - if key == keys.LEFT: - key_arg = ARG_LEFT - elif key == keys.RIGHT: - key_arg = ARG_RIGHT - elif key == "a": - key_arg = ARG_2LEFT - elif key == "d": - key_arg = ARG_2RIGHT - elif key == "q": - # Quit - key_arg = ARG_QUIT - if key_arg is not None: - send_command(dev, CommandVals.GameControl, [key_arg]) - - -def game_of_life_embedded(dev, arg): - # Start game - # TODO: Add a way to stop it - print("Game", int(arg)) - send_command(dev, CommandVals.StartGame, [Game.GameOfLife, int(arg)]) - - -def snake_embedded(dev): - # Start game - send_command(dev, CommandVals.StartGame, [Game.Snake]) - - snake_embedded_keyscan(dev) - - -def wpm_demo(dev): - """Capture keypresses and calculate the WPM of the last 10 seconds - TODO: I'm not sure my calculation is right.""" - start = datetime.now() - keypresses = [] - while True: - _ = getkey() - - now = datetime.now() - keypresses = [x for x in keypresses if (now - x).total_seconds() < 10] - keypresses.append(now) - # Word is five letters - wpm = (len(keypresses) / 5) * 6 - - total_time = (now - start).total_seconds() - if total_time < 10: - wpm = wpm / (total_time / 10) - - show_string(dev, " " + str(int(wpm))) diff --git a/python/inputmodule/gui/pygames/__init__.py b/python/inputmodule/gui/pygames/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/inputmodule/gui/games/ledris.py b/python/inputmodule/gui/pygames/ledris.py similarity index 100% rename from python/inputmodule/gui/games/ledris.py rename to python/inputmodule/gui/pygames/ledris.py diff --git a/python/inputmodule/gui/games/snake.py b/python/inputmodule/gui/pygames/snake.py similarity index 100% rename from python/inputmodule/gui/games/snake.py rename to python/inputmodule/gui/pygames/snake.py From ca29198056013a29c1a947065ba598200c9e9a8a Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 13:43:40 +0800 Subject: [PATCH 21/27] python: Add GUI screenshot Signed-off-by: Daniel Schaefer --- README.md | 6 +++++- res/ledmatrixgui-home.png | Bin 0 -> 63004 bytes 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 res/ledmatrixgui-home.png diff --git a/README.md b/README.md index ad2a53b..a6c102b 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,14 @@ To build your own application see the: [API command documentation](commands.md) Or use our `inputmodule-control` app, which you can download from the latest [GH Actions](https://github.com/FrameworkComputer/inputmodule-rs/actions) run or the [release page](https://github.com/FrameworkComputer/inputmodule-rs/releases). -Optionally there are is also a [Python script](python/README.md). For device specific commands, see their individual documentation pages. +### GUI and Python +There are also a python library and GUI tool. See their [README](python/README.md). + +![](res/ledmatrixgui-home.png) + ###### Permissions on Linux To ensure that the input module's port is accessible, install the `udev` rule and trigger a reload: diff --git a/res/ledmatrixgui-home.png b/res/ledmatrixgui-home.png new file mode 100644 index 0000000000000000000000000000000000000000..c0a5ca49a3f79390705c0881cf053d9c8ba299e9 GIT binary patch literal 63004 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 Date: Mon, 25 Nov 2024 13:52:39 +0800 Subject: [PATCH 22/27] python: Add links to online info Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index 0a49555..bbb5b8e 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -1,7 +1,9 @@ import os -import threading -import sys import platform +import sys +import threading +import webbrowser + import tkinter as tk from tkinter import ttk, messagebox @@ -80,6 +82,21 @@ def run_gui(devices): checkbox.pack(anchor="w") device_checkboxes[dev.name] = (checkbox_var, checkbox) + # Online Info + info_frame = ttk.LabelFrame(tab1, text="Online Info", style="TLabelframe") + info_frame.pack(fill="x", padx=10, pady=5) + infos = { + "Web Interface": "https://ledmatrix.frame.work", + "Latest Releases": "https://github.com/FrameworkComputer/inputmodule-rs/releases", + "Hardware Info": "https://github.com/FrameworkComputer/InputModules", + } + for (i, (text, url)) in enumerate(infos.items()): + # Organize in columns of three + row = int(i / 3) + column = i % 3 + btn = ttk.Button(info_frame, text=text, command=lambda url=url: webbrowser.open(url), style="TButton") + btn.grid(row=row, column=column) + # Brightness Slider brightness_frame = ttk.LabelFrame(tab1, text="Brightness", style="TLabelframe") brightness_frame.pack(fill="x", padx=10, pady=5) From ca30109f9ec605007426d386611ff716b7cf3caf Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 14:00:08 +0800 Subject: [PATCH 23/27] python: Don't import pygame if not needed - Make pygame an optional dependency - Avoid the pygame hello print on startup ``` pygame 2.6.1 (SDL 2.28.4, Python 3.13.0) Hello from the pygame community. https://www.pygame.org/contribute.html ``` Signed-off-by: Daniel Schaefer --- python/inputmodule/cli.py | 4 ---- python/inputmodule/gui/__init__.py | 15 ++++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/python/inputmodule/cli.py b/python/inputmodule/cli.py index c12e5f4..8bc91b1 100755 --- a/python/inputmodule/cli.py +++ b/python/inputmodule/cli.py @@ -62,10 +62,6 @@ RGB_COLORS, ) -# Optional dependencies: -# from PIL import Image -# import PySimpleGUI as sg - def main_cli(): parser = argparse.ArgumentParser() diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index bbb5b8e..c316579 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -18,7 +18,6 @@ Game, GameControlVal ) -from inputmodule.gui.pygames import snake, ledris from inputmodule.gui.ledmatrix import countdown, random_eq, clock from inputmodule.gui.gui_threading import stop_thread, is_dev_disconnected from inputmodule.inputmodule.ledmatrix import ( @@ -233,12 +232,14 @@ def run_gui(devices): root.mainloop() def perform_action(devices, action): - action_map = { - "game_snake": snake.main_devices, - "game_ledris": ledris.main_devices, - } - if action in action_map: - threading.Thread(target=action_map[action], args=(devices,), daemon=True).start(), + if action.startswith("game_"): + from inputmodule.gui.pygames import snake, ledris + action_map = { + "game_snake": snake.main_devices, + "game_ledris": ledris.main_devices, + } + if action in action_map: + threading.Thread(target=action_map[action], args=(devices,), daemon=True).start(), if action == "bootloader": disable_devices(devices) From c3d735982d77c45464024b4cf2ba79259ddb875b Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 14:08:57 +0800 Subject: [PATCH 24/27] python: Move sleep/wake buttons to advanced tab They're not really useful to the average user. Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index c316579..d47ddd0 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -220,7 +220,7 @@ def run_gui(devices): ttk.Button(equalizer_frame, text="Stop", command=stop_thread, style="TButton").pack(side="left", padx=5, pady=5) # Device Control Buttons - device_control_frame = ttk.LabelFrame(tab1, text="Device Control", style="TLabelframe") + device_control_frame = ttk.LabelFrame(tab3, text="Device Control", style="TLabelframe") device_control_frame.pack(fill="x", padx=10, pady=5) control_buttons = { "Sleep": "sleep", From fd0b85cdaa2401566fb9f28ea8a810c6561b43a8 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 14:10:13 +0800 Subject: [PATCH 25/27] python: Launch GUI by default pyinstaller just runs the script, so we need to make the default, the gui. Signed-off-by: Daniel Schaefer --- python/inputmodule/cli.py | 2 +- python/pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/python/inputmodule/cli.py b/python/inputmodule/cli.py index 8bc91b1..852641f 100755 --- a/python/inputmodule/cli.py +++ b/python/inputmodule/cli.py @@ -404,4 +404,4 @@ def main_gui(): if __name__ == "__main__": - main_cli() + main_gui() diff --git a/python/pyproject.toml b/python/pyproject.toml index d205db7..32b4108 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ Issues = "https://github.com/FrameworkComputer/inputmodule-rs/issues" Source = "https://github.com/FrameworkComputer/inputmodule-rs" -# TODO: Figure out how to add a runnable-script [project.scripts] ledmatrixctl = "inputmodule.cli:main_cli" From 13d3d02ccf0e019609ada627e9babb925675e418 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 14:17:21 +0800 Subject: [PATCH 26/27] python: Add windows app icon Signed-off-by: Daniel Schaefer --- python/inputmodule/gui/__init__.py | 5 +++++ res/framework_startmenuicon.ico | Bin 0 -> 89962 bytes 2 files changed, 5 insertions(+) create mode 100644 res/framework_startmenuicon.ico diff --git a/python/inputmodule/gui/__init__.py b/python/inputmodule/gui/__init__.py index d47ddd0..f7e53a6 100644 --- a/python/inputmodule/gui/__init__.py +++ b/python/inputmodule/gui/__init__.py @@ -52,6 +52,11 @@ def run_gui(devices): root = tk.Tk() root.title("LED Matrix Control") + ico = "framework_startmenuicon.ico" + res_path = resource_path() + if os.name == 'nt': + root.iconbitmap(f"{res_path}/res/{ico}") + tabControl = ttk.Notebook(root) tab1 = ttk.Frame(tabControl) tab_games = ttk.Frame(tabControl) diff --git a/res/framework_startmenuicon.ico b/res/framework_startmenuicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8c6be29a6b1462e13005c3c113d81888f51b73ab GIT binary patch literal 89962 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 P!S%KZZ+yQv)_?v#QgVSl literal 0 HcmV?d00001 From b5b8739e74f44ece641ee3d06157edc8930ce994 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 25 Nov 2024 14:52:49 +0800 Subject: [PATCH 27/27] python: Add executable icon Signed-off-by: Daniel Schaefer --- .github/workflows/software.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml index bcf1200..105dde7 100644 --- a/.github/workflows/software.yml +++ b/.github/workflows/software.yml @@ -112,7 +112,7 @@ jobs: Invoke-WebRequest -Uri https://github.com/FrameworkComputer/inputmodule-rs/releases/download/v0.2.0/ledmatrix.uf2 -OutFile releases\0.2.0\ledmatrix.uf2 # To run locally, need to make sure to include the pywin32 DLL - # pyinstaller --onefile, --name "python/inputmodule/cli.py", --windowed, --add-data "releases;releases" --path C:\users\skype\appdata\local\packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\localcache\local-packages\Python312\site-packages\pywin32_system32 --add-data 'res;res' -p python/inputmodule python/inputmodule/cli.py + # pyinstaller --onefile, --name "python/inputmodule/cli.py", --windowed, --add-data "releases;releases" --icon=res\framework_startmenuicon.ico --path C:\users\skype\appdata\local\packages\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0\localcache\local-packages\Python312\site-packages\pywin32_system32 --add-data 'res;res' -p python/inputmodule python/inputmodule/cli.py - name: Create Executable uses: JohnAZoidberg/pyinstaller-action@dont-clean with: @@ -120,7 +120,7 @@ jobs: spec: python/inputmodule/cli.py #'src/build.spec' requirements: 'python/requirements.txt' upload_exe_with_name: 'ledmatrixgui.exe' - options: --onefile, --name "ledmatrixgui", --windowed, --add-data "releases;releases" --add-data 'res;res' -p python/inputmodule + options: --onefile, --name "ledmatrixgui", --windowed, --add-data "releases;releases" --icon=res/framework_startmenuicon.ico --add-data 'res;res' -p python/inputmodule package-python: name: Package Python