ui/backend: theme switch using GPIO

This commit is contained in:
Mikkeli Matlock
2026-02-03 23:28:54 +09:00
parent 5cb0be0aaa
commit 952a42b3e9
3 changed files with 118 additions and 0 deletions

View File

@@ -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

View File

@@ -8,6 +8,7 @@ from flask_socketio import SocketIO, emit
from gps_service import GPSService from gps_service import GPSService
from arduino_service import ArduinoService from arduino_service import ArduinoService
from gpio_service import GPIOService
from throttle import Throttle from throttle import Throttle
app = Flask(__name__) app = Flask(__name__)
@@ -19,6 +20,7 @@ socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
# Services # Services
gps = GPSService() gps = GPSService()
arduino = ArduinoService() arduino = ArduinoService()
gpio = GPIOService()
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS) # Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS)
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
@@ -43,6 +45,7 @@ def handle_connect():
emit("status", { emit("status", {
"gps_connected": gps.connected, "gps_connected": gps.connected,
"arduino_connected": arduino.connected, "arduino_connected": arduino.connected,
"theme_switch": gpio.theme_switch,
}) })
# Send latest data if available # Send latest data if available
@@ -126,6 +129,12 @@ def handle_emergency(data):
def on_arduino_data(data): def on_arduino_data(data):
"""Called by ArduinoService when new telemetry arrives.""" """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): def emit_fn(d):
socketio.emit("arduino", d) socketio.emit("arduino", d)
@@ -219,6 +228,7 @@ def main():
# Start services # Start services
gps.start() gps.start()
arduino.start() arduino.start()
gpio.start()
# Start throttle flusher in background # Start throttle flusher in background
socketio.start_background_task(throttle_flusher) socketio.start_background_task(throttle_flusher)
@@ -230,6 +240,7 @@ def main():
finally: finally:
arduino.stop() arduino.stop()
gps.stop() gps.stop()
gpio.stop()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io; import 'package:socket_io_client/socket_io_client.dart' as io;
import 'backend_service.dart'; // Reuse ArduinoData, GpsData import 'backend_service.dart'; // Reuse ArduinoData, GpsData
import 'theme_service.dart';
/// Connection state for WebSocket /// Connection state for WebSocket
enum WsConnectionState { enum WsConnectionState {
@@ -188,6 +189,13 @@ class WebSocketService {
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : ''; final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : ''; final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr'); _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; _latestStatus = status;
_statusController.add(status); _statusController.add(status);
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}'); _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)');
}
} }
}); });