From dc1fdc024414761b3b4a03c7f63c1018274c2441 Mon Sep 17 00:00:00 2001 From: Jonas_Jones Date: Fri, 10 Oct 2025 21:27:00 +0200 Subject: [PATCH] added basic code for host software --- main.py | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..cc0b3e4 --- /dev/null +++ b/main.py @@ -0,0 +1,368 @@ +import sys +import threading +import time +import json +from functools import partial + +import serial +import serial.tools.list_ports + +import tkinter as tk +from tkinter import ttk, messagebox, simpledialog + +from PIL import Image, ImageDraw +import pystray + +# --- Serial / Arduino interface part --- + +BAUDRATE = 9600 +SERIAL_TIMEOUT = 1.0 # seconds + +def list_serial_ports(): + """Return a list of port names, e.g. ['COM3', '/dev/ttyUSB0'].""" + ports = serial.tools.list_ports.comports() + return [p.device for p in ports] + +def test_arduino_port(port): + """Try opening `port`, reading a line, and see if it matches the expected format.""" + try: + ser = serial.Serial(port, BAUDRATE, timeout=SERIAL_TIMEOUT) + # Wait a short time for data to arrive / reset + time.sleep(0.5) + ser.reset_input_buffer() + line = ser.readline().decode(errors='ignore').strip() + ser.close() + if line.startswith("POTS:") and "BUTTONS:" in line: + return True + except Exception as e: + # print("Port test failed", port, e) + pass + return False + +class ArduinoReader(threading.Thread): + def __init__(self, port, on_data_callback): + super().__init__(daemon=True) + self.port = port + self.on_data = on_data_callback + self._stop_event = threading.Event() + try: + self.ser = serial.Serial(self.port, BAUDRATE, timeout=0.1) + except Exception as e: + self.ser = None + self.lock = threading.Lock() + + def run(self): + if not self.ser: + return + while not self._stop_event.is_set(): + try: + line = self.ser.readline().decode(errors='ignore').strip() + if line: + # Forward valid lines + self.on_data(line) + except Exception as e: + # perhaps disconnected + break + self.close() + + def close(self): + with self.lock: + if self.ser: + try: + self.ser.close() + except: + pass + self.ser = None + + def stop(self): + self._stop_event.set() + self.close() + + +# --- GUI / Application logic --- + +class ButtonMapping: + def __init__(self, button_index): + self.button_index = button_index + # Defaults: no mapping + self.keypresses = [] # list of strings, e.g. ["ctrl+z", "F5"] + self.command = None # shell command string + +class PotMapping: + def __init__(self, pot_index): + self.pot_index = pot_index + # Map type: "master", "mic", "app", or None + self.mapping_type = None + self.app_name = None + self.min_val = 0 + self.max_val = 1023 + +class MainApp: + def __init__(self, root, minimized=False): + self.root = root + self.root.title("Arduino Control") + self.minimized = minimized + + self.arduino_reader = None + self.connected_port = None + + # State: latest readings + self.latest_pots = [0]*5 + self.latest_buttons = [0]*14 + + # Mappings + self.button_mappings = {i: ButtonMapping(i) for i in range(14)} + self.pot_mappings = {i: PotMapping(i) for i in range(5)} + + # GUI elements + self.port_var = tk.StringVar() + self.status_var = tk.StringVar(value="Not connected") + + self._setup_gui() + self._setup_tray() + + # If we should start minimized + if self.minimized: + self.root.withdraw() + + # After GUI is ready, scan ports + self.scan_ports() + + def _setup_gui(self): + frm = ttk.Frame(self.root, padding=10) + frm.pack(fill=tk.BOTH, expand=True) + + # Top: port selection + row = 0 + ttk.Label(frm, text="Serial Port:").grid(row=row, column=0, sticky=tk.W) + self.port_combo = ttk.Combobox(frm, textvariable=self.port_var, width=30, state="readonly") + self.port_combo.grid(row=row, column=1, sticky=tk.W) + btn_connect = ttk.Button(frm, text="Connect", command=self.on_connect_btn) + btn_connect.grid(row=row, column=2, sticky=tk.W) + row += 1 + + ttk.Label(frm, textvariable=self.status_var).grid(row=row, column=0, columnspan=3, sticky=tk.W) + row += 1 + + # Buttons area + btnframe = ttk.LabelFrame(frm, text="Buttons") + btnframe.grid(row=row, column=0, columnspan=3, sticky=tk.W+tk.E, pady=10) + for i in range(14): + b = ttk.Button(btnframe, text=f"B{i}", command=partial(self.configure_button, i)) + b.grid(row=i//7, column=i%7, padx=5, pady=3) + row += 1 + + # Pots area + potframe = ttk.LabelFrame(frm, text="Potentiometers") + potframe.grid(row=row, column=0, columnspan=3, sticky=tk.W+tk.E) + self.pot_labels = {} + for i in range(5): + ttk.Label(potframe, text=f"Poti {i}:").grid(row=i, column=0, sticky=tk.W) + lab = ttk.Label(potframe, text="0") + lab.grid(row=i, column=1, sticky=tk.W) + self.pot_labels[i] = lab + btn = ttk.Button(potframe, text="Configure", command=partial(self.configure_pot, i)) + btn.grid(row=i, column=2, padx=5, sticky=tk.W) + + def _setup_tray(self): + # Create an icon image (dummy) + icon_img = Image.new("RGB", (64, 64), color=(0, 0, 0)) + d = ImageDraw.Draw(icon_img) + d.rectangle((0,0,63,63), fill=(20,20,20)) + d.text((10, 20), "A", fill=(200,200,200)) + + menu = ( + pystray.MenuItem("Show", self.show_window), + pystray.MenuItem("Exit", self.on_quit) + ) + self.tray_icon = pystray.Icon("arduino_control", icon_img, "Arduino Ctrl", menu) + + # Run icon in background thread + threading.Thread(target=self.tray_icon.run, daemon=True).start() + + def show_window(self): + self.root.after(0, self.root.deiconify) + + def on_quit(self, *args): + if messagebox.askokcancel("Quit", "Exit application?"): + # Stop serial reader + if self.arduino_reader: + self.arduino_reader.stop() + self.tray_icon.stop() + self.root.quit() + + def scan_ports(self): + ports = list_serial_ports() + valid = [] + for p in ports: + if test_arduino_port(p): + valid.append(p) + self.port_combo['values'] = valid + if valid: + self.port_var.set(valid[0]) + else: + self.port_var.set("") + + def on_connect_btn(self): + port = self.port_var.get().strip() + if not port: + messagebox.showerror("Error", "No port selected") + return + # Stop existing + if self.arduino_reader: + self.arduino_reader.stop() + # Start new reader + self.arduino_reader = ArduinoReader(port, self.on_serial_data) + self.arduino_reader.start() + self.connected_port = port + self.status_var.set(f"Connected to {port}") + + def on_serial_data(self, line): + # Called in background thread when a line arrives + # Parse something like: + # POTS: 512, 345, 789, 123, 999 | BUTTONS: 0, 1, 0, 1, ... + try: + if not line.startswith("POTS:"): + return + part1, part2 = line.split("|", 1) + # parse pots + pots_str = part1.split(":", 1)[1].strip() + pots_vals = [int(x.strip()) for x in pots_str.split(",")] + # parse buttons + btn_str = part2.split(":", 1)[1].strip() + btn_vals = [int(x.strip()) for x in btn_str.split(",")] + + # Update state + for i in range(min(5, len(pots_vals))): + self.latest_pots[i] = pots_vals[i] + for i in range(min(14, len(btn_vals))): + self.latest_buttons[i] = btn_vals[i] + + # Schedule GUI update + self.root.after(0, self.refresh_display) + # Could also trigger mapped actions + self.handle_mappings() + except Exception as e: + # ignore parse errors + # print("Parse error:", line, e) + pass + + def refresh_display(self): + for i, lab in self.pot_labels.items(): + lab.config(text=str(self.latest_pots[i])) + # Optionally also show button states somewhere + + def handle_mappings(self): + # Called when new data arrives + # For buttons: detect transitions (0->1) and fire mapped actions + # For simplicity, we skip edge detection here, just if button pressed + for i in range(14): + if self.latest_buttons[i] == 1: + mapping = self.button_mappings[i] + if mapping.command: + # Run shell command + threading.Thread(target=lambda cmd=mapping.command: sys.stdout.write("\n")).start() + # Actually: os.system, subprocess, etc. + for kp in mapping.keypresses: + # Simulate keypress (platform‑specific). You’d use e.g. pynput or keyboard module + pass + + # For pots: you might map to volume control + # (This part is platform‑dependent — e.g. on Windows use `pycaw` or on Linux `pactl` / `amixer`) + for i in range(5): + pm = self.pot_mappings[i] + if pm.mapping_type is not None: + val = self.latest_pots[i] + # Normalize within user min..max to 0.0–1.0 range + norm = (val - pm.min_val) / (pm.max_val - pm.min_val) if pm.max_val > pm.min_val else 0.0 + norm = max(0.0, min(1.0, norm)) + # Then apply to master / mic / app + # You need to implement platform‐specific control + pass + + def configure_button(self, btn_idx): + cfg = self.button_mappings[btn_idx] + d = tk.Toplevel(self.root) + d.title(f"Config Button {btn_idx}") + d.grab_set() + + # Keypresses + ttk.Label(d, text="Keypresses (comma separated):").pack(padx=10, pady=5) + ent = ttk.Entry(d, width=40) + ent.pack(padx=10) + ent.insert(0, ",".join(cfg.keypresses)) + + # Command + ttk.Label(d, text="Shell command to run:").pack(padx=10, pady=5) + ent2 = ttk.Entry(d, width=40) + ent2.pack(padx=10) + if cfg.command: + ent2.insert(0, cfg.command) + + def on_ok(): + kp = [x.strip() for x in ent.get().split(",") if x.strip()] + cfg.keypresses = kp + cmd = ent2.get().strip() + cfg.command = cmd if cmd else None + d.destroy() + + ttk.Button(d, text="OK", command=on_ok).pack(pady=10) + + def configure_pot(self, pot_idx): + pm = self.pot_mappings[pot_idx] + d = tk.Toplevel(self.root) + d.title(f"Config Pot {pot_idx}") + d.grab_set() + + # Mapping type + ttk.Label(d, text="Map to:").pack(padx=10, pady=5) + mapvar = tk.StringVar(value=pm.mapping_type or "") + cb = ttk.Combobox(d, textvariable=mapvar, values=["", "master", "mic", "app"], state="readonly") + cb.pack(padx=10) + + # App name + ttk.Label(d, text="App name (if mapping to app):").pack(padx=10, pady=5) + ent_app = ttk.Entry(d, width=30) + ent_app.pack(padx=10) + if pm.app_name: + ent_app.insert(0, pm.app_name) + + # Min/Max values + ttk.Label(d, text="Min value:").pack(padx=10, pady=2) + ent_min = ttk.Entry(d, width=10) + ent_min.pack(padx=10) + ent_min.insert(0, str(pm.min_val)) + ttk.Label(d, text="Max value:").pack(padx=10, pady=2) + ent_max = ttk.Entry(d, width=10) + ent_max.pack(padx=10) + ent_max.insert(0, str(pm.max_val)) + + def on_ok(): + pm.mapping_type = mapvar.get() or None + pm.app_name = ent_app.get().strip() if pm.mapping_type == "app" else None + try: + pm.min_val = int(ent_min.get().strip()) + pm.max_val = int(ent_max.get().strip()) + except: + pass + d.destroy() + + ttk.Button(d, text="OK", command=on_ok).pack(pady=10) + + def run(self): + self.root.protocol("WM_DELETE_WINDOW", self.on_quit) + self.root.mainloop() + + +def main(): + minimized = False + if len(sys.argv) > 1: + if sys.argv[1] in ("--minimized", "-m"): + minimized = True + + root = tk.Tk() + app = MainApp(root, minimized=minimized) + app.run() + +if __name__ == "__main__": + main()