diff --git a/arrive_audio_ui.py b/arrive_audio_ui.py index 7b099a8..3e57e25 100644 --- a/arrive_audio_ui.py +++ b/arrive_audio_ui.py @@ -14,6 +14,18 @@ import struct import wave import tempfile import io +import time + +try: + import usb.core + import usb.util + _HAS_USB = True +except ImportError: + _HAS_USB = False + +# ── STM32 companion USB IDs — update after running lsusb ───────────────────── +_STM32_VID = None # e.g. 0x0483 +_STM32_PID = None # e.g. 0x5740 # ── Force framebuffer QPA before any Qt import ──────────────────────────────── if 'DISPLAY' not in os.environ and 'QT_QPA_PLATFORM' not in os.environ: @@ -295,6 +307,117 @@ def _vol_btn_style() -> str: """ +# ───────────────────────────────────────────────────────────────────────────── +# LED Controller (STM32 companion via USB vendor control transfers) +# ───────────────────────────────────────────────────────────────────────────── + +# Arrive brand colours for the LEDs +_LED_PURPLE = (95, 1, 111) # P1 #5F016F +_LED_PINK = (255, 51, 187) # P2 #FF33BB + +_BMREQ = 0x41 # Vendor | Interface | Out +_CMD_BRI = 0x00 # SET_BRIGHTNESS +_CMD_COL = 0x01 # SET_COLOR / SET_PATTERN + + +class LedController: + """ + Controls 2 RGB LED strings on the STM32 companion chip via USB. + Runs a knight-rider thread: pink strobe bounces over a purple base. + """ + + STRING_MAIN = 0 + STRING_EXT = 1 + + def __init__(self): + self._dev = None + self._running = False + self._thread = None + if _HAS_USB and _STM32_VID: + try: + self._dev = usb.core.find(idVendor=_STM32_VID, idProduct=_STM32_PID) + if self._dev: + self._dev.set_configuration() + except Exception: + self._dev = None + + # ── Public ──────────────────────────────────────────────────────────────── + + def start(self): + if self._dev is None or self._running: + return + self._running = True + self._thread = threading.Thread(target=self._knight_rider, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + if self._dev: + self._set_brightness(self.STRING_MAIN, 0) + + # ── USB helpers ─────────────────────────────────────────────────────────── + + def _windex(self, string): + return (string << 8) | 0x01 + + def _set_color(self, string, r, g, b, brightness=255): + try: + self._dev.ctrl_transfer( + _BMREQ, _CMD_COL, brightness, self._windex(string), [r, g, b] + ) + except Exception: + pass + + def _set_brightness(self, string, brightness): + try: + self._dev.ctrl_transfer( + _BMREQ, _CMD_BRI, brightness, self._windex(string), None + ) + except Exception: + pass + + def _set_pattern(self, string, steps): + """steps: list of (brightness, duration_ms) — max 5.""" + data = [] + for bri, dur in steps[:5]: + data += [bri & 0xFF, dur & 0xFF, (dur >> 8) & 0xFF] + try: + self._dev.ctrl_transfer( + _BMREQ, _CMD_COL, 0, self._windex(string), data + ) + except Exception: + pass + + # ── Knight rider loop ───────────────────────────────────────────────────── + + def _knight_rider(self): + STEPS = 20 # steps per half-cycle + STEP_MS = 0.03 # 30 ms per step → ~600 ms fade up, ~600 ms fade down + + while self._running: + # Fade purple → pink + for i in range(STEPS): + if not self._running: + return + t = i / STEPS + r = int(_LED_PURPLE[0] + (_LED_PINK[0] - _LED_PURPLE[0]) * t) + g = int(_LED_PURPLE[1] + (_LED_PINK[1] - _LED_PURPLE[1]) * t) + b = int(_LED_PURPLE[2] + (_LED_PINK[2] - _LED_PURPLE[2]) * t) + self._set_color(self.STRING_MAIN, r, g, b) + time.sleep(STEP_MS) + + # Fade pink → purple + for i in range(STEPS): + if not self._running: + return + t = i / STEPS + r = int(_LED_PINK[0] + (_LED_PURPLE[0] - _LED_PINK[0]) * t) + g = int(_LED_PINK[1] + (_LED_PURPLE[1] - _LED_PINK[1]) * t) + b = int(_LED_PINK[2] + (_LED_PURPLE[2] - _LED_PINK[2]) * t) + self._set_color(self.STRING_MAIN, r, g, b) + time.sleep(STEP_MS) + + def _autocrop(img): """Crop a QImage (ARGB32) to the bounding box of non-transparent pixels.""" if not _HAS_NUMPY: @@ -361,8 +484,10 @@ class MainWindow(QMainWindow): super().__init__() self.audio = AudioEngine() self.audio.signals.status.connect(self._on_audio_status) + self.leds = LedController() self._build_ui() self._set_bg(C_BG) + self.leds.start() # ── UI construction ─────────────────────────────────────────────────────── @@ -604,6 +729,7 @@ class MainWindow(QMainWindow): self._fb_snapshot = data def closeEvent(self, event): + self.leds.stop() self.audio.shutdown() super().closeEvent(event) # Restore the framebuffer to what it looked like before Qt launched