import html import threading from enum import Enum, auto from PySide6.QtCore import QObject, Qt, QTimer, Signal, QPropertyAnimation from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( QDialog, QFrame, QGraphicsOpacityEffect, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget, ) import config import presets import voice from arm import Arm, ArmError, available_ports from planner import Planner BG_DARK = "#0D0D0D" BG_CARD = "#1A1A2E" TEXT = "#FFFFFF" LABEL = "#888888" GREEN = "#00E676" YELLOW = "#FFD600" RED = "#FF5252" BTN_CONNECT = "#1565C0" BTN_DISCONNECT = "#B71C1C" BTN_VOICE = "#6A1B9A" DIVIDER = "#333333" STYLE = f""" QWidget#root, QDialog {{ background: {BG_DARK}; }} QFrame#card {{ background: {BG_CARD}; border-radius: 12px; }} QLabel {{ color: {TEXT}; background: transparent; }} QLabel#caption {{ color: {LABEL}; font-size: 14px; }} QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: none; }} QPushButton {{ color: white; border: none; border-radius: 6px; font-weight: bold; }} QPushButton:disabled {{ color: rgba(255, 255, 255, 0.4); }} QLineEdit {{ background: {BG_CARD}; color: {TEXT}; border: 1px solid {DIVIDER}; border-radius: 8px; padding: 10px 12px; font-size: 15px; }} QLineEdit:focus {{ border-color: {BTN_VOICE}; }} QLineEdit:disabled {{ color: {LABEL}; }} QListWidget {{ background: {BG_DARK}; color: {TEXT}; border: none; font-size: 20px; outline: none; }} QListWidget::item {{ background: {BG_CARD}; padding: 16px; border-bottom: 1px solid {DIVIDER}; }} QListWidget::item:hover {{ background: #24243e; }} QListWidget::item:selected {{ background: #2c2c4a; }} """ def _button_style(color, height, size): r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5)) return f""" QPushButton {{ background: {color}; min-height: {height}px; max-height: {height}px; font-size: {size}px; }} QPushButton:hover {{ background: {color}; border: 2px solid rgba(255,255,255,0.15); }} QPushButton:pressed {{ background: {color}; padding-top: 3px; }} QPushButton:disabled {{ background: rgba({r},{g},{b},0.4); }} """ def _caption(text): label = QLabel(text.upper()) label.setObjectName("caption") font = label.font() font.setLetterSpacing(QFont.PercentageSpacing, 110) label.setFont(font) return label def _card(): frame = QFrame() frame.setObjectName("card") layout = QVBoxLayout(frame) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(8) return frame, layout class BtState(Enum): NOT_CONNECTED = auto() CONNECTING = auto() CONNECTED = auto() class Busy(Enum): IDLE = auto() LISTENING = auto() THINKING = auto() MOVING = auto() class Events(QObject): connected = Signal(object, str) connect_failed = Signal(str) lost = Signal(str) heard = Signal(str) hear_failed = Signal(str) planned = Signal(str, str, object) plan_failed = Signal(str, str) step = Signal(int, int, object) done = Signal(bool) class Toast(QLabel): def __init__(self, parent): super().__init__(parent) self.setStyleSheet( "background: rgba(50,50,50,0.95); color: white; border-radius: 18px;" "padding: 10px 18px; font-size: 14px;" ) self.setAlignment(Qt.AlignCenter) self.setWordWrap(True) self._fx = QGraphicsOpacityEffect(self) self.setGraphicsEffect(self._fx) self._fade = QPropertyAnimation(self._fx, b"opacity", self) self._fade.setDuration(300) self._fade.finished.connect(lambda: self._fx.opacity() == 0 and self.hide()) self._timer = QTimer(self, singleShot=True, timeout=self._out) self.hide() def show_text(self, text, long=False): self.setText(text) width = min(self.parent().width() - 48, 360) self.setFixedWidth(width) self.adjustSize() self.move((self.parent().width() - width) // 2, self.parent().height() - self.height() - 140) self.raise_() self.show() self._fade.stop() self._fx.setOpacity(1) self._timer.start(3500 if long else 2000) def _out(self): self._fade.setStartValue(1.0) self._fade.setEndValue(0.0) self._fade.start() class DeviceDialog(QDialog): def __init__(self, ports, parent=None): super().__init__(parent) self.setWindowTitle("Select Paired Device") self.resize(parent.size()) self.selected = None layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) header = QLabel("Select Paired Device") header.setStyleSheet(f"background: {BG_CARD}; font-size: 24px; font-weight: bold; padding: 24px;") hint = QLabel("Please select your HC-05 device") hint.setStyleSheet(f"color: {LABEL}; font-size: 16px; padding: 16px;") self.list = QListWidget() for label, device in ports: item = QListWidgetItem(f"{label}\n{device}") item.setData(Qt.UserRole, (label, device)) self.list.addItem(item) self.list.itemClicked.connect(self._pick) self.list.itemActivated.connect(self._pick) layout.addWidget(header) layout.addWidget(hint) layout.addWidget(self.list, 1) def _pick(self, item): self.selected = item.data(Qt.UserRole) self.accept() class MainWindow(QWidget): def __init__(self): super().__init__() self.setObjectName("root") self.setWindowTitle("Stage Control") self.setStyleSheet(STYLE) self.resize(440, 800) self.setMinimumSize(360, 620) self.arm = None self.device_name = "" self.bt = BtState.NOT_CONNECTED self.busy = Busy.IDLE self._planner = None self.ev = Events() self.ev.connected.connect(self._on_connected) self.ev.connect_failed.connect(self._on_connect_failed) self.ev.lost.connect(self._on_lost) self.ev.heard.connect(self._on_heard) self.ev.hear_failed.connect(self._on_hear_failed) self.ev.planned.connect(self._on_planned) self.ev.plan_failed.connect(self._on_plan_failed) self.ev.step.connect(self._on_step) self.ev.done.connect(self._on_done) self._build() self.toast = Toast(self) QShortcut(QKeySequence(Qt.Key_Space), self, self._voice_shortcut) QShortcut(QKeySequence(Qt.Key_Escape), self, self._stop) self._set_bt(BtState.NOT_CONNECTED) if not config.OPENAI_API_KEY: QTimer.singleShot(400, lambda: self.toast.show_text( "OPENAI_API_KEY is not set.\nOnly preset keywords will work.", long=True)) def _build(self): root = QVBoxLayout(self) root.setContentsMargins(24, 24, 24, 24) root.setSpacing(0) status_card, lay = _card() lay.addWidget(_caption("Bluetooth Status")) self.status = QLabel() self.status.setStyleSheet("font-size: 26px; font-weight: bold;") lay.addWidget(self.status) root.addWidget(status_card) root.addSpacing(16) voice_card, lay = _card() lay.addWidget(_caption("Recognized commands")) self.recognized = QLabel("β€”") self.recognized.setWordWrap(True) self.recognized.setTextFormat(Qt.RichText) self.recognized.setAlignment(Qt.AlignLeft | Qt.AlignTop) self.recognized.setTextInteractionFlags(Qt.TextSelectableByMouse) self.recognized.setStyleSheet("font-size: 16px;") scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(self.recognized) lay.addWidget(scroll, 1) root.addWidget(voice_card, 15) root.addSpacing(16) sent_card, lay = _card() lay.addWidget(_caption("Commands sent")) self.sent = QLabel("β€”") self.sent.setAlignment(Qt.AlignCenter) self.sent.setStyleSheet(f"color: {GREEN}; font-size: 60px; font-weight: bold;") self.sent_detail = QLabel("") self.sent_detail.setAlignment(Qt.AlignCenter) self.sent_detail.setStyleSheet(f"color: {LABEL}; font-size: 13px;") lay.addStretch(1) lay.addWidget(self.sent) lay.addWidget(self.sent_detail) lay.addStretch(1) root.addWidget(sent_card, 10) root.addSpacing(24) self.btn_connect = QPushButton() self.btn_connect.setCursor(Qt.PointingHandCursor) self.btn_connect.setFocusPolicy(Qt.NoFocus) self.btn_connect.clicked.connect(self._connect_clicked) root.addWidget(self.btn_connect) root.addSpacing(16) self.btn_voice = QPushButton("Voice commands") self.btn_voice.setCursor(Qt.PointingHandCursor) self.btn_voice.setFocusPolicy(Qt.NoFocus) self.btn_voice.setStyleSheet(_button_style(BTN_VOICE, 96, 28)) self.btn_voice.clicked.connect(self._voice_clicked) root.addWidget(self.btn_voice) root.addSpacing(12) self.typed = QLineEdit() self.typed.setPlaceholderText("…or type a command and press Enter") self.typed.returnPressed.connect(self._typed_command) root.addWidget(self.typed) # state def _set_bt(self, state): self.bt = state if state is BtState.NOT_CONNECTED: self.status.setText("⚫ Not Connected") self._status_color(RED) self.btn_connect.setText("Connect Bluetooth") self.btn_connect.setStyleSheet(_button_style(BTN_CONNECT, 72, 22)) self.btn_connect.setEnabled(True) elif state is BtState.CONNECTING: self.status.setText("🟑 Connecting...") self._status_color(YELLOW) self.btn_connect.setText("Connecting...") self.btn_connect.setEnabled(False) else: self.status.setText(f"🟒 Connected: {self.device_name}") self._status_color(GREEN) self.btn_connect.setText("Disconnect") self.btn_connect.setStyleSheet(_button_style(BTN_DISCONNECT, 72, 22)) self.btn_connect.setEnabled(True) self._refresh_voice() def _set_busy(self, busy): self.busy = busy self._refresh_voice() def _refresh_voice(self): connected = self.bt is BtState.CONNECTED labels = { Busy.IDLE: "Voice commands", Busy.LISTENING: "Listening...", Busy.THINKING: "Thinking...", Busy.MOVING: "β–  Stop", } self.btn_voice.setText(labels[self.busy]) color = RED if self.busy is Busy.MOVING else BTN_VOICE self.btn_voice.setStyleSheet(_button_style(color, 96, 28)) self.btn_voice.setEnabled(connected and self.busy in (Busy.IDLE, Busy.MOVING)) self.typed.setEnabled(connected and self.busy is Busy.IDLE) def _status_color(self, color): self.status.setStyleSheet(f"font-size: 26px; font-weight: bold; color: {color};") def _show_recognized(self, text, note=None, note_color=LABEL): body = html.escape(text) if note: body += f'
{html.escape(note)}' self.recognized.setText(body) def _show_sent(self, big, detail=""): size = 60 if len(big) <= 4 else 36 self.sent.setStyleSheet(f"color: {GREEN}; font-size: {size}px; font-weight: bold;") self.sent.setText(big) self.sent_detail.setText(detail) # connect def _connect_clicked(self): if self.bt is BtState.CONNECTED: self._disconnect() return ports = available_ports() if not ports: self.toast.show_text("No paired devices found.\nPair your HC-05 in system settings first.", long=True) return dialog = DeviceDialog(ports, self) if dialog.exec() != QDialog.Accepted or not dialog.selected: return label, device = dialog.selected self.device_name = label self._set_bt(BtState.CONNECTING) def work(): arm = Arm(device, on_lost=self.ev.lost.emit) try: arm.open() except ArmError as e: self.ev.connect_failed.emit(str(e)) return self.ev.connected.emit(arm, label) threading.Thread(target=work, daemon=True).start() def _on_connected(self, arm, label): self.arm = arm self.device_name = label self._set_bt(BtState.CONNECTED) def _on_connect_failed(self, err): self._set_bt(BtState.NOT_CONNECTED) self.toast.show_text(f"Connection failed: {err}", long=True) def _on_lost(self, err): self.arm = None self._set_busy(Busy.IDLE) self._set_bt(BtState.NOT_CONNECTED) self.toast.show_text("Connection lost") def _disconnect(self): if self.arm: self.arm.close() self.arm = None self._set_busy(Busy.IDLE) self._set_bt(BtState.NOT_CONNECTED) # voice def _voice_shortcut(self): if self.btn_voice.isEnabled(): self._voice_clicked() def _voice_clicked(self): if self.busy is Busy.MOVING: self._stop() return self._set_busy(Busy.LISTENING) self._show_recognized("πŸŽ™ Speak command") def work(): try: self.ev.heard.emit(voice.listen(config.STT_LANGUAGE)) except voice.NothingHeard: self.ev.hear_failed.emit("Didn't catch that. Try again.") except Exception as e: self.ev.hear_failed.emit(f"Voice recognition failed: {e}") threading.Thread(target=work, daemon=True).start() def _on_hear_failed(self, msg): self._set_busy(Busy.IDLE) self._show_recognized(msg) def _typed_command(self): text = self.typed.text().strip() if text and self.typed.isEnabled(): self.typed.clear() self._on_heard(text) def _on_heard(self, text): if presets.is_stop(text): self._show_recognized(text) self._stop() self._set_busy(Busy.IDLE) return self._set_busy(Busy.THINKING) self._show_recognized(f"Analyzing: {text}") pose = self.arm.pose if self.arm else config.HOME def work(): try: if self._planner is None: self._planner = Planner() plan = self._planner.plan(text, pose) self.ev.planned.emit(text, plan.summary, plan.steps) except Exception as e: self.ev.plan_failed.emit(text, str(e)) threading.Thread(target=work, daemon=True).start() def _on_planned(self, text, summary, steps): if not steps: self._set_busy(Busy.IDLE) self._show_recognized(text, summary or "Nothing to do.") return self._show_recognized(text, f"β†’ {summary}") self._run(steps) def _on_plan_failed(self, text, err): print(f"[planner] {err}") fallback = presets.match_keyword(text) if fallback: label, steps = fallback self._show_recognized(text, f"Fallback: {label}", YELLOW) self._run(steps) else: self._set_busy(Busy.IDLE) self._show_recognized(text, f"LLM error: {err}", RED) # motion def _run(self, steps): arm = self.arm if arm is None: self._set_busy(Busy.IDLE) return self._set_busy(Busy.MOVING) total = len(steps) def work(): try: finished = arm.run(steps, lambda i, pose: self.ev.step.emit(i, total, pose)) except ArmError: finished = False self.ev.done.emit(finished) threading.Thread(target=work, daemon=True).start() def _on_step(self, i, total, pose): self._show_sent(" Β· ".join(map(str, pose)), f"step {i + 1} / {total}") def _on_done(self, finished): if self.busy is Busy.MOVING: self._set_busy(Busy.IDLE) if not finished and self.arm: self.sent_detail.setText("stopped") def _stop(self): if not self.arm: return try: self.arm.stop() except ArmError: return self._show_sent("S", "stop") def closeEvent(self, event): if self.arm: self.arm.close() super().closeEvent(event)