gps manipulations tailored to sim7600h hat

This commit is contained in:
2026-02-09 02:11:55 +09:00
parent 992270ed00
commit 629c735eec
20 changed files with 2503 additions and 2355 deletions

View File

@@ -1,5 +1,5 @@
Note to keep inspirations lest I forget. Note to keep inspirations lest I forget.
# Things to do, but not really urgent # Things to do, but not really urgent
- Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am) - Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am)
- Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps. - Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps.

4
arduino/.gitignore vendored
View File

@@ -1,3 +1,3 @@
# arduino test files # arduino test files
test/ test/

View File

@@ -178,6 +178,19 @@ The Pi's internal pull-down (~50kΩ) will overpower high-value external resistor
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. 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.
## Utilities
Standalone tools live in `pi/utils/` (not part of the backend service):
| Tool | Description |
|------|-------------|
| `at_terminal.py` | Interactive AT command terminal for SIM7600 (pyserial). Default port: `/dev/ttyUSB2` |
```bash
python pi/utils/at_terminal.py # default /dev/ttyUSB2
python pi/utils/at_terminal.py /dev/ttyUSB3 # specify port
```
## Deploy ## Deploy
TODO: Add to `scripts/deploy.py` as second target + systemd service. TODO: Add to `scripts/deploy.py` as second target + systemd service.

View File

