diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 343ef1d..394009b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -261,7 +261,7 @@ pip install -r requirements.txt ### aircube_app.py -- Full desktop GUI -Live sensor display, color-coded VOC Level, three-panel charts (temp/humidity, VOC Level, gas levels), optional CSV logging, configurable history depth (50--1000 points). +Live sensor display, color-coded VOC Level, three-panel charts (temp/humidity, VOC Level, gas levels), optional CSV logging, configurable history depth (50--1000 points), a light/dark theme (View > Appearance: System, Light, or Dark), and a Celsius/Fahrenheit toggle (View > Units). ```bash python aircube_app.py diff --git a/scripts/aircube_app.py b/scripts/aircube_app.py index fb86c05..0ffc1ca 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -8,31 +8,26 @@ import collections import csv -import json import os -import re import sys from datetime import datetime from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QFileDialog, - QGroupBox, QStatusBar, QMessageBox, QSpinBox, QSplitter, - QFrame, QGridLayout + QGroupBox, QStatusBar, QMessageBox, QSpinBox ) -from PyQt6.QtCore import QTimer, Qt, QThread, pyqtSignal -from PyQt6.QtGui import QFont, QIcon, QAction +from PyQt6.QtCore import QTimer, Qt, QSettings +from PyQt6.QtGui import QActionGroup -import matplotlib -matplotlib.use('QtAgg') -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure - -import serial from serial.tools import list_ports -# JSON pattern for parsing sensor data -JSON_PATTERN = re.compile(r"\{.*\}") +from aircube_ui.serial_reader import SerialReaderThread +from aircube_ui.theme import ( + Mode, Unit, LIGHT, resolve_palette, build_stylesheet, accent_button_qss, ui_font +) +from aircube_ui.widgets import SensorDisplay +from aircube_ui.plotting import PlotCanvas # CSV header compatible with other AirCube scripts CSV_HEADER = [ @@ -41,329 +36,113 @@ ] -def parse_json_line(line): - """Parse a JSON sensor data line into a flat dict.""" - match = JSON_PATTERN.search(line) - if not match: - return None - try: - data = json.loads(match.group(0)) - return { - "timestamp": data.get("timestamp"), - "temperature_c": data["ens210"].get("temperature_c"), - "temperature_f": data["ens210"].get("temperature_f"), - "humidity": data["ens210"].get("humidity"), - "ens210_status": data["ens210"].get("status"), - "ens16x_status": data["ens16x"].get("status"), - "etvoc": data["ens16x"].get("etvoc"), - "eco2": data["ens16x"].get("eco2"), - "aqi": data["ens16x"].get("aqi"), - } - except (KeyError, TypeError, json.JSONDecodeError): - return None - - -class SerialReaderThread(QThread): - """Background thread for reading serial data.""" - data_received = pyqtSignal(dict) - error_occurred = pyqtSignal(str) - - def __init__(self, port, baud=115200): - super().__init__() - self.port = port - self.baud = baud - self.running = False - self.serial = None - - def run(self): - try: - self.serial = serial.Serial(self.port, self.baud, timeout=0.1) - self.running = True - while self.running: - try: - line = self.serial.readline() - if line: - decoded = line.decode(errors="ignore").strip() - parsed = parse_json_line(decoded) - if parsed: - self.data_received.emit(parsed) - except (serial.SerialException, OSError) as e: - if self.running: - self.error_occurred.emit(str(e)) - break - except serial.SerialException as e: - self.error_occurred.emit(str(e)) - finally: - if self.serial and self.serial.is_open: - self.serial.close() - - def stop(self): - self.running = False - self.wait(2000) - - -class SensorDisplay(QFrame): - """Widget showing current sensor values.""" - def __init__(self): - super().__init__() - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) - self.setup_ui() - - def setup_ui(self): - layout = QGridLayout(self) - layout.setSpacing(15) - - # Style for value labels - value_font = QFont("Segoe UI", 24, QFont.Weight.Bold) - unit_font = QFont("Segoe UI", 12) - label_font = QFont("Segoe UI", 10) - - # Temperature - self.temp_label = QLabel("--.-") - self.temp_label.setFont(value_font) - self.temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - temp_unit = QLabel("°C") - temp_unit.setFont(unit_font) - temp_title = QLabel("Temperature") - temp_title.setFont(label_font) - temp_title.setStyleSheet("color: #666;") - - # Humidity - self.humidity_label = QLabel("--.-") - self.humidity_label.setFont(value_font) - self.humidity_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - hum_unit = QLabel("%") - hum_unit.setFont(unit_font) - hum_title = QLabel("Humidity") - hum_title.setFont(label_font) - hum_title.setStyleSheet("color: #666;") - - # VOC Level - self.aqi_label = QLabel("---") - self.aqi_label.setFont(value_font) - self.aqi_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - aqi_unit = QLabel("") - aqi_unit.setFont(unit_font) - aqi_title = QLabel("VOC Level") - aqi_title.setFont(label_font) - aqi_title.setStyleSheet("color: #666;") - - # eCO2 - self.eco2_label = QLabel("----") - self.eco2_label.setFont(value_font) - self.eco2_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - eco2_unit = QLabel("ppm") - eco2_unit.setFont(unit_font) - eco2_title = QLabel("eCO2") - eco2_title.setFont(label_font) - eco2_title.setStyleSheet("color: #666;") - - # eTVOC - self.etvoc_label = QLabel("----") - self.etvoc_label.setFont(value_font) - self.etvoc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - etvoc_unit = QLabel("ppb") - etvoc_unit.setFont(unit_font) - etvoc_title = QLabel("eTVOC") - etvoc_title.setFont(label_font) - etvoc_title.setStyleSheet("color: #666;") - - # Layout grid - col = 0 - for title, value, unit in [ - (temp_title, self.temp_label, temp_unit), - (hum_title, self.humidity_label, hum_unit), - (aqi_title, self.aqi_label, aqi_unit), - (eco2_title, self.eco2_label, eco2_unit), - (etvoc_title, self.etvoc_label, etvoc_unit), - ]: - box = QVBoxLayout() - box.addWidget(title, alignment=Qt.AlignmentFlag.AlignCenter) - row = QHBoxLayout() - row.addWidget(value) - row.addWidget(unit, alignment=Qt.AlignmentFlag.AlignBottom) - box.addLayout(row) - layout.addLayout(box, 0, col) - col += 1 - - def update_values(self, data): - temp = data.get("temperature_c") - hum = data.get("humidity") - aqi = data.get("aqi") - eco2 = data.get("eco2") - etvoc = data.get("etvoc") - - if temp is not None: - self.temp_label.setText(f"{temp:.1f}") - if hum is not None: - self.humidity_label.setText(f"{hum:.1f}") - if aqi is not None: - self.aqi_label.setText(f"{int(aqi)}") - # Color code VOC Level (matches canonical TVOC bands / LED gradient) - if aqi <= 50: - self.aqi_label.setStyleSheet("color: #2e7d32;") # Green - elif aqi <= 100: - self.aqi_label.setStyleSheet("color: #f9a825;") # Yellow - elif aqi <= 200: - self.aqi_label.setStyleSheet("color: #ef6c00;") # Orange - else: - self.aqi_label.setStyleSheet("color: #c62828;") # Red - if eco2 is not None: - self.eco2_label.setText(f"{int(eco2)}") - if etvoc is not None: - self.etvoc_label.setText(f"{int(etvoc)}") - - def clear_values(self): - self.temp_label.setText("--.-") - self.humidity_label.setText("--.-") - self.aqi_label.setText("---") - self.aqi_label.setStyleSheet("") - self.eco2_label.setText("----") - self.etvoc_label.setText("----") - - -class PlotCanvas(FigureCanvas): - """Matplotlib canvas for plotting sensor data.""" - def __init__(self, parent=None): - self.fig = Figure(figsize=(10, 6), dpi=100) - self.fig.set_facecolor('#fafafa') - super().__init__(self.fig) - self.setParent(parent) - - # Create subplots - self.ax_temp_hum = self.fig.add_subplot(311) - self.ax_aqi = self.fig.add_subplot(312, sharex=self.ax_temp_hum) - self.ax_gases = self.fig.add_subplot(313, sharex=self.ax_temp_hum) - - self.fig.tight_layout(pad=2.0) - self.setup_plots() - - def setup_plots(self): - """Initialize plot styling.""" - for ax in [self.ax_temp_hum, self.ax_aqi, self.ax_gases]: - ax.set_facecolor('#ffffff') - ax.grid(True, linestyle='--', alpha=0.7) - - self.ax_temp_hum.set_ylabel("Temp (°C) / Humidity (%)") - self.ax_aqi.set_ylabel("VOC Level") - self.ax_gases.set_ylabel("eCO2 (ppm) / eTVOC (ppb)") - self.ax_gases.set_xlabel("Time (seconds)") - - def update_plot(self, x, temp, hum, aqi, eco2, etvoc): - """Update all three plots with new data.""" - self.ax_temp_hum.cla() - self.ax_aqi.cla() - self.ax_gases.cla() - - # Temperature and Humidity - self.ax_temp_hum.plot(x, temp, label="Temperature (°C)", color='#e53935', linewidth=1.5) - self.ax_temp_hum.plot(x, hum, label="Humidity (%)", color='#1e88e5', linewidth=1.5) - self.ax_temp_hum.set_ylabel("Temp / Humidity") - self.ax_temp_hum.legend(loc="upper left", fontsize=8) - self.ax_temp_hum.grid(True, linestyle='--', alpha=0.7) - - # VOC Level - self.ax_aqi.plot(x, aqi, label="VOC Level", color='#7cb342', linewidth=1.5) - self.ax_aqi.set_ylabel("VOC Level") - self.ax_aqi.legend(loc="upper left", fontsize=8) - self.ax_aqi.grid(True, linestyle='--', alpha=0.7) - - # Gases - self.ax_gases.plot(x, eco2, label="eCO2 (ppm)", color='#8e24aa', linewidth=1.5) - self.ax_gases.plot(x, etvoc, label="eTVOC (ppb)", color='#00897b', linewidth=1.5) - self.ax_gases.set_ylabel("Gas levels") - self.ax_gases.set_xlabel("Time (s)") - self.ax_gases.legend(loc="upper left", fontsize=8) - self.ax_gases.grid(True, linestyle='--', alpha=0.7) - - self.fig.tight_layout(pad=2.0) - self.draw() - - class AirCubeApp(QMainWindow): """Main application window.""" def __init__(self): super().__init__() self.setWindowTitle(f"{__app_name__} v{__version__} - Air Quality Monitor") self.setMinimumSize(900, 700) - + + # Appearance and unit settings (persisted via QSettings). + # Default to Light to preserve the original out-of-box look; System + # (follow the OS) and Dark are opt-in via View > Appearance. + self.settings = QSettings("StuckAtPrototype", "AirCube") + self.mode = Mode(self.settings.value("appearance", Mode.LIGHT.value)) + self.unit = Unit(self.settings.value("unit", Unit.CELSIUS.value)) + self._palette = LIGHT + # Data storage self.max_points = 300 self.data_buffer = collections.deque(maxlen=self.max_points) self.t0 = None self.sample_count = 0 - + # Serial and CSV self.serial_thread = None self.csv_file = None self.csv_writer = None self.csv_path = None - + self.setup_ui() self.setup_timers() self.refresh_ports() - + + # Apply persisted appearance/unit now that widgets exist. + self.apply_mode(self.mode) + self.sensor_display.set_unit(self.unit) + self.canvas.set_unit(self.unit) + + def setup_menu(self): + """Build the View menu (Appearance and Units).""" + menubar = self.menuBar() + view_menu = menubar.addMenu("View") + + appearance_menu = view_menu.addMenu("Appearance") + self.mode_group = QActionGroup(self) + for label, mode in [("System", Mode.SYSTEM), ("Light", Mode.LIGHT), ("Dark", Mode.DARK)]: + act = appearance_menu.addAction(label) + act.setCheckable(True) + act.setChecked(self.mode == mode) + act.triggered.connect(lambda _checked, m=mode: self.apply_mode(m)) + self.mode_group.addAction(act) + + units_menu = view_menu.addMenu("Units") + self.unit_group = QActionGroup(self) + for label, unit in [("Celsius (°C)", Unit.CELSIUS), ("Fahrenheit (°F)", Unit.FAHRENHEIT)]: + act = units_menu.addAction(label) + act.setCheckable(True) + act.setChecked(self.unit == unit) + act.triggered.connect(lambda _checked, u=unit: self.apply_unit(u)) + self.unit_group.addAction(act) + def setup_ui(self): """Build the main UI.""" + self.setup_menu() central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setSpacing(10) main_layout.setContentsMargins(10, 10, 10, 10) - + # Connection panel conn_group = QGroupBox("Connection") conn_layout = QHBoxLayout(conn_group) - + conn_layout.addWidget(QLabel("Port:")) self.port_combo = QComboBox() self.port_combo.setMinimumWidth(150) conn_layout.addWidget(self.port_combo) - + self.refresh_btn = QPushButton("Refresh") self.refresh_btn.clicked.connect(self.refresh_ports) conn_layout.addWidget(self.refresh_btn) - + conn_layout.addSpacing(20) - + self.connect_btn = QPushButton("Connect") self.connect_btn.setMinimumWidth(100) self.connect_btn.clicked.connect(self.toggle_connection) - self.connect_btn.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: #45a049; - } - QPushButton:disabled { - background-color: #cccccc; - } - """) conn_layout.addWidget(self.connect_btn) - + conn_layout.addSpacing(30) - + # CSV logging self.csv_checkbox = QCheckBox("Log to CSV") self.csv_checkbox.stateChanged.connect(self.toggle_csv_logging) conn_layout.addWidget(self.csv_checkbox) - + self.csv_path_label = QLabel("No file selected") - self.csv_path_label.setStyleSheet("color: #666; font-style: italic;") + self.csv_path_label.setStyleSheet( + f"color: {self._palette.text_muted}; font-style: italic;" + ) conn_layout.addWidget(self.csv_path_label) - + self.csv_browse_btn = QPushButton("Browse...") self.csv_browse_btn.clicked.connect(self.browse_csv) conn_layout.addWidget(self.csv_browse_btn) - + conn_layout.addStretch() - + # Settings conn_layout.addWidget(QLabel("History:")) self.history_spin = QSpinBox() @@ -372,40 +151,42 @@ def setup_ui(self): self.history_spin.setSuffix(" pts") self.history_spin.valueChanged.connect(self.update_max_points) conn_layout.addWidget(self.history_spin) - + main_layout.addWidget(conn_group) - + # Sensor display panel self.sensor_display = SensorDisplay() main_layout.addWidget(self.sensor_display) - + # Plot canvas plot_group = QGroupBox("Sensor History") plot_layout = QVBoxLayout(plot_group) self.canvas = PlotCanvas() plot_layout.addWidget(self.canvas) main_layout.addWidget(plot_group, stretch=1) - + # Status bar self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) - + self.connection_status = QLabel("Disconnected") - self.connection_status.setStyleSheet("color: #c62828; font-weight: bold;") + self.connection_status.setStyleSheet( + f"color: {self._palette.danger}; font-weight: 600;" + ) self.status_bar.addWidget(self.connection_status) - + self.sample_status = QLabel("Samples: 0") self.status_bar.addPermanentWidget(self.sample_status) - + self.csv_status = QLabel("") self.status_bar.addPermanentWidget(self.csv_status) - + def setup_timers(self): """Setup update timer for plots.""" self.plot_timer = QTimer() self.plot_timer.timeout.connect(self.update_plot) self.plot_timer.start(500) # Update plot every 500ms - + def refresh_ports(self): """Refresh the list of available serial ports.""" self.port_combo.clear() @@ -414,104 +195,88 @@ def refresh_ports(self): self.port_combo.addItem(f"{p.device} - {p.description}", p.device) if not ports: self.port_combo.addItem("No ports found", None) - + def toggle_connection(self): """Connect or disconnect from the serial port.""" if self.serial_thread and self.serial_thread.running: self.disconnect_serial() else: self.connect_serial() - + def connect_serial(self): """Start serial connection.""" port = self.port_combo.currentData() if not port: QMessageBox.warning(self, "No Port", "Please select a serial port.") return - + self.serial_thread = SerialReaderThread(port) self.serial_thread.data_received.connect(self.on_data_received) self.serial_thread.error_occurred.connect(self.on_serial_error) self.serial_thread.start() - + self.connect_btn.setText("Disconnect") - self.connect_btn.setStyleSheet(""" - QPushButton { - background-color: #f44336; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: #da190b; - } - """) + self.connect_btn.setStyleSheet( + accent_button_qss(self._palette.danger, self._palette.danger_hover, self._palette.text_muted) + ) self.port_combo.setEnabled(False) self.refresh_btn.setEnabled(False) - + self.connection_status.setText(f"Connected to {port}") - self.connection_status.setStyleSheet("color: #2e7d32; font-weight: bold;") - + self.connection_status.setStyleSheet( + f"color: {self._palette.success}; font-weight: 600;" + ) + # Reset data self.data_buffer.clear() self.t0 = None self.sample_count = 0 self.sensor_display.clear_values() - + def disconnect_serial(self): """Stop serial connection.""" if self.serial_thread: self.serial_thread.stop() self.serial_thread = None - + self.connect_btn.setText("Connect") - self.connect_btn.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - font-weight: bold; - } - QPushButton:hover { - background-color: #45a049; - } - """) + self.connect_btn.setStyleSheet( + accent_button_qss(self._palette.start, self._palette.start_hover, self._palette.text_muted) + ) self.port_combo.setEnabled(True) self.refresh_btn.setEnabled(True) - + self.connection_status.setText("Disconnected") - self.connection_status.setStyleSheet("color: #c62828; font-weight: bold;") - + self.connection_status.setStyleSheet( + f"color: {self._palette.danger}; font-weight: 600;" + ) + def on_data_received(self, data): """Handle incoming sensor data.""" ts = data.get("timestamp") if ts is None: return - + try: ts = float(ts) except (TypeError, ValueError): return - + if self.t0 is None: self.t0 = ts - + # Convert to seconds (firmware sends ms) t_rel = (ts - self.t0) / 1000.0 if ts > 1000 else (ts - self.t0) - + temp_c = data.get("temperature_c") hum = data.get("humidity") aqi = data.get("aqi") eco2 = data.get("eco2") etvoc = data.get("etvoc") - + if temp_c is None or hum is None or aqi is None: return - + try: temp_c = float(temp_c) hum = float(hum) @@ -520,15 +285,15 @@ def on_data_received(self, data): etvoc = float(etvoc) if etvoc is not None else float("nan") except (TypeError, ValueError): return - + # Store data self.data_buffer.append((t_rel, temp_c, hum, aqi, eco2, etvoc)) self.sample_count += 1 self.sample_status.setText(f"Samples: {self.sample_count}") - + # Update display self.sensor_display.update_values(data) - + # Write to CSV if self.csv_writer: row = [ @@ -544,32 +309,32 @@ def on_data_received(self, data): ] self.csv_writer.writerow(row) self.csv_file.flush() - + def on_serial_error(self, error): """Handle serial errors.""" QMessageBox.critical(self, "Serial Error", f"Serial connection error:\n{error}") self.disconnect_serial() - + def update_plot(self): """Update the plot with current data.""" if not self.data_buffer: return - + x = [p[0] for p in self.data_buffer] temp = [p[1] for p in self.data_buffer] hum = [p[2] for p in self.data_buffer] aqi = [p[3] for p in self.data_buffer] eco2 = [p[4] for p in self.data_buffer] etvoc = [p[5] for p in self.data_buffer] - + self.canvas.update_plot(x, temp, hum, aqi, eco2, etvoc) - + def update_max_points(self, value): """Update the data buffer size.""" self.max_points = value old_data = list(self.data_buffer) self.data_buffer = collections.deque(old_data[-value:], maxlen=value) - + def toggle_csv_logging(self, state): """Enable or disable CSV logging.""" if state == Qt.CheckState.Checked.value: @@ -581,7 +346,7 @@ def toggle_csv_logging(self, state): self.start_csv_logging() else: self.stop_csv_logging() - + def browse_csv(self): """Browse for CSV file location.""" default_name = f"aircube_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" @@ -591,24 +356,24 @@ def browse_csv(self): if path: self.csv_path = path self.csv_path_label.setText(os.path.basename(path)) - self.csv_path_label.setStyleSheet("color: #333;") - + self.csv_path_label.setStyleSheet(f"color: {self._palette.text_secondary};") + def start_csv_logging(self): """Start logging to CSV file.""" if not self.csv_path: return - + new_file = not os.path.exists(self.csv_path) or os.path.getsize(self.csv_path) == 0 self.csv_file = open(self.csv_path, "a", newline="") self.csv_writer = csv.writer(self.csv_file) - + if new_file: self.csv_writer.writerow(CSV_HEADER) self.csv_file.flush() - + self.csv_status.setText(f"Logging to {os.path.basename(self.csv_path)}") - self.csv_status.setStyleSheet("color: #2e7d32;") - + self.csv_status.setStyleSheet(f"color: {self._palette.success};") + def stop_csv_logging(self): """Stop logging to CSV file.""" if self.csv_file: @@ -616,7 +381,51 @@ def stop_csv_logging(self): self.csv_file = None self.csv_writer = None self.csv_status.setText("") - + + def apply_mode(self, mode): + """Apply and persist an appearance mode (System/Light/Dark).""" + self.mode = mode + self.settings.setValue("appearance", mode.value) + self._palette = resolve_palette(mode, QApplication.instance()) + QApplication.instance().setStyleSheet(build_stylesheet(self._palette)) + self.sensor_display.apply_palette(self._palette) + self.canvas.apply_palette(self._palette) + + connected = bool(self.serial_thread and self.serial_thread.running) + if connected: + self.connect_btn.setStyleSheet( + accent_button_qss(self._palette.danger, self._palette.danger_hover, self._palette.text_muted) + ) + self.connection_status.setStyleSheet( + f"color: {self._palette.success}; font-weight: 600;" + ) + else: + self.connect_btn.setStyleSheet( + accent_button_qss(self._palette.start, self._palette.start_hover, self._palette.text_muted) + ) + self.connection_status.setStyleSheet( + f"color: {self._palette.danger}; font-weight: 600;" + ) + + # Re-theme the CSV labels for the active palette. + if self.csv_path: + self.csv_path_label.setStyleSheet(f"color: {self._palette.text_secondary};") + else: + self.csv_path_label.setStyleSheet( + f"color: {self._palette.text_muted}; font-style: italic;" + ) + if self.csv_writer: + self.csv_status.setStyleSheet(f"color: {self._palette.success};") + + def apply_unit(self, unit): + """Apply and persist a temperature display unit (Celsius/Fahrenheit).""" + self.unit = unit + self.settings.setValue("unit", unit.value) + self.sensor_display.set_unit(unit) + self.canvas.set_unit(unit) + if self.data_buffer: + self.update_plot() + def closeEvent(self, event): """Handle window close.""" self.disconnect_serial() @@ -627,49 +436,9 @@ def closeEvent(self, event): def main(): app = QApplication(sys.argv) app.setStyle("Fusion") - - # Set application-wide font - font = QFont("Segoe UI", 10) - app.setFont(font) - - # Set stylesheet for modern look - app.setStyleSheet(""" - QMainWindow { - background-color: #f5f5f5; - } - QGroupBox { - font-weight: bold; - border: 1px solid #ddd; - border-radius: 6px; - margin-top: 12px; - padding-top: 10px; - background-color: white; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 5px; - } - QComboBox, QSpinBox { - padding: 5px; - border: 1px solid #ccc; - border-radius: 4px; - background: white; - } - QComboBox:hover, QSpinBox:hover { - border-color: #999; - } - QCheckBox { - spacing: 8px; - } - QStatusBar { - background-color: #e0e0e0; - } - """) - + app.setFont(ui_font(10)) window = AirCubeApp() window.show() - sys.exit(app.exec()) diff --git a/scripts/aircube_ui/__init__.py b/scripts/aircube_ui/__init__.py new file mode 100644 index 0000000..7fa3a77 --- /dev/null +++ b/scripts/aircube_ui/__init__.py @@ -0,0 +1 @@ +"""AirCube desktop UI package.""" diff --git a/scripts/aircube_ui/plotting.py b/scripts/aircube_ui/plotting.py new file mode 100644 index 0000000..eeb2714 --- /dev/null +++ b/scripts/aircube_ui/plotting.py @@ -0,0 +1,75 @@ +"""Theme- and unit-aware matplotlib canvas with three stacked subplots.""" +import matplotlib +matplotlib.use('QtAgg') +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from .theme import LIGHT, Unit, c_to_f + + +class PlotCanvas(FigureCanvas): + """Temp/humidity, VOC, and gas subplots that re-theme with the palette.""" + + def __init__(self, parent=None): + self.palette = LIGHT + self.unit = Unit.CELSIUS + self.fig = Figure(figsize=(10, 6), dpi=100) + super().__init__(self.fig) + self.setParent(parent) + + self.ax_temp_hum = self.fig.add_subplot(311) + self.ax_aqi = self.fig.add_subplot(312, sharex=self.ax_temp_hum) + self.ax_gases = self.fig.add_subplot(313, sharex=self.ax_temp_hum) + + self.fig.tight_layout(pad=2.0) + self._apply_theme_styling() + + def _temp_label(self): + return "Temperature (°F)" if self.unit == Unit.FAHRENHEIT else "Temperature (°C)" + + def _apply_theme_styling(self): + p = self.palette + self.fig.set_facecolor(p.plot_bg) + for ax in (self.ax_temp_hum, self.ax_aqi, self.ax_gases): + ax.set_facecolor(p.plot_bg) + ax.grid(True, linestyle='--', alpha=0.7, color=p.grid) + ax.tick_params(colors=p.text_secondary) + for spine in ax.spines.values(): + spine.set_color(p.border) + ax.yaxis.label.set_color(p.text_secondary) + ax.xaxis.label.set_color(p.text_secondary) + + def apply_palette(self, palette): + self.palette = palette + self._apply_theme_styling() + self.draw() + + def set_unit(self, unit): + self.unit = unit + self.draw() + + def update_plot(self, x, temp_c, hum, aqi, eco2, etvoc): + p = self.palette + temp = [c_to_f(v) for v in temp_c] if self.unit == Unit.FAHRENHEIT else list(temp_c) + + for ax in (self.ax_temp_hum, self.ax_aqi, self.ax_gases): + ax.cla() + + self.ax_temp_hum.plot(x, temp, label=self._temp_label(), color=p.temp, linewidth=1.5) + self.ax_temp_hum.plot(x, hum, label="Humidity (%)", color=p.humidity, linewidth=1.5) + self.ax_temp_hum.set_ylabel("Temp / Humidity") + self.ax_temp_hum.legend(loc="upper left", fontsize=8) + + self.ax_aqi.plot(x, aqi, label="VOC Level", color=p.voc, linewidth=1.5) + self.ax_aqi.set_ylabel("VOC Level") + self.ax_aqi.legend(loc="upper left", fontsize=8) + + self.ax_gases.plot(x, eco2, label="eCO2 (ppm)", color=p.eco2, linewidth=1.5) + self.ax_gases.plot(x, etvoc, label="eTVOC (ppb)", color=p.etvoc, linewidth=1.5) + self.ax_gases.set_ylabel("Gas levels") + self.ax_gases.set_xlabel("Time (s)") + self.ax_gases.legend(loc="upper left", fontsize=8) + + self._apply_theme_styling() + self.fig.tight_layout(pad=2.0) + self.draw() diff --git a/scripts/aircube_ui/serial_reader.py b/scripts/aircube_ui/serial_reader.py new file mode 100644 index 0000000..e288c9a --- /dev/null +++ b/scripts/aircube_ui/serial_reader.py @@ -0,0 +1,71 @@ +"""Serial reading and sensor-line parsing for AirCube.""" +import json +import re + +from PyQt6.QtCore import QThread, pyqtSignal + +import serial + +# JSON pattern for parsing sensor data +JSON_PATTERN = re.compile(r"\{.*\}") + + +def parse_json_line(line): + """Parse a JSON sensor data line into a flat dict, or None if invalid.""" + match = JSON_PATTERN.search(line) + if not match: + return None + try: + data = json.loads(match.group(0)) + return { + "timestamp": data.get("timestamp"), + "temperature_c": data["ens210"].get("temperature_c"), + "temperature_f": data["ens210"].get("temperature_f"), + "humidity": data["ens210"].get("humidity"), + "ens210_status": data["ens210"].get("status"), + "ens16x_status": data["ens16x"].get("status"), + "etvoc": data["ens16x"].get("etvoc"), + "eco2": data["ens16x"].get("eco2"), + "aqi": data["ens16x"].get("aqi"), + } + except (KeyError, TypeError, json.JSONDecodeError): + return None + + +class SerialReaderThread(QThread): + """Background thread for reading serial data.""" + data_received = pyqtSignal(dict) + error_occurred = pyqtSignal(str) + + def __init__(self, port, baud=115200): + super().__init__() + self.port = port + self.baud = baud + self.running = False + self.serial = None + + def run(self): + try: + self.serial = serial.Serial(self.port, self.baud, timeout=0.1) + self.running = True + while self.running: + try: + line = self.serial.readline() + if line: + decoded = line.decode(errors="ignore").strip() + parsed = parse_json_line(decoded) + if parsed: + self.data_received.emit(parsed) + except (serial.SerialException, OSError) as e: + if self.running: + self.error_occurred.emit(str(e)) + break + except serial.SerialException as e: + self.error_occurred.emit(str(e)) + finally: + if self.serial and self.serial.is_open: + self.serial.close() + + def stop(self): + self.running = False + self.wait(2000) diff --git a/scripts/aircube_ui/theme.py b/scripts/aircube_ui/theme.py new file mode 100644 index 0000000..f6afd47 --- /dev/null +++ b/scripts/aircube_ui/theme.py @@ -0,0 +1,225 @@ +"""Centralized appearance: palettes, stylesheet, color bands, units, fonts.""" +from dataclasses import dataclass +from enum import Enum + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QFontDatabase + + +class Mode(str, Enum): + SYSTEM = "system" + LIGHT = "light" + DARK = "dark" + + +class Unit(str, Enum): + CELSIUS = "celsius" + FAHRENHEIT = "fahrenheit" + + +@dataclass(frozen=True) +class Palette: + name: str + window_bg: str + surface: str + surface_alt: str + border: str + text_primary: str + text_secondary: str + text_muted: str + accent: str + accent_hover: str + start: str + start_hover: str + danger: str + danger_hover: str + success: str + temp: str + humidity: str + voc: str + eco2: str + etvoc: str + plot_bg: str + grid: str + + +LIGHT = Palette( + name="light", + window_bg="#f4f6f8", + surface="#ffffff", + surface_alt="#eef1f5", + border="#e3e8ee", + text_primary="#1a2230", + text_secondary="#5b6573", + text_muted="#8a94a3", + accent="#2f8fed", + accent_hover="#1f7fe0", + start="#2e9e57", + start_hover="#268a4b", + danger="#e5484d", + danger_hover="#d13b40", + success="#1a8a4a", + temp="#e5484d", + humidity="#2f8fed", + voc="#34a853", + eco2="#8b5cf6", + etvoc="#14b8a6", + plot_bg="#ffffff", + grid="#d8dee6", +) + +DARK = Palette( + name="dark", + window_bg="#15191f", + surface="#1e242c", + surface_alt="#262d36", + border="#2c343d", + text_primary="#f0f3f7", + text_secondary="#98a3b2", + text_muted="#6b7682", + accent="#4ea8ff", + accent_hover="#3f97ee", + start="#3bbf6e", + start_hover="#34ab62", + danger="#ff6b6f", + danger_hover="#f1595d", + success="#4ade80", + temp="#ff6b6f", + humidity="#4ea8ff", + voc="#4ade80", + eco2="#a78bfa", + etvoc="#2dd4bf", + plot_bg="#1e242c", + grid="#2c343d", +) + + +def resolve_palette(mode, app): + """Resolve a Mode (resolving SYSTEM via the OS color scheme) to a Palette.""" + if mode == Mode.LIGHT: + return LIGHT + if mode == Mode.DARK: + return DARK + try: + if app.styleHints().colorScheme() == Qt.ColorScheme.Dark: + return DARK + except (AttributeError, TypeError): + pass + return LIGHT + + +# VOC bands preserve the legacy thresholds and colors; labels are new. +def voc_band(value): + """Return (hex_color, label) for a VOC level value.""" + if value <= 50: + return "#2e7d32", "Good" + if value <= 100: + return "#f9a825", "Moderate" + if value <= 200: + return "#ef6c00", "Poor" + return "#c62828", "Unhealthy" + + +def c_to_f(celsius): + """Convert Celsius to Fahrenheit.""" + return celsius * 9.0 / 5.0 + 32.0 + + +def format_temperature(temp_c, unit): + """Return (value_text, unit_text) for a Celsius reading in the chosen unit.""" + if unit == Unit.FAHRENHEIT: + return f"{c_to_f(temp_c):.1f}", "°F" + return f"{temp_c:.1f}", "°C" + + +def ui_font(size=10, weight=QFont.Weight.Normal, tabular=False): + """Return the platform's default UI font at the given size/weight. + + When tabular=True, enable tabular (monospaced) figures so updating + numbers don't shift width. Uses the Qt >= 6.7 font-feature API and + degrades gracefully on older PyQt6. + """ + family = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont).family() + font = QFont(family, size) + font.setWeight(weight) + if tabular: + try: + font.setFeature(QFont.Tag("tnum"), 1) + except (AttributeError, TypeError): + pass + return font + + +def accent_button_qss(bg, hover, disabled): + """QSS for a solid colored action button (Connect/Disconnect).""" + return f""" + QPushButton {{ + background-color: {bg}; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-weight: 600; + }} + QPushButton:hover {{ background-color: {hover}; }} + QPushButton:disabled {{ background-color: {disabled}; }} + """ + + +def build_stylesheet(palette): + """Build the global application stylesheet for the given palette.""" + p = palette + return f""" + QMainWindow {{ + background-color: {p.window_bg}; + color: {p.text_primary}; + }} + QWidget {{ + color: {p.text_primary}; + }} + QMenuBar {{ background-color: {p.surface}; color: {p.text_primary}; }} + QMenuBar::item:selected {{ background: {p.surface_alt}; }} + QMenu {{ background-color: {p.surface}; color: {p.text_primary}; + border: 1px solid {p.border}; }} + QMenu::item:selected {{ background: {p.accent}; color: white; }} + QGroupBox {{ + font-weight: 600; + border: 1px solid {p.border}; + border-radius: 10px; + margin-top: 12px; + padding-top: 12px; + background-color: {p.surface}; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: {p.text_secondary}; + }} + QLabel {{ color: {p.text_primary}; background: transparent; }} + QPushButton {{ + background-color: {p.surface_alt}; + color: {p.text_primary}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 6px 12px; + }} + QPushButton:hover {{ border-color: {p.accent}; }} + QPushButton:disabled {{ color: {p.text_muted}; }} + QComboBox, QSpinBox {{ + padding: 5px 8px; + border: 1px solid {p.border}; + border-radius: 6px; + background: {p.surface}; + color: {p.text_primary}; + }} + QComboBox:hover, QSpinBox:hover {{ border-color: {p.accent}; }} + QComboBox QAbstractItemView {{ + background: {p.surface}; + color: {p.text_primary}; + selection-background-color: {p.accent}; + }} + QCheckBox {{ spacing: 8px; color: {p.text_primary}; }} + QStatusBar {{ background-color: {p.surface}; color: {p.text_secondary}; }} + QStatusBar::item {{ border: none; }} + """ diff --git a/scripts/aircube_ui/widgets.py b/scripts/aircube_ui/widgets.py new file mode 100644 index 0000000..24f416a --- /dev/null +++ b/scripts/aircube_ui/widgets.py @@ -0,0 +1,174 @@ +"""Metric cards and the live sensor value row.""" +from PyQt6.QtWidgets import ( + QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGridLayout +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +from .theme import LIGHT, Unit, voc_band, format_temperature, ui_font + + +class MetricCard(QFrame): + """A single metric: accent stripe, label, large value, unit, optional pill.""" + + def __init__(self, title, accent, show_pill=False): + super().__init__() + self.accent = accent + self.setObjectName("metricCard") + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self.stripe = QFrame() + self.stripe.setFixedHeight(3) + outer.addWidget(self.stripe) + + body = QVBoxLayout() + body.setContentsMargins(14, 12, 14, 14) + body.setSpacing(6) + + self.title_label = QLabel(title) + self.title_label.setFont(ui_font(10)) + body.addWidget(self.title_label) + + row = QHBoxLayout() + row.setSpacing(6) + self.value_label = QLabel("--.-") + self.value_label.setFont(ui_font(24, QFont.Weight.DemiBold, tabular=True)) + row.addWidget(self.value_label, alignment=Qt.AlignmentFlag.AlignBottom) + + self.unit_label = QLabel("") + self.unit_label.setFont(ui_font(11)) + row.addWidget(self.unit_label, alignment=Qt.AlignmentFlag.AlignBottom) + + self.pill_label = QLabel("") + self.pill_label.setFont(ui_font(9, QFont.Weight.DemiBold)) + self.pill_label.setVisible(show_pill) + row.addWidget(self.pill_label, alignment=Qt.AlignmentFlag.AlignVCenter) + row.addStretch() + + body.addLayout(row) + outer.addLayout(body) + + self._palette = LIGHT + # True once a metric-specific value color (e.g. VOC band) is set, so + # theme switches don't overwrite it. Plain values follow the theme. + self._value_color_overridden = False + self.apply_palette(LIGHT, accent) + + def set_value(self, value_text, unit_text): + self.value_label.setText(value_text) + self.unit_label.setText(unit_text) + + def set_value_color(self, color): + self._value_color_overridden = True + self.value_label.setStyleSheet(f"color: {color};") + + def reset_value_color(self): + """Clear a metric-specific value color so the value follows the theme.""" + self._value_color_overridden = False + self.value_label.setStyleSheet(f"color: {self._palette.text_primary};") + + def set_pill(self, text, color): + self.pill_label.setText(text) + self.pill_label.setStyleSheet( + f"color: white; background-color: {color};" + "border-radius: 8px; padding: 1px 8px;" + ) + + def apply_palette(self, palette, accent=None): + if accent is not None: + self.accent = accent + self._palette = palette + p = palette + self.setStyleSheet( + f"#metricCard {{ background-color: {p.surface};" + f" border: 1px solid {p.border}; border-radius: 12px; }}" + ) + self.stripe.setStyleSheet( + f"background-color: {self.accent};" + "border-top-left-radius: 12px; border-top-right-radius: 12px;" + ) + self.title_label.setStyleSheet(f"color: {p.text_secondary};") + self.unit_label.setStyleSheet(f"color: {p.text_secondary};") + if not self._value_color_overridden: + self.value_label.setStyleSheet(f"color: {p.text_primary};") + + +class SensorDisplay(QWidget): + """Row of five metric cards with live values.""" + + def __init__(self): + super().__init__() + self._unit = Unit.CELSIUS + self._palette = LIGHT + self._last = {} + + layout = QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + + self.cards = { + "temp": MetricCard("Temperature", LIGHT.temp), + "humidity": MetricCard("Humidity", LIGHT.humidity), + "voc": MetricCard("VOC Level", LIGHT.voc, show_pill=True), + "eco2": MetricCard("eCO2", LIGHT.eco2), + "etvoc": MetricCard("eTVOC", LIGHT.etvoc), + } + for col, key in enumerate(["temp", "humidity", "voc", "eco2", "etvoc"]): + layout.addWidget(self.cards[key], 0, col) + + def update_values(self, data): + self._last = dict(data) + temp = data.get("temperature_c") + hum = data.get("humidity") + aqi = data.get("aqi") + eco2 = data.get("eco2") + etvoc = data.get("etvoc") + + if temp is not None: + text, unit_text = format_temperature(float(temp), self._unit) + self.cards["temp"].set_value(text, unit_text) + if hum is not None: + self.cards["humidity"].set_value(f"{float(hum):.1f}", "%") + if aqi is not None: + color, label = voc_band(float(aqi)) + self.cards["voc"].set_value(f"{int(aqi)}", "") + self.cards["voc"].set_value_color(color) + self.cards["voc"].set_pill(label, color) + if eco2 is not None: + self.cards["eco2"].set_value(f"{int(eco2)}", "ppm") + if etvoc is not None: + self.cards["etvoc"].set_value(f"{int(etvoc)}", "ppb") + + def clear_values(self): + self._last = {} + self.cards["temp"].set_value( + "--.-", "°C" if self._unit == Unit.CELSIUS else "°F" + ) + self.cards["humidity"].set_value("--.-", "%") + self.cards["voc"].set_value("---", "") + self.cards["voc"].reset_value_color() + self.cards["voc"].pill_label.setText("") + self.cards["voc"].pill_label.setStyleSheet("") + self.cards["eco2"].set_value("----", "ppm") + self.cards["etvoc"].set_value("----", "ppb") + + def set_unit(self, unit): + self._unit = unit + if self._last: + self.update_values(self._last) + else: + self.clear_values() + + def apply_palette(self, palette): + self._palette = palette + accents = { + "temp": palette.temp, "humidity": palette.humidity, + "voc": palette.voc, "eco2": palette.eco2, "etvoc": palette.etvoc, + } + for key, card in self.cards.items(): + card.apply_palette(palette, accents[key]) + if self._last: + self.update_values(self._last) diff --git a/scripts/requirements.txt b/scripts/requirements.txt index c6f9e34..5f760f8 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,7 +1,11 @@ # Runtime dependencies pyserial>=3.5 matplotlib>=3.3 -PyQt6>=6.4 +# 6.5+ for QStyleHints.colorScheme() used by the auto light/dark theme +PyQt6>=6.5 # Build dependencies (for creating standalone executable) # pyinstaller>=6.0 + +# Test dependencies (optional) +# pytest>=7.0 diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py new file mode 100644 index 0000000..0f4450c --- /dev/null +++ b/scripts/tests/conftest.py @@ -0,0 +1,20 @@ +"""Shared pytest fixtures. Forces Qt offscreen so tests need no display.""" +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +try: + from PyQt6.QtWidgets import QApplication + HAVE_QT = True +except ImportError: # pragma: no cover + HAVE_QT = False + + +@pytest.fixture(scope="session") +def qapp(): + if not HAVE_QT: + pytest.skip("PyQt6 not available") + app = QApplication.instance() or QApplication([]) + yield app diff --git a/scripts/tests/test_app_smoke.py b/scripts/tests/test_app_smoke.py new file mode 100644 index 0000000..303e4c1 --- /dev/null +++ b/scripts/tests/test_app_smoke.py @@ -0,0 +1,36 @@ +from PyQt6.QtCore import QSettings + +from aircube_ui.theme import Mode, Unit + + +def test_app_constructs_and_toggles(qapp): + import aircube_app + + # Preserve the user's real settings; the toggles below would overwrite them. + settings = QSettings("StuckAtPrototype", "AirCube") + prev_appearance = settings.value("appearance") + prev_unit = settings.value("unit") + + win = aircube_app.AirCubeApp() + try: + win.on_data_received({ + "timestamp": 1000, "temperature_c": 25.0, "temperature_f": 77.0, + "humidity": 42.5, "aqi": 148, "eco2": 1238, "etvoc": 1396, + "ens210_status": "ok", "ens16x_status": "ok", + }) + assert win.sample_count == 1 + + win.apply_mode(Mode.DARK) + win.apply_unit(Unit.FAHRENHEIT) + assert win.sensor_display.cards["temp"].value_label.text() == "77.0" + + win.apply_mode(Mode.LIGHT) + win.apply_unit(Unit.CELSIUS) + assert win.sensor_display.cards["temp"].value_label.text() == "25.0" + finally: + win.close() + for key, value in (("appearance", prev_appearance), ("unit", prev_unit)): + if value is None: + settings.remove(key) + else: + settings.setValue(key, value) diff --git a/scripts/tests/test_csv_logging.py b/scripts/tests/test_csv_logging.py new file mode 100644 index 0000000..7b9c43a --- /dev/null +++ b/scripts/tests/test_csv_logging.py @@ -0,0 +1,29 @@ +import csv + + +def test_csv_logging_matches_canonical_format(qapp, tmp_path): + """The CSV log must stay byte-compatible with the other AirCube scripts: + the canonical header, and both temperature_c and temperature_f logged.""" + import aircube_app + + out = tmp_path / "log.csv" + win = aircube_app.AirCubeApp() + win.csv_path = str(out) + try: + win.start_csv_logging() + win.on_data_received({ + "timestamp": 84231, "ens210_status": "ok", + "temperature_c": 25.0, "temperature_f": 77.0, "humidity": 42.5, + "ens16x_status": "ok", "etvoc": 1396, "eco2": 1238, "aqi": 148, + }) + win.stop_csv_logging() + finally: + win.close() + + with open(out, newline="") as f: + rows = list(csv.reader(f)) + + assert rows[0] == aircube_app.CSV_HEADER + assert rows[1] == [ + "84231", "ok", "25.0", "77.0", "42.5", "ok", "1396", "1238", "148" + ] diff --git a/scripts/tests/test_parser.py b/scripts/tests/test_parser.py new file mode 100644 index 0000000..3e9da25 --- /dev/null +++ b/scripts/tests/test_parser.py @@ -0,0 +1,35 @@ +from aircube_ui.serial_reader import parse_json_line + +VALID = ( + '{"timestamp": 84231, ' + '"ens210": {"temperature_c": 25.0, "temperature_f": 77.0, "humidity": 42.5, "status": "ok"}, ' + '"ens16x": {"status": "ok", "etvoc": 1396, "eco2": 1238, "aqi": 148}}' +) + + +def test_parses_valid_line(): + d = parse_json_line(VALID) + assert d["timestamp"] == 84231 + assert d["temperature_c"] == 25.0 + assert d["temperature_f"] == 77.0 + assert d["humidity"] == 42.5 + assert d["aqi"] == 148 + assert d["eco2"] == 1238 + assert d["etvoc"] == 1396 + + +def test_parses_json_embedded_in_noise(): + d = parse_json_line("LOG: " + VALID + " <-- sample") + assert d is not None and d["aqi"] == 148 + + +def test_returns_none_for_no_json(): + assert parse_json_line("just a log line, no json here") is None + + +def test_returns_none_for_malformed_json(): + assert parse_json_line('{"ens210": {bad json}}') is None + + +def test_returns_none_for_missing_sensor_keys(): + assert parse_json_line('{"timestamp": 1, "ens210": {"temperature_c": 25}}') is None diff --git a/scripts/tests/test_plotting.py b/scripts/tests/test_plotting.py new file mode 100644 index 0000000..abb6849 --- /dev/null +++ b/scripts/tests/test_plotting.py @@ -0,0 +1,17 @@ +from aircube_ui.theme import LIGHT, DARK, Unit +from aircube_ui.plotting import PlotCanvas + + +def test_plot_canvas_draws_and_rethemes(qapp): + c = PlotCanvas() + c.apply_palette(LIGHT) + x = [0, 1, 2] + c.update_plot(x, [25, 25.1, 25.2], [42, 43, 44], + [148, 150, 149], [1238, 1240, 1239], [1396, 1400, 1398]) + assert c.unit == Unit.CELSIUS + + c.set_unit(Unit.FAHRENHEIT) + c.update_plot(x, [25, 25.1, 25.2], [42, 43, 44], + [148, 150, 149], [1238, 1240, 1239], [1396, 1400, 1398]) + assert c.unit == Unit.FAHRENHEIT + c.apply_palette(DARK) # must not raise diff --git a/scripts/tests/test_theme.py b/scripts/tests/test_theme.py new file mode 100644 index 0000000..59beb18 --- /dev/null +++ b/scripts/tests/test_theme.py @@ -0,0 +1,36 @@ +import pytest + +from aircube_ui.theme import voc_band, c_to_f, format_temperature, Unit + + +@pytest.mark.parametrize("value,label", [ + (0, "Good"), (50, "Good"), + (51, "Moderate"), (100, "Moderate"), + (101, "Poor"), (200, "Poor"), + (201, "Unhealthy"), (999, "Unhealthy"), +]) +def test_voc_band_labels(value, label): + color, got = voc_band(value) + assert got == label + assert color.startswith("#") and len(color) == 7 + + +def test_voc_band_colors_match_legacy_thresholds(): + assert voc_band(50)[0] == "#2e7d32" + assert voc_band(100)[0] == "#f9a825" + assert voc_band(200)[0] == "#ef6c00" + assert voc_band(201)[0] == "#c62828" + + +def test_c_to_f(): + assert c_to_f(0) == 32.0 + assert c_to_f(100) == 212.0 + assert c_to_f(25) == 77.0 + + +def test_format_temperature_celsius(): + assert format_temperature(25.0, Unit.CELSIUS) == ("25.0", "°C") + + +def test_format_temperature_fahrenheit(): + assert format_temperature(25.0, Unit.FAHRENHEIT) == ("77.0", "°F") diff --git a/scripts/tests/test_widgets.py b/scripts/tests/test_widgets.py new file mode 100644 index 0000000..b8fb9e5 --- /dev/null +++ b/scripts/tests/test_widgets.py @@ -0,0 +1,47 @@ +from aircube_ui.theme import LIGHT, DARK, Unit, voc_band +from aircube_ui.widgets import SensorDisplay + + +def test_sensor_display_updates_and_switches_units(qapp): + sd = SensorDisplay() + sd.apply_palette(LIGHT) + sd.update_values({ + "temperature_c": 25.0, "humidity": 42.5, + "aqi": 148, "eco2": 1238, "etvoc": 1396, + }) + assert sd.cards["temp"].value_label.text() == "25.0" + assert sd.cards["temp"].unit_label.text() == "°C" + + sd.set_unit(Unit.FAHRENHEIT) + assert sd.cards["temp"].value_label.text() == "77.0" + assert sd.cards["temp"].unit_label.text() == "°F" + + assert sd.cards["voc"].pill_label.text() == "Poor" + + sd.apply_palette(DARK) # must not raise + sd.clear_values() + assert sd.cards["temp"].value_label.text() == "--.-" + + +def test_plain_value_color_follows_theme_switch(qapp): + sd = SensorDisplay() + sd.apply_palette(LIGHT) + assert LIGHT.text_primary in sd.cards["temp"].value_label.styleSheet() + sd.apply_palette(DARK) + # Regression: plain value text must re-color for the dark theme, not stay + # the light text color (which would be invisible on the dark card). + assert DARK.text_primary in sd.cards["temp"].value_label.styleSheet() + + +def test_voc_band_color_survives_theme_switch(qapp): + sd = SensorDisplay() + sd.apply_palette(LIGHT) + sd.update_values({ + "temperature_c": 25.0, "humidity": 42.5, + "aqi": 250, "eco2": 1238, "etvoc": 1396, + }) + band_color, _ = voc_band(250) + assert band_color in sd.cards["voc"].value_label.styleSheet() + sd.apply_palette(DARK) + # The VOC band color is semantic and must survive a theme switch. + assert band_color in sd.cards["voc"].value_label.styleSheet()