us final version
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
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'<br><span style="color:{note_color}; font-size:14px;">{html.escape(note)}</span>'
|
||||
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)
|
||||
Reference in New Issue
Block a user