@@ -1,308 +1,308 @@
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry.""" """Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
import json import json
import math import math
import re import re
import threading import threading
import time import time
from collections import deque from collections import deque
from typing import Any from typing import Any
# pyserial for UART communication # pyserial for UART communication
try: try:
import serial import serial
except ImportError: except ImportError:
serial = None # Allow import without pyserial for testing structure serial = None # Allow import without pyserial for testing structure
class ArduinoService: class ArduinoService:
"""Threaded Arduino serial reader with buffering and auto-reconnect.""" """Threaded Arduino serial reader with buffering and auto-reconnect."""
# TSV field names (order per PROTOCOL.md) # TSV field names (order per PROTOCOL.md)
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear'] TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
# Regex patterns for legacy text protocol (backwards compatibility) # Regex patterns for legacy text protocol (backwards compatibility)
PATTERNS = { PATTERNS = {
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE), "voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE), "rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE), "eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE), "gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
} }
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra" # ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?") ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
def __init__( def __init__(
self, self,
port: str = "/dev/serial0", port: str = "/dev/serial0",
baudrate: int = 115200, baudrate: int = 115200,
buffer_size: int = 100, buffer_size: int = 100,
): ):
self.port = port self.port = port
self.baudrate = baudrate self.baudrate = baudrate
self.buffer_size = buffer_size self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {} self._latest: dict[str, Any] = {}
self._connected = False self._connected = False
self._running = False self._running = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
# Callbacks for push-based updates # Callbacks for push-based updates
self._on_data_callback = None self._on_data_callback = None
self._on_ack_callback = None self._on_ack_callback = None
# Serial port handle for sending commands # Serial port handle for sending commands
self._serial: Any = None self._serial: Any = None
self._serial_lock = threading.Lock() self._serial_lock = threading.Lock()
# Periodic status logging # Periodic status logging
self._last_status_log = 0.0 self._last_status_log = 0.0
self._frame_count = 0 self._frame_count = 0
def set_on_data(self, callback): def set_on_data(self, callback):
"""Set callback for new telemetry data. Called with data dict.""" """Set callback for new telemetry data. Called with data dict."""
self._on_data_callback = callback self._on_data_callback = callback
def set_on_ack(self, callback): def set_on_ack(self, callback):
"""Set callback for ACK responses. Called with (cmd, status, extra).""" """Set callback for ACK responses. Called with (cmd, status, extra)."""
self._on_ack_callback = callback self._on_ack_callback = callback
def send_command(self, cmd: str, params: dict | None = None) -> bool: def send_command(self, cmd: str, params: dict | None = None) -> bool:
"""Send a command to Arduino via serial. """Send a command to Arduino via serial.
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
Args: Args:
cmd: Command name (e.g., "HORN", "LIGHT") cmd: Command name (e.g., "HORN", "LIGHT")
params: Optional parameters dict params: Optional parameters dict
Returns: Returns:
True if sent successfully, False if serial unavailable True if sent successfully, False if serial unavailable
""" """
with self._serial_lock: with self._serial_lock:
if self._serial is None or not self._connected: if self._serial is None or not self._connected:
print(f"[Arduino] Cannot send command, not connected") print(f"[Arduino] Cannot send command, not connected")
return False return False
try: try:
# Build command string # Build command string
parts = ["CMD", cmd.upper()] parts = ["CMD", cmd.upper()]
if params: if params:
for key, val in params.items(): for key, val in params.items():
parts.append(f"{key}={val}") parts.append(f"{key}={val}")
line = ":".join(parts) + "\n" line = ":".join(parts) + "\n"
self._serial.write(line.encode("utf-8")) self._serial.write(line.encode("utf-8"))
self._serial.flush() self._serial.flush()
print(f"[Arduino] Sent: {line.strip()}") print(f"[Arduino] Sent: {line.strip()}")
return True return True
except Exception as e: except Exception as e:
print(f"[Arduino] Failed to send command: {e}") print(f"[Arduino] Failed to send command: {e}")
return False return False
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
def get_latest(self) -> dict[str, Any]: def get_latest(self) -> dict[str, Any]:
"""Get most recent telemetry values.""" """Get most recent telemetry values."""
with self._lock: with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"} return self._latest.copy() if self._latest else {"error": "no data"}
def get_buffer(self) -> list[dict[str, Any]]: def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered telemetry history.""" """Get buffered telemetry history."""
with self._lock: with self._lock:
return list(self._buffer) return list(self._buffer)
def start(self): def start(self):
"""Start background serial reader thread.""" """Start background serial reader thread."""
if self._running: if self._running:
return return
self._running = True self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True) self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
"""Stop background reader.""" """Stop background reader."""
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=2.0) self._thread.join(timeout=2.0)
def _reader_loop(self): def _reader_loop(self):
"""Main reader loop with reconnection logic.""" """Main reader loop with reconnection logic."""
while self._running: while self._running:
try: try:
self._connect_and_read() self._connect_and_read()
except Exception as e: except Exception as e:
self._connected = False self._connected = False
print(f"[Arduino] Connection error: {e}, retrying in 5s...") print(f"[Arduino] Connection error: {e}, retrying in 5s...")
time.sleep(5) time.sleep(5)
def _connect_and_read(self): def _connect_and_read(self):
"""Connect to Arduino serial and read data.""" """Connect to Arduino serial and read data."""
if serial is None: if serial is None:
print("[Arduino] pyserial not installed, cannot connect") print("[Arduino] pyserial not installed, cannot connect")
return # Will retry via _reader_loop after 5s return # Will retry via _reader_loop after 5s
try: try:
ser = serial.Serial( ser = serial.Serial(
port=self.port, port=self.port,
baudrate=self.baudrate, baudrate=self.baudrate,
timeout=1.0, timeout=1.0,
) )
except serial.SerialException as e: except serial.SerialException as e:
print(f"[Arduino] Cannot open {self.port}: {e}") print(f"[Arduino] Cannot open {self.port}: {e}")
return # Will retry via _reader_loop after 5s return # Will retry via _reader_loop after 5s
try: try:
# Store serial handle for send_command() # Store serial handle for send_command()
with self._serial_lock: with self._serial_lock:
self._serial = ser self._serial = ser
self._connected = True self._connected = True
self._last_status_log = time.time() self._last_status_log = time.time()
self._frame_count = 0 self._frame_count = 0
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
while self._running: while self._running:
try: try:
# Read null-terminated line (TSV protocol) # Read null-terminated line (TSV protocol)
line = self._read_null_terminated(ser) line = self._read_null_terminated(ser)
if not line: if not line:
continue continue
# Check for ACK responses first (legacy newline-terminated) # Check for ACK responses first (legacy newline-terminated)
ack_match = self.ACK_PATTERN.match(line) ack_match = self.ACK_PATTERN.match(line)
if ack_match: if ack_match:
cmd, status, extra = ack_match.groups() cmd, status, extra = ack_match.groups()
if self._on_ack_callback: if self._on_ack_callback:
self._on_ack_callback(cmd, status, extra) self._on_ack_callback(cmd, status, extra)
continue continue
data = self._parse_line(line) data = self._parse_line(line)
if data: if data:
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with self._lock: with self._lock:
# Merge new values into latest (preserve old values for partial updates) # Merge new values into latest (preserve old values for partial updates)
for key, val in data.items(): for key, val in data.items():
if val is not None and not (isinstance(val, float) and math.isnan(val)): if val is not None and not (isinstance(val, float) and math.isnan(val)):
self._latest[key] = val self._latest[key] = val
self._latest["time"] = data["time"] self._latest["time"] = data["time"]
self._buffer.append(self._latest.copy()) self._buffer.append(self._latest.copy())
# Invoke callback with new data # Invoke callback with new data
if self._on_data_callback: if self._on_data_callback:
self._on_data_callback(self._latest.copy()) self._on_data_callback(self._latest.copy())
# Periodic status log (every 5s) # Periodic status log (every 5s)
self._frame_count += 1 self._frame_count += 1
now = time.time() now = time.time()
if now - self._last_status_log >= 5.0: if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log elapsed = now - self._last_status_log
fps = self._frame_count / elapsed fps = self._frame_count / elapsed
v = self._latest.get('voltage', 0) v = self._latest.get('voltage', 0)
rpm = self._latest.get('rpm', 0) rpm = self._latest.get('rpm', 0)
gear = self._latest.get('gear', 0) gear = self._latest.get('gear', 0)
roll = self._latest.get('roll', 0) roll = self._latest.get('roll', 0)
print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°") print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
self._last_status_log = now self._last_status_log = now
self._frame_count = 0 self._frame_count = 0
except serial.SerialException as e: except serial.SerialException as e:
print(f"[Arduino] Serial error: {e}") print(f"[Arduino] Serial error: {e}")
break break
finally: finally:
self._connected = False self._connected = False
with self._serial_lock: with self._serial_lock:
self._serial = None self._serial = None
ser.close() ser.close()
def _read_null_terminated(self, ser) -> str: def _read_null_terminated(self, ser) -> str:
"""Read bytes until null terminator or newline (fallback for legacy).""" """Read bytes until null terminator or newline (fallback for legacy)."""
buf = bytearray() buf = bytearray()
while self._running: while self._running:
byte = ser.read(1) byte = ser.read(1)
if not byte: if not byte:
# Timeout # Timeout
if buf: if buf:
# Return partial buffer if we have data # Return partial buffer if we have data
return buf.decode("utf-8", errors="ignore").strip() return buf.decode("utf-8", errors="ignore").strip()
return "" return ""
if byte == b'\x00' or byte == b'\n' or byte == b'\r': if byte == b'\x00' or byte == b'\n' or byte == b'\r':
# End of frame # End of frame
if buf: if buf:
return buf.decode("utf-8", errors="ignore").strip() return buf.decode("utf-8", errors="ignore").strip()
# Skip empty lines / consecutive terminators # Skip empty lines / consecutive terminators
continue continue
buf.append(byte[0]) buf.append(byte[0])
# Safety limit # Safety limit
if len(buf) > 256: if len(buf) > 256:
return buf.decode("utf-8", errors="ignore").strip() return buf.decode("utf-8", errors="ignore").strip()
def _parse_line(self, line: str) -> dict[str, Any] | None: def _parse_line(self, line: str) -> dict[str, Any] | None:
"""Parse a line from Arduino - TSV first, then JSON, fallback to regex. """Parse a line from Arduino - TSV first, then JSON, fallback to regex.
TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md) TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md)
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3} JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
Legacy text: V_bat: 12.45V Legacy text: V_bat: 12.45V
""" """
# Try TSV first (new protocol) # Try TSV first (new protocol)
if '\t' in line: if '\t' in line:
return self._parse_tsv(line) return self._parse_tsv(line)
# Try JSON (may still be used for special messages) # Try JSON (may still be used for special messages)
try: try:
obj = json.loads(line) obj = json.loads(line)
return { return {
"voltage": obj.get("v"), "voltage": obj.get("v"),
"rpm": obj.get("rpm"), "rpm": obj.get("rpm"),
"eng_temp": obj.get("eng"), "eng_temp": obj.get("eng"),
"gear": obj.get("gear"), "gear": obj.get("gear"),
} }
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
# Fallback to regex for legacy text protocol # Fallback to regex for legacy text protocol
result = {} result = {}
for key, pattern in self.PATTERNS.items(): for key, pattern in self.PATTERNS.items():
match = pattern.search(line) match = pattern.search(line)
if match: if match:
val = match.group(1) val = match.group(1)
result[key] = float(val) if "." in val else int(val) result[key] = float(val) if "." in val else int(val)
return result if result else None return result if result else None
def _parse_tsv(self, line: str) -> dict[str, Any] | None: def _parse_tsv(self, line: str) -> dict[str, Any] | None:
"""Parse TSV telemetry frame per PROTOCOL.md. """Parse TSV telemetry frame per PROTOCOL.md.
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
Empty fields (stale IMU) become NaN. Empty fields (stale IMU) become NaN.
""" """
fields = line.split('\t') fields = line.split('\t')
if len(fields) != len(self.TSV_FIELDS): if len(fields) != len(self.TSV_FIELDS):
# Wrong field count - might be debug output or malformed # Wrong field count - might be debug output or malformed
return None return None
result = {} result = {}
for i, name in enumerate(self.TSV_FIELDS): for i, name in enumerate(self.TSV_FIELDS):
val_str = fields[i].strip() val_str = fields[i].strip()
if val_str == '': if val_str == '':
# Empty field = stale/missing data # Empty field = stale/missing data
result[name] = float('nan') result[name] = float('nan')
else: else:
try: try:
result[name] = float(val_str) result[name] = float(val_str)
except ValueError: except ValueError:
result[name] = float('nan') result[name] = float('nan')
# IMU axis correction for mounting orientation # IMU axis correction for mounting orientation
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is) # Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
if 'pitch' in result and not math.isnan(result['pitch']): if 'pitch' in result and not math.isnan(result['pitch']):
result['pitch'] = -result['pitch'] result['pitch'] = -result['pitch']
if 'yaw' in result and not math.isnan(result['yaw']): if 'yaw' in result and not math.isnan(result['yaw']):
result['yaw'] = -result['yaw'] result['yaw'] = -result['yaw']
return result return result

View File

@@ -1,269 +1,305 @@
"""GPS service - connects to gpsd, buffers data, handles reconnection.""" """GPS service - connects to gpsd, buffers data, handles reconnection."""
import random import random
import threading import threading
import time import time
from collections import deque from collections import deque
from typing import Any from typing import Any
# ============================================================================ # ============================================================================
# DEBUG MODE - Set True for development without GPS hardware # DEBUG MODE - Set True for development without GPS hardware
# When True: skips gpsd entirely, generates realistic mock data # When True: skips gpsd entirely, generates realistic mock data
# When False: connects to real gpsd (requires GPS device) # When False: connects to real gpsd (requires GPS device)
# ============================================================================ # ============================================================================
_GPS_DEBUG = True _GPS_DEBUG = True
# gpsdclient is a modern, simple gpsd client # gpsdclient is a modern, simple gpsd client
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients # Install gpsd on Pi: sudo apt install gpsd gpsd-clients
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar) # Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
try: try:
from gpsdclient import GPSDClient from gpsdclient import GPSDClient
except ImportError: except ImportError:
GPSDClient = None # Allow import without gpsd for testing structure GPSDClient = None # Allow import without gpsd for testing structure
class GPSService: class GPSService:
"""Threaded GPS reader with buffering and auto-reconnect.""" """Threaded GPS reader with buffering and auto-reconnect."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100): def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self.host = host self.host = host
self.port = port self.port = port
self.buffer_size = buffer_size self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {} self._latest: dict[str, Any] = {}
self._connected = False self._connected = False
self._running = False self._running = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
# Callback for push-based updates # Callback for push-based updates
self._on_data_callback = None self._on_data_callback = None
# Periodic status logging # GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
self._last_status_log = 0.0 self._has_ever_fixed = False # True after first valid fix this session
self._fix_count = 0
# Periodic status logging
def set_on_data(self, callback): self._last_status_log = 0.0
"""Set callback for new GPS fix. Called with fix dict.""" self._fix_count = 0
self._on_data_callback = callback
def set_on_data(self, callback):
@property """Set callback for new GPS fix. Called with fix dict."""
def connected(self) -> bool: self._on_data_callback = callback
return self._connected
@property
def get_latest(self) -> dict[str, Any]: def connected(self) -> bool:
"""Get most recent GPS fix.""" return self._connected
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"} def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
def get_buffer(self) -> list[dict[str, Any]]: with self._lock:
"""Get buffered GPS history.""" return self._latest.copy() if self._latest else {"error": "no data"}
with self._lock:
return list(self._buffer) def _gps_state(self, fix: dict) -> str:
"""Determine GPS state: acquiring, fix, or lost.
def start(self):
"""Start background GPS reader thread.""" NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
if self._running: report mode 1 with no position. We track it ourselves.
return """
self._running = True has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
self._thread = threading.Thread(target=self._reader_loop, daemon=True) if has_fix:
self._thread.start() return "fix"
print("[GPS] Service started") return "lost" if self._has_ever_fixed else "acquiring"
def stop(self): def get_buffer(self) -> list[dict[str, Any]]:
"""Stop background reader.""" """Get buffered GPS history."""
self._running = False with self._lock:
if self._thread: return list(self._buffer)
self._thread.join(timeout=2.0)
def start(self):
def _reader_loop(self): """Start background GPS reader thread."""
"""Main reader loop with reconnection logic.""" if self._running:
print("[GPS] Reader thread running") return
while self._running: self._running = True
try: self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._connect_and_read() self._thread.start()
except Exception as e: print("[GPS] Service started")
self._connected = False
print(f"[GPS] Connection error: {e}, retrying in 5s...") def stop(self):
time.sleep(5) """Stop background reader."""
self._running = False
def _connect_and_read(self): if self._thread:
"""Connect to gpsd and read data.""" self._thread.join(timeout=2.0)
# Debug mode: skip gpsd entirely, use stub data
if _GPS_DEBUG: def _reader_loop(self):
print("[GPS] Debug mode enabled, using stub data") """Main reader loop with reconnection logic."""
self._stub_mode() print("[GPS] Reader thread running")
return while self._running:
try:
if GPSDClient is None: self._connect_and_read()
print("[GPS] gpsdclient not installed, running in stub mode") except Exception as e:
self._stub_mode() self._connected = False
return print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5)
# Quick check if gpsd is reachable before attempting connection
import socket def _connect_and_read(self):
try: """Connect to gpsd and read data."""
sock = socket.create_connection((self.host, self.port), timeout=2.0) # Debug mode: skip gpsd entirely, use stub data
sock.close() if _GPS_DEBUG:
except (socket.timeout, socket.error, OSError) as e: print("[GPS] Debug mode enabled, using stub data")
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}") self._stub_mode()
raise ConnectionError(f"gpsd not reachable: {e}") return
try: if GPSDClient is None:
client = GPSDClient(host=self.host, port=self.port) print("[GPS] gpsdclient not installed, running in stub mode")
except Exception as e: self._stub_mode()
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}") return
raise ConnectionError(f"gpsd connection failed: {e}")
# Quick check if gpsd is reachable before attempting connection
with client: import socket
self._connected = True try:
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}") sock = socket.create_connection((self.host, self.port), timeout=2.0)
sock.close()
self._last_status_log = time.time() except (socket.timeout, socket.error, OSError) as e:
self._fix_count = 0 print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
first_fix_timeout = time.time() + 5.0 # 5s to get first fix raise ConnectionError(f"gpsd not reachable: {e}")
for result in client.dict_stream(filter=["TPV"]): try:
if not self._running: client = GPSDClient(host=self.host, port=self.port)
break except Exception as e:
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
# TPV = Time-Position-Velocity report raise ConnectionError(f"gpsd connection failed: {e}")
fix = {
"time": result.get("time"), with client:
"lat": result.get("lat"), self._connected = True
"lon": result.get("lon"), print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
"alt": result.get("alt"),
"speed": result.get("speed"), # m/s self._last_status_log = time.time()
"track": result.get("track"), # heading in degrees self._fix_count = 0
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D # 120s for initial cold fix, 10s for signal loss after first fix
"satellites": result.get("satellites"), # from SKY messages fix_timeout = time.time() + 120.0
}
for result in client.dict_stream(filter=["TPV"]):
# Check if this is a real fix (has position) or just empty TPV if not self._running:
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1): break
# No real data yet, check timeout
if time.time() > first_fix_timeout: # TPV = Time-Position-Velocity report
print("[GPS] No GPS fix after 5s, will retry connection") fix = {
raise ConnectionError("No GPS fix within timeout") "time": result.get("time"),
continue # Skip empty fixes "lat": result.get("lat"),
"lon": result.get("lon"),
# Got real data, disable timeout "alt": result.get("alt"),
first_fix_timeout = float('inf') "speed": result.get("speed"), # m/s
"track": result.get("track"), # heading in degrees
with self._lock: "mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
self._latest = fix "satellites": result.get("satellites"), # from SKY messages
if fix.get("lat") is not None: }
self._buffer.append(fix)
# Compute state and attach to fix
# Invoke callback with new fix fix["gps_state"] = self._gps_state(fix)
if self._on_data_callback:
self._on_data_callback(fix) # Check if this is a real fix (has position) or just empty TPV
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
# Periodic status log (every 5s) # No real data yet, check timeout
self._fix_count += 1 if time.time() > fix_timeout:
now = time.time() timeout_s = "120s" if not self._has_ever_fixed else "10s"
if now - self._last_status_log >= 5.0: print(f"[GPS] No GPS fix after {timeout_s}, will retry connection")
elapsed = now - self._last_status_log raise ConnectionError("No GPS fix within timeout")
fps = self._fix_count / elapsed continue # Skip empty fixes
speed = fix.get('speed', 0) or 0
track = fix.get('track', 0) or 0 # Got real data — mark first fix, reset timeout to shorter window
mode = fix.get('mode', 0) or 0 if not self._has_ever_fixed:
sats = fix.get('satellites', '?') self._has_ever_fixed = True
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}") print("[GPS] First fix acquired")
self._last_status_log = now fix_timeout = time.time() + 10.0 # 10s timeout for signal loss
self._fix_count = 0
with self._lock:
def _stub_mode(self): self._latest = fix
"""Generate realistic mock GPS data for development/testing. if fix.get("lat") is not None:
self._buffer.append(fix)
Simulates:
- Normal 3D fix with satellites # Invoke callback with new fix
- Occasional signal loss (~3% chance per second, lasts ~5s) if self._on_data_callback:
- Wandering position near Tokyo self._on_data_callback(fix)
"""
self._last_status_log = time.time() # Periodic status log (every 5s)
self._fix_count = 0 self._fix_count += 1
now = time.time()
# Signal loss state if now - self._last_status_log >= 5.0:
signal_lost = False elapsed = now - self._last_status_log
signal_lost_until = 0.0 fps = self._fix_count / elapsed
speed = fix.get('speed', 0) or 0
# Base position (Tokyo area) track = fix.get('track', 0) or 0
base_lat = 35.6762 mode = fix.get('mode', 0) or 0
base_lon = 139.6503 sats = fix.get('satellites', '?')
base_alt = 40.0 print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
self._last_status_log = now
# Smoothly varying heading/speed self._fix_count = 0
heading = random.uniform(0, 360)
speed = random.uniform(5, 15) def _stub_mode(self):
"""Generate realistic mock GPS data for development/testing.
while self._running:
self._connected = True Simulates:
now = time.time() - Initial acquisition delay (~3s before first fix)
- Normal 3D fix with satellites
# Check for signal loss simulation - Occasional signal loss (~30% chance per second, lasts ~2s)
if signal_lost: - Wandering position near Tokyo
if now >= signal_lost_until: """
signal_lost = False self._last_status_log = time.time()
print("[GPS] Signal recovered (stub)") self._fix_count = 0
else:
# ~30% chance per second to lose signal # Signal loss state
if random.random() < 0.3: signal_lost = False
signal_lost = True signal_lost_until = 0.0
signal_lost_until = now + 2 # fixed 2s loss
print("[GPS] Signal loss simulation (stub)") # Simulate cold start acquisition (~3s)
acquiring_until = time.time() + 3.0
if signal_lost:
# No fix - mode 1, no satellites, no track # Base position (Tokyo area)
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON base_lat = 35.6762
fix = { base_lon = 139.6503
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), base_alt = 40.0
"lat": None,
"lon": None, # Smoothly varying heading/speed
"alt": None, heading = random.uniform(0, 360)
"speed": None, speed = random.uniform(5, 15)
"track": None,
"mode": 1, while self._running:
"satellites": 0, self._connected = True
} now = time.time()
else:
# Smoothly vary heading and speed # Simulate initial acquisition period
heading = (heading + random.uniform(1, 3)) % 360 if now < acquiring_until:
speed = max(0, min(30, speed + random.uniform(-2, 2))) signal_lost = True # No fix yet
elif signal_lost and now >= signal_lost_until:
fix = { signal_lost = False
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), if self._has_ever_fixed:
"lat": base_lat + random.uniform(-0.001, 0.001), print("[GPS] Signal recovered (stub)")
"lon": base_lon + random.uniform(-0.001, 0.001), else:
"alt": base_alt + random.uniform(-5, 5), print("[GPS] First fix acquired (stub)")
"speed": speed, elif not signal_lost:
"track": heading, # ~30% chance per second to lose signal
"mode": 3, if random.random() < 0.3:
"satellites": random.randint(6, 12), signal_lost = True
} signal_lost_until = now + 2 # fixed 2s loss
print("[GPS] Signal loss simulation (stub)")
with self._lock:
self._latest = fix if signal_lost:
if fix.get("lat") is not None: # No fix - mode 1, no satellites, no track
self._buffer.append(fix) # Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
fix = {
# Invoke callback with new fix "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
if self._on_data_callback: "lat": None,
self._on_data_callback(fix) "lon": None,
"alt": None,
# Periodic status log (every 5s) "speed": None,
self._fix_count += 1 "track": None,
if now - self._last_status_log >= 5.0: "mode": 1,
elapsed = now - self._last_status_log "satellites": 0,
fps = self._fix_count / elapsed }
speed_val = fix.get('speed') or 0 else:
track_val = fix.get('track') # Smoothly vary heading and speed
track_str = f"{track_val:.0f}" if track_val is not None else "---" heading = (heading + random.uniform(1, 3)) % 360
mode = fix.get('mode', 0) speed = max(0, min(30, speed + random.uniform(-2, 2)))
sats = fix.get('satellites', 0)
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)") if not self._has_ever_fixed:
self._last_status_log = now self._has_ever_fixed = True
self._fix_count = 0
fix = {
time.sleep(1) "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": base_lat + random.uniform(-0.001, 0.001),
"lon": base_lon + random.uniform(-0.001, 0.001),
"alt": base_alt + random.uniform(-5, 5),
"speed": speed,
"track": heading,
"mode": 3,
"satellites": random.randint(6, 12),
}
# Attach state — same logic as real GPS path
fix["gps_state"] = self._gps_state(fix)
with self._lock:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
# Periodic status log (every 5s)
self._fix_count += 1
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._fix_count / elapsed
speed_val = fix.get('speed') or 0
track_val = fix.get('track')
track_str = f"{track_val:.0f}" if track_val is not None else "---"
mode = fix.get('mode', 0)
sats = fix.get('satellites', 0)
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
self._last_status_log = now
self._fix_count = 0
time.sleep(1)

