flutter: IMU attitude indicator and UART health check
- WhiskeyMark widget shows roll/pitch as horizon line - ArduinoData model includes IMU euler angles - startup waits for Arduino via /health endpoint - config_service exposes backendUrl Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import 'screens/splash_screen.dart';
|
import 'screens/splash_screen.dart';
|
||||||
import 'screens/dashboard_screen.dart';
|
import 'screens/dashboard_screen.dart';
|
||||||
@@ -36,14 +38,12 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
setState(() => _initStatus = 'Loading config...');
|
setState(() => _initStatus = 'Loading config...');
|
||||||
await ConfigService.instance.load();
|
await ConfigService.instance.load();
|
||||||
|
|
||||||
// Simulate init checks - replace with real checks later
|
|
||||||
// (UART, GPS, sensors, etc.)
|
|
||||||
|
|
||||||
setState(() => _initStatus = 'Checking systems...');
|
setState(() => _initStatus = 'Checking systems...');
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
setState(() => _initStatus = 'UART: standby');
|
// Check UART connection via backend health endpoint
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
setState(() => _initStatus = 'UART: connecting...');
|
||||||
|
await _waitForUart();
|
||||||
|
|
||||||
setState(() => _initStatus = 'GPS: standby');
|
setState(() => _initStatus = 'GPS: standby');
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
@@ -61,6 +61,42 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
setState(() => _initialized = true);
|
setState(() => _initialized = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Poll backend health endpoint until Arduino is connected
|
||||||
|
Future<void> _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<String, dynamic>;
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Determine which screen to show (priority: overheat > splash > dashboard)
|
// Determine which screen to show (priority: overheat > splash > dashboard)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../widgets/stat_box.dart';
|
|||||||
import '../widgets/stat_box_main.dart';
|
import '../widgets/stat_box_main.dart';
|
||||||
import '../widgets/system_bar.dart';
|
import '../widgets/system_bar.dart';
|
||||||
import '../widgets/debug_console.dart';
|
import '../widgets/debug_console.dart';
|
||||||
|
import '../widgets/whiskey_mark.dart';
|
||||||
|
|
||||||
// test service for triggers
|
// test service for triggers
|
||||||
import '../services/test_flipflop_service.dart';
|
import '../services/test_flipflop_service.dart';
|
||||||
@@ -41,6 +42,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
double? _voltage;
|
double? _voltage;
|
||||||
int? _engineTemp;
|
int? _engineTemp;
|
||||||
int? _gear;
|
int? _gear;
|
||||||
|
double? _roll;
|
||||||
|
double? _pitch;
|
||||||
|
|
||||||
// From backend - GPS data
|
// From backend - GPS data
|
||||||
double? _gpsSpeed;
|
double? _gpsSpeed;
|
||||||
@@ -66,6 +69,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
_rpm = data.rpm;
|
_rpm = data.rpm;
|
||||||
_engineTemp = data.engTemp;
|
_engineTemp = data.engTemp;
|
||||||
_gear = data.gear;
|
_gear = data.gear;
|
||||||
|
_roll = data.roll;
|
||||||
|
_pitch = data.pitch;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +104,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
_rpm = cachedArduino.rpm;
|
_rpm = cachedArduino.rpm;
|
||||||
_engineTemp = cachedArduino.engTemp;
|
_engineTemp = cachedArduino.engTemp;
|
||||||
_gear = cachedArduino.gear;
|
_gear = cachedArduino.gear;
|
||||||
|
_roll = cachedArduino.roll;
|
||||||
|
_pitch = cachedArduino.pitch;
|
||||||
}
|
}
|
||||||
|
|
||||||
final cachedGps = WebSocketService.instance.latestGps;
|
final cachedGps = WebSocketService.instance.latestGps;
|
||||||
@@ -175,12 +182,17 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// RPM from Arduino
|
// RPM from Arduino
|
||||||
StatBoxMain(
|
// StatBoxMain(
|
||||||
value: _formatInt(_rpm),
|
// value: _formatInt(_rpm),
|
||||||
label: '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'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
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 {
|
class ArduinoData {
|
||||||
final double? voltage;
|
final double? voltage;
|
||||||
final int? rpm;
|
final int? rpm;
|
||||||
final int? engTemp;
|
final int? engTemp;
|
||||||
final int? gear; // 0 = neutral, 1-6 = gear
|
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<String, dynamic> json) {
|
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||||
return ArduinoData(
|
return ArduinoData(
|
||||||
@@ -17,6 +19,8 @@ class ArduinoData {
|
|||||||
rpm: (json['rpm'] as num?)?.toInt(),
|
rpm: (json['rpm'] as num?)?.toInt(),
|
||||||
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
||||||
gear: (json['gear'] 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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,11 @@ class ConfigService {
|
|||||||
if (value is String && value.isNotEmpty) return value;
|
if (value is String && value.isNotEmpty) return value;
|
||||||
return _defaultNavigator;
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ class WebSocketService {
|
|||||||
final arduino = ArduinoData.fromJson(data);
|
final arduino = ArduinoData.fromJson(data);
|
||||||
_latestArduino = arduino;
|
_latestArduino = arduino;
|
||||||
_arduinoController.add(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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
228
pi/ui/lib/widgets/whiskey_mark.dart
Normal file
228
pi/ui/lib/widgets/whiskey_mark.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user