diff --git a/pi/ui/lib/app_root.dart b/pi/ui/lib/app_root.dart index 395d163..5a2795f 100644 --- a/pi/ui/lib/app_root.dart +++ b/pi/ui/lib/app_root.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'screens/splash_screen.dart'; import 'screens/dashboard_screen.dart'; @@ -36,14 +38,12 @@ class _AppRootState extends State { setState(() => _initStatus = 'Loading config...'); await ConfigService.instance.load(); - // Simulate init checks - replace with real checks later - // (UART, GPS, sensors, etc.) - setState(() => _initStatus = 'Checking systems...'); - await Future.delayed(const Duration(milliseconds: 800)); + await Future.delayed(const Duration(milliseconds: 500)); - setState(() => _initStatus = 'UART: standby'); - await Future.delayed(const Duration(milliseconds: 400)); + // Check UART connection via backend health endpoint + setState(() => _initStatus = 'UART: connecting...'); + await _waitForUart(); setState(() => _initStatus = 'GPS: standby'); await Future.delayed(const Duration(milliseconds: 400)); @@ -61,6 +61,42 @@ class _AppRootState extends State { setState(() => _initialized = true); } + /// Poll backend health endpoint until Arduino is connected + Future _waitForUart() async { + final backendUrl = ConfigService.instance.backendUrl; + const maxAttempts = 30; // ~30 seconds max wait + const retryDelay = Duration(seconds: 1); + + for (int attempt = 0; attempt < maxAttempts; attempt++) { + 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; + final arduinoOk = data['arduino_connected'] == true; + + if (arduinoOk) { + setState(() => _initStatus = 'UART: OK'); + await Future.delayed(const Duration(milliseconds: 300)); + return; + } + } + } catch (e) { + // Backend not reachable yet - keep trying + } + + // Not connected yet + setState(() => _initStatus = 'UART: waiting...'); + await Future.delayed(retryDelay); + } + + // Timeout - proceed anyway (UI will show stale data indicators) + setState(() => _initStatus = 'UART: timeout'); + await Future.delayed(const Duration(milliseconds: 500)); + } + @override Widget build(BuildContext context) { // Determine which screen to show (priority: overheat > splash > dashboard) diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index bd78d75..3e944a8 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -10,6 +10,7 @@ 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'; // test service for triggers import '../services/test_flipflop_service.dart'; @@ -41,6 +42,8 @@ class _DashboardScreenState extends State { double? _voltage; int? _engineTemp; int? _gear; + double? _roll; + double? _pitch; // From backend - GPS data double? _gpsSpeed; @@ -66,6 +69,8 @@ class _DashboardScreenState extends State { _rpm = data.rpm; _engineTemp = data.engTemp; _gear = data.gear; + _roll = data.roll; + _pitch = data.pitch; }); }); @@ -99,6 +104,8 @@ class _DashboardScreenState extends State { _rpm = cachedArduino.rpm; _engineTemp = cachedArduino.engTemp; _gear = cachedArduino.gear; + _roll = cachedArduino.roll; + _pitch = cachedArduino.pitch; } final cachedGps = WebSocketService.instance.latestGps; @@ -175,12 +182,17 @@ class _DashboardScreenState extends State { child: Row( children: [ // RPM from Arduino - StatBoxMain( - value: _formatInt(_rpm), - label: 'RPM', + // StatBoxMain( + // value: _formatInt(_rpm), + // label: 'RPM', + // ), + // Attitude indicator (whiskey mark) + Expanded( + child: WhiskeyMark( + roll: _roll, + pitch: _pitch, + ), ), - // Add second StatBoxMain here for 2-up layout: - // StatBoxMain(value: '4500', unit: 'rpm', label: 'TACH'), ], ), ), diff --git a/pi/ui/lib/services/backend_service.dart b/pi/ui/lib/services/backend_service.dart index 265d0e3..2df14a3 100644 --- a/pi/ui/lib/services/backend_service.dart +++ b/pi/ui/lib/services/backend_service.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; -/// Data from Arduino (voltage, rpm, engine temp, gear) +/// 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) - ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear}); + ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch}); factory ArduinoData.fromJson(Map json) { return ArduinoData( @@ -17,6 +19,8 @@ class ArduinoData { 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(), ); } } diff --git a/pi/ui/lib/services/config_service.dart b/pi/ui/lib/services/config_service.dart index 4e64af9..7186e42 100644 --- a/pi/ui/lib/services/config_service.dart +++ b/pi/ui/lib/services/config_service.dart @@ -86,4 +86,11 @@ class ConfigService { if (value is String && value.isNotEmpty) return value; return _defaultNavigator; } + + /// Backend URL for API calls + String get backendUrl { + final value = _config?['backend_url']; + if (value is String && value.isNotEmpty) return value; + return 'http://127.0.0.1:5000'; + } } diff --git a/pi/ui/lib/services/websocket_service.dart b/pi/ui/lib/services/websocket_service.dart index 1ba8e14..203531e 100644 --- a/pi/ui/lib/services/websocket_service.dart +++ b/pi/ui/lib/services/websocket_service.dart @@ -184,7 +184,10 @@ class WebSocketService { final arduino = ArduinoData.fromJson(data); _latestArduino = arduino; _arduinoController.add(arduino); - _log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}'); + 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'); } }); diff --git a/pi/ui/lib/widgets/whiskey_mark.dart b/pi/ui/lib/widgets/whiskey_mark.dart new file mode 100644 index 0000000..44de318 --- /dev/null +++ b/pi/ui/lib/widgets/whiskey_mark.dart @@ -0,0 +1,228 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Primitive attitude indicator (whiskey mark) displaying roll/pitch. +/// +/// Visual: tilting horizon line based on roll angle +/// Hard left (-45°+): ╲ +/// Left (-15°): ╲─ +/// Level (0°): ─ +/// Right (+15°): ─╱ +/// Hard right (+45°+): ╱ +/// +/// Below the horizon: numeric readout "R: -12° P: 5°" +class WhiskeyMark extends StatelessWidget { + /// Roll angle in degrees. Negative = left bank, positive = right bank. + final double? roll; + + /// Pitch angle in degrees. Negative = nose down, positive = nose up. + final double? pitch; + + const WhiskeyMark({ + super.key, + this.roll, + this.pitch, + }); + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min(constraints.maxWidth, constraints.maxHeight); + final horizonSize = size * 0.6; + final fontSize = size * 0.12; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Horizon indicator + SizedBox( + width: horizonSize, + height: horizonSize, + child: CustomPaint( + painter: _HorizonPainter( + roll: roll ?? 0, + pitch: pitch ?? 0, + lineColor: theme.foreground, + skyColor: theme.subdued.withValues(alpha: 0.2), + groundColor: theme.highlight.withValues(alpha: 0.3), + ), + ), + ), + + SizedBox(height: size * 0.05), + + // Numeric readout + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'R: ${_formatAngle(roll)}', + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.foreground, + ), + ), + SizedBox(width: size * 0.1), + Text( + 'P: ${_formatAngle(pitch)}', + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w400, + fontFeatures: const [FontFeature.tabularFigures()], + color: theme.subdued, + ), + ), + ], + ), + + SizedBox(height: size * 0.02), + + // Label + Text( + 'ATTITUDE', + style: TextStyle( + fontSize: fontSize * 0.8, + fontWeight: FontWeight.w400, + color: theme.subdued, + letterSpacing: 1, + ), + ), + ], + ); + }, + ); + } + + String _formatAngle(double? angle) { + if (angle == null) return '—°'; + return '${angle.round()}°'; + } +} + +/// Custom painter for the tilting horizon line +class _HorizonPainter extends CustomPainter { + final double roll; + final double pitch; + final Color lineColor; + final Color skyColor; + final Color groundColor; + + _HorizonPainter({ + required this.roll, + required this.pitch, + required this.lineColor, + required this.skyColor, + required this.groundColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + // Clip to circle + canvas.save(); + canvas.clipPath(Path()..addOval(Rect.fromCircle(center: center, radius: radius))); + + // Convert roll to radians (negate so positive roll tilts right visually) + final rollRad = -roll * math.pi / 180; + + // Pitch offset (positive pitch moves horizon down, showing more sky) + // Scale: 90° pitch = full radius displacement + final pitchOffset = (pitch / 90) * radius; + + // Calculate horizon line endpoints + // The horizon is a horizontal line that we rotate by roll and offset by pitch + final horizonY = center.dy + pitchOffset; + + // Paint sky (above horizon) + final skyPaint = Paint()..color = skyColor; + final groundPaint = Paint()..color = groundColor; + + // Create rotated horizon path + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(rollRad); + canvas.translate(-center.dx, -center.dy); + + // Sky rectangle (above horizon) + canvas.drawRect( + Rect.fromLTRB( + center.dx - radius * 2, + center.dy - radius * 2, + center.dx + radius * 2, + horizonY, + ), + skyPaint, + ); + + // Ground rectangle (below horizon) + canvas.drawRect( + Rect.fromLTRB( + center.dx - radius * 2, + horizonY, + center.dx + radius * 2, + center.dy + radius * 2, + ), + groundPaint, + ); + + // Horizon line + final linePaint = Paint() + ..color = lineColor + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + canvas.drawLine( + Offset(center.dx - radius, horizonY), + Offset(center.dx + radius, horizonY), + linePaint, + ); + + canvas.restore(); + + // Draw circle border + final borderPaint = Paint() + ..color = lineColor.withValues(alpha: 0.5) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + canvas.drawCircle(center, radius - 1, borderPaint); + + // Draw center reference mark (fixed, doesn't rotate) + final refPaint = Paint() + ..color = lineColor + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + + // Small wings + canvas.drawLine( + Offset(center.dx - radius * 0.3, center.dy), + Offset(center.dx - radius * 0.1, center.dy), + refPaint, + ); + canvas.drawLine( + Offset(center.dx + radius * 0.1, center.dy), + Offset(center.dx + radius * 0.3, center.dy), + refPaint, + ); + // Center dot + canvas.drawCircle(center, 3, Paint()..color = lineColor); + + canvas.restore(); + } + + @override + bool shouldRepaint(_HorizonPainter oldDelegate) { + return roll != oldDelegate.roll || + pitch != oldDelegate.pitch || + lineColor != oldDelegate.lineColor; + } +}