View File

@@ -1,245 +1,247 @@
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket.""" """Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
from gevent import monkey from gevent import monkey
monkey.patch_all() # Must be at the very top before other imports monkey.patch_all() # Must be at the very top before other imports
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_socketio import SocketIO, emit 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 gpio_service import GPIOService
from throttle import Throttle from throttle import Throttle
app = Flask(__name__) app = Flask(__name__)
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
# SocketIO with gevent async mode (eventlet is deprecated) # SocketIO with gevent async mode (eventlet is deprecated)
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*") socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
# Services # Services
gps = GPSService() gps = GPSService()
arduino = ArduinoService() arduino = ArduinoService()
gpio = GPIOService() 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
gps_throttle = Throttle(min_interval=1.0) # 1Hz max gps_throttle = Throttle(min_interval=1.0) # 1Hz max
# Track connected clients # Track connected clients
connected_clients = set() connected_clients = set()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# WebSocket Event Handlers # WebSocket Event Handlers
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@socketio.on("connect") @socketio.on("connect")
def handle_connect(): def handle_connect():
"""Client connected.""" """Client connected."""
client_id = id(socketio) # Simple identifier client_id = id(socketio) # Simple identifier
connected_clients.add(client_id) connected_clients.add(client_id)
print(f"[WS] Client connected ({len(connected_clients)} total)") print(f"[WS] Client connected ({len(connected_clients)} total)")
# Send current status immediately # Send current status immediately
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, "theme_switch": gpio.theme_switch,
}) })
# Send latest data if available # Send latest data if available
arduino_data = arduino.get_latest() arduino_data = arduino.get_latest()
if "error" not in arduino_data: if "error" not in arduino_data:
emit("arduino", arduino_data) emit("arduino", arduino_data)
gps_data = gps.get_latest() gps_data = gps.get_latest()
if "error" not in gps_data: if "error" not in gps_data:
emit("gps", gps_data) emit("gps", gps_data)
@socketio.on("disconnect") @socketio.on("disconnect")
def handle_disconnect(): def handle_disconnect():
"""Client disconnected.""" """Client disconnected."""
client_id = id(socketio) client_id = id(socketio)
connected_clients.discard(client_id) connected_clients.discard(client_id)
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)") print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
@socketio.on("button") @socketio.on("button")
def handle_button(data): def handle_button(data):
"""Handle button press from UI. """Handle button press from UI.
Expected data: {"id": "horn", "action": "press", ...params} Expected data: {"id": "horn", "action": "press", ...params}
""" """
btn_id = data.get("id", "unknown") btn_id = data.get("id", "unknown")
action = data.get("action", "press") action = data.get("action", "press")
params = {k: v for k, v in data.items() if k not in ("id", "action")} params = {k: v for k, v in data.items() if k not in ("id", "action")}
print(f"[WS] Button: {btn_id} {action} {params}") print(f"[WS] Button: {btn_id} {action} {params}")
# Map button ID to Arduino command # Map button ID to Arduino command
cmd_map = { cmd_map = {
"horn": "HORN", "horn": "HORN",
"light": "LIGHT", "light": "LIGHT",
"indicator_left": "IND_L", "indicator_left": "IND_L",
"indicator_right": "IND_R", "indicator_right": "IND_R",
"hazard": "HAZARD", "hazard": "HAZARD",
} }
cmd = cmd_map.get(btn_id) cmd = cmd_map.get(btn_id)
if cmd: if cmd:
# Add action to params (e.g., ON/OFF based on press/release) # Add action to params (e.g., ON/OFF based on press/release)
params["state"] = "ON" if action == "press" else "OFF" params["state"] = "ON" if action == "press" else "OFF"
success = arduino.send_command(cmd, params) success = arduino.send_command(cmd, params)
# Send immediate ack for the attempt # Send immediate ack for the attempt
emit("ack", { emit("ack", {
"id": btn_id, "id": btn_id,
"status": "sent" if success else "failed", "status": "sent" if success else "failed",
"error": None if success else "arduino not connected", "error": None if success else "arduino not connected",
}) })
else: else:
emit("ack", { emit("ack", {
"id": btn_id, "id": btn_id,
"status": "error", "status": "error",
"error": f"unknown button: {btn_id}", "error": f"unknown button: {btn_id}",
}) })
@socketio.on("emergency") @socketio.on("emergency")
def handle_emergency(data): def handle_emergency(data):
"""Handle emergency signal from UI.""" """Handle emergency signal from UI."""
etype = data.get("type", "stop") etype = data.get("type", "stop")
print(f"[WS] EMERGENCY: {etype}") print(f"[WS] EMERGENCY: {etype}")
# Send emergency command to Arduino # Send emergency command to Arduino
arduino.send_command("EMERGENCY", {"type": etype}) arduino.send_command("EMERGENCY", {"type": etype})
# Broadcast alert to all clients # Broadcast alert to all clients
socketio.emit("alert", { socketio.emit("alert", {
"type": "emergency", "type": "emergency",
"message": f"Emergency {etype} triggered", "message": f"Emergency {etype} triggered",
}) })
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Service Callbacks (push data to WebSocket) # Service Callbacks (push data to WebSocket)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def on_arduino_data(data): def on_arduino_data(data):
"""Called by ArduinoService when new telemetry arrives.""" """Called by ArduinoService when new telemetry arrives."""
# Always include current GPIO state (UI dedupes) # Always include current GPIO state (UI dedupes)
data = dict(data) # Don't mutate original data = dict(data) # Don't mutate original
data["theme_switch"] = gpio.theme_switch data["theme_switch"] = gpio.theme_switch
def emit_fn(d): def emit_fn(d):
socketio.emit("arduino", d) socketio.emit("arduino", d)
arduino_throttle.maybe_emit(data, emit_fn) arduino_throttle.maybe_emit(data, emit_fn)
def on_gps_data(data): def on_gps_data(data):
"""Called by GPSService when new fix arrives.""" """Called by GPSService when new fix arrives."""
def emit_fn(d): def emit_fn(d):
socketio.emit("gps", d) socketio.emit("gps", d)
gps_throttle.maybe_emit(data, emit_fn) gps_throttle.maybe_emit(data, emit_fn)
def on_arduino_ack(cmd, status, extra): def on_arduino_ack(cmd, status, extra):
"""Called by ArduinoService when ACK received from Arduino.""" """Called by ArduinoService when ACK received from Arduino."""
socketio.emit("ack", { socketio.emit("ack", {
"id": cmd.lower(), "id": cmd.lower(),
"status": status.lower(), "status": status.lower(),
"extra": extra, "extra": extra,
}) })
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Background task to flush pending throttled data # Background task to flush pending throttled data
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def throttle_flusher(): def throttle_flusher():
"""Periodically flush pending throttled data.""" """Periodically flush pending throttled data."""
import gevent import gevent
while True: while True:
gevent.sleep(0.05) # 20Hz flush rate gevent.sleep(0.05) # 20Hz flush rate
if arduino_throttle.has_pending: if arduino_throttle.has_pending:
arduino_throttle.flush(lambda d: socketio.emit("arduino", d)) arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
if gps_throttle.has_pending: if gps_throttle.has_pending:
gps_throttle.flush(lambda d: socketio.emit("gps", d)) gps_throttle.flush(lambda d: socketio.emit("gps", d))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# REST API (backward compatibility) # REST API (backward compatibility)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@app.route("/health") @app.route("/health")
def health(): def health():
"""Health check endpoint.""" """Health check endpoint."""
return jsonify({ gps_latest = gps.get_latest()
"status": "ok", return jsonify({
"gps_connected": gps.connected, "status": "ok",
"arduino_connected": arduino.connected, "gps_connected": gps.connected,
"ws_clients": len(connected_clients), "gps_state": gps_latest.get("gps_state", "acquiring"),
}) "arduino_connected": arduino.connected,
"ws_clients": len(connected_clients),
})
@app.route("/gps")
def gps_data():
"""Current GPS data.""" @app.route("/gps")
return jsonify(gps.get_latest()) def gps_data():
"""Current GPS data."""
return jsonify(gps.get_latest())
@app.route("/gps/history")
def gps_history():
"""Buffered GPS history.""" @app.route("/gps/history")
return jsonify(gps.get_buffer()) def gps_history():
"""Buffered GPS history."""
return jsonify(gps.get_buffer())
@app.route("/arduino")
def arduino_data():
"""Current Arduino telemetry (voltage, rpm, etc).""" @app.route("/arduino")
return jsonify(arduino.get_latest()) def arduino_data():
"""Current Arduino telemetry (voltage, rpm, etc)."""
return jsonify(arduino.get_latest())
@app.route("/arduino/history")
def arduino_history():
"""Buffered Arduino telemetry history.""" @app.route("/arduino/history")
return jsonify(arduino.get_buffer()) def arduino_history():
"""Buffered Arduino telemetry history."""
return jsonify(arduino.get_buffer())
# -----------------------------------------------------------------------------
# Main Entry Point
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Main Entry Point
def main(): # -----------------------------------------------------------------------------
"""Entry point."""
# Wire up callbacks def main():
arduino.set_on_data(on_arduino_data) """Entry point."""
arduino.set_on_ack(on_arduino_ack) # Wire up callbacks
gps.set_on_data(on_gps_data) arduino.set_on_data(on_arduino_data)
arduino.set_on_ack(on_arduino_ack)
# Start services gps.set_on_data(on_gps_data)
gps.start()
arduino.start() # Start services
gpio.start() gps.start()
arduino.start()
# Start throttle flusher in background gpio.start()
socketio.start_background_task(throttle_flusher)
# Start throttle flusher in background
try: socketio.start_background_task(throttle_flusher)
# Use socketio.run() instead of app.run() for WebSocket support
print("[Backend] Starting on http://0.0.0.0:5000") try:
socketio.run(app, host="0.0.0.0", port=5000, debug=False) # Use socketio.run() instead of app.run() for WebSocket support
finally: print("[Backend] Starting on http://0.0.0.0:5000")
arduino.stop() socketio.run(app, host="0.0.0.0", port=5000, debug=False)
gps.stop() finally:
gpio.stop() arduino.stop()
gps.stop()
gpio.stop()
if __name__ == "__main__":
main()
if __name__ == "__main__":
main()

View File

@@ -1,26 +1,26 @@
[project] [project]
name = "smartserow-backend" name = "smartserow-backend"
version = "0.1.0" version = "0.1.0"
description = "GPS and Arduino telemetry service for Smart Serow" description = "GPS and Arduino telemetry service for Smart Serow"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"flask>=3.0", "flask>=3.0",
"flask-socketio>=5.3.0", "flask-socketio>=5.3.0",
"gevent>=24.0", "gevent>=24.0",
"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) # GPIO: install via apt (sudo apt install python3-rpi.gpio)
# Not listed here because pip versions require compilation # Not listed here because pip versions require compilation
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff", "ruff",
] ]
[project.scripts] [project.scripts]
smartserow-backend = "main:main" smartserow-backend = "main:main"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

View File

