From 4a830dde91a800ee756b3612fb6bb0b4b1186527 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 4 Feb 2026 11:13:07 +0900 Subject: [PATCH] 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) --- pi/backend/README.md | 43 +++++++++++++- pi/backend/gpio_service.py | 113 ++++++++++++++++++++++++++---------- pi/backend/main.py | 8 +-- pi/backend/pyproject.toml | 2 + scripts/deploy_backend.py | 22 ++++++- scripts/pi_setup_backend.sh | 11 ++-- 6 files changed, 154 insertions(+), 45 deletions(-) diff --git a/pi/backend/README.md b/pi/backend/README.md index 8b7ad9a..f6e212c 100644 --- a/pi/backend/README.md +++ b/pi/backend/README.md @@ -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 diff --git a/pi/backend/gpio_service.py b/pi/backend/gpio_service.py index cbfe385..6a4dd85 100644 --- a/pi/backend/gpio_service.py +++ b/pi/backend/gpio_service.py @@ -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): diff --git a/pi/backend/main.py b/pi/backend/main.py index cc2e86a..609ceb7 100644 --- a/pi/backend/main.py +++ b/pi/backend/main.py @@ -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) diff --git a/pi/backend/pyproject.toml b/pi/backend/pyproject.toml index d68a979..708c579 100644 --- a/pi/backend/pyproject.toml +++ b/pi/backend/pyproject.toml @@ -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] diff --git a/scripts/deploy_backend.py b/scripts/deploy_backend.py index 02220ff..6cd92ab 100644 --- a/scripts/deploy_backend.py +++ b/scripts/deploy_backend.py @@ -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 diff --git a/scripts/pi_setup_backend.sh b/scripts/pi_setup_backend.sh index 33d6443..50547cf 100644 --- a/scripts/pi_setup_backend.sh +++ b/scripts/pi_setup_backend.sh @@ -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:"