gps manipulations tailored to sim7600h hat
This commit is contained in:
@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
|
||||
// Show all items from the start so the row doesn't jump around
|
||||
_updateStatus('Config', '...');
|
||||
_updateStatus('UART', '...');
|
||||
_updateStatus('GPS', '...');
|
||||
_updateStatus('Navigator', '...');
|
||||
|
||||
// Config must load first (everything else depends on it)
|
||||
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
|
||||
await ConfigService.instance.load();
|
||||
_updateStatus('Config', 'Ready');
|
||||
|
||||
// UART health check and navigator image preload run truly in parallel
|
||||
// UART, GPS, and navigator image preload run truly in parallel
|
||||
_updateStatus('UART', 'Connecting');
|
||||
_updateStatus('GPS', 'Waiting');
|
||||
_updateStatus('Navigator', 'Loading');
|
||||
await Future.wait([
|
||||
_waitForUart(),
|
||||
_waitForGps(),
|
||||
_preloadNavigatorImages(),
|
||||
]);
|
||||
|
||||
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
|
||||
_updateStatus('UART', 'Timeout');
|
||||
}
|
||||
|
||||
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
|
||||
Future<void> _waitForGps() async {
|
||||
final backendUrl = ConfigService.instance.backendUrl;
|
||||
const bailOut = Duration(milliseconds: 7500);
|
||||
const retryDelay = Duration(seconds: 1);
|
||||
final deadline = DateTime.now().add(bailOut);
|
||||
|
||||
_updateStatus('GPS', 'Acquiring');
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$backendUrl/health'))
|
||||
.timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (data['gps_state'] == 'fix') {
|
||||
_updateStatus('GPS', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
|
||||
// Bail out - dashboard will show live GPS state when it arrives
|
||||
_updateStatus('GPS', 'Timeout');
|
||||
}
|
||||
|
||||
/// Preload navigator images into Flutter's image cache
|
||||
///
|
||||
/// Scans for all PNGs in the navigator folder and precaches them.
|
||||
|
||||
@@ -1,282 +1,286 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show sqrt, sin, cos, pi;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/backend_service.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/pi_io.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/navigator_widget.dart';
|
||||
import '../widgets/stat_box.dart';
|
||||
import '../widgets/stat_box_main.dart';
|
||||
import '../widgets/system_bar.dart';
|
||||
import '../widgets/debug_console.dart';
|
||||
import '../widgets/whiskey_mark.dart';
|
||||
import '../widgets/accel_graph.dart';
|
||||
import '../widgets/gps_compass.dart';
|
||||
|
||||
// test service for triggers
|
||||
import '../services/test_flipflop_service.dart';
|
||||
|
||||
/// Main dashboard - displays Pi vitals and placeholder stats
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
|
||||
// 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;
|
||||
|
||||
// From backend - Arduino data
|
||||
int? _rpm;
|
||||
double? _voltage;
|
||||
int? _engineTemp;
|
||||
int? _gear;
|
||||
double? _roll;
|
||||
double? _pitch;
|
||||
double? _ax;
|
||||
double? _ay;
|
||||
double? _dynamicAx; // Gravity-compensated
|
||||
double? _dynamicAy;
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
double? _gpsTrack;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Connect to WebSocket
|
||||
WebSocketService.instance.connect();
|
||||
|
||||
// Subscribe to Arduino data stream
|
||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||
// Gravity-compensated acceleration
|
||||
// When tilted, gravity "leaks" into horizontal axes - subtract it out
|
||||
final rollRad = (data.roll ?? 0) * pi / 180;
|
||||
final pitchRad = (data.pitch ?? 0) * pi / 180;
|
||||
|
||||
// Subtract gravity leakage from measured acceleration
|
||||
// Axes swapped for IMU mounting orientation
|
||||
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
|
||||
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
|
||||
|
||||
setState(() {
|
||||
_voltage = data.voltage;
|
||||
_rpm = data.rpm;
|
||||
_engineTemp = data.engTemp;
|
||||
_gear = data.gear;
|
||||
_roll = data.roll;
|
||||
_pitch = data.pitch;
|
||||
_ax = data.ax;
|
||||
_ay = data.ay;
|
||||
_dynamicAx = dynamicAx;
|
||||
_dynamicAy = dynamicAy;
|
||||
});
|
||||
|
||||
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
|
||||
if (gMagnitude > _surpriseThreshold) {
|
||||
_navigatorKey.currentState?.setEmotion('surprise');
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to GPS data stream
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
_gpsTrack = data.track;
|
||||
_gpsSatellites = data.satellites;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
_wsState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
setState(() {
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with any cached data from WebSocketService
|
||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||
if (cachedArduino != null) {
|
||||
_voltage = cachedArduino.voltage;
|
||||
_rpm = cachedArduino.rpm;
|
||||
_engineTemp = cachedArduino.engTemp;
|
||||
_gear = cachedArduino.gear;
|
||||
_roll = cachedArduino.roll;
|
||||
_pitch = cachedArduino.pitch;
|
||||
_ax = cachedArduino.ax;
|
||||
_ay = cachedArduino.ay;
|
||||
}
|
||||
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsTrack = cachedGps.track;
|
||||
_gpsSatellites = cachedGps.satellites;
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
||||
_lteSignal = null;
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||
String _formatGear(int? gear) {
|
||||
if (gear == null) return '—';
|
||||
if (gear == 0) return 'N';
|
||||
return gear.toString();
|
||||
}
|
||||
|
||||
/// Format nullable int for display
|
||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||
|
||||
/// Format nullable double for display with decimal places
|
||||
String _formatDouble(double? value, [int decimals = 1]) {
|
||||
if (value == null) return '—';
|
||||
return value.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left side: All dashboard widgets (flex: 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
// Main content area - big widgets
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
// Attitude indicator (whiskey mark)
|
||||
Expanded(
|
||||
child: WhiskeyMark(
|
||||
roll: _roll,
|
||||
pitch: _pitch,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AccelGraph(
|
||||
ax: _dynamicAx, // Gravity-compensated lateral
|
||||
ay: _dynamicAy, // Gravity-compensated longitudinal
|
||||
maxG: 0.8,
|
||||
ghostTrackPeriod: const Duration(seconds: 4),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom stats row
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||
GpsCompass(heading: _gpsTrack),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// Right side: Navigator on top, debug console below
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
children: [
|
||||
// Navigator
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: NavigatorWidget(key: _navigatorKey),
|
||||
),
|
||||
),
|
||||
// Debug console
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child:
|
||||
DebugConsole(
|
||||
messageStream: WebSocketService.instance.debugStream,
|
||||
initialMessages: WebSocketService.instance.debugMessages,
|
||||
maxLines: 6,
|
||||
title: 'WebSocket messages',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:math' show sqrt, sin, cos, pi;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/backend_service.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/pi_io.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/navigator_widget.dart';
|
||||
import '../widgets/stat_box.dart';
|
||||
import '../widgets/stat_box_main.dart';
|
||||
import '../widgets/system_bar.dart';
|
||||
import '../widgets/debug_console.dart';
|
||||
import '../widgets/whiskey_mark.dart';
|
||||
import '../widgets/accel_graph.dart';
|
||||
import '../widgets/gps_compass.dart';
|
||||
|
||||
// test service for triggers
|
||||
import '../services/test_flipflop_service.dart';
|
||||
|
||||
/// Main dashboard - displays Pi vitals and placeholder stats
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
|
||||
// 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;
|
||||
|
||||
// From backend - Arduino data
|
||||
int? _rpm;
|
||||
double? _voltage;
|
||||
int? _engineTemp;
|
||||
int? _gear;
|
||||
double? _roll;
|
||||
double? _pitch;
|
||||
double? _ax;
|
||||
double? _ay;
|
||||
double? _dynamicAx; // Gravity-compensated
|
||||
double? _dynamicAy;
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
double? _gpsTrack;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
String? _gpsState;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Connect to WebSocket
|
||||
WebSocketService.instance.connect();
|
||||
|
||||
// Subscribe to Arduino data stream
|
||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||
// Gravity-compensated acceleration
|
||||
// When tilted, gravity "leaks" into horizontal axes - subtract it out
|
||||
final rollRad = (data.roll ?? 0) * pi / 180;
|
||||
final pitchRad = (data.pitch ?? 0) * pi / 180;
|
||||
|
||||
// Subtract gravity leakage from measured acceleration
|
||||
// Axes swapped for IMU mounting orientation
|
||||
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
|
||||
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
|
||||
|
||||
setState(() {
|
||||
_voltage = data.voltage;
|
||||
_rpm = data.rpm;
|
||||
_engineTemp = data.engTemp;
|
||||
_gear = data.gear;
|
||||
_roll = data.roll;
|
||||
_pitch = data.pitch;
|
||||
_ax = data.ax;
|
||||
_ay = data.ay;
|
||||
_dynamicAx = dynamicAx;
|
||||
_dynamicAy = dynamicAy;
|
||||
});
|
||||
|
||||
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
|
||||
if (gMagnitude > _surpriseThreshold) {
|
||||
_navigatorKey.currentState?.setEmotion('surprise');
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to GPS data stream
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
_gpsTrack = data.track;
|
||||
_gpsSatellites = data.satellites;
|
||||
_gpsState = data.gpsState;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
_wsState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
setState(() {
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with any cached data from WebSocketService
|
||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||
if (cachedArduino != null) {
|
||||
_voltage = cachedArduino.voltage;
|
||||
_rpm = cachedArduino.rpm;
|
||||
_engineTemp = cachedArduino.engTemp;
|
||||
_gear = cachedArduino.gear;
|
||||
_roll = cachedArduino.roll;
|
||||
_pitch = cachedArduino.pitch;
|
||||
_ax = cachedArduino.ax;
|
||||
_ay = cachedArduino.ay;
|
||||
}
|
||||
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsTrack = cachedGps.track;
|
||||
_gpsSatellites = cachedGps.satellites;
|
||||
_gpsState = cachedGps.gpsState;
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
||||
_lteSignal = null;
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||
String _formatGear(int? gear) {
|
||||
if (gear == null) return '—';
|
||||
if (gear == 0) return 'N';
|
||||
return gear.toString();
|
||||
}
|
||||
|
||||
/// Format nullable int for display
|
||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||
|
||||
/// Format nullable double for display with decimal places
|
||||
String _formatDouble(double? value, [int decimals = 1]) {
|
||||
if (value == null) return '—';
|
||||
return value.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left side: All dashboard widgets (flex: 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
gpsState: _gpsState,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
// Main content area - big widgets
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
// Attitude indicator (whiskey mark)
|
||||
Expanded(
|
||||
child: WhiskeyMark(
|
||||
roll: _roll,
|
||||
pitch: _pitch,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AccelGraph(
|
||||
ax: _dynamicAx, // Gravity-compensated lateral
|
||||
ay: _dynamicAy, // Gravity-compensated longitudinal
|
||||
maxG: 0.8,
|
||||
ghostTrackPeriod: const Duration(seconds: 4),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom stats row
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||
GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// Right side: Navigator on top, debug console below
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
children: [
|
||||
// Navigator
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: NavigatorWidget(key: _navigatorKey),
|
||||
),
|
||||
),
|
||||
// Debug console
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child:
|
||||
DebugConsole(
|
||||
messageStream: WebSocketService.instance.debugStream,
|
||||
initialMessages: WebSocketService.instance.debugMessages,
|
||||
maxLines: 6,
|
||||
title: 'WebSocket messages',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
|
||||
|
||||
class GpsCompass extends StatelessWidget {
|
||||
final double? heading;
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
|
||||
const GpsCompass({super.key, this.heading});
|
||||
const GpsCompass({super.key, this.heading, this.gpsState});
|
||||
|
||||
bool get _hasSignal => heading != null;
|
||||
bool get _isAcquiring => gpsState == 'acquiring';
|
||||
|
||||
String get _displayHeading {
|
||||
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal
|
||||
return '${(heading! % 360).round()}'; // No need for the degree symbol
|
||||
if (!_hasSignal) return 'N/A';
|
||||
return '${(heading! % 360).round()}';
|
||||
}
|
||||
|
||||
String get _compassDirection {
|
||||
@@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _emotion = 'default';
|
||||
late AnimationController _shakeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
// Auto-reset to default after surprise animation completes
|
||||
_shakeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||
setState(() => _emotion = 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
if (emotion == 'surprise') {
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
final image = Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
// Shake animation for surprise
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeController,
|
||||
child: image,
|
||||
builder: (context, child) {
|
||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||
return Transform.translate(
|
||||
offset: Offset(shake, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _emotion = 'default';
|
||||
late AnimationController _shakeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
// Auto-reset to default after surprise animation completes
|
||||
_shakeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||
setState(() => _emotion = 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
if (emotion == 'surprise') {
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
final image = Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
// Shake animation for surprise
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeController,
|
||||
child: image,
|
||||
builder: (context, child) {
|
||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||
return Transform.translate(
|
||||
offset: Offset(shake, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/websocket_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Android-style persistent status bar for system indicators.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
final WsConnectionState? wsState; // WebSocket connection state
|
||||
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
this.wsState,
|
||||
});
|
||||
|
||||
/// Get WebSocket status text and abnormal flag
|
||||
(String, bool) _wsStatus() {
|
||||
switch (wsState) {
|
||||
case WsConnectionState.connected:
|
||||
return ('OK', false);
|
||||
case WsConnectionState.connecting:
|
||||
return ('...', true);
|
||||
case WsConnectionState.disconnected:
|
||||
case null:
|
||||
return ('OFF', true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final (wsText, wsAbnormal) = _wsStatus();
|
||||
|
||||
return Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left group: WS, GPS, LTE
|
||||
_Indicator(
|
||||
label: 'WS',
|
||||
value: wsText,
|
||||
isAbnormal: wsAbnormal,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsSatellites?.toString() ?? 'N/A',
|
||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/websocket_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Android-style persistent status bar for system indicators.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
final WsConnectionState? wsState; // WebSocket connection state
|
||||
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.gpsState,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
this.wsState,
|
||||
});
|
||||
|
||||
/// Get WebSocket status text and abnormal flag
|
||||
(String, bool) _wsStatus() {
|
||||
switch (wsState) {
|
||||
case WsConnectionState.connected:
|
||||
return ('OK', false);
|
||||
case WsConnectionState.connecting:
|
||||
return ('...', true);
|
||||
case WsConnectionState.disconnected:
|
||||
case null:
|
||||
return ('OFF', true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final (wsText, wsAbnormal) = _wsStatus();
|
||||
|
||||
return Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left group: WS, GPS, LTE
|
||||
_Indicator(
|
||||
label: 'WS',
|
||||
value: wsText,
|
||||
isAbnormal: wsAbnormal,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix',
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
name: smartserow_ui
|
||||
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
socket_io_client: ^2.0.3+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
fonts:
|
||||
- family: DIN1451
|
||||
fonts:
|
||||
- asset: assets/fonts/din1451alt.ttf
|
||||
name: smartserow_ui
|
||||
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
socket_io_client: ^2.0.3+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
fonts:
|
||||
- family: DIN1451
|
||||
fonts:
|
||||
- asset: assets/fonts/din1451alt.ttf
|
||||
|
||||
Reference in New Issue
Block a user