@@ -1,61 +1,61 @@
"""Throttle layer for rate-limiting telemetry emissions.""" """Throttle layer for rate-limiting telemetry emissions."""
import time import time
from typing import Any, Callable from typing import Any, Callable
class Throttle: class Throttle:
"""Rate limiter for WebSocket emissions. """Rate limiter for WebSocket emissions.
Coalesces rapid updates - only emits at most once per min_interval. Coalesces rapid updates - only emits at most once per min_interval.
If multiple updates arrive within the interval, the latest value wins. If multiple updates arrive within the interval, the latest value wins.
""" """
def __init__(self, min_interval: float = 0.5): def __init__(self, min_interval: float = 0.5):
""" """
Args: Args:
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max) min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
""" """
self._last_emit: float = 0 self._last_emit: float = 0
self._min_interval = min_interval self._min_interval = min_interval
self._pending: Any = None self._pending: Any = None
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool: def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
"""Emit if interval has passed, otherwise store as pending. """Emit if interval has passed, otherwise store as pending.
Args: Args:
data: Data to emit data: Data to emit
emit_fn: Function to call with data when emitting emit_fn: Function to call with data when emitting
Returns: Returns:
True if emitted, False if stored as pending True if emitted, False if stored as pending
""" """
now = time.time() now = time.time()
if now - self._last_emit >= self._min_interval: if now - self._last_emit >= self._min_interval:
emit_fn(data) emit_fn(data)
self._last_emit = now self._last_emit = now
self._pending = None self._pending = None
return True return True
else: else:
self._pending = data # Latest value wins self._pending = data # Latest value wins
return False return False
def flush(self, emit_fn: Callable[[Any], None]) -> bool: def flush(self, emit_fn: Callable[[Any], None]) -> bool:
"""Emit pending data if any. """Emit pending data if any.
Call this periodically to ensure pending data gets sent. Call this periodically to ensure pending data gets sent.
Returns: Returns:
True if pending data was emitted, False if nothing pending True if pending data was emitted, False if nothing pending
""" """
if self._pending is not None: if self._pending is not None:
emit_fn(self._pending) emit_fn(self._pending)
self._last_emit = time.time() self._last_emit = time.time()
self._pending = None self._pending = None
return True return True
return False return False
@property @property
def has_pending(self) -> bool: def has_pending(self) -> bool:
"""Check if there's pending data waiting to be emitted.""" """Check if there's pending data waiting to be emitted."""
return self._pending is not None return self._pending is not None

View File

@@ -0,0 +1,49 @@
"""Quick AT command terminal - minicom but less hostile.
Usage:
python at_terminal.py [port]
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
"""
import sys
import serial
import threading
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
BAUD = 115200
def reader(ser):
"""Background thread: print everything the modem sends."""
while True:
try:
data = ser.read(ser.in_waiting or 1)
if data:
sys.stdout.write(data.decode("utf-8", errors="replace"))
sys.stdout.flush()
except Exception:
break
def main():
print(f"Opening {PORT} @ {BAUD} baud")
print("Type AT commands. Ctrl+C to quit.\n")
ser = serial.Serial(PORT, BAUD, timeout=0.1)
t = threading.Thread(target=reader, args=(ser,), daemon=True)
t.start()
try:
while True:
line = input()
ser.write((line + "\r\n").encode())
except (KeyboardInterrupt, EOFError):
print("\nBye.")
finally:
ser.close()
if __name__ == "__main__":
main()

View File

@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
// Show all items from the start so the row doesn't jump around // Show all items from the start so the row doesn't jump around
_updateStatus('Config', '...'); _updateStatus('Config', '...');
_updateStatus('UART', '...'); _updateStatus('UART', '...');
_updateStatus('GPS', '...');
_updateStatus('Navigator', '...'); _updateStatus('Navigator', '...');
// Config must load first (everything else depends on it) // Config must load first (everything else depends on it)
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
await ConfigService.instance.load(); await ConfigService.instance.load();
_updateStatus('Config', 'Ready'); _updateStatus('Config', 'Ready');
// UART health check and navigator image preload run truly in parallel // UART, GPS, and navigator image preload run truly in parallel
_updateStatus('UART', 'Connecting'); _updateStatus('UART', 'Connecting');
_updateStatus('GPS', 'Waiting');
_updateStatus('Navigator', 'Loading'); _updateStatus('Navigator', 'Loading');
await Future.wait([ await Future.wait([
_waitForUart(), _waitForUart(),
_waitForGps(),
_preloadNavigatorImages(), _preloadNavigatorImages(),
]); ]);
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
_updateStatus('UART', 'Timeout'); _updateStatus('UART', 'Timeout');
} }
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
Future<void> _waitForGps() async {
final backendUrl = ConfigService.instance.backendUrl;
const bailOut = Duration(milliseconds: 7500);
const retryDelay = Duration(seconds: 1);
final deadline = DateTime.now().add(bailOut);
_updateStatus('GPS', 'Acquiring');
while (DateTime.now().isBefore(deadline)) {
try {
final response = await http
.get(Uri.parse('$backendUrl/health'))
.timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['gps_state'] == 'fix') {
_updateStatus('GPS', 'Ready');
return;
}
}
} catch (e) {
// Backend not reachable yet - keep trying
}
await Future.delayed(retryDelay);
}
// Bail out - dashboard will show live GPS state when it arrives
_updateStatus('GPS', 'Timeout');
}
/// Preload navigator images into Flutter's image cache /// Preload navigator images into Flutter's image cache
/// ///
/// Scans for all PNGs in the navigator folder and precaches them. /// Scans for all PNGs in the navigator folder and precaches them.

View File

@@ -1,282 +1,286 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' show sqrt, sin, cos, pi; import 'dart:math' show sqrt, sin, cos, pi;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/backend_service.dart'; import '../services/backend_service.dart';
import '../services/websocket_service.dart'; import '../services/websocket_service.dart';
import '../services/pi_io.dart'; import '../services/pi_io.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/navigator_widget.dart'; import '../widgets/navigator_widget.dart';
import '../widgets/stat_box.dart'; import '../widgets/stat_box.dart';
import '../widgets/stat_box_main.dart'; import '../widgets/stat_box_main.dart';
import '../widgets/system_bar.dart'; import '../widgets/system_bar.dart';
import '../widgets/debug_console.dart'; import '../widgets/debug_console.dart';
import '../widgets/whiskey_mark.dart'; import '../widgets/whiskey_mark.dart';
import '../widgets/accel_graph.dart'; import '../widgets/accel_graph.dart';
import '../widgets/gps_compass.dart'; import '../widgets/gps_compass.dart';
// test service for triggers // test service for triggers
import '../services/test_flipflop_service.dart'; import '../services/test_flipflop_service.dart';
/// Main dashboard - displays Pi vitals and placeholder stats /// Main dashboard - displays Pi vitals and placeholder stats
class DashboardScreen extends StatefulWidget { class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key}); const DashboardScreen({super.key});
@override @override
State<DashboardScreen> createState() => _DashboardScreenState(); State<DashboardScreen> createState() => _DashboardScreenState();
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
final _navigatorKey = GlobalKey<NavigatorWidgetState>(); final _navigatorKey = GlobalKey<NavigatorWidgetState>();
// Timer for Pi temp only (safety critical, direct file read) // Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer; Timer? _piTempTimer;
// WebSocket stream subscriptions // WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub; StreamSubscription<ArduinoData>? _arduinoSub;
StreamSubscription<GpsData>? _gpsSub; StreamSubscription<GpsData>? _gpsSub;
StreamSubscription<WsConnectionState>? _connectionSub; StreamSubscription<WsConnectionState>? _connectionSub;
// Pi temperature - direct file read (safety critical) // Pi temperature - direct file read (safety critical)
double? _piTemp; double? _piTemp;
// From backend - Arduino data // From backend - Arduino data
int? _rpm; int? _rpm;
double? _voltage; double? _voltage;
int? _engineTemp; int? _engineTemp;
int? _gear; int? _gear;
double? _roll; double? _roll;
double? _pitch; double? _pitch;
double? _ax; double? _ax;
double? _ay; double? _ay;
double? _dynamicAx; // Gravity-compensated double? _dynamicAx; // Gravity-compensated
double? _dynamicAy; double? _dynamicAy;
// From backend - GPS data // From backend - GPS data
double? _gpsSpeed; double? _gpsSpeed;
double? _gpsTrack; double? _gpsTrack;
// Placeholder values for system bar // Placeholder values for system bar
int? _gpsSatellites; int? _gpsSatellites;
int? _lteSignal; String? _gpsState;
int? _lteSignal;
// WebSocket connection state
WsConnectionState _wsState = WsConnectionState.disconnected; // WebSocket connection state
WsConnectionState _wsState = WsConnectionState.disconnected;
@override
void initState() { @override
super.initState(); void initState() {
super.initState();
// Connect to WebSocket
WebSocketService.instance.connect(); // Connect to WebSocket
WebSocketService.instance.connect();
// Subscribe to Arduino data stream
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { // Subscribe to Arduino data stream
// Gravity-compensated acceleration _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
// When tilted, gravity "leaks" into horizontal axes - subtract it out // Gravity-compensated acceleration
final rollRad = (data.roll ?? 0) * pi / 180; // When tilted, gravity "leaks" into horizontal axes - subtract it out
final pitchRad = (data.pitch ?? 0) * pi / 180; final rollRad = (data.roll ?? 0) * pi / 180;
final pitchRad = (data.pitch ?? 0) * pi / 180;
// Subtract gravity leakage from measured acceleration
// Axes swapped for IMU mounting orientation // Subtract gravity leakage from measured acceleration
final dynamicAx = (data.ay ?? 0) + sin(rollRad); // Axes swapped for IMU mounting orientation
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad)); final dynamicAx = (data.ay ?? 0) + sin(rollRad);
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
setState(() {
_voltage = data.voltage; setState(() {
_rpm = data.rpm; _voltage = data.voltage;
_engineTemp = data.engTemp; _rpm = data.rpm;
_gear = data.gear; _engineTemp = data.engTemp;
_roll = data.roll; _gear = data.gear;
_pitch = data.pitch; _roll = data.roll;
_ax = data.ax; _pitch = data.pitch;
_ay = data.ay; _ax = data.ax;
_dynamicAx = dynamicAx; _ay = data.ay;
_dynamicAy = dynamicAy; _dynamicAx = dynamicAx;
}); _dynamicAy = dynamicAy;
});
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
if (gMagnitude > _surpriseThreshold) { final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
_navigatorKey.currentState?.setEmotion('surprise'); if (gMagnitude > _surpriseThreshold) {
} _navigatorKey.currentState?.setEmotion('surprise');
}); }
});
// Subscribe to GPS data stream
_gpsSub = WebSocketService.instance.gpsStream.listen((data) { // Subscribe to GPS data stream
setState(() { _gpsSub = WebSocketService.instance.gpsStream.listen((data) {
_gpsSpeed = data.speed; setState(() {
_gpsTrack = data.track; _gpsSpeed = data.speed;
_gpsSatellites = data.satellites; _gpsTrack = data.track;
}); _gpsSatellites = data.satellites;
}); _gpsState = data.gpsState;
});
// Subscribe to connection state });
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
setState(() { // Subscribe to connection state
_wsState = state; _connectionSub = WebSocketService.instance.connectionStream.listen((state) {
}); setState(() {
}); _wsState = state;
});
// Timer for Pi temp only (safety critical - bypasses backend) });
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
setState(() { // Timer for Pi temp only (safety critical - bypasses backend)
_piTemp = PiIO.instance.getTemperature(); _piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
}); setState(() {
}); _piTemp = PiIO.instance.getTemperature();
});
// Initialize with any cached data from WebSocketService });
final cachedArduino = WebSocketService.instance.latestArduino;
if (cachedArduino != null) { // Initialize with any cached data from WebSocketService
_voltage = cachedArduino.voltage; final cachedArduino = WebSocketService.instance.latestArduino;
_rpm = cachedArduino.rpm; if (cachedArduino != null) {
_engineTemp = cachedArduino.engTemp; _voltage = cachedArduino.voltage;
_gear = cachedArduino.gear; _rpm = cachedArduino.rpm;
_roll = cachedArduino.roll; _engineTemp = cachedArduino.engTemp;
_pitch = cachedArduino.pitch; _gear = cachedArduino.gear;
_ax = cachedArduino.ax; _roll = cachedArduino.roll;
_ay = cachedArduino.ay; _pitch = cachedArduino.pitch;
} _ax = cachedArduino.ax;
_ay = cachedArduino.ay;
final cachedGps = WebSocketService.instance.latestGps; }
if (cachedGps != null) {
_gpsSpeed = cachedGps.speed; final cachedGps = WebSocketService.instance.latestGps;
_gpsTrack = cachedGps.track; if (cachedGps != null) {
_gpsSatellites = cachedGps.satellites; _gpsSpeed = cachedGps.speed;
} _gpsTrack = cachedGps.track;
_gpsSatellites = cachedGps.satellites;
_wsState = WebSocketService.instance.connectionState; _gpsState = cachedGps.gpsState;
}
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
_lteSignal = null; _wsState = WebSocketService.instance.connectionState;
// DEBUG: flip-flop theme + navigator every 2s // Placeholder: LTE signal (TODO: wire up when LTE service exists)
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); _lteSignal = null;
}
// DEBUG: flip-flop theme + navigator every 2s
@override TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
void dispose() { }
_piTempTimer?.cancel();
_arduinoSub?.cancel(); @override
_gpsSub?.cancel(); void dispose() {
_connectionSub?.cancel(); _piTempTimer?.cancel();
TestFlipFlopService.instance.stop(); _arduinoSub?.cancel();
super.dispose(); _gpsSub?.cancel();
} _connectionSub?.cancel();
TestFlipFlopService.instance.stop();
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6" super.dispose();
String _formatGear(int? gear) { }
if (gear == null) return '';
if (gear == 0) return 'N'; /// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
return gear.toString(); String _formatGear(int? gear) {
} if (gear == null) return '';
if (gear == 0) return 'N';
/// Format nullable int for display return gear.toString();
String _formatInt(int? value) => value?.toString() ?? ''; }
/// Format nullable double for display with decimal places /// Format nullable int for display
String _formatDouble(double? value, [int decimals = 1]) { String _formatInt(int? value) => value?.toString() ?? '';
if (value == null) return '';
return value.toStringAsFixed(decimals); /// Format nullable double for display with decimal places
} String _formatDouble(double? value, [int decimals = 1]) {
if (value == null) return '';
@override return value.toStringAsFixed(decimals);
Widget build(BuildContext context) { }
final theme = AppTheme.of(context);
@override
return Scaffold( Widget build(BuildContext context) {
backgroundColor: theme.background, final theme = AppTheme.of(context);
body: Padding(
padding: const EdgeInsets.all(16), return Scaffold(
child: Row( backgroundColor: theme.background,
children: [ body: Padding(
// Left side: All dashboard widgets (flex: 2) padding: const EdgeInsets.all(16),
Expanded( child: Row(
flex: 2, children: [
child: Column( // Left side: All dashboard widgets (flex: 2)
crossAxisAlignment: CrossAxisAlignment.stretch, Expanded(
children: [ flex: 2,
// System status bar child: Column(
SystemBar( crossAxisAlignment: CrossAxisAlignment.stretch,
gpsSatellites: _gpsSatellites, children: [
lteSignal: _lteSignal, // System status bar
piTemp: _piTemp, SystemBar(
voltage: _voltage, gpsSatellites: _gpsSatellites,
wsState: _wsState, gpsState: _gpsState,
), lteSignal: _lteSignal,
piTemp: _piTemp,
// Main content area - big widgets voltage: _voltage,
Expanded( wsState: _wsState,
flex: 7, ),
child: Row(
children: [ // Main content area - big widgets
// Attitude indicator (whiskey mark) Expanded(
Expanded( flex: 7,
child: WhiskeyMark( child: Row(
roll: _roll, children: [
pitch: _pitch, // Attitude indicator (whiskey mark)
), Expanded(
), child: WhiskeyMark(
Expanded( roll: _roll,
child: AccelGraph( pitch: _pitch,
ax: _dynamicAx, // Gravity-compensated lateral ),
ay: _dynamicAy, // Gravity-compensated longitudinal ),
maxG: 0.8, Expanded(
ghostTrackPeriod: const Duration(seconds: 4), child: AccelGraph(
), ax: _dynamicAx, // Gravity-compensated lateral
) ay: _dynamicAy, // Gravity-compensated longitudinal
], maxG: 0.8,
), ghostTrackPeriod: const Duration(seconds: 4),
), ),
)
// Bottom stats row ],
Expanded( ),
flex: 3, ),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, // Bottom stats row
children: [ Expanded(
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), flex: 3,
GpsCompass(heading: _gpsTrack), child: Row(
StatBox(value: _formatGear(_gear), label: 'GEAR'), mainAxisAlignment: MainAxisAlignment.spaceEvenly,
], children: [
), StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
), GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
], StatBox(value: _formatGear(_gear), label: 'GEAR'),
), ],
), ),
),
const SizedBox(width: 32), ],
),
// Right side: Navigator on top, debug console below ),
Expanded(
flex: 1, const SizedBox(width: 32),
child: Column(
children: [ // Right side: Navigator on top, debug console below
// Navigator Expanded(
Expanded( flex: 1,
flex: 3, child: Column(
child: Center( children: [
child: NavigatorWidget(key: _navigatorKey), // Navigator
), Expanded(
), flex: 3,
// Debug console child: Center(
Expanded( child: NavigatorWidget(key: _navigatorKey),
flex: 1, ),
child: ),
DebugConsole( // Debug console
messageStream: WebSocketService.instance.debugStream, Expanded(
initialMessages: WebSocketService.instance.debugMessages, flex: 1,
maxLines: 6, child:
title: 'WebSocket messages', DebugConsole(
), messageStream: WebSocketService.instance.debugStream,
), initialMessages: WebSocketService.instance.debugMessages,
], maxLines: 6,
), title: 'WebSocket messages',
), ),
], ),
), ],
), ),
); ),
} ],
} ),
),
);
}
}

