#!/usr/bin/env python3 """ HSV NeoPixel Color Visualizer By Fab Academy Student This interface connects to the Arduino running HSV_potentialmeter.ino, reads the sensor values, and displays the corresponding color in the window. The background color matches the NeoPixel colors in real-time. Requires -------- pip install pyserial pyqt5 """ import sys from typing import Optional import serial import serial.tools.list_ports from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QSizePolicy, QFrame, ) from PyQt5.QtCore import ( Qt, QTimer, QRect, pyqtSignal, ) from PyQt5.QtGui import ( QPainter, QColor, QFont, QBrush, QLinearGradient, QPalette, QPixmap, ) # ============================================================================= # CONSTANTS # ============================================================================= BAUD_RATE = 9600 # Must match the Arduino sketch # Color palette COL_BG_DARK = QColor(20, 20, 28) COL_PANEL_BG = QColor(35, 35, 45) COL_TEXT = QColor(220, 220, 230) COL_TEXT_DIM = QColor(150, 150, 165) COL_BORDER = QColor(80, 80, 95) COL_VALUE_HIGHLIGHT = QColor(100, 220, 130) # ============================================================================= # COLOR WIDGET # ============================================================================= class ColorDisplayWidget(QWidget): """ Custom widget that displays a solid color background. Updates its color when new HSV values are received. """ def __init__(self, parent=None): super().__init__(parent) self.current_color = QColor(50, 50, 60) # Default dark gray self.setMinimumSize(400, 300) self.setAutoFillBackground(True) def update_color(self, r: int, g: int, b: int) -> None: """Update the displayed color and refresh the widget.""" self.current_color = QColor(r, g, b) self.update() # Schedule repaint def paintEvent(self, event) -> None: """Paint the widget with the current color.""" painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # Fill the entire widget with the current color painter.fillRect(self.rect(), self.current_color) # Draw a subtle inner glow effect gradient = QLinearGradient(0, 0, 0, self.height()) gradient.setColorAt(0.0, QColor(255, 255, 255, 30)) gradient.setColorAt(0.5, QColor(255, 255, 255, 0)) gradient.setColorAt(1.0, QColor(0, 0, 0, 30)) painter.fillRect(self.rect(), gradient) painter.end() # ============================================================================= # VALUE DISPLAY WIDGET # ============================================================================= class ValueDisplayWidget(QFrame): """ Widget that displays sensor readings and RGB values in a clean card layout. """ def __init__(self, parent=None): super().__init__(parent) self.setStyleSheet(self._card_style()) self.setFixedHeight(120) self.sensor_value = 0 self.hue_value = 0 self.rgb_values = (0, 0, 0) self._build_ui() def _build_ui(self): """Create the layout for value displays.""" layout = QHBoxLayout(self) layout.setContentsMargins(20, 15, 20, 15) layout.setSpacing(30) # Sensor Value Display sensor_widget = QWidget() sensor_layout = QVBoxLayout(sensor_widget) sensor_layout.setSpacing(5) sensor_label = QLabel("📊 SENSOR VALUE") sensor_label.setStyleSheet("color: rgb(150, 150, 165); font-size: 11px; font-weight: bold;") sensor_layout.addWidget(sensor_label) self.sensor_display = QLabel("0") self.sensor_display.setStyleSheet("color: rgb(100, 220, 130); font-size: 32px; font-weight: bold; font-family: monospace;") sensor_layout.addWidget(self.sensor_display) layout.addWidget(sensor_widget) # Separator line line = QFrame() line.setFrameShape(QFrame.VLine) line.setStyleSheet("background-color: rgb(80, 80, 95);") line.setFixedWidth(2) layout.addWidget(line) # Hue Value Display hue_widget = QWidget() hue_layout = QVBoxLayout(hue_widget) hue_layout.setSpacing(5) hue_label = QLabel("🎨 HUE VALUE") hue_label.setStyleSheet("color: rgb(150, 150, 165); font-size: 11px; font-weight: bold;") hue_layout.addWidget(hue_label) self.hue_display = QLabel("0") self.hue_display.setStyleSheet("color: rgb(220, 220, 230); font-size: 32px; font-weight: bold; font-family: monospace;") hue_layout.addWidget(self.hue_display) layout.addWidget(hue_widget) # Separator line line2 = QFrame() line2.setFrameShape(QFrame.VLine) line2.setStyleSheet("background-color: rgb(80, 80, 95);") line2.setFixedWidth(2) layout.addWidget(line2) # RGB Value Display rgb_widget = QWidget() rgb_layout = QVBoxLayout(rgb_widget) rgb_layout.setSpacing(5) rgb_label = QLabel("🌈 RGB VALUES") rgb_label.setStyleSheet("color: rgb(150, 150, 165); font-size: 11px; font-weight: bold;") rgb_layout.addWidget(rgb_label) rgb_value_layout = QHBoxLayout() rgb_value_layout.setSpacing(8) self.r_display = QLabel("R: 0") self.r_display.setStyleSheet("color: rgb(255, 100, 100); font-size: 18px; font-weight: bold;") self.g_display = QLabel("G: 0") self.g_display.setStyleSheet("color: rgb(100, 255, 100); font-size: 18px; font-weight: bold;") self.b_display = QLabel("B: 0") self.b_display.setStyleSheet("color: rgb(100, 100, 255); font-size: 18px; font-weight: bold;") rgb_value_layout.addWidget(self.r_display) rgb_value_layout.addWidget(self.g_display) rgb_value_layout.addWidget(self.b_display) rgb_layout.addLayout(rgb_value_layout) layout.addWidget(rgb_widget) def update_values(self, sensor: int, hue: int, rgb: tuple) -> None: """Update all value displays.""" self.sensor_value = sensor self.hue_value = hue self.rgb_values = rgb self.sensor_display.setText(str(sensor)) self.hue_display.setText(str(hue)) self.r_display.setText(f"R: {rgb[0]}") self.g_display.setText(f"G: {rgb[1]}") self.b_display.setText(f"B: {rgb[2]}") @staticmethod def _card_style() -> str: return """ QFrame { background-color: rgb(35, 35, 45); border-radius: 12px; border: 1px solid rgb(80, 80, 95); } """ # ============================================================================= # COLOR PREVIEW WIDGET # ============================================================================= class ColorPreviewWidget(QFrame): """ Widget that shows a small color swatch preview of the current color. """ def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(80, 80) self.current_color = QColor(50, 50, 60) self.setStyleSheet(self._preview_style()) def _preview_style(self) -> str: return """ QFrame { border-radius: 12px; border: 2px solid rgb(100, 100, 120); } """ def update_color(self, r: int, g: int, b: int) -> None: """Update the preview color.""" self.current_color = QColor(r, g, b) self.setStyleSheet(f""" QFrame {{ background-color: rgb({r}, {g}, {b}); border-radius: 12px; border: 2px solid rgb(100, 100, 120); }} """) def paintEvent(self, event) -> None: """Paint the preview with the current color.""" painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) painter.fillRect(self.rect(), self.current_color) painter.end() # ============================================================================= # MAIN WINDOW # ============================================================================= class MainWindow(QMainWindow): """ Main application window that connects to Arduino and displays the color. """ def __init__(self): super().__init__() self.setWindowTitle("HSV NeoPixel Color Visualizer") self.setMinimumSize(500, 500) self.serial_port: Optional[serial.Serial] = None self.current_hue = 0 self.current_sensor = 0 self._build_ui() self._refresh_ports() # Timer for polling serial data self.poll_timer = QTimer(self) self.poll_timer.timeout.connect(self._poll_serial) self.poll_timer.start(20) # 50 times per second def _build_ui(self) -> None: """Build the user interface.""" root = QWidget() root.setStyleSheet("background-color: rgb(20, 20, 28);") self.setCentralWidget(root) main_layout = QVBoxLayout(root) main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(15) # ---- Header Section ---- header_layout = QHBoxLayout() # Title title_label = QLabel("🎨 HSV NeoPixel Visualizer") title_label.setStyleSheet("color: rgb(220, 220, 230); font-size: 20px; font-weight: bold;") header_layout.addWidget(title_label) header_layout.addStretch() # Color Preview self.preview = ColorPreviewWidget() header_layout.addWidget(self.preview) main_layout.addLayout(header_layout) # ---- Subtitle ---- subtitle = QLabel("Real-time color matching with NeoPixel") subtitle.setStyleSheet("color: rgb(150, 150, 165); font-size: 12px;") main_layout.addWidget(subtitle) # ---- Main Color Display ---- self.color_display = ColorDisplayWidget() self.color_display.setMinimumHeight(350) main_layout.addWidget(self.color_display) # ---- Value Display Panel ---- self.value_display = ValueDisplayWidget() main_layout.addWidget(self.value_display) # ---- Serial Control Panel ---- control_panel = QFrame() control_panel.setStyleSheet(self._panel_style()) control_layout = QHBoxLayout(control_panel) control_layout.setContentsMargins(15, 10, 15, 10) # Port selector port_label = QLabel("Serial Port:") port_label.setStyleSheet("color: rgb(220, 220, 230);") control_layout.addWidget(port_label) self.port_combo = QComboBox() self.port_combo.setStyleSheet(self._combo_style()) self.port_combo.setMinimumWidth(150) control_layout.addWidget(self.port_combo) # Buttons self.refresh_btn = self._make_btn("Refresh", self._refresh_ports) self.open_btn = self._make_btn("Open", self._open_port) self.close_btn = self._make_btn("Close", self._close_port) control_layout.addWidget(self.refresh_btn) control_layout.addWidget(self.open_btn) control_layout.addWidget(self.close_btn) control_layout.addStretch() # Status indicator self.status_label = QLabel("● Disconnected") self.status_label.setStyleSheet("color: rgb(200, 80, 80); font-weight: bold;") control_layout.addWidget(self.status_label) main_layout.addWidget(control_panel) # ---- Instruction Label ---- instruction = QLabel("💡 Connect to Arduino and rotate the potentiometer to see the color change") instruction.setStyleSheet("color: rgb(100, 100, 110); font-size: 11px;") instruction.setAlignment(Qt.AlignCenter) main_layout.addWidget(instruction) self.setFixedSize(600, 680) def _make_btn(self, label: str, slot) -> QPushButton: """Create a styled button.""" btn = QPushButton(label) btn.setFixedHeight(32) btn.setStyleSheet(self._btn_style()) btn.clicked.connect(slot) return btn # ------------------------------------------------------------- Serial -- def _refresh_ports(self) -> None: """Refresh the list of available serial ports.""" self.port_combo.clear() ports = sorted(serial.tools.list_ports.comports(), key=lambda p: p.device) for p in ports: self.port_combo.addItem(p.device) if self.port_combo.count() == 0: self.port_combo.addItem("No ports found") def _open_port(self) -> None: """Open the selected serial port.""" name = self.port_combo.currentText() if not name or name == "No ports found": return try: self.serial_port = serial.Serial(name, BAUD_RATE, timeout=0) self.status_label.setText("● Connected") self.status_label.setStyleSheet("color: rgb(100, 220, 130); font-weight: bold;") self.port_combo.setStyleSheet(self._combo_style(highlight=True)) print(f"Opened: {name}") except serial.SerialException as e: print(f"Could not open {name}: {e}") self.status_label.setText("● Connection Failed") self.status_label.setStyleSheet("color: rgb(200, 80, 80); font-weight: bold;") def _close_port(self) -> None: """Close the serial port.""" if self.serial_port and self.serial_port.is_open: self.serial_port.close() self.serial_port = None self.status_label.setText("● Disconnected") self.status_label.setStyleSheet("color: rgb(200, 80, 80); font-weight: bold;") self.port_combo.setStyleSheet(self._combo_style()) print("Port closed.") def _poll_serial(self) -> None: """ Poll the serial port for incoming data. The Arduino sends: "Sensor: [value] | Mapped: [hue]" We need to parse these lines to extract the values. """ if not self.serial_port or not self.serial_port.is_open: return try: while self.serial_port.in_waiting: raw = self.serial_port.readline() line = raw.decode("utf-8", errors="ignore").strip() if line: self._parse_message(line) except serial.SerialException: self._close_port() def _parse_message(self, line: str) -> None: """ Parse the serial message from Arduino. Expected format: "Sensor: 512 | Mapped: 28672" """ try: # Look for "Sensor:" and "Mapped:" patterns if "Sensor:" in line and "Mapped:" in line: parts = line.split("|") # Extract sensor value sensor_part = parts[0].split("Sensor:")[1].strip() sensor_value = int(sensor_part) # Extract mapped value (hue) mapped_part = parts[1].split("Mapped:")[1].strip() hue_value = int(mapped_part) self.current_sensor = sensor_value self.current_hue = hue_value # Convert hue to RGB using the same formula as NeoPixel rgb = self._hue_to_rgb(hue_value) # Update the display self.color_display.update_color(rgb[0], rgb[1], rgb[2]) self.preview.update_color(rgb[0], rgb[1], rgb[2]) self.value_display.update_values(sensor_value, hue_value, rgb) except (ValueError, IndexError) as e: # Silently ignore parsing errors pass def _hue_to_rgb(self, hue: int) -> tuple: """ Convert hue value (0-57344) to RGB. This mimics the NeoPixel ColorHSV conversion. """ # Normalize hue to 0-360 degrees hue_deg = (hue / 57344.0) * 360.0 # Convert HSV to RGB (full saturation and value for max brightness) h = hue_deg / 60.0 i = int(h) f = h - i p = 0 q = int(255 * (1 - f)) t = int(255 * f) if i == 0: r, g, b = 255, t, p elif i == 1: r, g, b = q, 255, p elif i == 2: r, g, b = p, 255, t elif i == 3: r, g, b = p, q, 255 elif i == 4: r, g, b = t, p, 255 else: r, g, b = 255, p, q return (r, g, b) # ------------------------------------------------------------ Styles -- @staticmethod def _btn_style() -> str: return """ QPushButton { background-color: rgb(65, 65, 78); color: rgb(210, 210, 210); border: 1px solid rgb(110, 110, 110); border-radius: 5px; padding: 0 12px; font-size: 12px; } QPushButton:hover { background-color: rgb(80, 80, 95); } QPushButton:pressed { background-color: rgb(50, 50, 62); } """ @staticmethod def _combo_style(highlight: bool = False) -> str: border = "rgb(100, 220, 130)" if highlight else "rgb(90, 90, 90)" text = "rgb(100, 220, 130)" if highlight else "rgb(210, 210, 210)" return f""" QComboBox {{ background-color: rgb(45, 45, 52); color: {text}; border: 1px solid {border}; border-radius: 4px; padding: 4px 8px; font-size: 12px; }} QComboBox::drop-down {{ border: none; }} QComboBox QAbstractItemView {{ background-color: rgb(45, 45, 52); color: rgb(200, 200, 200); selection-background-color: rgb(65, 65, 78); }} """ @staticmethod def _panel_style() -> str: return """ QFrame { background-color: rgb(28, 28, 35); border-radius: 8px; border: 1px solid rgb(60, 60, 70); } """ def closeEvent(self, event) -> None: """Clean up before closing.""" self._close_port() self.poll_timer.stop() event.accept() # ============================================================================= # ENTRY POINT # ============================================================================= if __name__ == "__main__": app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())