pi: GPIO-controlled theme switch

- apt dependencies (RPI.GPIO somehow needs to be installed from apt to work & re-establish uv venv with --system-site-packages
- GPIO 20 triggers mode switching (can link to a photodiode or just switch)
This commit is contained in:
Mikkeli Matlock
2026-02-04 11:13:07 +09:00
parent 64ce2472ab
commit 4a830dde91
6 changed files with 154 additions and 45 deletions

View File

@@ -8,8 +8,12 @@ Python GPS and Arduino telemetry service for Smart Serow. Connects to `gpsd` and
# Install uv if you haven't # Install uv if you haven't
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
# Install dependencies # Install system dependencies (GPIO)
sudo apt install python3-rpi.gpio
# Create venv with access to system packages, then sync
cd pi/backend cd pi/backend
uv venv --system-site-packages
uv sync uv sync
``` ```
@@ -106,8 +110,43 @@ arduino = ArduinoService(port="/dev/ttyACM0", baudrate=115200)
- **GPS**: If `gpsdclient` isn't installed or gpsd isn't running, generates fake GPS data - **GPS**: If `gpsdclient` isn't installed or gpsd isn't running, generates fake GPS data
- **Arduino**: If `pyserial` isn't installed or serial port unavailable, generates fake telemetry - **Arduino**: If `pyserial` isn't installed or serial port unavailable, generates fake telemetry
- **GPIO**: If `RPi.GPIO` isn't available, runs in mock mode (always returns default state)
Both services run in stub mode for UI testing without hardware. All services run in stub mode for UI testing without hardware.
## GPIO Setup
The `gpio_service.py` handles physical switch inputs (e.g., theme toggle on GPIO20).
### Known Quirks
**Use apt-installed RPi.GPIO, not pip:**
```bash
sudo apt install python3-rpi.gpio
```
The pip version (`RPi.GPIO`) requires compilation with `python3-dev` headers. The apt package is pre-compiled and Just Works. The venv must be created with `--system-site-packages` to see it.
**gpiozero doesn't work (TODO):**
`gpiozero` is the "modern" GPIO library but has issues in this setup:
- Requires a pin factory backend (`lgpio`, `rpigpio`, `pigpio`, or `native`)
- `lgpio`/`rpi-lgpio` via pip needs `swig` to compile
- `native` backend breaks under gevent monkey-patching (`select.epoll` missing)
- May revisit if we need gpiozero-specific features
**Software pull-up/down conflicts with external resistors:**
If using an external pull-down resistor (especially high values like 1MΩ), disable the software pull:
```python
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
```
The Pi's internal pull-down (~50kΩ) will overpower high-value external resistors, causing unexpected voltage divider behavior.
**Debouncing:**
Physical switches/connectors need debouncing. Current implementation requires 15 consecutive identical readings (~750ms at 20Hz) before accepting a state change. Tune `required_consecutive` in `gpio_service.py` as needed.
## Deploy ## Deploy

View File

@@ -2,16 +2,25 @@
Polls GPIO pins and exposes state changes for inclusion in other payloads. Polls GPIO pins and exposes state changes for inclusion in other payloads.
Keeps UART separate (handled by arduino_service). Keeps UART separate (handled by arduino_service).
Tries RPi.GPIO first (apt install python3-rpi.gpio), falls back to gpiozero.
""" """
import gevent import gevent
# Try RPi.GPIO first (commonly installed via apt on Pi)
_BACKEND = None
try: try:
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
_GPIO_AVAILABLE = True _BACKEND = "rpigpio"
print("[GPIO] Using RPi.GPIO backend")
except ImportError: except ImportError:
_GPIO_AVAILABLE = False try:
print("[GPIO] RPi.GPIO not available - running in mock mode") from gpiozero import Button
_BACKEND = "gpiozero"
print("[GPIO] Using gpiozero backend")
except ImportError:
print("[GPIO] No GPIO library available - running in mock mode")
# Pin assignments # Pin assignments
@@ -24,16 +33,37 @@ class GPIOService:
def __init__(self): def __init__(self):
self._running = False self._running = False
self._greenlet = None self._greenlet = None
self._theme_button = None # gpiozero only
self._gpio_working = False
# Theme switch state # Theme switch state
self._theme_switch_state = False # False = light, True = dark self._theme_switch_state = False # False = light, True = dark
self._theme_switch_pending = None # None = no change, bool = new value self._pending_state = None # Candidate new state
self._pending_count = 0 # Consecutive readings of pending state
if _GPIO_AVAILABLE: if _BACKEND == "rpigpio":
GPIO.setmode(GPIO.BCM) try:
GPIO.setwarnings(False) GPIO.setmode(GPIO.BCM)
# Input with software pull-down (belt + suspenders with hardware pulldown) GPIO.setwarnings(False)
GPIO.setup(PIN_THEME_SWITCH, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) # No software pull - using external hardware pull-down
GPIO.setup(PIN_THEME_SWITCH, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
self._gpio_working = True
except Exception as e:
print(f"[GPIO] RPi.GPIO init failed: {e}")
elif _BACKEND == "gpiozero":
try:
self._theme_button = Button(PIN_THEME_SWITCH, pull_up=False)
self._gpio_working = True
except Exception as e:
print(f"[GPIO] gpiozero init failed: {e}")
def _read_pin(self):
"""Read current pin state. Returns True if HIGH (dark mode)."""
if _BACKEND == "rpigpio":
return GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH
elif _BACKEND == "gpiozero" and self._theme_button:
return self._theme_button.is_pressed
return self._theme_switch_state # Mock: return current
def start(self): def start(self):
"""Start background polling.""" """Start background polling."""
@@ -42,8 +72,8 @@ class GPIOService:
self._running = True self._running = True
# Read initial state # Read initial state
if _GPIO_AVAILABLE: if self._gpio_working:
self._theme_switch_state = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH self._theme_switch_state = self._read_pin()
else: else:
self._theme_switch_state = True # Mock: default dark self._theme_switch_state = True # Mock: default dark
@@ -56,35 +86,54 @@ class GPIOService:
if self._greenlet: if self._greenlet:
self._greenlet.kill() self._greenlet.kill()
self._greenlet = None self._greenlet = None
if _GPIO_AVAILABLE: if _BACKEND == "rpigpio":
GPIO.cleanup([PIN_THEME_SWITCH]) try:
GPIO.cleanup([PIN_THEME_SWITCH])
except Exception:
pass
elif self._theme_button:
self._theme_button.close()
self._theme_button = None
def _poll_loop(self): def _poll_loop(self):
"""Poll GPIO at ~20Hz, detect edges.""" """Poll GPIO at ~20Hz, update state with consecutive-read debounce."""
poll_count = 0
# Require N consecutive same readings to accept state change
# At 20Hz: 10 readings = 500ms, 20 readings = 1s
required_consecutive = 11 # ~550ms of stable signal
while self._running: while self._running:
gevent.sleep(0.05) # 20Hz gevent.sleep(0.05) # 20Hz
poll_count += 1
if _GPIO_AVAILABLE: if self._gpio_working:
current = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH current = self._read_pin()
else:
current = self._theme_switch_state # Mock: no change
if current != self._theme_switch_state: if current != self._theme_switch_state:
self._theme_switch_state = current # Different from accepted state - count towards change
self._theme_switch_pending = current if current == self._pending_state:
print(f"[GPIO] Theme switch changed to {current}") self._pending_count += 1
else:
# New candidate state
self._pending_state = current
self._pending_count = 1
def get_theme_switch_change(self): # Accept change after enough consecutive readings
"""Get pending theme switch change, if any. if self._pending_count >= required_consecutive:
self._theme_switch_state = current
self._pending_state = None
self._pending_count = 0
print(f"[GPIO] Theme switch: {current} (dark={current})")
else:
# Matches current state - reset any pending change
self._pending_state = None
self._pending_count = 0
Returns: # Heartbeat log every ~5 seconds (100 polls at 20Hz)
bool or None: New state if changed since last call, None otherwise. if poll_count >= 100:
""" poll_count = 0
if self._theme_switch_pending is not None: raw = 1 if self._theme_switch_state else 0
value = self._theme_switch_pending print(f"[GPIO] Pin {PIN_THEME_SWITCH}: {raw} (dark={self._theme_switch_state})")
self._theme_switch_pending = None
return value
return None
@property @property
def theme_switch(self): def theme_switch(self):

View File

@@ -129,11 +129,9 @@ 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 # Always include current GPIO state (UI dedupes)
theme_change = gpio.get_theme_switch_change() data = dict(data) # Don't mutate original
if theme_change is not None: data["theme_switch"] = gpio.theme_switch
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)

View File

@@ -10,6 +10,8 @@ dependencies = [
"gevent-websocket>=0.10", "gevent-websocket>=0.10",
"gpsdclient>=1.3", "gpsdclient>=1.3",
"pyserial>=3.5", "pyserial>=3.5",
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
# Not listed here because pip versions require compilation
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -78,6 +78,23 @@ def deploy(restart: bool = False) -> bool:
f"{ssh_target}:{remote_path}/", f"{ssh_target}:{remote_path}/",
]) ])
# Ensure system GPIO package is installed (pip version needs compilation)
print()
print("Ensuring system GPIO package...")
run(
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
check=False,
)
# Create venv with system-site-packages if it doesn't exist
# This allows access to apt-installed packages like python3-rpi.gpio
print()
print("Ensuring venv with system-site-packages...")
run(
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
check=False,
)
# Run uv sync to install/update dependencies # Run uv sync to install/update dependencies
# Use full path since non-interactive SSH doesn't load .bashrc # Use full path since non-interactive SSH doesn't load .bashrc
print() print()
@@ -115,9 +132,10 @@ def deploy(restart: bool = False) -> bool:
print("Or run this script with --restart flag") print("Or run this script with --restart flag")
print() print()
print("Note: First-time setup on Pi requires uv to be installed:") print("Note: First-time setup on Pi requires:")
print(f" ssh {ssh_target}") print(f" ssh {ssh_target}")
print(" curl -LsSf https://astral.sh/uv/install.sh | sh") print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
print(" sudo apt install python3-rpi.gpio # GPIO support")
return True return True