View File

@@ -1,161 +1,163 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU) /// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
class ArduinoData { class ArduinoData {
final double? voltage; final double? voltage;
final int? rpm; final int? rpm;
final int? engTemp; final int? engTemp;
final int? gear; // 0 = neutral, 1-6 = gear final int? gear; // 0 = neutral, 1-6 = gear
final double? roll; // Euler angle in degrees (negative = left, positive = right) final double? roll; // Euler angle in degrees (negative = left, positive = right)
final double? pitch; // Euler angle in degrees (negative = nose down) final double? pitch; // Euler angle in degrees (negative = nose down)
final double? ax; // Lateral acceleration (g) final double? ax; // Lateral acceleration (g)
final double? ay; // Longitudinal acceleration (g) final double? ay; // Longitudinal acceleration (g)
final double? az; // Vertical acceleration (g) final double? az; // Vertical acceleration (g)
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az}); ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
factory ArduinoData.fromJson(Map<String, dynamic> json) { factory ArduinoData.fromJson(Map<String, dynamic> json) {
return ArduinoData( return ArduinoData(
voltage: (json['voltage'] as num?)?.toDouble(), voltage: (json['voltage'] as num?)?.toDouble(),
rpm: (json['rpm'] as num?)?.toInt(), rpm: (json['rpm'] as num?)?.toInt(),
engTemp: (json['eng_temp'] as num?)?.toInt(), engTemp: (json['eng_temp'] as num?)?.toInt(),
gear: (json['gear'] as num?)?.toInt(), gear: (json['gear'] as num?)?.toInt(),
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
pitch: (json['pitch'] as num?)?.toDouble(), pitch: (json['pitch'] as num?)?.toDouble(),
ax: (json['ax'] as num?)?.toDouble(), ax: (json['ax'] as num?)?.toDouble(),
ay: (json['ay'] as num?)?.toDouble(), ay: (json['ay'] as num?)?.toDouble(),
az: (json['az'] as num?)?.toDouble(), az: (json['az'] as num?)?.toDouble(),
); );
} }
} }
/// Data from GPS /// Data from GPS
class GpsData { class GpsData {
final double? lat; final double? lat;
final double? lon; final double? lon;
final double? speed; // m/s final double? speed; // m/s
final double? alt; final double? alt;
final double? track; final double? track;
final int? mode; // 0=no fix, 2=2D, 3=3D final int? mode; // 0=no fix, 2=2D, 3=3D
final int? satellites; final int? satellites;
final String? gpsState; // "acquiring", "fix", or "lost"
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites});
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState});
factory GpsData.fromJson(Map<String, dynamic> json) {
return GpsData( factory GpsData.fromJson(Map<String, dynamic> json) {
lat: (json['lat'] as num?)?.toDouble(), return GpsData(
lon: (json['lon'] as num?)?.toDouble(), lat: (json['lat'] as num?)?.toDouble(),
speed: (json['speed'] as num?)?.toDouble(), lon: (json['lon'] as num?)?.toDouble(),
alt: (json['alt'] as num?)?.toDouble(), speed: (json['speed'] as num?)?.toDouble(),
track: (json['track'] as num?)?.toDouble(), alt: (json['alt'] as num?)?.toDouble(),
mode: (json['mode'] as num?)?.toInt(), track: (json['track'] as num?)?.toDouble(),
satellites: (json['satellites'] as num?)?.toInt(), mode: (json['mode'] as num?)?.toInt(),
); satellites: (json['satellites'] as num?)?.toInt(),
} gpsState: json['gps_state'] as String?,
} );
}
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return }
///
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data. /// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
class BackendService { ///
BackendService._() { /// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
// Kick off initial fetches class BackendService {
_refreshArduino(); BackendService._() {
_refreshGps(); // Kick off initial fetches
} _refreshArduino();
static final instance = BackendService._(); _refreshGps();
}
static const _baseUrl = 'http://127.0.0.1:5000'; static final instance = BackendService._();
static const _timeout = Duration(seconds: 2);
static const _baseUrl = 'http://127.0.0.1:5000';
// Caches static const _timeout = Duration(seconds: 2);
ArduinoData? _arduinoCache;
GpsData? _gpsCache; // Caches
bool _connected = false; ArduinoData? _arduinoCache;
GpsData? _gpsCache;
// In-progress flags (prevent duplicate requests) bool _connected = false;
bool _arduinoFetchInProgress = false;
bool _gpsFetchInProgress = false; // In-progress flags (prevent duplicate requests)
bool _arduinoFetchInProgress = false;
/// Whether backend is reachable bool _gpsFetchInProgress = false;
bool get isConnected => _connected;
/// Whether backend is reachable
/// Get Arduino data (sync, returns cached value) bool get isConnected => _connected;
ArduinoData? getArduinoData() {
if (!_arduinoFetchInProgress) { /// Get Arduino data (sync, returns cached value)
_refreshArduino(); ArduinoData? getArduinoData() {
} if (!_arduinoFetchInProgress) {
return _arduinoCache; _refreshArduino();
} }
return _arduinoCache;
/// Get GPS data (sync, returns cached value) }
GpsData? getGpsData() {
if (!_gpsFetchInProgress) { /// Get GPS data (sync, returns cached value)
_refreshGps(); GpsData? getGpsData() {
} if (!_gpsFetchInProgress) {
return _gpsCache; _refreshGps();
} }
return _gpsCache;
/// Background fetch for Arduino data }
Future<void> _refreshArduino() async {
if (_arduinoFetchInProgress) return; /// Background fetch for Arduino data
_arduinoFetchInProgress = true; Future<void> _refreshArduino() async {
if (_arduinoFetchInProgress) return;
try { _arduinoFetchInProgress = true;
final response = await http
.get(Uri.parse('$_baseUrl/arduino')) try {
.timeout(_timeout); final response = await http
.get(Uri.parse('$_baseUrl/arduino'))
if (response.statusCode == 200) { .timeout(_timeout);
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value if (response.statusCode == 200) {
if (!json.containsKey('error')) { final json = jsonDecode(response.body) as Map<String, dynamic>;
_arduinoCache = ArduinoData.fromJson(json); // Skip if backend returns error (no data yet) - keep cached value
} if (!json.containsKey('error')) {
_connected = true; _arduinoCache = ArduinoData.fromJson(json);
} }
// Non-200: keep cached data, just mark disconnected _connected = true;
} catch (e) { }
// Network error, timeout, etc - keep cached data for transient hiccups // Non-200: keep cached data, just mark disconnected
_connected = false; } catch (e) {
} finally { // Network error, timeout, etc - keep cached data for transient hiccups
_arduinoFetchInProgress = false; _connected = false;
} } finally {
} _arduinoFetchInProgress = false;
}
/// Background fetch for GPS data }
Future<void> _refreshGps() async {
if (_gpsFetchInProgress) return; /// Background fetch for GPS data
_gpsFetchInProgress = true; Future<void> _refreshGps() async {
if (_gpsFetchInProgress) return;
try { _gpsFetchInProgress = true;
final response = await http
.get(Uri.parse('$_baseUrl/gps')) try {
.timeout(_timeout); final response = await http
.get(Uri.parse('$_baseUrl/gps'))
if (response.statusCode == 200) { .timeout(_timeout);
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value if (response.statusCode == 200) {
if (!json.containsKey('error')) { final json = jsonDecode(response.body) as Map<String, dynamic>;
_gpsCache = GpsData.fromJson(json); // Skip if backend returns error (no data yet) - keep cached value
} if (!json.containsKey('error')) {
_connected = true; _gpsCache = GpsData.fromJson(json);
} }
// Non-200: keep cached data, just mark disconnected _connected = true;
} catch (e) { }
// Network error, timeout, etc - keep cached data for transient hiccups // Non-200: keep cached data, just mark disconnected
_connected = false; } catch (e) {
} finally { // Network error, timeout, etc - keep cached data for transient hiccups
_gpsFetchInProgress = false; _connected = false;
} } finally {
} _gpsFetchInProgress = false;
}
/// Force clear all caches }
void clearCache() {
_arduinoCache = null; /// Force clear all caches
_gpsCache = null; void clearCache() {
_connected = false; _arduinoCache = null;
} _gpsCache = null;
} _connected = false;
}
}

View File

@@ -1,332 +1,332 @@
import 'dart:async'; 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'; import 'theme_service.dart';
/// Connection state for WebSocket /// Connection state for WebSocket
enum WsConnectionState { enum WsConnectionState {
disconnected, disconnected,
connecting, connecting,
connected, connected,
} }
/// Acknowledgment from backend for a command /// Acknowledgment from backend for a command
class CommandAck { class CommandAck {
final String id; final String id;
final String status; final String status;
final String? error; final String? error;
final String? extra; final String? extra;
CommandAck({ CommandAck({
required this.id, required this.id,
required this.status, required this.status,
this.error, this.error,
this.extra, this.extra,
}); });
bool get isSuccess => status == 'ok' || status == 'sent'; bool get isSuccess => status == 'ok' || status == 'sent';
} }
/// Alert from backend /// Alert from backend
class BackendAlert { class BackendAlert {
final String type; final String type;
final String message; final String message;
BackendAlert({required this.type, required this.message}); BackendAlert({required this.type, required this.message});
} }
/// Backend status (connection states of GPS/Arduino) /// Backend status (connection states of GPS/Arduino)
class BackendStatus { class BackendStatus {
final bool gpsConnected; final bool gpsConnected;
final bool arduinoConnected; final bool arduinoConnected;
BackendStatus({required this.gpsConnected, required this.arduinoConnected}); BackendStatus({required this.gpsConnected, required this.arduinoConnected});
} }
/// WebSocket service for real-time data from backend. /// WebSocket service for real-time data from backend.
/// ///
/// Replaces HTTP polling with push-based updates. /// Replaces HTTP polling with push-based updates.
/// Maintains dual logical channels: /// Maintains dual logical channels:
/// - Telemetry: arduino/gps data streams (throttled by backend) /// - Telemetry: arduino/gps data streams (throttled by backend)
/// - Control: button commands and acknowledgments /// - Control: button commands and acknowledgments
class WebSocketService { class WebSocketService {
WebSocketService._() { WebSocketService._() {
_setupStreams(); _setupStreams();
} }
static final instance = WebSocketService._(); static final instance = WebSocketService._();
static const _serverUrl = 'http://127.0.0.1:5000'; static const _serverUrl = 'http://127.0.0.1:5000';
io.Socket? _socket; io.Socket? _socket;
WsConnectionState _connectionState = WsConnectionState.disconnected; WsConnectionState _connectionState = WsConnectionState.disconnected;
Timer? _reconnectTimer; Timer? _reconnectTimer;
// Latest values for sync access (backward compat) // Latest values for sync access (backward compat)
ArduinoData? _latestArduino; ArduinoData? _latestArduino;
GpsData? _latestGps; GpsData? _latestGps;
BackendStatus? _latestStatus; BackendStatus? _latestStatus;
// Stream controllers // Stream controllers
late StreamController<ArduinoData> _arduinoController; late StreamController<ArduinoData> _arduinoController;
late StreamController<GpsData> _gpsController; late StreamController<GpsData> _gpsController;
late StreamController<BackendStatus> _statusController; late StreamController<BackendStatus> _statusController;
late StreamController<CommandAck> _ackController; late StreamController<CommandAck> _ackController;
late StreamController<BackendAlert> _alertController; late StreamController<BackendAlert> _alertController;
late StreamController<WsConnectionState> _connectionController; late StreamController<WsConnectionState> _connectionController;
late StreamController<String> _debugController; late StreamController<String> _debugController;
// Debug message buffer // Debug message buffer
static const int _maxDebugMessages = 50; static const int _maxDebugMessages = 50;
final List<String> _debugMessages = []; final List<String> _debugMessages = [];
void _setupStreams() { void _setupStreams() {
_arduinoController = StreamController<ArduinoData>.broadcast(); _arduinoController = StreamController<ArduinoData>.broadcast();
_gpsController = StreamController<GpsData>.broadcast(); _gpsController = StreamController<GpsData>.broadcast();
_statusController = StreamController<BackendStatus>.broadcast(); _statusController = StreamController<BackendStatus>.broadcast();
_ackController = StreamController<CommandAck>.broadcast(); _ackController = StreamController<CommandAck>.broadcast();
_alertController = StreamController<BackendAlert>.broadcast(); _alertController = StreamController<BackendAlert>.broadcast();
_connectionController = StreamController<WsConnectionState>.broadcast(); _connectionController = StreamController<WsConnectionState>.broadcast();
_debugController = StreamController<String>.broadcast(); _debugController = StreamController<String>.broadcast();
} }
/// Log a debug message (adds to buffer and stream) /// Log a debug message (adds to buffer and stream)
void _log(String message) { void _log(String message) {
_debugMessages.add(message); _debugMessages.add(message);
if (_debugMessages.length > _maxDebugMessages) { if (_debugMessages.length > _maxDebugMessages) {
_debugMessages.removeAt(0); _debugMessages.removeAt(0);
} }
_debugController.add(message); _debugController.add(message);
} }
// --- Public API: Streams --- // --- Public API: Streams ---
/// Stream of Arduino telemetry updates /// Stream of Arduino telemetry updates
Stream<ArduinoData> get arduinoStream => _arduinoController.stream; Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
/// Stream of GPS updates /// Stream of GPS updates
Stream<GpsData> get gpsStream => _gpsController.stream; Stream<GpsData> get gpsStream => _gpsController.stream;
/// Stream of backend status updates /// Stream of backend status updates
Stream<BackendStatus> get statusStream => _statusController.stream; Stream<BackendStatus> get statusStream => _statusController.stream;
/// Stream of command acknowledgments /// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream; Stream<CommandAck> get ackStream => _ackController.stream;
/// Stream of alerts from backend /// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream; Stream<BackendAlert> get alertStream => _alertController.stream;
/// Stream of connection state changes /// Stream of connection state changes
Stream<WsConnectionState> get connectionStream => _connectionController.stream; Stream<WsConnectionState> get connectionStream => _connectionController.stream;
/// Stream of debug log messages /// Stream of debug log messages
Stream<String> get debugStream => _debugController.stream; Stream<String> get debugStream => _debugController.stream;
/// Current debug message buffer (for initial display) /// Current debug message buffer (for initial display)
List<String> get debugMessages => List.unmodifiable(_debugMessages); List<String> get debugMessages => List.unmodifiable(_debugMessages);
// --- Public API: Sync getters (backward compat) --- // --- Public API: Sync getters (backward compat) ---
/// Current connection state /// Current connection state
WsConnectionState get connectionState => _connectionState; WsConnectionState get connectionState => _connectionState;
/// Whether connected to backend /// Whether connected to backend
bool get isConnected => _connectionState == WsConnectionState.connected; bool get isConnected => _connectionState == WsConnectionState.connected;
/// Latest Arduino data (may be null if not yet received) /// Latest Arduino data (may be null if not yet received)
ArduinoData? get latestArduino => _latestArduino; ArduinoData? get latestArduino => _latestArduino;
/// Latest GPS data (may be null if not yet received) /// Latest GPS data (may be null if not yet received)
GpsData? get latestGps => _latestGps; GpsData? get latestGps => _latestGps;
/// Latest backend status /// Latest backend status
BackendStatus? get latestStatus => _latestStatus; BackendStatus? get latestStatus => _latestStatus;
// --- Public API: Connection --- // --- Public API: Connection ---
/// Connect to backend WebSocket /// Connect to backend WebSocket
void connect() { void connect() {
if (_socket != null) return; // Already connected or connecting if (_socket != null) return; // Already connected or connecting
_setConnectionState(WsConnectionState.connecting); _setConnectionState(WsConnectionState.connecting);
_socket = io.io(_serverUrl, <String, dynamic>{ _socket = io.io(_serverUrl, <String, dynamic>{
'transports': ['websocket'], 'transports': ['websocket'],
'autoConnect': true, 'autoConnect': true,
'reconnection': false, // We handle reconnection ourselves 'reconnection': false, // We handle reconnection ourselves
}); });
_socket!.onConnect((_) { _socket!.onConnect((_) {
_log('connected'); _log('connected');
_setConnectionState(WsConnectionState.connected); _setConnectionState(WsConnectionState.connected);
_cancelReconnect(); _cancelReconnect();
}); });
_socket!.onDisconnect((_) { _socket!.onDisconnect((_) {
_log('disconnected'); _log('disconnected');
_setConnectionState(WsConnectionState.disconnected); _setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect(); _scheduleReconnect();
}); });
_socket!.onConnectError((error) { _socket!.onConnectError((error) {
_log('error: $error'); _log('error: $error');
_setConnectionState(WsConnectionState.disconnected); _setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect(); _scheduleReconnect();
}); });
_socket!.onError((error) { _socket!.onError((error) {
_log('error: $error'); _log('error: $error');
}); });
// --- Telemetry Events --- // --- Telemetry Events ---
_socket!.on('arduino', (data) { _socket!.on('arduino', (data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final arduino = ArduinoData.fromJson(data); final arduino = ArduinoData.fromJson(data);
_latestArduino = arduino; _latestArduino = arduino;
_arduinoController.add(arduino); _arduinoController.add(arduino);
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : ''; final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
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) // Theme switch piggybacks on arduino packets (edge-triggered from backend)
if (data.containsKey('theme_switch')) { if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool; final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark); ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"}'); _log('theme: ${isDark ? "dark" : "light"}');
} }
} }
}); });
_socket!.on('gps', (data) { _socket!.on('gps', (data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final gps = GpsData.fromJson(data); final gps = GpsData.fromJson(data);
_latestGps = gps; _latestGps = gps;
_gpsController.add(gps); _gpsController.add(gps);
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}'); _log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
} }
}); });
_socket!.on('status', (data) { _socket!.on('status', (data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final status = BackendStatus( final status = BackendStatus(
gpsConnected: data['gps_connected'] ?? false, gpsConnected: data['gps_connected'] ?? false,
arduinoConnected: data['arduino_connected'] ?? false, arduinoConnected: data['arduino_connected'] ?? false,
); );
_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 // Initial theme state comes with status on connect
if (data.containsKey('theme_switch')) { if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool; final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark); ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"} (initial)'); _log('theme: ${isDark ? "dark" : "light"} (initial)');
} }
} }
}); });
// --- Control Events --- // --- Control Events ---
_socket!.on('ack', (data) { _socket!.on('ack', (data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final ack = CommandAck( final ack = CommandAck(
id: data['id'] ?? 'unknown', id: data['id'] ?? 'unknown',
status: data['status'] ?? 'unknown', status: data['status'] ?? 'unknown',
error: data['error'], error: data['error'],
extra: data['extra'], extra: data['extra'],
); );
_ackController.add(ack); _ackController.add(ack);
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}'); _log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
} }
}); });
_socket!.on('alert', (data) { _socket!.on('alert', (data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final alert = BackendAlert( final alert = BackendAlert(
type: data['type'] ?? 'unknown', type: data['type'] ?? 'unknown',
message: data['message'] ?? '', message: data['message'] ?? '',
); );
_alertController.add(alert); _alertController.add(alert);
_log('alert: [${alert.type}] ${alert.message}'); _log('alert: [${alert.type}] ${alert.message}');
} }
}); });
_socket!.connect(); _socket!.connect();
} }
/// Disconnect from backend /// Disconnect from backend
void disconnect() { void disconnect() {
_cancelReconnect(); _cancelReconnect();
_socket?.disconnect(); _socket?.disconnect();
_socket?.dispose(); _socket?.dispose();
_socket = null; _socket = null;
_setConnectionState(WsConnectionState.disconnected); _setConnectionState(WsConnectionState.disconnected);
} }
// --- Public API: Commands --- // --- Public API: Commands ---
/// Send button event to backend /// Send button event to backend
void sendButton(String id, String action, [Map<String, dynamic>? params]) { void sendButton(String id, String action, [Map<String, dynamic>? params]) {
if (_socket == null || !isConnected) { if (_socket == null || !isConnected) {
print('[WS] Cannot send button, not connected'); print('[WS] Cannot send button, not connected');
return; return;
} }
final data = <String, dynamic>{ final data = <String, dynamic>{
'id': id, 'id': id,
'action': action, 'action': action,
...?params, ...?params,
}; };
_socket!.emit('button', data); _socket!.emit('button', data);
} }
/// Send emergency signal to backend /// Send emergency signal to backend
void sendEmergency(String type) { void sendEmergency(String type) {
if (_socket == null) { if (_socket == null) {
print('[WS] Cannot send emergency, not connected'); print('[WS] Cannot send emergency, not connected');
return; return;
} }
// Emergency should be sent even if not fully connected // Emergency should be sent even if not fully connected
_socket!.emit('emergency', {'type': type}); _socket!.emit('emergency', {'type': type});
} }
// --- Private --- // --- Private ---
void _setConnectionState(WsConnectionState state) { void _setConnectionState(WsConnectionState state) {
if (_connectionState != state) { if (_connectionState != state) {
_connectionState = state; _connectionState = state;
_connectionController.add(state); _connectionController.add(state);
} }
} }
void _scheduleReconnect() { void _scheduleReconnect() {
_cancelReconnect(); _cancelReconnect();
_reconnectTimer = Timer(const Duration(seconds: 3), () { _reconnectTimer = Timer(const Duration(seconds: 3), () {
print('[WS] Attempting reconnect...'); print('[WS] Attempting reconnect...');
_socket?.dispose(); _socket?.dispose();
_socket = null; _socket = null;
connect(); connect();
}); });
} }
void _cancelReconnect() { void _cancelReconnect() {
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_reconnectTimer = null; _reconnectTimer = null;
} }
/// Dispose all resources (call on app shutdown) /// Dispose all resources (call on app shutdown)
void dispose() { void dispose() {
disconnect(); disconnect();
_arduinoController.close(); _arduinoController.close();
_gpsController.close(); _gpsController.close();
_statusController.close(); _statusController.close();
_ackController.close(); _ackController.close();
_alertController.close(); _alertController.close();
_connectionController.close(); _connectionController.close();
_debugController.close(); _debugController.close();
} }
} }

