#!/usr/bin/env python3 """ Lightbulb Toggle — Python / PyQt5 GUI By gllbhh for Fab Academy GUI companion for code-examples/arduino/lightbulb-toggle/lightbulb-toggle.ino. Clicking the lightbulb or the toggle switch sends a command to the board over serial. Pressing the physical button on D7 updates the GUI in return. State is kept in sync in both directions. The controls are disabled until a serial port is opened. Requires -------- pip install PyQt5 pyserial Serial protocol PC → Arduino : "0,\\n" 0 = off, 1 = on Serial protocol Arduino → PC : "0,\\n" 0 = off, 1 = on How state sync works -------------------- GUI click → led_on flipped immediately (optimistic update) → toggled signal emitted → MainWindow slot sends "0,\\n" to board Board btn → Arduino sends "0,\\n" → poll_timer fires → _poll_serial reads the line → _handle_message() calls bulb.set_state() → BulbWidget.update() schedules a repaint Neither side echoes back after receiving, so state never loops. """ import sys from typing import Optional import serial # pyserial — serial port I/O import serial.tools.list_ports # helper to enumerate available ports # PyQt5 is split into sub-modules. We import only what we need. from PyQt5.QtWidgets import ( QApplication, # manages the Qt event loop and application-wide resources QMainWindow, # top-level window with menu bar / status bar support QWidget, # base class for all UI objects QHBoxLayout, # arranges child widgets in a horizontal row QVBoxLayout, # arranges child widgets in a vertical column QPushButton, # clickable button QComboBox, # drop-down selector QSizePolicy, # controls how a widget resizes when the window is resized ) from PyQt5.QtCore import ( Qt, # namespace of constants (Qt.AlignHCenter, Qt.NoPen, …) QTimer, # fires a signal at a fixed interval (like setInterval in JS) QPointF, # floating-point 2-D point QRectF, # floating-point rectangle (x, y, width, height) pyqtSignal, # decorator that creates a Qt signal on a class ) from PyQt5.QtGui import ( QPainter, # low-level 2-D drawing engine QColor, # RGBA colour value QPen, # controls stroke style (colour, width, dash pattern) QBrush, # controls fill style (colour, gradient, pattern) QRadialGradient, # gradient that spreads from a centre point outward QPolygonF, # ordered list of QPointF used for polylines / polygons QFont, # font family + size + weight ) # ============================================================================= # CONSTANTS # ============================================================================= BAUD_RATE = 9600 # must match Serial.begin() in the Arduino sketch # Colour palette — kept as module-level constants so every drawing method # can reference them by name instead of repeating magic numbers. # QColor(r, g, b) or QColor(r, g, b, alpha) where alpha 0=transparent 255=opaque COL_BG = QColor( 28, 28, 35) COL_BULB_ON = QColor(255, 235, 100) COL_BULB_OFF = QColor( 72, 72, 80) COL_STROKE_ON = QColor(240, 200, 60) COL_STROKE_OFF = QColor(105, 105, 105) COL_FILAMENT_ON = QColor(255, 248, 190, 220) COL_FILAMENT_OFF = QColor(135, 135, 145) COL_CAP_ON = QColor(195, 175, 65) COL_CAP_STROKE_ON = QColor(145, 125, 45) COL_CAP_OFF = QColor( 85, 85, 92) COL_CAP_STROKE_OFF = QColor(115, 115, 115) COL_TRACK_ON = QColor( 65, 170, 90) COL_TRACK_OFF = QColor( 72, 72, 88) COL_TRACK_DISABLED = QColor( 50, 50, 55) COL_THUMB = QColor(230, 230, 230) COL_THUMB_DISABLED = QColor(100, 100, 108) COL_LABEL_ON = QColor(100, 225, 130) COL_LABEL_OFF = QColor(130, 130, 145) COL_LABEL_DISABLED = QColor( 70, 70, 70) # ============================================================================= # BULB WIDGET # ============================================================================= class BulbWidget(QWidget): """ Custom widget that draws the lightbulb and the toggle switch. Qt custom widgets work by subclassing QWidget and overriding paintEvent(). Qt calls paintEvent() automatically whenever the widget needs to be redrawn — on first show, on resize, or when update() is called. All drawing is done with a QPainter object that is only valid for the duration of that paintEvent() call. User interaction is handled by overriding mousePressEvent(). When the user clicks a control, the widget flips its internal state, calls update() to schedule a repaint, and emits the 'toggled' signal so the parent window can send the command to the board. The widget never writes to the serial port directly — that responsibility stays in MainWindow. This separation of concerns (drawing ↔ I/O) is good Qt practice. """ # pyqtSignal declares a custom signal on this class. # Signals are the Qt mechanism for loose coupling: the widget announces # "something happened" without knowing who is listening. # The type argument (bool) is the value carried by the signal. # Slots (ordinary methods) are connected to signals in MainWindow. toggled = pyqtSignal(bool) # ---- geometry constants (local widget coordinates, origin = top-left) ---- BX, BY, BR = 150, 185, 75 # bulb: centre-x, centre-y, radius (px) TX, TY = 150, 375 # toggle: centre-x, centre-y TW, TH = 120, 50 # toggle: total width, height def __init__(self, parent: Optional[QWidget] = None): # Always call the parent __init__ first — this initialises the Qt # internals that every QWidget depends on. super().__init__(parent) self.led_on = False # current LED state reflected in the drawing self.connected = False # controls whether clicks are accepted # setFixedSize prevents the widget from being stretched by layouts. self.setFixedSize(300, 450) # A PointingHand cursor hints to the user that the widget is clickable. self.setCursor(Qt.PointingHandCursor) # ----------------------------------------------------------------- API -- def set_state(self, on: bool) -> None: """ Update the displayed LED state and request a repaint. update() does not repaint immediately — it posts a paint event to Qt's event queue. Qt may coalesce multiple rapid update() calls into a single paintEvent(), which avoids redundant redraws. """ self.led_on = on self.update() def set_connected(self, connected: bool) -> None: """Enable or disable the interactive controls and repaint.""" self.connected = connected self.update() # --------------------------------------------------------------- paint -- def paintEvent(self, event) -> None: """ Qt calls this whenever the widget surface needs to be redrawn. The 'event' parameter carries the dirty region (we repaint everything). QPainter must be constructed, used, and destroyed within this method. Creating it with 'with' ensures end() is called even if an exception occurs — equivalent to try/finally. """ p = QPainter(self) # Antialiasing tells the renderer to smooth edges using sub-pixel # blending. It costs a little performance but looks much better. p.setRenderHint(QPainter.Antialiasing) # Erase the previous frame by filling the whole widget rectangle. # self.rect() returns QRect(0, 0, width, height). p.fillRect(self.rect(), COL_BG) # Draw layers back-to-front (painter's algorithm). if self.led_on: self._draw_glow(p) self._draw_bulb(p) self._draw_toggle(p) self._draw_hint(p) # QPainter.end() releases the paint device. Calling it explicitly # (or letting the 'with' block do it) avoids a Qt warning. p.end() def _draw_glow(self, p: QPainter) -> None: """ Soft ambient halo using a radial gradient. QRadialGradient interpolates colour between a centre point and an outer radius. setColorAt(position, colour) places colour stops: 0.0 = centre, 1.0 = edge. Positions in between are interpolated. We set the outer colour to fully transparent so the glow fades out. """ cx, cy, r = self.BX, self.BY, self.BR glow_r = r + 7 * 20 # outermost glow ring radius grad = QRadialGradient(cx, cy, glow_r) grad.setColorAt(0.0, QColor(255, 200, 70, 110)) # warm yellow centre grad.setColorAt(1.0, QColor(255, 200, 70, 0)) # transparent edge p.setPen(Qt.NoPen) # no outline on the gradient ellipse p.setBrush(QBrush(grad)) # fill with the gradient # drawEllipse(centre: QPointF, rx: float, ry: float) — centre-based overload p.drawEllipse(QPointF(cx, cy), glow_r, glow_r) def _draw_bulb(self, p: QPainter) -> None: """Draw the glass globe, filament, and screw base.""" cx, cy, r = self.BX, self.BY, self.BR # --- glass globe --- # QPen controls the outline; QBrush controls the fill. # QPen(colour, width) — width in pixels. p.setPen(QPen(COL_STROKE_ON if self.led_on else COL_STROKE_OFF, 2)) p.setBrush(COL_BULB_ON if self.led_on else COL_BULB_OFF) # drawEllipse with a QPointF centre is the clearest overload for circles. p.drawEllipse(QPointF(cx, cy), r, r) # --- filament (W-shape open polyline) --- # QPolygonF is a list of QPointF vertices. # drawPolyline draws connected line segments without closing the shape. fw, fh = r / 4, r / 2 filament = QPolygonF([ QPointF(cx - fw, cy + fh / 2), # bottom-left QPointF(cx - fw, cy - fh / 2), # top-left QPointF(cx, cy), # centre dip QPointF(cx + fw, cy - fh / 2), # top-right QPointF(cx + fw, cy + fh / 2), # bottom-right ]) p.setPen(QPen( COL_FILAMENT_ON if self.led_on else COL_FILAMENT_OFF, 2.5 if self.led_on else 1.5, )) p.setBrush(Qt.NoBrush) # polyline has no fill p.drawPolyline(filament) # --- screw base (three stepped bands tapering toward the tip) --- # bTop is where the base starts — just inside the bottom of the globe. b_top = cy + r - 4 widths = [r * 0.85, r * 0.65, r * 0.46] # each band narrower than the last cap_fill = COL_CAP_ON if self.led_on else COL_CAP_OFF cap_stroke = COL_CAP_STROKE_ON if self.led_on else COL_CAP_STROKE_OFF p.setPen(QPen(cap_stroke, 1)) p.setBrush(cap_fill) for i, bw in enumerate(widths): # QRectF(x, y, width, height) — x/y is the TOP-LEFT corner in Qt. # drawRoundedRect(rect, x_radius, y_radius) p.drawRoundedRect(QRectF(cx - bw / 2, b_top + i * 14, bw, 14), 2, 2) def _draw_toggle(self, p: QPainter) -> None: """ Draw the pill-shaped toggle switch track and sliding thumb. The corner radius is set to h/2 so the left and right ends are fully rounded — this is the standard toggle-switch pill shape. The thumb position is calculated so it stays inset by one radius from each end of the track, keeping it fully inside the pill at both extremes. """ cx, cy = self.TX, self.TY w, h = self.TW, self.TH x, y = cx - w / 2, cy - h / 2 cr = h / 2 # corner radius = half height → pill ends # --- track --- if not self.connected: track_col = COL_TRACK_DISABLED elif self.led_on: track_col = COL_TRACK_ON else: track_col = COL_TRACK_OFF p.setPen(Qt.NoPen) p.setBrush(track_col) p.drawRoundedRect(QRectF(x, y, w, h), cr, cr) # --- thumb --- # Slide to the right when on, left when off. # Inset by cr from each end so the thumb never pokes outside the track. thumb_cx = (cx + w / 2 - cr) if self.led_on else (cx - w / 2 + cr) thumb_col = COL_THUMB if self.connected else COL_THUMB_DISABLED p.setBrush(thumb_col) thumb_r = (h - 10) / 2 p.drawEllipse(QPointF(thumb_cx, cy), thumb_r, thumb_r) # --- label below the toggle --- label_col = ( COL_LABEL_DISABLED if not self.connected else COL_LABEL_ON if self.led_on else COL_LABEL_OFF ) p.setPen(label_col) p.setFont(QFont("Arial", 13)) # drawText(rect, alignment_flags, text) — text is centred in the rect. # Qt.AlignHCenter | Qt.AlignTop centres horizontally, pins to the top. p.drawText( QRectF(cx - w, cy + h / 2 + 5, w * 2, 25), Qt.AlignHCenter | Qt.AlignTop, "ON" if self.led_on else "OFF", ) def _draw_hint(self, p: QPainter) -> None: """Small instruction line at the very bottom of the widget.""" p.setPen(QColor(85, 85, 85)) p.setFont(QFont("Arial", 10)) msg = "Connect to a port first" if not self.connected else "Click the bulb or toggle" p.drawText( QRectF(0, self.height() - 24, self.width(), 20), Qt.AlignHCenter | Qt.AlignVCenter, msg, ) # -------------------------------------------------------------- input -- def mousePressEvent(self, event) -> None: """ Qt calls this automatically when the user presses a mouse button while the cursor is inside the widget. We check whether the click landed on the toggle or the bulb using simple geometry (rectangle check and circle distance check), then flip the state, schedule a repaint, and emit the toggled signal. The parent window's slot will handle the serial write. """ if not self.connected: return # silently ignore clicks when no port is open x, y = event.x(), event.y() # mouse position in local widget coordinates # Rectangle hit-test for the toggle switch. in_toggle = ( abs(x - self.TX) <= self.TW / 2 and abs(y - self.TY) <= self.TH / 2 ) # Circle hit-test for the bulb globe. # Euclidean distance squared avoids an expensive sqrt() call. dx, dy = x - self.BX, y - self.BY in_bulb = (dx * dx + dy * dy) <= self.BR ** 2 if in_toggle or in_bulb: self.led_on = not self.led_on self.update() # Emit the signal — any connected slots are called synchronously here. self.toggled.emit(self.led_on) # ============================================================================= # MAIN WINDOW # ============================================================================= class MainWindow(QMainWindow): """ Top-level application window. Responsibilities ---------------- - Build and lay out the UI widgets. - Own and manage the serial port (open, close, poll). - Connect BulbWidget's 'toggled' signal to the serial send slot. - Parse incoming serial messages and update the BulbWidget. QMainWindow is a convenience subclass of QWidget that provides a menu bar, toolbars, a status bar, and a central widget area. We only use the central widget area here. """ def __init__(self): super().__init__() self.setWindowTitle("Lightbulb Toggle") # The serial port object. None means no port is currently open. # Optional[serial.Serial] is the type hint for "Serial or None". self.serial_port: Optional[serial.Serial] = None self._build_ui() self._refresh_ports() # populate the combo box with available ports # QTimer fires its timeout signal at a fixed interval. # Connecting it to _poll_serial lets us check the serial buffer # regularly without blocking the UI event loop. # A blocking serial.read() would freeze the window while waiting. self.poll_timer = QTimer(self) self.poll_timer.timeout.connect(self._poll_serial) self.poll_timer.start(20) # every 20 ms → up to 50 checks per second # ----------------------------------------------------------------- UI -- def _build_ui(self) -> None: """Construct and arrange all UI widgets.""" # QMainWindow requires a central widget that holds all other widgets. # We create a plain QWidget and give it a vertical layout. root = QWidget() root.setStyleSheet("background-color: rgb(28, 28, 35);") self.setCentralWidget(root) # QVBoxLayout stacks its children vertically, top to bottom. main_layout = QVBoxLayout(root) main_layout.setContentsMargins(16, 12, 16, 12) main_layout.setSpacing(8) # ---- port panel (horizontal row of controls) ---- # QHBoxLayout places its children side by side, left to right. port_row = QHBoxLayout() port_row.setSpacing(6) # QComboBox is a drop-down selector. # setSizePolicy(Expanding, Fixed) lets it grow horizontally to fill # available space while keeping a fixed height. self.port_combo = QComboBox() self.port_combo.setStyleSheet(self._combo_style()) self.port_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 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) port_row.addWidget(self.port_combo) port_row.addWidget(self.refresh_btn) port_row.addWidget(self.open_btn) port_row.addWidget(self.close_btn) main_layout.addLayout(port_row) # ---- bulb widget ---- self.bulb = BulbWidget() # Connect the 'toggled' signal to our slot. # Signal-slot syntax: signal.connect(slot) # Every time the user clicks the bulb or toggle, toggled(bool) fires, # which calls _send_state(bool) with the new state. self.bulb.toggled.connect(self._send_state) # Qt.AlignHCenter centres the fixed-size widget horizontally in the layout. main_layout.addWidget(self.bulb, alignment=Qt.AlignHCenter) # Prevent the window from being resized — the bulb widget has fixed # dimensions and looks odd if stretched. self.setFixedSize(340, 530) def _make_btn(self, label: str, slot) -> QPushButton: """Create a consistently styled button and connect its clicked signal.""" btn = QPushButton(label) btn.setFixedHeight(32) btn.setStyleSheet(self._btn_style()) # QPushButton.clicked is a built-in signal emitted on mouse release. btn.clicked.connect(slot) return btn # ------------------------------------------------------------- serial -- def _refresh_ports(self) -> None: """ Re-enumerate available serial ports and populate the combo box. serial.tools.list_ports.comports() returns a list of ListPortInfo objects; we only need the device name (e.g. 'COM3' or '/dev/ttyUSB0'). """ self.port_combo.clear() ports = sorted(serial.tools.list_ports.comports(), key=lambda p: p.device) for p in ports: # addItem(text) appends an entry to the combo box drop-down. 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 port currently selected in the combo box. serial.Serial(port, baudrate, timeout=0) opens in non-blocking mode (timeout=0): read calls return immediately with whatever bytes are available instead of waiting for the requested number. This is safe to use with our polling timer. """ 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.bulb.set_connected(True) # Highlight the combo box green to show the port is open. 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}") def _close_port(self) -> None: """ Close the serial port and reset the GUI to the disconnected state. Setting self.serial_port = None is the flag used everywhere else to mean "no port is open". """ if self.serial_port and self.serial_port.is_open: self.serial_port.close() self.serial_port = None self.bulb.set_connected(False) self.bulb.set_state(False) # turn the visual bulb off when disconnecting self.port_combo.setStyleSheet(self._combo_style()) print("Port closed.") def _poll_serial(self) -> None: """ Called every 20 ms by the QTimer. serial.in_waiting returns the number of bytes currently in the OS receive buffer. We loop so that if multiple lines arrived since the last poll they are all processed in this tick rather than being delayed until the next one. SerialException is raised if the board is unplugged mid-session; we catch it and close gracefully instead of crashing. """ if not self.serial_port or not self.serial_port.is_open: return try: while self.serial_port.in_waiting: # readline() reads bytes up to and including the next '\n'. # We decode from bytes to a Python str and strip whitespace. raw = self.serial_port.readline() line = raw.decode("utf-8", errors="ignore").strip() if line: self._handle_message(line) except serial.SerialException: # Board was unplugged — close the port cleanly. self._close_port() def _handle_message(self, line: str) -> None: """ Parse a "key,value" line from the Arduino and update GUI state. We use int() to convert the string tokens; ValueError is silently swallowed so that any garbled serial noise does not crash the app. """ parts = line.split(",") if len(parts) < 2: return # ignore malformed lines (no comma) try: key = int(parts[0]) value = int(parts[1]) except ValueError: return # ignore non-integer tokens if key == 0: # Update the GUI to reflect the board's new state. # No echo back — the Arduino already acted; we just catch up visually. self.bulb.set_state(value == 1) def _send_state(self, state: bool) -> None: """ Slot connected to BulbWidget.toggled. Encodes the state as "0,<1|0>\n" and writes it to the serial port. serial.write() expects bytes, so we encode the string to UTF-8. The Arduino's readBytesUntil('\n', …) will consume everything up to the newline character. """ if self.serial_port and self.serial_port.is_open: msg = f"0,{1 if state else 0}\n" self.serial_port.write(msg.encode("utf-8")) # ----------------------------------------------------------- teardown -- def closeEvent(self, event) -> None: """ Qt calls this when the user closes the window (clicks ✕ or Alt+F4). We override it to ensure the serial port and timer are stopped cleanly before the process exits — leaving a port open can block other apps from accessing it until the OS times it out. """ self._close_port() self.poll_timer.stop() event.accept() # allow the window to close (event.ignore() would cancel it) # ------------------------------------------------------------ styles -- @staticmethod def _btn_style() -> str: """ Qt Style Sheets use CSS-like syntax. The selector 'QPushButton' targets normal state; 'QPushButton:hover' and ':pressed' target mouse-over and click states. """ 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 10px; 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: """Return a combo box stylesheet; green border/text when a port is open.""" 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); }} """ # ============================================================================= # ENTRY POINT # ============================================================================= if __name__ == "__main__": # QApplication must be created before any QWidget. # It initialises the Qt platform plugin (handles OS window system, fonts, # events) and owns the main event loop. # sys.argv is passed so Qt can consume platform-specific command-line flags # (e.g. -display on X11) before our code sees them. app = QApplication(sys.argv) window = MainWindow() window.show() # show() makes the window visible; it is hidden by default # app.exec() starts the Qt event loop. # It blocks here, dispatching user input and timer events, until the last # window is closed. It then returns an exit code (0 = clean exit). # sys.exit() forwards that code to the OS so shell scripts can detect errors. sys.exit(app.exec())