View File

@@ -27,10 +27,10 @@ else
echo "uv already installed: $(uv --version)" echo "uv already installed: $(uv --version)"
fi fi
# Install gpsd # Install gpsd and GPIO support
echo "Installing gpsd..." echo "Installing system packages..."
sudo apt-get update sudo apt-get update
sudo apt-get install -y gpsd gpsd-clients sudo apt-get install -y gpsd gpsd-clients python3-rpi.gpio
# Configure gpsd (user needs to edit DEVICES) # Configure gpsd (user needs to edit DEVICES)
GPSD_CONFIG="/etc/default/gpsd" GPSD_CONFIG="/etc/default/gpsd"
@@ -66,7 +66,10 @@ echo ""
echo "Next steps:" echo "Next steps:"
echo "1. Configure gpsd: sudo nano /etc/default/gpsd" echo "1. Configure gpsd: sudo nano /etc/default/gpsd"
echo "2. Deploy backend: python3 scripts/deploy_backend.py (from dev machine)" echo "2. Deploy backend: python3 scripts/deploy_backend.py (from dev machine)"
echo "3. On Pi, install deps: cd $BACKEND_DIR && uv sync" echo "3. On Pi, create venv and install deps:"
echo " cd $BACKEND_DIR"
echo " uv venv --system-site-packages # Allows access to apt packages"
echo " uv sync"
echo "4. Start service: sudo systemctl start smartserow-backend" echo "4. Start service: sudo systemctl start smartserow-backend"
echo "" echo ""
echo "Useful commands:" echo "Useful commands:"