initial switch to websocket

This commit is contained in:
Mikkeli Matlock
2026-01-26 16:50:52 +09:00
parent 62eaaff88e
commit d6ea28163e
11 changed files with 976 additions and 35 deletions

View File

@@ -25,6 +25,9 @@ class ArduinoService:
"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 = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
def __init__( def __init__(
self, self,
port: str = "/dev/ttyUSB0", port: str = "/dev/ttyUSB0",
@@ -42,6 +45,55 @@ class ArduinoService:
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
self._on_data_callback: callable | None = None
self._on_ack_callback: callable | None = None
# Serial port handle for sending commands
self._serial: Any = None
self._serial_lock = threading.Lock()
def set_on_data(self, callback: callable | None):
"""Set callback for new telemetry data. Called with data dict."""
self._on_data_callback = callback
def set_on_ack(self, callback: callable | None):
"""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 @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
@@ -100,6 +152,9 @@ class ArduinoService:
return return
try: try:
# Store serial handle for send_command()
with self._serial_lock:
self._serial = ser
self._connected = True self._connected = True
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
@@ -109,6 +164,14 @@ class ArduinoService:
if not line: if not line:
continue 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) 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())
@@ -120,12 +183,18 @@ class ArduinoService:
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
if self._on_data_callback:
self._on_data_callback(self._latest.copy())
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:
self._serial = None
ser.close() ser.close()
def _parse_line(self, line: str) -> dict[str, Any] | None: def _parse_line(self, line: str) -> dict[str, Any] | None:
@@ -172,4 +241,9 @@ class ArduinoService:
with self._lock: with self._lock:
self._latest = data self._latest = data
self._buffer.append(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 time.sleep(0.5) # 2Hz stub updates

View File

@@ -29,6 +29,13 @@ class GPSService:
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
self._on_data_callback: callable | None = None
def set_on_data(self, callback: callable | None):
"""Set callback for new GPS fix. Called with fix dict."""
self._on_data_callback = callback
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
@@ -75,7 +82,14 @@ class GPSService:
self._stub_mode() self._stub_mode()
return 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 self._connected = True
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}") print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
@@ -99,6 +113,10 @@ class GPSService:
if fix.get("lat") is not None: if fix.get("lat") is not None:
self._buffer.append(fix) self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
def _stub_mode(self): def _stub_mode(self):
"""Fake data for testing without gpsd.""" """Fake data for testing without gpsd."""
import random import random
@@ -117,4 +135,9 @@ class GPSService:
with self._lock: with self._lock:
self._latest = fix self._latest = fix
self._buffer.append(fix) self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
time.sleep(1) time.sleep(1)

View File

@@ -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 import Flask, jsonify
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 throttle import Throttle
app = Flask(__name__) 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() gps = GPSService()
arduino = ArduinoService() 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") @app.route("/health")
def health(): def health():
@@ -16,6 +177,7 @@ def health():
"status": "ok", "status": "ok",
"gps_connected": gps.connected, "gps_connected": gps.connected,
"arduino_connected": arduino.connected, "arduino_connected": arduino.connected,
"ws_clients": len(connected_clients),
}) })
@@ -43,13 +205,28 @@ def arduino_history():
return jsonify(arduino.get_buffer()) return jsonify(arduino.get_buffer())
# -----------------------------------------------------------------------------
# Main Entry Point
# -----------------------------------------------------------------------------
def main(): def main():
"""Entry point.""" """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() gps.start()
arduino.start() arduino.start()
# Start throttle flusher in background
socketio.start_background_task(throttle_flusher)
try: try:
# Host 0.0.0.0 for access from Flutter app # Use socketio.run() instead of app.run() for WebSocket support
app.run(host="0.0.0.0", port=5000, debug=False) print("[Backend] Starting on http://0.0.0.0:5000")
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
finally: finally:
arduino.stop() arduino.stop()
gps.stop() gps.stop()

View File

@@ -5,6 +5,9 @@ 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",
"gevent>=24.0",
"gevent-websocket>=0.10",
"gpsdclient>=1.3", "gpsdclient>=1.3",
"pyserial>=3.5", "pyserial>=3.5",
] ]

61
pi/backend/throttle.py Normal file
View File

@@ -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

View File