View File

@@ -1,119 +1,119 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Generic debug console that displays streaming log messages. /// Generic debug console that displays streaming log messages.
/// ///
/// Can be wired to any message source via [messageStream] and [initialMessages]. /// Can be wired to any message source via [messageStream] and [initialMessages].
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc. /// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
class DebugConsole extends StatefulWidget { class DebugConsole extends StatefulWidget {
/// Stream of new messages to display /// Stream of new messages to display
final Stream<String> messageStream; final Stream<String> messageStream;
/// Initial messages to populate (e.g., from a buffer) /// Initial messages to populate (e.g., from a buffer)
final List<String> initialMessages; final List<String> initialMessages;
/// Maximum lines to display /// Maximum lines to display
final int maxLines; final int maxLines;
/// Optional title for the console (shown in title bar) /// Optional title for the console (shown in title bar)
final String? title; final String? title;
const DebugConsole({ const DebugConsole({
super.key, super.key,
required this.messageStream, required this.messageStream,
this.initialMessages = const [], this.initialMessages = const [],
this.maxLines = 8, this.maxLines = 8,
this.title, this.title,
}); });
@override @override
State<DebugConsole> createState() => _DebugConsoleState(); State<DebugConsole> createState() => _DebugConsoleState();
} }
class _DebugConsoleState extends State<DebugConsole> { class _DebugConsoleState extends State<DebugConsole> {
final List<String> _messages = []; final List<String> _messages = [];
StreamSubscription<String>? _sub; StreamSubscription<String>? _sub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize with existing buffer // Initialize with existing buffer
_messages.addAll(widget.initialMessages); _messages.addAll(widget.initialMessages);
_trimMessages(); _trimMessages();
// Subscribe to new messages // Subscribe to new messages
_sub = widget.messageStream.listen((msg) { _sub = widget.messageStream.listen((msg) {
setState(() { setState(() {
_messages.add(msg); _messages.add(msg);
_trimMessages(); _trimMessages();
}); });
}); });
} }
void _trimMessages() { void _trimMessages() {
while (_messages.length > widget.maxLines) { while (_messages.length > widget.maxLines) {
_messages.removeAt(0); _messages.removeAt(0);
} }
} }
@override @override
void dispose() { void dispose() {
_sub?.cancel(); _sub?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.background.withAlpha(64), color: theme.background.withAlpha(64),
border: Border.all(color: theme.subdued, width: 2), border: Border.all(color: theme.subdued, width: 2),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Title bar (optional) // Title bar (optional)
if (widget.title != null) if (widget.title != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: theme.subdued, width: 1), bottom: BorderSide(color: theme.subdued, width: 1),
), ),
), ),
child: Text( child: Text(
widget.title!, widget.title!,
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 24, fontSize: 24,
color: theme.subdued, color: theme.subdued,
), ),
), ),
), ),
// Console content // Console content
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
_messages.isEmpty ? '(no messages)' : _messages.join('\n'), _messages.isEmpty ? '(no messages)' : _messages.join('\n'),
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 30, fontSize: 30,
color: theme.foreground, color: theme.foreground,
height: 1.0, height: 1.0,
), ),
), ),
), ),
), ),
], ],
), ),
); );
} }
} }

