Files
om_arm_control_app/ui.py
T
2026-08-12 13:15:11 +09:00

503 lines
16 KiB
Python

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)