From 952a42b3e9fcfbb46c75a16a4247e9b062530859 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Tue, 3 Feb 2026 23:28:54 +0900 Subject: [PATCH] ui/backend: theme switch using GPIO --- pi/backend/gpio_service.py | 92 +++++++++++++++++++++++ pi/backend/main.py | 11 +++ pi/ui/lib/services/websocket_service.dart | 15 ++++ 3 files changed, 118 insertions(+) create mode 100644 pi/backend/gpio_service.py diff --git a/pi/backend/gpio_service.py b/pi/backend/gpio_service.py new file mode 100644 index 0000000..cbfe385 --- /dev/null +++ b/pi/backend/gpio_service.py @@ -0,0 +1,92 @@ +"""GPIO service for Pi Zero - edge-triggered monitoring. + +Polls GPIO pins and exposes state changes for inclusion in other payloads. +Keeps UART separate (handled by arduino_service). +""" + +import gevent + +try: + import RPi.GPIO as GPIO + _GPIO_AVAILABLE = True +except ImportError: + _GPIO_AVAILABLE = False + print("[GPIO] RPi.GPIO not available - running in mock mode") + + +# Pin assignments +PIN_THEME_SWITCH = 20 # Physical switch for light/dark theme + + +class GPIOService: + """Monitors GPIO pins and tracks state changes.""" + + def __init__(self): + self._running = False + self._greenlet = None + + # Theme switch state + self._theme_switch_state = False # False = light, True = dark + self._theme_switch_pending = None # None = no change, bool = new value + + if _GPIO_AVAILABLE: + GPIO.setmode(GPIO.BCM) + GPIO.setwarnings(False) + # Input with software pull-down (belt + suspenders with hardware pulldown) + GPIO.setup(PIN_THEME_SWITCH, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) + + def start(self): + """Start background polling.""" + if self._running: + return + self._running = True + + # Read initial state + if _GPIO_AVAILABLE: + self._theme_switch_state = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH + else: + self._theme_switch_state = True # Mock: default dark + + self._greenlet = gevent.spawn(self._poll_loop) + print(f"[GPIO] Started, theme_switch initial={self._theme_switch_state}") + + def stop(self): + """Stop background polling.""" + self._running = False + if self._greenlet: + self._greenlet.kill() + self._greenlet = None + if _GPIO_AVAILABLE: + GPIO.cleanup([PIN_THEME_SWITCH]) + + def _poll_loop(self): + """Poll GPIO at ~20Hz, detect edges.""" + while self._running: + gevent.sleep(0.05) # 20Hz + + if _GPIO_AVAILABLE: + current = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH + else: + current = self._theme_switch_state # Mock: no change + + if current != self._theme_switch_state: + self._theme_switch_state = current + self._theme_switch_pending = current + print(f"[GPIO] Theme switch changed to {current}") + + def get_theme_switch_change(self): + """Get pending theme switch change, if any. + + Returns: + bool or None: New state if changed since last call, None otherwise. + """ + if self._theme_switch_pending is not None: + value = self._theme_switch_pending + self._theme_switch_pending = None + return value + return None + + @property + def theme_switch(self): + """Current theme switch state (True = dark, False = light).""" + return self._theme_switch_state diff --git a/pi/backend/main.py b/pi/backend/main.py index 1e9a590..cc2e86a 100644 --- a/pi/backend/main.py +++ b/pi/backend/main.py @@ -8,6 +8,7 @@ from flask_socketio import SocketIO, emit from gps_service import GPSService from arduino_service import ArduinoService +from gpio_service import GPIOService from throttle import Throttle app = Flask(__name__) @@ -19,6 +20,7 @@ socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*") # Services gps = GPSService() arduino = ArduinoService() +gpio = GPIOService() # Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS) arduino_throttle = Throttle(min_interval=0.05) # 20Hz max @@ -43,6 +45,7 @@ def handle_connect(): emit("status", { "gps_connected": gps.connected, "arduino_connected": arduino.connected, + "theme_switch": gpio.theme_switch, }) # Send latest data if available @@ -126,6 +129,12 @@ def handle_emergency(data): def on_arduino_data(data): """Called by ArduinoService when new telemetry arrives.""" + # Check for GPIO state changes to piggyback on this emit + theme_change = gpio.get_theme_switch_change() + if theme_change is not None: + data = dict(data) # Don't mutate original + data["theme_switch"] = theme_change + def emit_fn(d): socketio.emit("arduino", d) @@ -219,6 +228,7 @@ def main(): # Start services gps.start() arduino.start() + gpio.start() # Start throttle flusher in background socketio.start_background_task(throttle_flusher) @@ -230,6 +240,7 @@ def main(): finally: arduino.stop() gps.stop() + gpio.stop() if __name__ == "__main__": diff --git a/pi/ui/lib/services/websocket_service.dart b/pi/ui/lib/services/websocket_service.dart index 203531e..697b881 100644 --- a/pi/ui/lib/services/websocket_service.dart +++ b/pi/ui/lib/services/websocket_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:socket_io_client/socket_io_client.dart' as io; import 'backend_service.dart'; // Reuse ArduinoData, GpsData +import 'theme_service.dart'; /// Connection state for WebSocket enum WsConnectionState { @@ -188,6 +189,13 @@ class WebSocketService { final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : ''; final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : ''; _log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr'); + + // Theme switch piggybacks on arduino packets (edge-triggered from backend) + if (data.containsKey('theme_switch')) { + final isDark = data['theme_switch'] as bool; + ThemeService.instance.setDarkMode(isDark); + _log('theme: ${isDark ? "dark" : "light"}'); + } } }); @@ -209,6 +217,13 @@ class WebSocketService { _latestStatus = status; _statusController.add(status); _log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}'); + + // Initial theme state comes with status on connect + if (data.containsKey('theme_switch')) { + final isDark = data['theme_switch'] as bool; + ThemeService.instance.setDarkMode(isDark); + _log('theme: ${isDark ? "dark" : "light"} (initial)'); + } } });