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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
if _BACKEND == "rpigpio":
|
||||
try:
|
||||
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)
|
||||
# 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:
|
||||
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:
|
||||
# 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
|
||||
|
||||
# Accept change after enough consecutive readings
|
||||
if self._pending_count >= required_consecutive:
|
||||
self._theme_switch_state = current
|
||||
self._theme_switch_pending = current
|
||||
print(f"[GPIO] Theme switch changed to {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
|
||||
|
||||
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
|
||||
# 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):
|
||||
|
||||
@@ -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:
|
||||
# Always include current GPIO state (UI dedupes)
|
||||
data = dict(data) # Don't mutate original
|
||||
data["theme_switch"] = theme_change
|
||||
data["theme_switch"] = gpio.theme_switch
|
||||
|
||||
def emit_fn(d):
|
||||
socketio.emit("arduino", d)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:"
|
||||
|
||||
Reference in New Issue
Block a user