View File

@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
class GpsCompass extends StatelessWidget { class GpsCompass extends StatelessWidget {
final double? heading; final double? heading;
final String? gpsState; // "acquiring", "fix", "lost"
const GpsCompass({super.key, this.heading}); const GpsCompass({super.key, this.heading, this.gpsState});
bool get _hasSignal => heading != null; bool get _hasSignal => heading != null;
bool get _isAcquiring => gpsState == 'acquiring';
String get _displayHeading { String get _displayHeading {
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal if (!_hasSignal) return 'N/A';
return '${(heading! % 360).round()}'; // No need for the degree symbol return '${(heading! % 360).round()}';
} }
String get _compassDirection { String get _compassDirection {
@@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget {
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: Text( child: Text(
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A", _hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
style: TextStyle( style: TextStyle(
fontSize: 80, fontSize: 80,
color: theme.subdued, color: theme.subdued,

View File

@@ -1,100 +1,100 @@
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/config_service.dart'; import '../services/config_service.dart';
/// Displays the navigator character with emotion support. /// Displays the navigator character with emotion support.
/// ///
/// Use a GlobalKey to control emotions from parent: /// Use a GlobalKey to control emotions from parent:
/// ```dart /// ```dart
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>(); /// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
/// NavigatorWidget(key: _navigatorKey) /// NavigatorWidget(key: _navigatorKey)
/// // Later: /// // Later:
/// _navigatorKey.currentState?.setEmotion('happy'); /// _navigatorKey.currentState?.setEmotion('happy');
/// ``` /// ```
class NavigatorWidget extends StatefulWidget { class NavigatorWidget extends StatefulWidget {
const NavigatorWidget({super.key}); const NavigatorWidget({super.key});
@override @override
State<NavigatorWidget> createState() => NavigatorWidgetState(); State<NavigatorWidget> createState() => NavigatorWidgetState();
} }
class NavigatorWidgetState extends State<NavigatorWidget> class NavigatorWidgetState extends State<NavigatorWidget>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
String _emotion = 'default'; String _emotion = 'default';
late AnimationController _shakeController; late AnimationController _shakeController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_shakeController = AnimationController( _shakeController = AnimationController(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
vsync: this, vsync: this,
); );
// Auto-reset to default after surprise animation completes // Auto-reset to default after surprise animation completes
_shakeController.addStatusListener((status) { _shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed && _emotion == 'surprise') { if (status == AnimationStatus.completed && _emotion == 'surprise') {
setState(() => _emotion = 'default'); setState(() => _emotion = 'default');
} }
}); });
} }
@override @override
void dispose() { void dispose() {
_shakeController.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
/// Change the displayed emotion. /// Change the displayed emotion.
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
void setEmotion(String emotion) { void setEmotion(String emotion) {
if (emotion != _emotion) { if (emotion != _emotion) {
setState(() => _emotion = emotion); setState(() => _emotion = emotion);
if (emotion == 'surprise') { if (emotion == 'surprise') {
_shakeController.forward(from: 0); _shakeController.forward(from: 0);
} }
} }
} }
/// Reset to default emotion /// Reset to default emotion
void reset() => setEmotion('default'); void reset() => setEmotion('default');
/// Current emotion /// Current emotion
String get emotion => _emotion; String get emotion => _emotion;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final config = ConfigService.instance; final config = ConfigService.instance;
final basePath = '${config.assetsPath}/navigator/${config.navigator}'; final basePath = '${config.assetsPath}/navigator/${config.navigator}';
final image = Image.file( final image = Image.file(
File('$basePath/$_emotion.png'), File('$basePath/$_emotion.png'),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
// Fallback: try default.png if specific emotion missing // Fallback: try default.png if specific emotion missing
if (_emotion != 'default') { if (_emotion != 'default') {
return Image.file( return Image.file(
File('$basePath/default.png'), File('$basePath/default.png'),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(), errorBuilder: (_, __, ___) => const SizedBox.shrink(),
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
); );
// Shake animation for surprise // Shake animation for surprise
return AnimatedBuilder( return AnimatedBuilder(
animation: _shakeController, animation: _shakeController,
child: image, child: image,
builder: (context, child) { builder: (context, child) {
final shake = sin(_shakeController.value * pi * 6) * 25 * final shake = sin(_shakeController.value * pi * 6) * 25 *
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay (1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
return Transform.translate( return Transform.translate(
offset: Offset(shake, 0), offset: Offset(shake, 0),
child: child, child: child,
); );
}, },
); );
} }
} }

View File

@@ -1,170 +1,174 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/websocket_service.dart'; import '../services/websocket_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Android-style persistent status bar for system indicators. /// Android-style persistent status bar for system indicators.
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. /// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
class SystemBar extends StatelessWidget { class SystemBar extends StatelessWidget {
final int? gpsSatellites; // null = disconnected final int? gpsSatellites; // null = disconnected
final int? lteSignal; // null = disconnected, 0-4 bars final String? gpsState; // "acquiring", "fix", "lost"
final double? piTemp; // null = unavailable final int? lteSignal; // null = disconnected, 0-4 bars
final double? voltage; // null = Arduino disconnected final double? piTemp; // null = unavailable
final WsConnectionState? wsState; // WebSocket connection state final double? voltage; // null = Arduino disconnected
final WsConnectionState? wsState; // WebSocket connection state
const SystemBar({
super.key, const SystemBar({
this.gpsSatellites, super.key,
this.lteSignal, this.gpsSatellites,
this.piTemp, this.gpsState,
this.voltage, this.lteSignal,
this.wsState, this.piTemp,
}); this.voltage,
this.wsState,
/// Get WebSocket status text and abnormal flag });
(String, bool) _wsStatus() {
switch (wsState) { /// Get WebSocket status text and abnormal flag
case WsConnectionState.connected: (String, bool) _wsStatus() {
return ('OK', false); switch (wsState) {
case WsConnectionState.connecting: case WsConnectionState.connected:
return ('...', true); return ('OK', false);
case WsConnectionState.disconnected: case WsConnectionState.connecting:
case null: return ('...', true);
return ('OFF', true); case WsConnectionState.disconnected:
} case null:
} return ('OFF', true);
}
@override }
Widget build(BuildContext context) {
final theme = AppTheme.of(context); @override
final (wsText, wsAbnormal) = _wsStatus(); Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Expanded( final (wsText, wsAbnormal) = _wsStatus();
flex: 1,
child: LayoutBuilder( return Expanded(
builder: (context, constraints) { flex: 1,
// Font sizes relative to bar height child: LayoutBuilder(
final labelSize = constraints.maxHeight * 0.5; builder: (context, constraints) {
final valueSize = constraints.maxHeight * 0.5; // Font sizes relative to bar height
final labelSize = constraints.maxHeight * 0.5;
return Container( final valueSize = constraints.maxHeight * 0.5;
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row( return Container(
crossAxisAlignment: CrossAxisAlignment.center, padding: const EdgeInsets.symmetric(horizontal: 24),
children: [ child: Row(
// Left group: WS, GPS, LTE crossAxisAlignment: CrossAxisAlignment.center,
_Indicator( children: [
label: 'WS', // Left group: WS, GPS, LTE
value: wsText, _Indicator(
isAbnormal: wsAbnormal, label: 'WS',
alignment: Alignment.centerLeft, value: wsText,
labelSize: labelSize, isAbnormal: wsAbnormal,
valueSize: valueSize, alignment: Alignment.centerLeft,
flex: 2, labelSize: labelSize,
theme: theme, valueSize: valueSize,
), flex: 2,
_Indicator( theme: theme,
label: 'GPS', ),
value: gpsSatellites?.toString() ?? 'N/A', _Indicator(
isAbnormal: gpsSatellites == null || gpsSatellites == 0, label: 'GPS',
alignment: Alignment.centerLeft, value: gpsState == 'acquiring' ? 'ACQ'
labelSize: labelSize, : gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
valueSize: valueSize, : '0', // lost or unknown
flex: 2, isAbnormal: gpsState != 'fix',
theme: theme, alignment: Alignment.centerLeft,
), labelSize: labelSize,
_Indicator( valueSize: valueSize,
label: 'LTE', flex: 2,
value: lteSignal?.toString() ?? 'N/A', theme: theme,
isAbnormal: lteSignal == null, ),
alignment: Alignment.centerLeft, _Indicator(
labelSize: labelSize, label: 'LTE',
valueSize: valueSize, value: lteSignal?.toString() ?? 'N/A',
flex: 2, isAbnormal: lteSignal == null,
theme: theme, alignment: Alignment.centerLeft,
), labelSize: labelSize,
valueSize: valueSize,
// Right group: Pi, Chassis flex: 2,
_Indicator( theme: theme,
label: 'Pi', ),
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
isAbnormal: piTemp == null || piTemp! > 80, // Right group: Pi, Chassis
alignment: Alignment.centerLeft, _Indicator(
labelSize: labelSize, label: 'Pi',
valueSize: valueSize, value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
flex: 2, isAbnormal: piTemp == null || piTemp! > 80,
theme: theme, alignment: Alignment.centerLeft,
), labelSize: labelSize,
_Indicator( valueSize: valueSize,
label: 'Mains', flex: 2,
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', theme: theme,
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5, ),
alignment: Alignment.centerLeft, _Indicator(
labelSize: labelSize, label: 'Mains',
valueSize: valueSize, value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
flex: 3, isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
theme: theme, alignment: Alignment.centerLeft,
), labelSize: labelSize,
], valueSize: valueSize,
), flex: 3,
); theme: theme,
}, ),
), ],
); ),
} );
} },
),
/// Single status indicator in a fixed-width flex slot. );
class _Indicator extends StatelessWidget { }
final String label; }
final String value;
final bool isAbnormal; /// Single status indicator in a fixed-width flex slot.
final Alignment alignment; class _Indicator extends StatelessWidget {
final double labelSize; final String label;
final double valueSize; final String value;
final int flex; final bool isAbnormal;
final AppTheme theme; final Alignment alignment;
final double labelSize;
const _Indicator({ final double valueSize;
required this.label, final int flex;
required this.value, final AppTheme theme;
required this.isAbnormal,
required this.alignment, const _Indicator({
required this.labelSize, required this.label,
required this.valueSize, required this.value,
required this.flex, required this.isAbnormal,
required this.theme, required this.alignment,
}); required this.labelSize,
required this.valueSize,
@override required this.flex,
Widget build(BuildContext context) { required this.theme,
return Expanded( });
flex: flex,
child: Align( @override
alignment: alignment, Widget build(BuildContext context) {
child: Row( return Expanded(
mainAxisSize: MainAxisSize.min, flex: flex,
crossAxisAlignment: CrossAxisAlignment.baseline, child: Align(
textBaseline: TextBaseline.alphabetic, alignment: alignment,
children: [ child: Row(
Text( mainAxisSize: MainAxisSize.min,
'$label ', crossAxisAlignment: CrossAxisAlignment.baseline,
style: TextStyle( textBaseline: TextBaseline.alphabetic,
fontSize: labelSize, children: [
color: theme.subdued, Text(
), '$label ',
), style: TextStyle(
Text( fontSize: labelSize,
value, color: theme.subdued,
style: TextStyle( ),
fontSize: valueSize, ),
fontFeatures: const [FontFeature.tabularFigures()], Text(
color: isAbnormal ? theme.highlight : theme.foreground, value,
), style: TextStyle(
), fontSize: valueSize,
], fontFeatures: const [FontFeature.tabularFigures()],
), color: isAbnormal ? theme.highlight : theme.foreground,
), ),
); ),
} ],
} ),
),
);
}
}

View File

@@ -1,26 +1,26 @@
name: smartserow_ui name: smartserow_ui
description: Smart Serow embedded UI for Raspberry Pi Zero 2W description: Smart Serow embedded UI for Raspberry Pi Zero 2W
publish_to: 'none' publish_to: 'none'
version: 0.1.0 version: 0.1.0
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.2.0 http: ^1.2.0
socket_io_client: ^2.0.3+1 socket_io_client: ^2.0.3+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true
fonts: fonts:
- family: DIN1451 - family: DIN1451
fonts: fonts:
- asset: assets/fonts/din1451alt.ttf - asset: assets/fonts/din1451alt.ttf

View File

@@ -1,88 +1,88 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""One-click build and deploy for Smart Serow. """One-click build and deploy for Smart Serow.
Combines build.py and deploy.py with sensible defaults. Combines build.py and deploy.py with sensible defaults.
Defaults to --restart since that's usually what you want. Defaults to --restart since that's usually what you want.
""" """
import argparse import argparse
import sys import sys
from pathlib import Path from pathlib import Path
# Import sibling modules # Import sibling modules
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from build import build from build import build
from deploy import deploy from deploy import deploy
from deploy_backend import deploy as deploy_backend from deploy_backend import deploy as deploy_backend
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Build and deploy Smart Serow in one step", description="Build and deploy Smart Serow in one step",
) )
parser.add_argument( parser.add_argument(
"--clean", "-c", "--clean", "-c",
action="store_true", action="store_true",
help="Clean CMake cache before building", help="Clean CMake cache before building",
) )
parser.add_argument( parser.add_argument(
"--no-restart", "--no-restart",
action="store_true", action="store_true",
help="Don't restart service after deploy (default: restart)", help="Don't restart service after deploy (default: restart)",
) )
parser.add_argument( parser.add_argument(
"--build-only", "--build-only",
action="store_true", action="store_true",
help="Only build, don't deploy", help="Only build, don't deploy",
) )
parser.add_argument( parser.add_argument(
"--deploy-only", "--deploy-only",
action="store_true", action="store_true",
help="Only deploy, don't build", help="Only deploy, don't build",
) )
parser.add_argument( parser.add_argument(
"--ui", "--ui",
action="store_true", action="store_true",
help="Build/deploy UI only (no backend)", help="Build/deploy UI only (no backend)",
) )
parser.add_argument( parser.add_argument(
"--backend", "--backend",
action="store_true", action="store_true",
help="Deploy backend only (no UI, no build)", help="Deploy backend only (no UI, no build)",
) )
args = parser.parse_args() args = parser.parse_args()
# Default: both UI and backend if neither flag specified # Default: both UI and backend if neither flag specified
do_ui = args.ui or not args.backend do_ui = args.ui or not args.backend
do_backend = args.backend or not args.ui do_backend = args.backend or not args.ui
restart = not args.no_restart restart = not args.no_restart
# Build UI (only if doing UI and not deploy-only) # Build UI (only if doing UI and not deploy-only)
if do_ui and not args.deploy_only: if do_ui and not args.deploy_only:
print() print()
if not build(clean=args.clean): if not build(clean=args.clean):
print("UI build failed!") print("UI build failed!")
sys.exit(1) sys.exit(1)
# Deploy backend FIRST (no build step needed - it's Python) # Deploy backend FIRST (no build step needed - it's Python)
# Backend must be up before UI connects to WebSocket # Backend must be up before UI connects to WebSocket
if do_backend and not args.build_only: if do_backend and not args.build_only:
print() print()
if not deploy_backend(restart=restart): if not deploy_backend(restart=restart):
print("Backend deploy failed!") print("Backend deploy failed!")
sys.exit(1) sys.exit(1)
# Deploy UI after backend is ready # Deploy UI after backend is ready
if do_ui and not args.build_only: if do_ui and not args.build_only:
print() print()
if not deploy(restart=restart): if not deploy(restart=restart):
print("UI deploy failed!") print("UI deploy failed!")
sys.exit(1) sys.exit(1)
print() print()
print("=== All done! ===") print("=== All done! ===")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,157 +1,157 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Deploy script for Smart Serow Python backend. """Deploy script for Smart Serow Python backend.
Pushes backend source to Pi and optionally restarts service. Pushes backend source to Pi and optionally restarts service.
Completely independent from UI deploy. Completely independent from UI deploy.
""" """
import argparse import argparse
import json import json
import subprocess import subprocess
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service" SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
"""Run a command.""" """Run a command."""
print(f"{' '.join(cmd)}") print(f"{' '.join(cmd)}")
return subprocess.run(cmd, check=check, **kwargs) return subprocess.run(cmd, check=check, **kwargs)
def load_config() -> dict: def load_config() -> dict:
"""Load deploy target configuration.""" """Load deploy target configuration."""
if not CONFIG_FILE.exists(): if not CONFIG_FILE.exists():
print(f"ERROR: Config file not found: {CONFIG_FILE}") print(f"ERROR: Config file not found: {CONFIG_FILE}")
print("Create it based on deploy_target.sample.json") print("Create it based on deploy_target.sample.json")
sys.exit(1) sys.exit(1)
with open(CONFIG_FILE) as f: with open(CONFIG_FILE) as f:
return json.load(f) return json.load(f)
def deploy(restart: bool = False) -> bool: def deploy(restart: bool = False) -> bool:
"""Deploy backend to Pi. Returns True on success.""" """Deploy backend to Pi. Returns True on success."""
config = load_config() config = load_config()
pi_user = config["user"] pi_user = config["user"]
pi_host = config["host"] pi_host = config["host"]
# Backend-specific config (with defaults) # Backend-specific config (with defaults)
remote_path = config.get("backend_path", "/opt/smartserow-backend") remote_path = config.get("backend_path", "/opt/smartserow-backend")
service_name = config.get("backend_service", "smartserow-backend") service_name = config.get("backend_service", "smartserow-backend")
ssh_target = f"{pi_user}@{pi_host}" ssh_target = f"{pi_user}@{pi_host}"
print("=== Smart Serow Backend Deploy ===") print("=== Smart Serow Backend Deploy ===")
print(f"Target: {ssh_target}:{remote_path}") print(f"Target: {ssh_target}:{remote_path}")
print(f"Source: {BACKEND_DIR}") print(f"Source: {BACKEND_DIR}")
if not BACKEND_DIR.exists(): if not BACKEND_DIR.exists():
print(f"ERROR: Backend directory not found: {BACKEND_DIR}") print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
return False return False
# Ensure remote directory exists # Ensure remote directory exists
print() print()
print("Ensuring remote directory...") print("Ensuring remote directory...")
run(["ssh", ssh_target, f"mkdir -p {remote_path}"]) run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
# Sync backend source to Pi # Sync backend source to Pi
# Exclude __pycache__, .venv, etc. # Exclude __pycache__, .venv, etc.
print() print()
print("Syncing files...") print("Syncing files...")
run([ run([
"rsync", "-avz", "--delete", "rsync", "-avz", "--delete",
"--exclude", "__pycache__", "--exclude", "__pycache__",
"--exclude", "*.pyc", "--exclude", "*.pyc",
"--exclude", ".venv", "--exclude", ".venv",
"--exclude", ".ruff_cache", "--exclude", ".ruff_cache",
"--exclude", "uv.lock", # Let Pi generate its own lockfile "--exclude", "uv.lock", # Let Pi generate its own lockfile
f"{BACKEND_DIR}/", f"{BACKEND_DIR}/",
f"{ssh_target}:{remote_path}/", f"{ssh_target}:{remote_path}/",
]) ])
# Ensure system GPIO package is installed (pip version needs compilation) # Ensure system GPIO package is installed (pip version needs compilation)
print() print()
print("Ensuring system GPIO package...") print("Ensuring system GPIO package...")
run( run(
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"], ["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
check=False, check=False,
) )
# Create venv with system-site-packages if it doesn't exist # Create venv with system-site-packages if it doesn't exist
# This allows access to apt-installed packages like python3-rpi.gpio # This allows access to apt-installed packages like python3-rpi.gpio
print() print()
print("Ensuring venv with system-site-packages...") print("Ensuring venv with system-site-packages...")
run( run(
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"], ["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
check=False, 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()
print("Running uv sync...") print("Running uv sync...")
result = run( result = run(
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"], ["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
check=False, check=False,
) )
if result.returncode != 0: if result.returncode != 0:
print("WARNING: uv sync failed - dependencies may be out of date") print("WARNING: uv sync failed - dependencies may be out of date")
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh") print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
# Deploy service file if it exists # Deploy service file if it exists
if SERVICE_FILE.exists(): if SERVICE_FILE.exists():
print() print()
print("Deploying systemd service file...") print("Deploying systemd service file...")
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"]) run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
run([ run([
"ssh", ssh_target, "ssh", ssh_target,
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload" f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
]) ])
# Restart service if requested # Restart service if requested
if restart: if restart:
print() print()
print(f"Restarting service: {service_name}") print(f"Restarting service: {service_name}")
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False) run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
time.sleep(2) time.sleep(2)
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False) run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
else: else:
print() print()
print("Deploy complete. To restart service, run:") print("Deploy complete. To restart service, run:")
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'") print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
print() print()
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:") 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 # Install uv") print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
print(" sudo apt install python3-rpi.gpio # GPIO support") print(" sudo apt install python3-rpi.gpio # GPIO support")
return True return True
def main(): def main():
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi") parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
parser.add_argument( parser.add_argument(
"--restart", "-r", "--restart", "-r",
action="store_true", action="store_true",
help="Restart the systemd service after deploy", help="Restart the systemd service after deploy",
) )
args = parser.parse_args() args = parser.parse_args()
success = deploy(restart=args.restart) success = deploy(restart=args.restart)
sys.exit(0 if success else 1) sys.exit(0 if success else 1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()