""" serial_display.py — QPad XIAO RP2040 companion (PyQt6) ──────────────────────────────────────────────────────── Type text → press Send (or Enter) → text appears on the OLED. Touch a button on the QPad → the matching shape in the GUI lights up. Serial protocol (matches serial-display.ino): PC → Arduino "0,\\n" display text (use | for newlines) Arduino → PC "1,,<0|1>\\n" button state change Button indices: 0=A 1=B 2=DOWN 3=LEFT 4=RIGHT 5=UP Requirements: pip install PyQt6 pyserial """ import sys import serial import serial.tools.list_ports from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLineEdit, QLabel, QComboBox, QStatusBar, QFrame ) from PyQt6.QtCore import Qt, QTimer, QPointF, pyqtSignal from PyQt6.QtGui import ( QPainter, QColor, QPen, QBrush, QRadialGradient, QFont, QFontMetrics ) # ── Button metadata ──────────────────────────────────────────────────────────── BUTTON_NAMES = ["A", "B", "DOWN", "LEFT", "RIGHT", "UP"] BUTTON_LABELS = ["A", "B", "▼", "◄", "►", "▲"] BUTTON_COLORS_ON = [ QColor(80, 220, 80), # A – green QColor(220, 60, 60), # B – red QColor(80, 160, 255), # DOWN QColor(80, 160, 255), # LEFT QColor(80, 160, 255), # RIGHT QColor(80, 160, 255), # UP ] BUTTON_COLORS_OFF = QColor(60, 60, 60) NUM_BUTTONS = len(BUTTON_NAMES) # ── Single button widget ─────────────────────────────────────────────────────── class PadButton(QWidget): """A circular indicator that lights up when the physical pad is pressed.""" RADIUS = 30 def __init__(self, index: int, parent=None): super().__init__(parent) self.index = index self.pressed = False side = (self.RADIUS + 20) * 2 self.setFixedSize(side, side) def setState(self, pressed: bool): if self.pressed != pressed: self.pressed = pressed self.update() def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) cx = self.width() / 2 cy = self.height() / 2 r = self.RADIUS if self.pressed: # Glow rings for glow_r, alpha in [(r + 18, 40), (r + 10, 70)]: grad = QRadialGradient(cx, cy, glow_r) c = QColor(BUTTON_COLORS_ON[self.index]) c.setAlpha(alpha) grad.setColorAt(0, c) grad.setColorAt(1, QColor(0, 0, 0, 0)) p.setBrush(QBrush(grad)) p.setPen(Qt.PenStyle.NoPen) p.drawEllipse(QPointF(cx, cy), glow_r, glow_r) # Filled circle p.setBrush(QBrush(BUTTON_COLORS_ON[self.index])) p.setPen(QPen(BUTTON_COLORS_ON[self.index].lighter(150), 2)) else: p.setBrush(QBrush(BUTTON_COLORS_OFF)) p.setPen(QPen(QColor(100, 100, 100), 2)) p.drawEllipse(QPointF(cx, cy), r, r) # Label font = QFont("SansSerif", 13, QFont.Weight.Bold) p.setFont(font) p.setPen(QPen(QColor(20, 20, 20) if self.pressed else QColor(180, 180, 180))) p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, BUTTON_LABELS[self.index]) # ── D-pad + action button panel ─────────────────────────────────────────────── class ButtonPanel(QWidget): """Lays out six PadButton widgets in a gamepad-style arrangement.""" def __init__(self, parent=None): super().__init__(parent) self.buttons = [PadButton(i) for i in range(NUM_BUTTONS)] # Use an absolute-position container for the gamepad layout self.setFixedSize(560, 200) self._place_buttons() def _place_buttons(self): """Position each button widget manually (gamepad layout).""" # D-pad centre dpad_cx, dpad_cy = 190, 100 step = 72 # index: 5=UP 2=DOWN 3=LEFT 4=RIGHT positions = { 5: (dpad_cx, dpad_cy - step), # UP 2: (dpad_cx, dpad_cy + step), # DOWN 3: (dpad_cx - step, dpad_cy), # LEFT 4: (dpad_cx + step, dpad_cy), # RIGHT 1: (420, dpad_cy - step), # B — above A 0: (420, dpad_cy + step), # A — below B } for idx, (cx, cy) in positions.items(): btn = self.buttons[idx] btn.setParent(self) btn.move(cx - btn.width() // 2, cy - btn.height() // 2) def setState(self, index: int, pressed: bool): if 0 <= index < NUM_BUTTONS: self.buttons[index].setState(pressed) def resetAll(self): for btn in self.buttons: btn.setState(False) def paintEvent(self, event): """Draw subtle cross lines behind the D-pad.""" p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.setPen(QPen(QColor(55, 55, 55), 2)) dpad_cx, dpad_cy = 190, 100 step = 72 r = PadButton.RADIUS p.drawLine(int(dpad_cx - step + r + 4), dpad_cy, int(dpad_cx + step - r - 4), dpad_cy) p.drawLine(dpad_cx, int(dpad_cy - step + r + 4), dpad_cx, int(dpad_cy + step - r - 4)) # ── Main window ─────────────────────────────────────────────────────────────── class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Serial Display — QPad XIAO RP2040") self.setFixedSize(600, 480) self.serial_port: serial.Serial | None = None self._read_buffer = "" self._build_ui() self._refresh_ports() # Poll serial every 20 ms self.poll_timer = QTimer(self) self.poll_timer.timeout.connect(self._poll_serial) self.poll_timer.start(20) # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setSpacing(10) root.setContentsMargins(16, 12, 16, 8) # ── Serial controls ── serial_row = QHBoxLayout() self.port_combo = QComboBox() self.port_combo.setMinimumWidth(220) self.btn_refresh = QPushButton("Refresh") self.btn_open = QPushButton("Open") self.btn_close = QPushButton("Close") self.btn_close.setEnabled(False) self.btn_refresh.clicked.connect(self._refresh_ports) self.btn_open.clicked.connect(self._open_port) self.btn_close.clicked.connect(self._close_port) serial_row.addWidget(QLabel("Port:")) serial_row.addWidget(self.port_combo, 1) serial_row.addWidget(self.btn_refresh) serial_row.addWidget(self.btn_open) serial_row.addWidget(self.btn_close) root.addLayout(serial_row) # ── Divider ── root.addWidget(self._divider()) # ── Text input ── root.addWidget(QLabel("Text to display on QPad OLED:")) text_row = QHBoxLayout() self.text_input = QLineEdit() self.text_input.setPlaceholderText("Type text here… use | for line breaks on the OLED") self.text_input.returnPressed.connect(self._send_text) self.btn_send = QPushButton("Send") self.btn_send.setEnabled(False) self.btn_send.clicked.connect(self._send_text) text_row.addWidget(self.text_input, 1) text_row.addWidget(self.btn_send) root.addLayout(text_row) # ── Divider ── root.addWidget(self._divider()) # ── Button panel ── lbl = QLabel("QPad Buttons") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) root.addWidget(lbl) self.btn_panel = ButtonPanel() root.addWidget(self.btn_panel, alignment=Qt.AlignmentFlag.AlignCenter) # ── Status bar ── self.status = QStatusBar() self.setStatusBar(self.status) self.status.showMessage("Select a port and click Open.") # ── Stylesheet ── self.setStyleSheet(""" QMainWindow, QWidget { background: #1e1e1e; color: #ddd; font-size: 13px; } QComboBox, QLineEdit { background: #2d2d2d; border: 1px solid #555; border-radius: 4px; padding: 4px 8px; color: #ddd; } QComboBox:focus, QLineEdit:focus { border-color: #5a9ef0; } QPushButton { background: #3c6498; border: none; border-radius: 4px; padding: 5px 14px; color: #fff; } QPushButton:hover { background: #5a82c0; } QPushButton:pressed { background: #2a4d7a; } QPushButton:disabled { background: #444; color: #888; } QLabel { color: #bbb; } QStatusBar { background: #252525; color: #aaa; font-size: 12px; } """) @staticmethod def _divider() -> QFrame: f = QFrame() f.setFrameShape(QFrame.Shape.HLine) f.setStyleSheet("color: #3a3a3a;") return f # ── Serial helpers ──────────────────────────────────────────────────────── def _refresh_ports(self): self.port_combo.clear() ports = [p.device for p in serial.tools.list_ports.comports()] self.port_combo.addItems(ports) self.btn_open.setEnabled(bool(ports)) self.status.showMessage( f"{len(ports)} port(s) found." if ports else "No serial ports found." ) def _open_port(self): device = self.port_combo.currentText() if not device: return try: self.serial_port = serial.Serial(device, 9600, timeout=0) self.btn_open.setEnabled(False) self.btn_close.setEnabled(True) self.btn_refresh.setEnabled(False) self.btn_send.setEnabled(True) self.status.showMessage(f"Opened {device}") except serial.SerialException as e: self.status.showMessage(f"Error: {e}") def _close_port(self): if self.serial_port: self.serial_port.close() self.serial_port = None self.btn_open.setEnabled(True) self.btn_close.setEnabled(False) self.btn_refresh.setEnabled(True) self.btn_send.setEnabled(False) self.btn_panel.resetAll() self.status.showMessage("Port closed.") def _send_text(self): text = self.text_input.text().strip() if not text or not self.serial_port: return try: self.serial_port.write(f"0,{text}\n".encode()) self.status.showMessage(f"Sent: {text}") self.text_input.clear() except serial.SerialException as e: self.status.showMessage(f"Write error: {e}") def _poll_serial(self): if not self.serial_port: return try: if self.serial_port.in_waiting: raw = self.serial_port.read(self.serial_port.in_waiting) self._read_buffer += raw.decode(errors="replace") while "\n" in self._read_buffer: line, self._read_buffer = self._read_buffer.split("\n", 1) self._parse_line(line.strip()) except serial.SerialException: self._close_port() self.status.showMessage("Device disconnected.") def _parse_line(self, line: str): """Parse '1,,<0|1>' button state messages.""" parts = line.split(",") if len(parts) >= 3 and parts[0] == "1": try: idx = int(parts[1]) state = int(parts[2]) == 1 self.btn_panel.setState(idx, state) except ValueError: pass def closeEvent(self, event): if self.serial_port: self.serial_port.close() event.accept() # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())