@@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/backend_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';
@@ -21,53 +22,125 @@ class DashboardScreen extends StatefulWidget {
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
final _random = Random();
final _navigatorKey = GlobalKey<NavigatorWidgetState>(); final _navigatorKey = GlobalKey<NavigatorWidgetState>();
Timer? _timer;
// Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer;
// WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub;
StreamSubscription<GpsData>? _gpsSub;
StreamSubscription<WsConnectionState>? _connectionSub;
// Pi temperature - direct file read (safety critical)
double? _piTemp; double? _piTemp;
int _rpm = 0;
double _voltage = 12.6; // From backend - Arduino data
int _engineTemp = 25; int? _rpm;
double? _voltage;
int? _engineTemp;
int? _gear;
// From backend - GPS data
double? _gpsSpeed;
// Placeholder values for system bar // Placeholder values for system bar
int? _gpsSatellites; int? _gpsSatellites;
int? _lteSignal; int? _lteSignal;
// WebSocket connection state
WsConnectionState _wsState = WsConnectionState.disconnected;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Update values periodically // Connect to WebSocket
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) { WebSocketService.instance.connect();
// Subscribe to Arduino data stream
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
setState(() {
_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(() { setState(() {
// Pi temp - sync read from cache, async refresh happens in background
_piTemp = PiIO.instance.getTemperature(); _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;
}); });
}); });
// 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 // DEBUG: flip-flop theme + navigator every 2s
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _piTempTimer?.cancel();
_arduinoSub?.cancel();
_gpsSub?.cancel();
_connectionSub?.cancel();
TestFlipFlopService.instance.stop(); TestFlipFlopService.instance.stop();
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
@@ -90,6 +163,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
lteSignal: _lteSignal, lteSignal: _lteSignal,
piTemp: _piTemp, piTemp: _piTemp,
voltage: _voltage, voltage: _voltage,
wsState: _wsState,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -99,9 +173,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
flex: 8, flex: 8,
child: Row( child: Row(
children: [ children: [
// Speed - placeholder, will come from GPS // RPM from Arduino
StatBoxMain( StatBoxMain(
value: _rpm.toString(), value: _formatInt(_rpm),
label: 'RPM', label: 'RPM',
), ),
// Add second StatBoxMain here for 2-up layout: // Add second StatBoxMain here for 2-up layout:
@@ -116,9 +190,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
StatBox(value: _rpm.toString(), label: 'RPM'), StatBox(value: _formatInt(_rpm), label: 'RPM'),
StatBox(value: '$_engineTemp', unit: '°C', label: 'ENG'), StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'),
const StatBox(value: '', label: 'GEAR'), StatBox(value: _formatGear(_gear), label: 'GEAR'),
], ],
), ),
), ),

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<void> _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<String, dynamic>;
// 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<void> _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<String, dynamic>;
// 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;
}
}

View File

@@ -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<ArduinoData> _arduinoController;
late StreamController<GpsData> _gpsController;
late StreamController<BackendStatus> _statusController;
late StreamController<CommandAck> _ackController;
late StreamController<BackendAlert> _alertController;
late StreamController<WsConnectionState> _connectionController;
void _setupStreams() {
_arduinoController = StreamController<ArduinoData>.broadcast();
_gpsController = StreamController<GpsData>.broadcast();
_statusController = StreamController<BackendStatus>.broadcast();
_ackController = StreamController<CommandAck>.broadcast();
_alertController = StreamController<BackendAlert>.broadcast();
_connectionController = StreamController<WsConnectionState>.broadcast();
}
// --- Public API: Streams ---
/// Stream of Arduino telemetry updates
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
/// Stream of GPS updates
Stream<GpsData> get gpsStream => _gpsController.stream;
/// Stream of backend status updates
Stream<BackendStatus> get statusStream => _statusController.stream;
/// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream;
/// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream;
/// Stream of connection state changes
Stream<WsConnectionState> 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, <String, dynamic>{
'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<String, dynamic>) {
final arduino = ArduinoData.fromJson(data);
_latestArduino = arduino;
_arduinoController.add(arduino);
}
});
_socket!.on('gps', (data) {
if (data is Map<String, dynamic>) {
final gps = GpsData.fromJson(data);
_latestGps = gps;
_gpsController.add(gps);
}
});
_socket!.on('status', (data) {
if (data is Map<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic>? params]) {
if (_socket == null || !isConnected) {
print('[WS] Cannot send button, not connected');
return;
}
final data = <String, dynamic>{
'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();
}
}

View File

@@ -1,14 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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 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 int? lteSignal; // null = disconnected, 0-4 bars
final double? piTemp; // null = unavailable final double? piTemp; // null = unavailable
final double? voltage; // null = Arduino disconnected final double? voltage; // null = Arduino disconnected
final WsConnectionState? wsState; // WebSocket connection state
const SystemBar({ const SystemBar({
super.key, super.key,
@@ -16,11 +18,26 @@ class SystemBar extends StatelessWidget {
this.lteSignal, this.lteSignal,
this.piTemp, this.piTemp,
this.voltage, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
final (wsText, wsAbnormal) = _wsStatus();
return Expanded( return Expanded(
flex: 1, flex: 1,
@@ -43,7 +60,17 @@ class SystemBar extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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( _Indicator(
label: 'GPS', label: 'GPS',
value: gpsSatellites?.toString() ?? 'N/A', value: gpsSatellites?.toString() ?? 'N/A',
@@ -70,7 +97,7 @@ class SystemBar extends StatelessWidget {
label: 'Pi', label: 'Pi',
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A', value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
isAbnormal: piTemp == null || piTemp! > 80, isAbnormal: piTemp == null || piTemp! > 80,
alignment: Alignment.centerRight, alignment: Alignment.centerLeft,
labelSize: labelSize, labelSize: labelSize,
valueSize: valueSize, valueSize: valueSize,
flex: 2, flex: 2,
@@ -80,7 +107,7 @@ class SystemBar extends StatelessWidget {
label: 'Chassis', label: 'Chassis',
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
isAbnormal: voltage == null || voltage! < 11.9, isAbnormal: voltage == null || voltage! < 11.9,
alignment: Alignment.centerRight, alignment: Alignment.centerLeft,
labelSize: labelSize, labelSize: labelSize,
valueSize: valueSize, valueSize: valueSize,
flex: 3, flex: 3,

View File

@@ -67,6 +67,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -99,6 +123,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -136,6 +168,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -184,6 +232,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +256,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "14.3.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks: sdks:
dart: ">=3.7.0-0 <4.0.0" dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@@ -9,6 +9,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.2.0
socket_io_client: ^2.0.3+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: