initial switch to websocket
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
61
pi/backend/throttle.py
Normal 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
|
||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
149
pi/ui/lib/services/backend_service.dart
Normal file
149
pi/ui/lib/services/backend_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
287
pi/ui/lib/services/websocket_service.dart
Normal file
287
pi/ui/lib/services/websocket_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user