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
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
uv venv --system-site-packages
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
- **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

View File

@@ -2,16 +2,25 @@
Polls GPIO pins and exposes state changes for inclusion in other payloads.
Keeps UART separate (handled by arduino_service).
Tries RPi.GPIO first (apt install python3-rpi.gpio), falls back to gpiozero.
"""
import gevent
# Try RPi.GPIO first (commonly installed via apt on Pi)
_BACKEND = None
try:
import RPi.GPIO as GPIO
_GPIO_AVAILABLE = True
_BACKEND = "rpigpio"
print("[GPIO] Using RPi.GPIO backend")
except ImportError:
_GPIO_AVAILABLE = False
print("[GPIO] RPi.GPIO not available - running in mock mode")
try:
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
@@ -24,16 +33,37 @@ class GPIOService:
def __init__(self):
self._running = False
self._greenlet = None
self._theme_button = None # gpiozero only
self._gpio_working = False
# Theme switch state
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:
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)
if _BACKEND == "rpigpio":
try:
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
# 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):
"""Start background polling."""
@@ -42,8 +72,8 @@ class GPIOService:
self._running = True
# Read initial state
if _GPIO_AVAILABLE:
self._theme_switch_state = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH
if self._gpio_working:
self._theme_switch_state = self._read_pin()
else:
self._theme_switch_state = True # Mock: default dark
@@ -56,35 +86,54 @@ class GPIOService:
if self._greenlet:
self._greenlet.kill()
self._greenlet = None
if _GPIO_AVAILABLE:
GPIO.cleanup([PIN_THEME_SWITCH])
if _BACKEND == "rpigpio":
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):
"""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:
gevent.sleep(0.05) # 20Hz
poll_count += 1
if _GPIO_AVAILABLE:
current = GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH
else:
current = self._theme_switch_state # Mock: no change
if self._gpio_working:
current = self._read_pin()
if current != self._theme_switch_state:
self._theme_switch_state = current
self._theme_switch_pending = current
print(f"[GPIO] Theme switch changed to {current}")
if current != self._theme_switch_state:
# Different from accepted state - count towards change
if current == self._pending_state:
self._pending_count += 1
else:
# New candidate state
self._pending_state = current
self._pending_count = 1
def get_theme_switch_change(self):
"""Get pending theme switch change, if any.
# Accept change after enough consecutive readings
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:
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
# Heartbeat log every ~5 seconds (100 polls at 20Hz)
if poll_count >= 100:
poll_count = 0
raw = 1 if self._theme_switch_state else 0
print(f"[GPIO] Pin {PIN_THEME_SWITCH}: {raw} (dark={self._theme_switch_state})")
@property
def theme_switch(self):

View File

@@ -129,11 +129,9 @@ 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
# Always include current GPIO state (UI dedupes)
data = dict(data) # Don't mutate original
data["theme_switch"] = gpio.theme_switch
def emit_fn(d):
socketio.emit("arduino", d)

View File

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

View File

@@ -78,6 +78,23 @@ def deploy(restart: bool = False) -> bool:
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
# Use full path since non-interactive SSH doesn't load .bashrc
print()
@@ -115,9 +132,10 @@ def deploy(restart: bool = False) -> bool:
print("Or run this script with --restart flag")
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(" 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

View File

@@ -27,10 +27,10 @@ else
echo "uv already installed: $(uv --version)"
fi
# Install gpsd
echo "Installing gpsd..."
# Install gpsd and GPIO support
echo "Installing system packages..."
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)
GPSD_CONFIG="/etc/default/gpsd"
@@ -66,7 +66,10 @@ echo ""
echo "Next steps:"
echo "1. Configure gpsd: sudo nano /etc/default/gpsd"
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 ""
echo "Useful commands:"