From 522f574c079eb51aeef99cf419ecc746b8ded0f2 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:45:09 -0500 Subject: [PATCH 01/14] Add aircube_ui package and test scaffolding Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/__init__.py | 1 + scripts/requirements.txt | 3 +++ scripts/tests/__init__.py | 0 scripts/tests/conftest.py | 20 ++++++++++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 scripts/aircube_ui/__init__.py create mode 100644 scripts/tests/__init__.py create mode 100644 scripts/tests/conftest.py 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/requirements.txt b/scripts/requirements.txt index c6f9e34..058590b 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -5,3 +5,6 @@ PyQt6>=6.4 # 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 From 68ab708a02e1a702ccd9a45611d3ce6f613b6721 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:47:37 -0500 Subject: [PATCH 02/14] Extract serial reader and parser into aircube_ui.serial_reader Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_app.py | 69 +--------------------------- scripts/aircube_ui/serial_reader.py | 71 +++++++++++++++++++++++++++++ scripts/tests/test_parser.py | 35 ++++++++++++++ 3 files changed, 108 insertions(+), 67 deletions(-) create mode 100644 scripts/aircube_ui/serial_reader.py create mode 100644 scripts/tests/test_parser.py diff --git a/scripts/aircube_app.py b/scripts/aircube_app.py index fb86c05..8e3dd8e 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -8,9 +8,7 @@ import collections import csv -import json import os -import re import sys from datetime import datetime @@ -20,7 +18,7 @@ QGroupBox, QStatusBar, QMessageBox, QSpinBox, QSplitter, QFrame, QGridLayout ) -from PyQt6.QtCore import QTimer, Qt, QThread, pyqtSignal +from PyQt6.QtCore import QTimer, Qt from PyQt6.QtGui import QFont, QIcon, QAction import matplotlib @@ -28,11 +26,9 @@ 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 # CSV header compatible with other AirCube scripts CSV_HEADER = [ @@ -41,67 +37,6 @@ ] -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): 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/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 From 96169c06de3c5d76c5643248b5c96ea8f434cfa4 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:50:01 -0500 Subject: [PATCH 03/14] Add theme module: light/dark palettes, QSS, VOC bands, unit conversion Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/theme.py | 212 ++++++++++++++++++++++++++++++++++++ scripts/tests/test_theme.py | 36 ++++++ 2 files changed, 248 insertions(+) create mode 100644 scripts/aircube_ui/theme.py create mode 100644 scripts/tests/test_theme.py diff --git a/scripts/aircube_ui/theme.py b/scripts/aircube_ui/theme.py new file mode 100644 index 0000000..16a8100 --- /dev/null +++ b/scripts/aircube_ui/theme.py @@ -0,0 +1,212 @@ +"""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): + """Return the platform's default UI font at the given size/weight.""" + family = QFontDatabase.systemFont(QFontDatabase.SystemFont.GeneralFont).family() + font = QFont(family, size) + font.setWeight(weight) + return font + + +def accent_button_qss(bg, hover): + """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: #9aa3ad; }} + """ + + +def build_stylesheet(palette): + """Build the global application stylesheet for the given palette.""" + p = palette + return f""" + QMainWindow, QWidget {{ + background-color: {p.window_bg}; + 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}; }} + 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/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") From 9fbc0655929e0b5679f19d8696e6fea9c5831d89 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:52:59 -0500 Subject: [PATCH 04/14] Add theme-aware MetricCard and SensorDisplay widgets Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/widgets.py | 168 ++++++++++++++++++++++++++++++++++ scripts/tests/test_widgets.py | 23 +++++ 2 files changed, 191 insertions(+) create mode 100644 scripts/aircube_ui/widgets.py create mode 100644 scripts/tests/test_widgets.py diff --git a/scripts/aircube_ui/widgets.py b/scripts/aircube_ui/widgets.py new file mode 100644 index 0000000..3b0327b --- /dev/null +++ b/scripts/aircube_ui/widgets.py @@ -0,0 +1,168 @@ +"""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)) + self.value_label.setStyleSheet("font-variant-numeric: tabular-nums;") + 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.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_label.setStyleSheet( + f"color: {color}; font-variant-numeric: tabular-nums;" + ) + + 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 + 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_label.styleSheet().startswith("color"): + self.value_label.setStyleSheet( + f"color: {p.text_primary}; font-variant-numeric: tabular-nums;" + ) + + +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"].set_value_color(self._palette.text_primary) + 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/tests/test_widgets.py b/scripts/tests/test_widgets.py new file mode 100644 index 0000000..f58ba2f --- /dev/null +++ b/scripts/tests/test_widgets.py @@ -0,0 +1,23 @@ +from aircube_ui.theme import LIGHT, DARK, Unit +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() == "--.-" From 2d39e374bceadef26a99b34a2461604e2ba42b0b Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:56:04 -0500 Subject: [PATCH 05/14] Use Qt font feature for tabular numerals instead of invalid QSS Qt Style Sheets do not support the CSS font-variant-numeric property; it was ignored and logged a warning on every restyle. Enable tabular figures via QFont.setFeature(Tag('tnum')) guarded for older PyQt6. Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/theme.py | 14 ++++++++++++-- scripts/aircube_ui/widgets.py | 9 +++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/aircube_ui/theme.py b/scripts/aircube_ui/theme.py index 16a8100..62a801b 100644 --- a/scripts/aircube_ui/theme.py +++ b/scripts/aircube_ui/theme.py @@ -132,11 +132,21 @@ def format_temperature(temp_c, unit): return f"{temp_c:.1f}", "°C" -def ui_font(size=10, weight=QFont.Weight.Normal): - """Return the platform's default UI font at the given size/weight.""" +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 diff --git a/scripts/aircube_ui/widgets.py b/scripts/aircube_ui/widgets.py index 3b0327b..a80a4a1 100644 --- a/scripts/aircube_ui/widgets.py +++ b/scripts/aircube_ui/widgets.py @@ -35,8 +35,7 @@ def __init__(self, title, accent, show_pill=False): row = QHBoxLayout() row.setSpacing(6) self.value_label = QLabel("--.-") - self.value_label.setFont(ui_font(24, QFont.Weight.DemiBold)) - self.value_label.setStyleSheet("font-variant-numeric: tabular-nums;") + 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("") @@ -59,9 +58,7 @@ def set_value(self, value_text, unit_text): self.unit_label.setText(unit_text) def set_value_color(self, color): - self.value_label.setStyleSheet( - f"color: {color}; font-variant-numeric: tabular-nums;" - ) + self.value_label.setStyleSheet(f"color: {color};") def set_pill(self, text, color): self.pill_label.setText(text) @@ -86,7 +83,7 @@ def apply_palette(self, palette, accent=None): self.unit_label.setStyleSheet(f"color: {p.text_secondary};") if not self.value_label.styleSheet().startswith("color"): self.value_label.setStyleSheet( - f"color: {p.text_primary}; font-variant-numeric: tabular-nums;" + f"color: {p.text_primary};" ) From bc5054695fdcee4cab9a4b73c78ab624af17ae93 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 07:57:59 -0500 Subject: [PATCH 06/14] Add theme- and unit-aware PlotCanvas Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/plotting.py | 75 ++++++++++++++++++++++++++++++++++ scripts/tests/test_plotting.py | 17 ++++++++ 2 files changed, 92 insertions(+) create mode 100644 scripts/aircube_ui/plotting.py create mode 100644 scripts/tests/test_plotting.py 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/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 From 4c14b1c22958cf21b9b678a51753e937ebf081cd Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 08:01:04 -0500 Subject: [PATCH 07/14] Wire main window to aircube_ui modules; add View menu for theme and units Replace the inline SensorDisplay/PlotCanvas with the aircube_ui widgets, drop the hardcoded Segoe UI font and global stylesheet, and add a View menu with Appearance (System/Light/Dark) and Units (Celsius/Fahrenheit), both persisted via QSettings. CSV format and serial handling unchanged. Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_app.py | 478 +++++++++++++---------------------------- 1 file changed, 149 insertions(+), 329 deletions(-) diff --git a/scripts/aircube_app.py b/scripts/aircube_app.py index 8e3dd8e..4b64223 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -15,20 +15,19 @@ 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 -from PyQt6.QtGui import QFont, QIcon, QAction - -import matplotlib -matplotlib.use('QtAgg') -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure +from PyQt6.QtCore import QTimer, Qt, QSettings +from PyQt6.QtGui import QActionGroup from serial.tools import list_ports 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 = [ @@ -37,268 +36,109 @@ ] -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) + self.settings = QSettings("StuckAtPrototype", "AirCube") + self.mode = Mode(self.settings.value("appearance", Mode.SYSTEM.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;") 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() @@ -307,40 +147,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() @@ -349,104 +191,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.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.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) @@ -455,15 +281,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 = [ @@ -479,32 +305,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: @@ -516,7 +342,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" @@ -527,23 +353,23 @@ def browse_csv(self): self.csv_path = path self.csv_path_label.setText(os.path.basename(path)) self.csv_path_label.setStyleSheet("color: #333;") - + 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;") - + def stop_csv_logging(self): """Stop logging to CSV file.""" if self.csv_file: @@ -551,7 +377,41 @@ 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.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.connection_status.setStyleSheet( + f"color: {self._palette.danger}; font-weight: 600;" + ) + + 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() @@ -562,49 +422,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()) From 8b5b24a35fc3bbf0039419ba1764cfd9b5ae03fb Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 08:08:55 -0500 Subject: [PATCH 08/14] Fix dark-mode value contrast and theme the CSV labels Code review found that MetricCard's value text kept the light-theme color after switching to dark (invisible on the dark card), because the guard keyed off the stylesheet string. Track a value-color override flag instead, so plain values follow the theme while the VOC band color is preserved. Also theme the CSV path/status labels via the palette rather than hardcoded hex. Adds regression tests for both behaviors. Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_app.py | 18 +++++++++++++++--- scripts/aircube_ui/widgets.py | 19 ++++++++++++++----- scripts/tests/test_widgets.py | 26 +++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/scripts/aircube_app.py b/scripts/aircube_app.py index 4b64223..3aeb4bc 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -130,7 +130,9 @@ def setup_ui(self): 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...") @@ -352,7 +354,7 @@ 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.""" @@ -368,7 +370,7 @@ def start_csv_logging(self): 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.""" @@ -403,6 +405,16 @@ def apply_mode(self, mode): 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 diff --git a/scripts/aircube_ui/widgets.py b/scripts/aircube_ui/widgets.py index a80a4a1..24f416a 100644 --- a/scripts/aircube_ui/widgets.py +++ b/scripts/aircube_ui/widgets.py @@ -51,6 +51,10 @@ def __init__(self, title, accent, show_pill=False): 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): @@ -58,8 +62,14 @@ def set_value(self, value_text, unit_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( @@ -70,6 +80,7 @@ def set_pill(self, text, color): 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};" @@ -81,10 +92,8 @@ def apply_palette(self, palette, accent=None): ) self.title_label.setStyleSheet(f"color: {p.text_secondary};") self.unit_label.setStyleSheet(f"color: {p.text_secondary};") - if not self.value_label.styleSheet().startswith("color"): - self.value_label.setStyleSheet( - f"color: {p.text_primary};" - ) + if not self._value_color_overridden: + self.value_label.setStyleSheet(f"color: {p.text_primary};") class SensorDisplay(QWidget): @@ -140,7 +149,7 @@ def clear_values(self): ) self.cards["humidity"].set_value("--.-", "%") self.cards["voc"].set_value("---", "") - self.cards["voc"].set_value_color(self._palette.text_primary) + self.cards["voc"].reset_value_color() self.cards["voc"].pill_label.setText("") self.cards["voc"].pill_label.setStyleSheet("") self.cards["eco2"].set_value("----", "ppm") diff --git a/scripts/tests/test_widgets.py b/scripts/tests/test_widgets.py index f58ba2f..b8fb9e5 100644 --- a/scripts/tests/test_widgets.py +++ b/scripts/tests/test_widgets.py @@ -1,4 +1,4 @@ -from aircube_ui.theme import LIGHT, DARK, Unit +from aircube_ui.theme import LIGHT, DARK, Unit, voc_band from aircube_ui.widgets import SensorDisplay @@ -21,3 +21,27 @@ def test_sensor_display_updates_and_switches_units(qapp): 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() From 291125087eaa249d1dfd040a34ed7d1efa90b7dd Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 08:09:53 -0500 Subject: [PATCH 09/14] Add headless smoke test for main window theme/unit toggles Co-Authored-By: Claude Opus 4.8 --- scripts/tests/test_app_smoke.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/tests/test_app_smoke.py 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) From 573099740ae471ade13c862c67bc81b868da9f9c Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 18:50:44 -0500 Subject: [PATCH 10/14] Only paint the window background, not every widget Labels with a per-widget color stylesheet were picking up the page background instead of the card surface, leaving mismatched boxes behind the metric text. Scope the background fill to QMainWindow and keep labels transparent so they show the surface behind them. Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_ui/theme.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/aircube_ui/theme.py b/scripts/aircube_ui/theme.py index 62a801b..cdc4453 100644 --- a/scripts/aircube_ui/theme.py +++ b/scripts/aircube_ui/theme.py @@ -170,10 +170,13 @@ def build_stylesheet(palette): """Build the global application stylesheet for the given palette.""" p = palette return f""" - QMainWindow, QWidget {{ + 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}; @@ -193,7 +196,7 @@ def build_stylesheet(palette): padding: 0 6px; color: {p.text_secondary}; }} - QLabel {{ color: {p.text_primary}; }} + QLabel {{ color: {p.text_primary}; background: transparent; }} QPushButton {{ background-color: {p.surface_alt}; color: {p.text_primary}; From 748d19142e563e974fa7304cccf0291bc9d57188 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 18:55:06 -0500 Subject: [PATCH 11/14] Require PyQt6>=6.5 and theme the disabled button color The auto light/dark theme relies on QStyleHints.colorScheme() (Qt 6.5+), so raise the floor from 6.4 to match. Drive the Connect/Disconnect button's disabled color from the palette instead of a fixed gray. Co-Authored-By: Claude Opus 4.8 --- scripts/aircube_app.py | 8 ++++---- scripts/aircube_ui/theme.py | 4 ++-- scripts/requirements.txt | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/aircube_app.py b/scripts/aircube_app.py index 3aeb4bc..6c4b376 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -215,7 +215,7 @@ def connect_serial(self): self.connect_btn.setText("Disconnect") self.connect_btn.setStyleSheet( - accent_button_qss(self._palette.danger, self._palette.danger_hover) + accent_button_qss(self._palette.danger, self._palette.danger_hover, self._palette.text_muted) ) self.port_combo.setEnabled(False) self.refresh_btn.setEnabled(False) @@ -239,7 +239,7 @@ def disconnect_serial(self): self.connect_btn.setText("Connect") self.connect_btn.setStyleSheet( - accent_button_qss(self._palette.start, self._palette.start_hover) + accent_button_qss(self._palette.start, self._palette.start_hover, self._palette.text_muted) ) self.port_combo.setEnabled(True) self.refresh_btn.setEnabled(True) @@ -392,14 +392,14 @@ def apply_mode(self, mode): 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) + 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) + 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;" diff --git a/scripts/aircube_ui/theme.py b/scripts/aircube_ui/theme.py index cdc4453..f6afd47 100644 --- a/scripts/aircube_ui/theme.py +++ b/scripts/aircube_ui/theme.py @@ -150,7 +150,7 @@ def ui_font(size=10, weight=QFont.Weight.Normal, tabular=False): return font -def accent_button_qss(bg, hover): +def accent_button_qss(bg, hover, disabled): """QSS for a solid colored action button (Connect/Disconnect).""" return f""" QPushButton {{ @@ -162,7 +162,7 @@ def accent_button_qss(bg, hover): font-weight: 600; }} QPushButton:hover {{ background-color: {hover}; }} - QPushButton:disabled {{ background-color: #9aa3ad; }} + QPushButton:disabled {{ background-color: {disabled}; }} """ diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 058590b..5f760f8 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,7 +1,8 @@ # 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 From d2c0208ae95b244404a157b25729ea5edf015887 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 19:00:09 -0500 Subject: [PATCH 12/14] Add regression test pinning the CSV log format Locks the header and row layout (both temperature_c and temperature_f) so the desktop log stays compatible with the other AirCube scripts. Co-Authored-By: Claude Opus 4.8 --- scripts/tests/test_csv_logging.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 scripts/tests/test_csv_logging.py 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" + ] From 2a45b1a258f2b1237a8b8c3f915928dc7d9ff95e Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 19:04:19 -0500 Subject: [PATCH 13/14] Document desktop app theme and unit options Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 343ef1d..4416742 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 that follows the OS by default (View > Appearance), and a Celsius/Fahrenheit toggle (View > Units). ```bash python aircube_app.py From 6894ad178d572de6b7768873256ff54c1b2c3593 Mon Sep 17 00:00:00 2001 From: Jerry Gamblin Date: Mon, 29 Jun 2026 19:09:55 -0500 Subject: [PATCH 14/14] Default appearance to Light to preserve the original look Existing users see no change out of the box; System (follow the OS) and Dark remain available under View > Appearance. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 2 +- scripts/aircube_app.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4416742..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), a light/dark theme that follows the OS by default (View > Appearance), and a Celsius/Fahrenheit toggle (View > Units). +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 6c4b376..0ffc1ca 100644 --- a/scripts/aircube_app.py +++ b/scripts/aircube_app.py @@ -43,9 +43,11 @@ def __init__(self): self.setWindowTitle(f"{__app_name__} v{__version__} - Air Quality Monitor") self.setMinimumSize(900, 700) - # Appearance and unit settings (persisted via QSettings) + # 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.SYSTEM.value)) + self.mode = Mode(self.settings.value("appearance", Mode.LIGHT.value)) self.unit = Unit(self.settings.value("unit", Unit.CELSIUS.value)) self._palette = LIGHT