diff --git a/pi/backend/arduino_service.py b/pi/backend/arduino_service.py index a508e18..ce0364d 100644 --- a/pi/backend/arduino_service.py +++ b/pi/backend/arduino_service.py @@ -25,6 +25,9 @@ class ArduinoService: "gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE), } + # ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra" + ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?") + def __init__( self, port: str = "/dev/ttyUSB0", @@ -42,6 +45,55 @@ class ArduinoService: self._thread: threading.Thread | None = None self._lock = threading.Lock() + # Callbacks for push-based updates + self._on_data_callback = None + self._on_ack_callback = None + + # Serial port handle for sending commands + self._serial: Any = None + self._serial_lock = threading.Lock() + + def set_on_data(self, callback): + """Set callback for new telemetry data. Called with data dict.""" + self._on_data_callback = callback + + def set_on_ack(self, callback): + """Set callback for ACK responses. Called with (cmd, status, extra).""" + self._on_ack_callback = callback + + def send_command(self, cmd: str, params: dict | None = None) -> bool: + """Send a command to Arduino via serial. + + Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline + + Args: + cmd: Command name (e.g., "HORN", "LIGHT") + params: Optional parameters dict + + Returns: + True if sent successfully, False if serial unavailable + """ + with self._serial_lock: + if self._serial is None or not self._connected: + print(f"[Arduino] Cannot send command, not connected") + return False + + try: + # Build command string + parts = ["CMD", cmd.upper()] + if params: + for key, val in params.items(): + parts.append(f"{key}={val}") + line = ":".join(parts) + "\n" + + self._serial.write(line.encode("utf-8")) + self._serial.flush() + print(f"[Arduino] Sent: {line.strip()}") + return True + except Exception as e: + print(f"[Arduino] Failed to send command: {e}") + return False + @property def connected(self) -> bool: return self._connected @@ -100,6 +152,9 @@ class ArduinoService: return try: + # Store serial handle for send_command() + with self._serial_lock: + self._serial = ser self._connected = True print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") @@ -109,6 +164,14 @@ class ArduinoService: if not line: continue + # Check for ACK responses first + ack_match = self.ACK_PATTERN.match(line) + if ack_match: + cmd, status, extra = ack_match.groups() + if self._on_ack_callback: + self._on_ack_callback(cmd, status, extra) + continue + data = self._parse_line(line) if data: data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -120,12 +183,18 @@ class ArduinoService: self._latest["time"] = data["time"] self._buffer.append(self._latest.copy()) + # Invoke callback with new data + if self._on_data_callback: + self._on_data_callback(self._latest.copy()) + except serial.SerialException as e: print(f"[Arduino] Serial error: {e}") break finally: self._connected = False + with self._serial_lock: + self._serial = None ser.close() def _parse_line(self, line: str) -> dict[str, Any] | None: @@ -172,4 +241,9 @@ class ArduinoService: with self._lock: self._latest = data self._buffer.append(data) + + # Invoke callback with new data + if self._on_data_callback: + self._on_data_callback(data) + time.sleep(0.5) # 2Hz stub updates diff --git a/pi/backend/gps_service.py b/pi/backend/gps_service.py index 65a87d5..ead6655 100644 --- a/pi/backend/gps_service.py +++ b/pi/backend/gps_service.py @@ -29,6 +29,13 @@ class GPSService: self._thread: threading.Thread | None = None self._lock = threading.Lock() + # Callback for push-based updates + self._on_data_callback = None + + def set_on_data(self, callback): + """Set callback for new GPS fix. Called with fix dict.""" + self._on_data_callback = callback + @property def connected(self) -> bool: return self._connected @@ -75,7 +82,14 @@ class GPSService: self._stub_mode() return - with GPSDClient(host=self.host, port=self.port) as client: + try: + client = GPSDClient(host=self.host, port=self.port) + except Exception as e: + print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode") + self._stub_mode() + return + + with client: self._connected = True print(f"[GPS] Connected to gpsd at {self.host}:{self.port}") @@ -99,6 +113,10 @@ class GPSService: 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) + def _stub_mode(self): """Fake data for testing without gpsd.""" import random @@ -117,4 +135,9 @@ class GPSService: with self._lock: self._latest = fix self._buffer.append(fix) + + # Invoke callback with new fix + if self._on_data_callback: + self._on_data_callback(fix) + time.sleep(1) diff --git a/pi/backend/main.py b/pi/backend/main.py index 2a26c88..df11a1a 100644 --- a/pi/backend/main.py +++ b/pi/backend/main.py @@ -1,13 +1,174 @@ -"""Smart Serow Backend - GPS and Arduino services with HTTP API.""" +"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket.""" + +from gevent import monkey +monkey.patch_all() # Must be at the very top before other imports from flask import Flask, jsonify +from flask_socketio import SocketIO, emit + from gps_service import GPSService from arduino_service import ArduinoService +from throttle import Throttle app = Flask(__name__) +app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session + +# SocketIO with gevent async mode (eventlet is deprecated) +socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*") + +# Services gps = GPSService() arduino = ArduinoService() +# Throttles for emission rate limiting (2Hz for arduino, 1Hz for GPS) +arduino_throttle = Throttle(min_interval=0.5) # 2Hz max +gps_throttle = Throttle(min_interval=1.0) # 1Hz max + +# Track connected clients +connected_clients = set() + + +# ----------------------------------------------------------------------------- +# WebSocket Event Handlers +# ----------------------------------------------------------------------------- + +@socketio.on("connect") +def handle_connect(): + """Client connected.""" + client_id = id(socketio) # Simple identifier + connected_clients.add(client_id) + print(f"[WS] Client connected ({len(connected_clients)} total)") + + # Send current status immediately + emit("status", { + "gps_connected": gps.connected, + "arduino_connected": arduino.connected, + }) + + # Send latest data if available + arduino_data = arduino.get_latest() + if "error" not in arduino_data: + emit("arduino", arduino_data) + + gps_data = gps.get_latest() + if "error" not in gps_data: + emit("gps", gps_data) + + +@socketio.on("disconnect") +def handle_disconnect(): + """Client disconnected.""" + client_id = id(socketio) + connected_clients.discard(client_id) + print(f"[WS] Client disconnected ({len(connected_clients)} remaining)") + + +@socketio.on("button") +def handle_button(data): + """Handle button press from UI. + + Expected data: {"id": "horn", "action": "press", ...params} + """ + btn_id = data.get("id", "unknown") + action = data.get("action", "press") + params = {k: v for k, v in data.items() if k not in ("id", "action")} + + print(f"[WS] Button: {btn_id} {action} {params}") + + # Map button ID to Arduino command + cmd_map = { + "horn": "HORN", + "light": "LIGHT", + "indicator_left": "IND_L", + "indicator_right": "IND_R", + "hazard": "HAZARD", + } + + cmd = cmd_map.get(btn_id) + if cmd: + # Add action to params (e.g., ON/OFF based on press/release) + params["state"] = "ON" if action == "press" else "OFF" + success = arduino.send_command(cmd, params) + + # Send immediate ack for the attempt + emit("ack", { + "id": btn_id, + "status": "sent" if success else "failed", + "error": None if success else "arduino not connected", + }) + else: + emit("ack", { + "id": btn_id, + "status": "error", + "error": f"unknown button: {btn_id}", + }) + + +@socketio.on("emergency") +def handle_emergency(data): + """Handle emergency signal from UI.""" + etype = data.get("type", "stop") + print(f"[WS] EMERGENCY: {etype}") + + # Send emergency command to Arduino + arduino.send_command("EMERGENCY", {"type": etype}) + + # Broadcast alert to all clients + socketio.emit("alert", { + "type": "emergency", + "message": f"Emergency {etype} triggered", + }) + + +# ----------------------------------------------------------------------------- +# Service Callbacks (push data to WebSocket) +# ----------------------------------------------------------------------------- + +def on_arduino_data(data): + """Called by ArduinoService when new telemetry arrives.""" + def emit_fn(d): + socketio.emit("arduino", d) + + arduino_throttle.maybe_emit(data, emit_fn) + + +def on_gps_data(data): + """Called by GPSService when new fix arrives.""" + def emit_fn(d): + socketio.emit("gps", d) + + gps_throttle.maybe_emit(data, emit_fn) + + +def on_arduino_ack(cmd, status, extra): + """Called by ArduinoService when ACK received from Arduino.""" + socketio.emit("ack", { + "id": cmd.lower(), + "status": status.lower(), + "extra": extra, + }) + + +# ----------------------------------------------------------------------------- +# Background task to flush pending throttled data +# ----------------------------------------------------------------------------- + +def throttle_flusher(): + """Periodically flush pending throttled data.""" + import gevent + while True: + gevent.sleep(0.5) + + if arduino_throttle.has_pending: + arduino_throttle.flush(lambda d: socketio.emit("arduino", d)) + + if gps_throttle.has_pending: + gps_throttle.flush(lambda d: socketio.emit("gps", d)) + + +# ----------------------------------------------------------------------------- +# REST API (backward compatibility) +# ----------------------------------------------------------------------------- @app.route("/health") def health(): @@ -16,6 +177,7 @@ def health(): "status": "ok", "gps_connected": gps.connected, "arduino_connected": arduino.connected, + "ws_clients": len(connected_clients), }) @@ -43,13 +205,28 @@ def arduino_history(): return jsonify(arduino.get_buffer()) +# ----------------------------------------------------------------------------- +# Main Entry Point +# ----------------------------------------------------------------------------- + def main(): """Entry point.""" + # Wire up callbacks + arduino.set_on_data(on_arduino_data) + arduino.set_on_ack(on_arduino_ack) + gps.set_on_data(on_gps_data) + + # Start services gps.start() arduino.start() + + # Start throttle flusher in background + socketio.start_background_task(throttle_flusher) + try: - # Host 0.0.0.0 for access from Flutter app - app.run(host="0.0.0.0", port=5000, debug=False) + # Use socketio.run() instead of app.run() for WebSocket support + print("[Backend] Starting on http://0.0.0.0:5000") + socketio.run(app, host="0.0.0.0", port=5000, debug=False) finally: arduino.stop() gps.stop() diff --git a/pi/backend/pyproject.toml b/pi/backend/pyproject.toml index 3f7e118..d68a979 100644 --- a/pi/backend/pyproject.toml +++ b/pi/backend/pyproject.toml @@ -5,6 +5,9 @@ description = "GPS and Arduino telemetry service for Smart Serow" requires-python = ">=3.11" dependencies = [ "flask>=3.0", + "flask-socketio>=5.3.0", + "gevent>=24.0", + "gevent-websocket>=0.10", "gpsdclient>=1.3", "pyserial>=3.5", ] diff --git a/pi/backend/throttle.py b/pi/backend/throttle.py new file mode 100644 index 0000000..44b8dab --- /dev/null +++ b/pi/backend/throttle.py @@ -0,0 +1,61 @@ +"""Throttle layer for rate-limiting telemetry emissions.""" + +import time +from typing import Any, Callable + + +class Throttle: + """Rate limiter for WebSocket emissions. + + Coalesces rapid updates - only emits at most once per min_interval. + If multiple updates arrive within the interval, the latest value wins. + """ + + def __init__(self, min_interval: float = 0.5): + """ + Args: + min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max) + """ + self._last_emit: float = 0 + self._min_interval = min_interval + self._pending: Any = None + + def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool: + """Emit if interval has passed, otherwise store as pending. + + Args: + data: Data to emit + emit_fn: Function to call with data when emitting + + Returns: + True if emitted, False if stored as pending + """ + now = time.time() + if now - self._last_emit >= self._min_interval: + emit_fn(data) + self._last_emit = now + self._pending = None + return True + else: + self._pending = data # Latest value wins + return False + + def flush(self, emit_fn: Callable[[Any], None]) -> bool: + """Emit pending data if any. + + Call this periodically to ensure pending data gets sent. + + Returns: + True if pending data was emitted, False if nothing pending + """ + if self._pending is not None: + emit_fn(self._pending) + self._last_emit = time.time() + self._pending = None + return True + return False + + @property + def has_pending(self) -> bool: + """Check if there's pending data waiting to be emitted.""" + return self._pending is not None diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index d2d5086..c182854 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -1,7 +1,8 @@ import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; +import '../services/backend_service.dart'; +import '../services/websocket_service.dart'; import '../services/pi_io.dart'; import '../theme/app_theme.dart'; import '../widgets/navigator_widget.dart'; @@ -21,53 +22,125 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { - final _random = Random(); final _navigatorKey = GlobalKey(); - Timer? _timer; + // Timer for Pi temp only (safety critical, direct file read) + Timer? _piTempTimer; + + // WebSocket stream subscriptions + StreamSubscription? _arduinoSub; + StreamSubscription? _gpsSub; + StreamSubscription? _connectionSub; + + // Pi temperature - direct file read (safety critical) double? _piTemp; - int _rpm = 0; - double _voltage = 12.6; - int _engineTemp = 25; + + // From backend - Arduino data + int? _rpm; + double? _voltage; + int? _engineTemp; + int? _gear; + + // From backend - GPS data + double? _gpsSpeed; // Placeholder values for system bar int? _gpsSatellites; int? _lteSignal; + // WebSocket connection state + WsConnectionState _wsState = WsConnectionState.disconnected; + @override void initState() { super.initState(); - // Update values periodically - _timer = Timer.periodic(const Duration(milliseconds: 500), (_) { + // Connect to WebSocket + WebSocketService.instance.connect(); + + // Subscribe to Arduino data stream + _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { setState(() { - // Pi temp - sync read from cache, async refresh happens in background - _piTemp = PiIO.instance.getTemperature(); - - // Placeholder random data - will be replaced with real sensors - _rpm = 1000 + _random.nextInt(8000); - _voltage = 11.5 + _random.nextDouble() * 2; - _engineTemp = 20 + _random.nextInt(60); - - // Placeholder: GPS satellites (null = disconnected, 0 = no fix, 3-12 = typical) - _gpsSatellites = _random.nextBool() ? _random.nextInt(12) : null; - - // Placeholder: LTE signal (null = disconnected, 0-4 = signal bars) - _lteSignal = _random.nextBool() ? _random.nextInt(5) : null; + _voltage = data.voltage; + _rpm = data.rpm; + _engineTemp = data.engTemp; + _gear = data.gear; }); }); + // Subscribe to GPS data stream + _gpsSub = WebSocketService.instance.gpsStream.listen((data) { + setState(() { + _gpsSpeed = data.speed; + // Derive satellites from mode (placeholder logic) + _gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0); + }); + }); + + // Subscribe to connection 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(() { + _piTemp = PiIO.instance.getTemperature(); + }); + }); + + // Initialize with any cached data from WebSocketService + final cachedArduino = WebSocketService.instance.latestArduino; + if (cachedArduino != null) { + _voltage = cachedArduino.voltage; + _rpm = cachedArduino.rpm; + _engineTemp = cachedArduino.engTemp; + _gear = cachedArduino.gear; + } + + final cachedGps = WebSocketService.instance.latestGps; + if (cachedGps != null) { + _gpsSpeed = cachedGps.speed; + _gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0); + } + + _wsState = WebSocketService.instance.connectionState; + + // Placeholder: LTE signal (TODO: wire up when LTE service exists) + _lteSignal = null; + // DEBUG: flip-flop theme + navigator every 2s TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); } @override void dispose() { - _timer?.cancel(); + _piTempTimer?.cancel(); + _arduinoSub?.cancel(); + _gpsSub?.cancel(); + _connectionSub?.cancel(); TestFlipFlopService.instance.stop(); super.dispose(); } + /// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6" + String _formatGear(int? gear) { + if (gear == null) return '—'; + if (gear == 0) return 'N'; + return gear.toString(); + } + + /// Format nullable int for display + String _formatInt(int? value) => value?.toString() ?? '—'; + + /// Format nullable double for display with decimal places + String _formatDouble(double? value, [int decimals = 1]) { + if (value == null) return '—'; + return value.toStringAsFixed(decimals); + } + @override Widget build(BuildContext context) { final theme = AppTheme.of(context); @@ -90,6 +163,7 @@ class _DashboardScreenState extends State { lteSignal: _lteSignal, piTemp: _piTemp, voltage: _voltage, + wsState: _wsState, ), const SizedBox(height: 10), @@ -99,9 +173,9 @@ class _DashboardScreenState extends State { flex: 8, child: Row( children: [ - // Speed - placeholder, will come from GPS + // RPM from Arduino StatBoxMain( - value: _rpm.toString(), + value: _formatInt(_rpm), label: 'RPM', ), // Add second StatBoxMain here for 2-up layout: @@ -116,9 +190,9 @@ class _DashboardScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - StatBox(value: _rpm.toString(), label: 'RPM'), - StatBox(value: '$_engineTemp', unit: '°C', label: 'ENG'), - const StatBox(value: '—', label: 'GEAR'), + StatBox(value: _formatInt(_rpm), label: 'RPM'), + StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'), + StatBox(value: _formatGear(_gear), label: 'GEAR'), ], ), ), diff --git a/pi/ui/lib/services/backend_service.dart b/pi/ui/lib/services/backend_service.dart new file mode 100644 index 0000000..265d0e3 --- /dev/null +++ b/pi/ui/lib/services/backend_service.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Data from Arduino (voltage, rpm, engine temp, gear) +class ArduinoData { + final double? voltage; + final int? rpm; + final int? engTemp; + final int? gear; // 0 = neutral, 1-6 = gear + + ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear}); + + factory ArduinoData.fromJson(Map json) { + return ArduinoData( + voltage: (json['voltage'] as num?)?.toDouble(), + rpm: (json['rpm'] as num?)?.toInt(), + engTemp: (json['eng_temp'] as num?)?.toInt(), + gear: (json['gear'] as num?)?.toInt(), + ); + } +} + +/// Data from GPS +class GpsData { + final double? lat; + final double? lon; + final double? speed; // m/s + final double? alt; + final double? track; + final int? mode; // 0=no fix, 2=2D, 3=3D + + GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode}); + + factory GpsData.fromJson(Map json) { + return GpsData( + lat: (json['lat'] as num?)?.toDouble(), + lon: (json['lon'] as num?)?.toDouble(), + speed: (json['speed'] as num?)?.toDouble(), + alt: (json['alt'] as num?)?.toDouble(), + track: (json['track'] as num?)?.toDouble(), + mode: (json['mode'] as num?)?.toInt(), + ); + } +} + +/// 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. +class BackendService { + BackendService._() { + // Kick off initial fetches + _refreshArduino(); + _refreshGps(); + } + static final instance = BackendService._(); + + static const _baseUrl = 'http://127.0.0.1:5000'; + static const _timeout = Duration(seconds: 2); + + // Caches + ArduinoData? _arduinoCache; + GpsData? _gpsCache; + bool _connected = false; + + // In-progress flags (prevent duplicate requests) + bool _arduinoFetchInProgress = false; + bool _gpsFetchInProgress = false; + + /// Whether backend is reachable + bool get isConnected => _connected; + + /// Get Arduino data (sync, returns cached value) + ArduinoData? getArduinoData() { + if (!_arduinoFetchInProgress) { + _refreshArduino(); + } + return _arduinoCache; + } + + /// Get GPS data (sync, returns cached value) + GpsData? getGpsData() { + if (!_gpsFetchInProgress) { + _refreshGps(); + } + return _gpsCache; + } + + /// Background fetch for Arduino data + Future _refreshArduino() async { + if (_arduinoFetchInProgress) return; + _arduinoFetchInProgress = true; + + try { + final response = await http + .get(Uri.parse('$_baseUrl/arduino')) + .timeout(_timeout); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + // Skip if backend returns error (no data yet) - keep cached value + if (!json.containsKey('error')) { + _arduinoCache = ArduinoData.fromJson(json); + } + _connected = true; + } + // Non-200: keep cached data, just mark disconnected + } catch (e) { + // Network error, timeout, etc - keep cached data for transient hiccups + _connected = false; + } finally { + _arduinoFetchInProgress = false; + } + } + + /// Background fetch for GPS data + Future _refreshGps() async { + if (_gpsFetchInProgress) return; + _gpsFetchInProgress = true; + + try { + final response = await http + .get(Uri.parse('$_baseUrl/gps')) + .timeout(_timeout); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + // Skip if backend returns error (no data yet) - keep cached value + if (!json.containsKey('error')) { + _gpsCache = GpsData.fromJson(json); + } + _connected = true; + } + // Non-200: keep cached data, just mark disconnected + } catch (e) { + // Network error, timeout, etc - keep cached data for transient hiccups + _connected = false; + } finally { + _gpsFetchInProgress = false; + } + } + + /// Force clear all caches + void clearCache() { + _arduinoCache = null; + _gpsCache = null; + _connected = false; + } +} diff --git a/pi/ui/lib/services/websocket_service.dart b/pi/ui/lib/services/websocket_service.dart new file mode 100644 index 0000000..67589ea --- /dev/null +++ b/pi/ui/lib/services/websocket_service.dart @@ -0,0 +1,287 @@ +import 'dart:async'; +import 'package:socket_io_client/socket_io_client.dart' as io; + +import 'backend_service.dart'; // Reuse ArduinoData, GpsData + +/// Connection state for WebSocket +enum WsConnectionState { + disconnected, + connecting, + connected, +} + +/// Acknowledgment from backend for a command +class CommandAck { + final String id; + final String status; + final String? error; + final String? extra; + + CommandAck({ + required this.id, + required this.status, + this.error, + this.extra, + }); + + bool get isSuccess => status == 'ok' || status == 'sent'; +} + +/// Alert from backend +class BackendAlert { + final String type; + final String message; + + BackendAlert({required this.type, required this.message}); +} + +/// Backend status (connection states of GPS/Arduino) +class BackendStatus { + final bool gpsConnected; + final bool arduinoConnected; + + BackendStatus({required this.gpsConnected, required this.arduinoConnected}); +} + +/// WebSocket service for real-time data from backend. +/// +/// Replaces HTTP polling with push-based updates. +/// Maintains dual logical channels: +/// - Telemetry: arduino/gps data streams (throttled by backend) +/// - Control: button commands and acknowledgments +class WebSocketService { + WebSocketService._() { + _setupStreams(); + } + static final instance = WebSocketService._(); + + static const _serverUrl = 'http://127.0.0.1:5000'; + + io.Socket? _socket; + WsConnectionState _connectionState = WsConnectionState.disconnected; + Timer? _reconnectTimer; + + // Latest values for sync access (backward compat) + ArduinoData? _latestArduino; + GpsData? _latestGps; + BackendStatus? _latestStatus; + + // Stream controllers + late StreamController _arduinoController; + late StreamController _gpsController; + late StreamController _statusController; + late StreamController _ackController; + late StreamController _alertController; + late StreamController _connectionController; + + void _setupStreams() { + _arduinoController = StreamController.broadcast(); + _gpsController = StreamController.broadcast(); + _statusController = StreamController.broadcast(); + _ackController = StreamController.broadcast(); + _alertController = StreamController.broadcast(); + _connectionController = StreamController.broadcast(); + } + + // --- Public API: Streams --- + + /// Stream of Arduino telemetry updates + Stream get arduinoStream => _arduinoController.stream; + + /// Stream of GPS updates + Stream get gpsStream => _gpsController.stream; + + /// Stream of backend status updates + Stream get statusStream => _statusController.stream; + + /// Stream of command acknowledgments + Stream get ackStream => _ackController.stream; + + /// Stream of alerts from backend + Stream get alertStream => _alertController.stream; + + /// Stream of connection state changes + Stream get connectionStream => _connectionController.stream; + + // --- Public API: Sync getters (backward compat) --- + + /// Current connection state + WsConnectionState get connectionState => _connectionState; + + /// Whether connected to backend + bool get isConnected => _connectionState == WsConnectionState.connected; + + /// Latest Arduino data (may be null if not yet received) + ArduinoData? get latestArduino => _latestArduino; + + /// Latest GPS data (may be null if not yet received) + GpsData? get latestGps => _latestGps; + + /// Latest backend status + BackendStatus? get latestStatus => _latestStatus; + + // --- Public API: Connection --- + + /// Connect to backend WebSocket + void connect() { + if (_socket != null) return; // Already connected or connecting + + _setConnectionState(WsConnectionState.connecting); + + _socket = io.io(_serverUrl, { + 'transports': ['websocket'], + 'autoConnect': true, + 'reconnection': false, // We handle reconnection ourselves + }); + + _socket!.onConnect((_) { + print('[WS] Connected to $_serverUrl'); + _setConnectionState(WsConnectionState.connected); + _cancelReconnect(); + }); + + _socket!.onDisconnect((_) { + print('[WS] Disconnected'); + _setConnectionState(WsConnectionState.disconnected); + _scheduleReconnect(); + }); + + _socket!.onConnectError((error) { + print('[WS] Connection error: $error'); + _setConnectionState(WsConnectionState.disconnected); + _scheduleReconnect(); + }); + + _socket!.onError((error) { + print('[WS] Error: $error'); + }); + + // --- Telemetry Events --- + + _socket!.on('arduino', (data) { + if (data is Map) { + final arduino = ArduinoData.fromJson(data); + _latestArduino = arduino; + _arduinoController.add(arduino); + } + }); + + _socket!.on('gps', (data) { + if (data is Map) { + final gps = GpsData.fromJson(data); + _latestGps = gps; + _gpsController.add(gps); + } + }); + + _socket!.on('status', (data) { + if (data is Map) { + final status = BackendStatus( + gpsConnected: data['gps_connected'] ?? false, + arduinoConnected: data['arduino_connected'] ?? false, + ); + _latestStatus = status; + _statusController.add(status); + } + }); + + // --- Control Events --- + + _socket!.on('ack', (data) { + if (data is Map) { + final ack = CommandAck( + id: data['id'] ?? 'unknown', + status: data['status'] ?? 'unknown', + error: data['error'], + extra: data['extra'], + ); + _ackController.add(ack); + } + }); + + _socket!.on('alert', (data) { + if (data is Map) { + final alert = BackendAlert( + type: data['type'] ?? 'unknown', + message: data['message'] ?? '', + ); + _alertController.add(alert); + } + }); + + _socket!.connect(); + } + + /// Disconnect from backend + void disconnect() { + _cancelReconnect(); + _socket?.disconnect(); + _socket?.dispose(); + _socket = null; + _setConnectionState(WsConnectionState.disconnected); + } + + // --- Public API: Commands --- + + /// Send button event to backend + void sendButton(String id, String action, [Map? params]) { + if (_socket == null || !isConnected) { + print('[WS] Cannot send button, not connected'); + return; + } + + final data = { + 'id': id, + 'action': action, + ...?params, + }; + + _socket!.emit('button', data); + } + + /// Send emergency signal to backend + void sendEmergency(String type) { + if (_socket == null) { + print('[WS] Cannot send emergency, not connected'); + return; + } + + // Emergency should be sent even if not fully connected + _socket!.emit('emergency', {'type': type}); + } + + // --- Private --- + + void _setConnectionState(WsConnectionState state) { + if (_connectionState != state) { + _connectionState = state; + _connectionController.add(state); + } + } + + void _scheduleReconnect() { + _cancelReconnect(); + _reconnectTimer = Timer(const Duration(seconds: 3), () { + print('[WS] Attempting reconnect...'); + _socket?.dispose(); + _socket = null; + connect(); + }); + } + + void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + /// Dispose all resources (call on app shutdown) + void dispose() { + disconnect(); + _arduinoController.close(); + _gpsController.close(); + _statusController.close(); + _ackController.close(); + _alertController.close(); + _connectionController.close(); + } +} diff --git a/pi/ui/lib/widgets/system_bar.dart b/pi/ui/lib/widgets/system_bar.dart index b4648bd..b2a24fe 100644 --- a/pi/ui/lib/widgets/system_bar.dart +++ b/pi/ui/lib/widgets/system_bar.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; +import '../services/websocket_service.dart'; import '../theme/app_theme.dart'; /// Android-style persistent status bar for system indicators. -/// Shows GPS satellites, LTE signal, Pi temp, voltage at a glance. +/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. class SystemBar extends StatelessWidget { final int? gpsSatellites; // null = disconnected final int? lteSignal; // null = disconnected, 0-4 bars final double? piTemp; // null = unavailable final double? voltage; // null = Arduino disconnected + final WsConnectionState? wsState; // WebSocket connection state const SystemBar({ super.key, @@ -16,11 +18,26 @@ class SystemBar extends StatelessWidget { this.lteSignal, this.piTemp, this.voltage, + this.wsState, }); + /// Get WebSocket status text and abnormal flag + (String, bool) _wsStatus() { + switch (wsState) { + case WsConnectionState.connected: + return ('OK', false); + case WsConnectionState.connecting: + return ('...', true); + case WsConnectionState.disconnected: + case null: + return ('OFF', true); + } + } + @override Widget build(BuildContext context) { final theme = AppTheme.of(context); + final (wsText, wsAbnormal) = _wsStatus(); return Expanded( flex: 1, @@ -43,7 +60,17 @@ class SystemBar extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Left group: GPS, LTE + // Left group: WS, GPS, LTE + _Indicator( + label: 'WS', + value: wsText, + isAbnormal: wsAbnormal, + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 2, + theme: theme, + ), _Indicator( label: 'GPS', value: gpsSatellites?.toString() ?? 'N/A', @@ -70,7 +97,7 @@ class SystemBar extends StatelessWidget { label: 'Pi', value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A', isAbnormal: piTemp == null || piTemp! > 80, - alignment: Alignment.centerRight, + alignment: Alignment.centerLeft, labelSize: labelSize, valueSize: valueSize, flex: 2, @@ -80,7 +107,7 @@ class SystemBar extends StatelessWidget { label: 'Chassis', value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', isAbnormal: voltage == null || voltage! < 11.9, - alignment: Alignment.centerRight, + alignment: Alignment.centerLeft, labelSize: labelSize, valueSize: valueSize, flex: 3, diff --git a/pi/ui/pubspec.lock b/pi/ui/pubspec.lock index 5928308..e57949a 100644 --- a/pi/ui/pubspec.lock +++ b/pi/ui/pubspec.lock @@ -67,6 +67,30 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -99,6 +123,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -136,6 +168,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b + url: "https://pub.dev" + source: hosted + version: "2.0.3+1" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + url: "https://pub.dev" + source: hosted + version: "2.0.3" source_span: dependency: transitive description: @@ -184,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -200,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pi/ui/pubspec.yaml b/pi/ui/pubspec.yaml index 9459960..9006230 100644 --- a/pi/ui/pubspec.yaml +++ b/pi/ui/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + http: ^1.2.0 + socket_io_client: ^2.0.3+1 dev_dependencies: flutter_test: diff --git a/scripts/build-deploy.py b/scripts/build-deploy.py index 943633d..14ce632 100644 --- a/scripts/build-deploy.py +++ b/scripts/build-deploy.py @@ -13,6 +13,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from build import build from deploy import deploy +from deploy_backend import deploy as deploy_backend def main(): @@ -39,21 +40,44 @@ def main(): action="store_true", help="Only deploy, don't build", ) + parser.add_argument( + "--ui", + action="store_true", + help="Build/deploy UI only (no backend)", + ) + parser.add_argument( + "--backend", + action="store_true", + help="Deploy backend only (no UI, no build)", + ) args = parser.parse_args() - # Build - if not args.deploy_only: + # Default: both UI and backend if neither flag specified + do_ui = args.ui or not args.backend + do_backend = args.backend or not args.ui + + restart = not args.no_restart + + # Build UI (only if doing UI and not deploy-only) + if do_ui and not args.deploy_only: print() if not build(clean=args.clean): - print("Build failed!") + print("UI build failed!") sys.exit(1) - # Deploy - if not args.build_only: + # Deploy backend FIRST (no build step needed - it's Python) + # Backend must be up before UI connects to WebSocket + if do_backend and not args.build_only: + print() + if not deploy_backend(restart=restart): + print("Backend deploy failed!") + sys.exit(1) + + # Deploy UI after backend is ready + if do_ui and not args.build_only: print() - restart = not args.no_restart if not deploy(restart=restart): - print("Deploy failed!") + print("UI deploy failed!") sys.exit(1) print() diff --git a/scripts/build-deploy.sh b/scripts/build-deploy.sh deleted file mode 100644 index 311db76..0000000 --- a/scripts/build-deploy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Wrapper for build-deploy.py -# Usage: ./build-deploy.sh [options] - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec python3 "$SCRIPT_DIR/build-deploy.py" "$@" diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index 4de7eb1..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# Build script for Smart Serow Flutter UI -# Run this in WSL2 with flutter-elinux installed - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -UI_DIR="$PROJECT_ROOT/pi/ui" - -echo "=== Smart Serow Build ===" -echo "Project: $UI_DIR" - -# Check for flutter-elinux -if ! command -v flutter-elinux &> /dev/null; then - echo "ERROR: flutter-elinux not found in PATH" - echo "Install it or check your PATH" - echo "" - echo "Current PATH: $PATH" - which flutter 2>/dev/null && echo "Found flutter at: $(which flutter)" - exit 1 -fi - -echo "Using: $(which flutter-elinux)" - -# Cross-compilation toolchain for ARM64 -export CC=aarch64-linux-gnu-gcc -export CXX=aarch64-linux-gnu-g++ -export AR=aarch64-linux-gnu-ar -export LD=aarch64-linux-gnu-ld -# CMake-specific vars -export CMAKE_C_COMPILER=aarch64-linux-gnu-gcc -export CMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ - -echo "Cross-compiler: $CXX" - -cd "$UI_DIR" - -# Initialize elinux project if not already configured -if [ ! -d "elinux" ]; then - echo "Initializing elinux project structure..." - flutter-elinux create . --project-name smartserow_ui --org com.smartserow -fi - -# Clean CMake cache on --clean flag -# (CMake caches compiler choice, so stale cache = wrong linker) -if [ "${1:-}" = "--clean" ]; then - echo "Cleaning CMake cache..." - rm -rf build/elinux/arm64 -fi - -echo "Fetching dependencies..." -flutter-elinux pub get - -echo "Building for ARM64 (elinux) with DRM-GBM backend..." - -# Use Pi sysroot if available (for proper cross-linking) -SYSROOT_FLAG="" -if [ -d "$PROJECT_ROOT/pi_sysroot" ]; then - echo "Using Pi sysroot: $PROJECT_ROOT/pi_sysroot" - SYSROOT_FLAG="--target-sysroot=$PROJECT_ROOT/pi_sysroot" -fi - -flutter-elinux build elinux \ - --target-arch=arm64 \ - --target-backend-type=gbm \ - --target-compiler-triple=aarch64-linux-gnu \ - $SYSROOT_FLAG \ - --release - -BUILD_OUTPUT="$UI_DIR/build/elinux/arm64/release/bundle" - -if [ -d "$BUILD_OUTPUT" ]; then - echo "" - echo "=== Build Complete ===" - echo "Output: $BUILD_OUTPUT" - ls -lh "$BUILD_OUTPUT" -else - echo "ERROR: Build output not found at $BUILD_OUTPUT" - exit 1 -fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index c9991bd..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash -# Deploy script for Smart Serow Flutter UI -# Pushes build bundle to Pi and optionally restarts service - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -CONFIG_FILE="$SCRIPT_DIR/deploy_target.json" -# You'll need to create this file based on deploy_target.sample.json - -# Parse config -if [ ! -f "$CONFIG_FILE" ]; then - echo "ERROR: Config file not found: $CONFIG_FILE" - exit 1 -fi - -# Parse JSON with Python (more universal than jq) -read_json() { - python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['$1'])" -} - -PI_USER=$(read_json user) -PI_HOST=$(read_json host) -REMOTE_PATH=$(read_json remote_path) -SERVICE_NAME=$(read_json service_name) - -SSH_TARGET="$PI_USER@$PI_HOST" -BUILD_DIR="$PROJECT_ROOT/pi/ui/build/elinux/arm64/release/bundle" -CONFIG_SRC="$PROJECT_ROOT/pi/ui/config.json" -IMAGES_SRC="$PROJECT_ROOT/extra/images" - -echo "=== Smart Serow Deploy ===" -echo "Target: $SSH_TARGET:$REMOTE_PATH" -echo "Source: $BUILD_DIR" - -if [ ! -d "$BUILD_DIR" ]; then - echo "ERROR: Build directory not found. Run build.sh first." - exit 1 -fi - -# Sync build to Pi -echo "" -echo "Syncing files..." -rsync -avz --delete \ - "$BUILD_DIR/" \ - "$SSH_TARGET:$REMOTE_PATH/bundle/" - -# Sync config.json (sits next to executable in bundle) -if [ -f "$CONFIG_SRC" ]; then - echo "" - echo "Syncing config.json..." - rsync -avz "$CONFIG_SRC" "$SSH_TARGET:$REMOTE_PATH/bundle/config.json" -else - echo "" - echo "Note: No config.json found, using defaults" -fi - -# Sync images to assets path -if [ -d "$IMAGES_SRC" ]; then - # Read assets_path from config, fall back to default - ASSETS_PATH=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('assets_path', '$REMOTE_PATH/assets'))" 2>/dev/null || echo "$REMOTE_PATH/assets") - echo "" - echo "Syncing images to $ASSETS_PATH..." - rsync -avz "$IMAGES_SRC/" "$SSH_TARGET:$ASSETS_PATH/" -else - echo "" - echo "Note: No extra/images folder found, skipping image sync" -fi - -# Restart service if requested -RESTART="${1:-}" -if [ "$RESTART" = "--restart" ] || [ "$RESTART" = "-r" ]; then - echo "" - echo "Restarting service: $SERVICE_NAME" - ssh "$SSH_TARGET" "sudo systemctl restart $SERVICE_NAME" - sleep 2 - ssh "$SSH_TARGET" "systemctl status $SERVICE_NAME --no-pager" -else - echo "" - echo "Deploy complete. To restart service, run:" - echo " ssh $SSH_TARGET 'sudo systemctl restart $SERVICE_NAME'" - echo "" - echo "Or run this script with --restart flag" -fi diff --git a/scripts/deploy_backend.py b/scripts/deploy_backend.py index de7f1d4..09702ed 100644 --- a/scripts/deploy_backend.py +++ b/scripts/deploy_backend.py @@ -76,6 +76,18 @@ def deploy(restart: bool = False) -> bool: f"{ssh_target}:{remote_path}/", ]) + # Run uv sync to install/update dependencies + # Use full path since non-interactive SSH doesn't load .bashrc + print() + print("Running uv sync...") + result = run( + ["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"], + check=False, + ) + if result.returncode != 0: + 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") + # Restart service if requested if restart: print() @@ -91,11 +103,9 @@ def deploy(restart: bool = False) -> bool: print("Or run this script with --restart flag") print() - print("Note: First-time setup on Pi requires:") + print("Note: First-time setup on Pi requires uv to be installed:") print(f" ssh {ssh_target}") - print(f" cd {remote_path}") print(" curl -LsSf https://astral.sh/uv/install.sh | sh") - print(" uv sync") return True