gps manipulations tailored to sim7600h hat
This commit is contained in:
@@ -1,161 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
|
||||
class ArduinoData {
|
||||
final double? voltage;
|
||||
final int? rpm;
|
||||
final int? engTemp;
|
||||
final int? gear; // 0 = neutral, 1-6 = gear
|
||||
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
||||
final double? pitch; // Euler angle in degrees (negative = nose down)
|
||||
final double? ax; // Lateral acceleration (g)
|
||||
final double? ay; // Longitudinal acceleration (g)
|
||||
final double? az; // Vertical acceleration (g)
|
||||
|
||||
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
|
||||
|
||||
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(),
|
||||
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
||||
pitch: (json['pitch'] as num?)?.toDouble(),
|
||||
ax: (json['ax'] as num?)?.toDouble(),
|
||||
ay: (json['ay'] as num?)?.toDouble(),
|
||||
az: (json['az'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
final int? satellites;
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites});
|
||||
|
||||
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(),
|
||||
satellites: (json['satellites'] 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;
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
|
||||
class ArduinoData {
|
||||
final double? voltage;
|
||||
final int? rpm;
|
||||
final int? engTemp;
|
||||
final int? gear; // 0 = neutral, 1-6 = gear
|
||||
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
||||
final double? pitch; // Euler angle in degrees (negative = nose down)
|
||||
final double? ax; // Lateral acceleration (g)
|
||||
final double? ay; // Longitudinal acceleration (g)
|
||||
final double? az; // Vertical acceleration (g)
|
||||
|
||||
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
|
||||
|
||||
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(),
|
||||
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
||||
pitch: (json['pitch'] as num?)?.toDouble(),
|
||||
ax: (json['ax'] as num?)?.toDouble(),
|
||||
ay: (json['ay'] as num?)?.toDouble(),
|
||||
az: (json['az'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
final int? satellites;
|
||||
final String? gpsState; // "acquiring", "fix", or "lost"
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState});
|
||||
|
||||
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(),
|
||||
satellites: (json['satellites'] as num?)?.toInt(),
|
||||
gpsState: json['gps_state'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||
///
|
||||
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,332 +1,332 @@
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||
import 'theme_service.dart';
|
||||
|
||||
/// 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;
|
||||
late StreamController<String> _debugController;
|
||||
|
||||
// Debug message buffer
|
||||
static const int _maxDebugMessages = 50;
|
||||
final List<String> _debugMessages = [];
|
||||
|
||||
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();
|
||||
_debugController = StreamController<String>.broadcast();
|
||||
}
|
||||
|
||||
/// Log a debug message (adds to buffer and stream)
|
||||
void _log(String message) {
|
||||
_debugMessages.add(message);
|
||||
if (_debugMessages.length > _maxDebugMessages) {
|
||||
_debugMessages.removeAt(0);
|
||||
}
|
||||
_debugController.add(message);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
/// Stream of debug log messages
|
||||
Stream<String> get debugStream => _debugController.stream;
|
||||
|
||||
/// Current debug message buffer (for initial display)
|
||||
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
||||
|
||||
// --- 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((_) {
|
||||
_log('connected');
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
_cancelReconnect();
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
_log('disconnected');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
_log('error: $error');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onError((error) {
|
||||
_log('error: $error');
|
||||
});
|
||||
|
||||
// --- Telemetry Events ---
|
||||
|
||||
_socket!.on('arduino', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final arduino = ArduinoData.fromJson(data);
|
||||
_latestArduino = arduino;
|
||||
_arduinoController.add(arduino);
|
||||
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
|
||||
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
|
||||
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
|
||||
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
|
||||
|
||||
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('gps', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = gps;
|
||||
_gpsController.add(gps);
|
||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_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);
|
||||
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
||||
|
||||
// Initial theme state comes with status on connect
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"} (initial)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('alert', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final alert = BackendAlert(
|
||||
type: data['type'] ?? 'unknown',
|
||||
message: data['message'] ?? '',
|
||||
);
|
||||
_alertController.add(alert);
|
||||
_log('alert: [${alert.type}] ${alert.message}');
|
||||
}
|
||||
});
|
||||
|
||||
_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();
|
||||
_debugController.close();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||
import 'theme_service.dart';
|
||||
|
||||
/// 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;
|
||||
late StreamController<String> _debugController;
|
||||
|
||||
// Debug message buffer
|
||||
static const int _maxDebugMessages = 50;
|
||||
final List<String> _debugMessages = [];
|
||||
|
||||
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();
|
||||
_debugController = StreamController<String>.broadcast();
|
||||
}
|
||||
|
||||
/// Log a debug message (adds to buffer and stream)
|
||||
void _log(String message) {
|
||||
_debugMessages.add(message);
|
||||
if (_debugMessages.length > _maxDebugMessages) {
|
||||
_debugMessages.removeAt(0);
|
||||
}
|
||||
_debugController.add(message);
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
|
||||
/// Stream of debug log messages
|
||||
Stream<String> get debugStream => _debugController.stream;
|
||||
|
||||
/// Current debug message buffer (for initial display)
|
||||
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
||||
|
||||
// --- 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((_) {
|
||||
_log('connected');
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
_cancelReconnect();
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
_log('disconnected');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
_log('error: $error');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onError((error) {
|
||||
_log('error: $error');
|
||||
});
|
||||
|
||||
// --- Telemetry Events ---
|
||||
|
||||
_socket!.on('arduino', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final arduino = ArduinoData.fromJson(data);
|
||||
_latestArduino = arduino;
|
||||
_arduinoController.add(arduino);
|
||||
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
|
||||
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
|
||||
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
|
||||
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
|
||||
|
||||
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('gps', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = gps;
|
||||
_gpsController.add(gps);
|
||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_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);
|
||||
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
||||
|
||||
// Initial theme state comes with status on connect
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"} (initial)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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);
|
||||
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('alert', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final alert = BackendAlert(
|
||||
type: data['type'] ?? 'unknown',
|
||||
message: data['message'] ?? '',
|
||||
);
|
||||
_alertController.add(alert);
|
||||
_log('alert: [${alert.type}] ${alert.message}');
|
||||
}
|
||||
});
|
||||
|
||||
_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();
|
||||
_debugController.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user