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)