Compare commits
6 Commits
bc53bd7e82
...
0c342d7989
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c342d7989 | ||
|
|
58a523aab2 | ||
|
|
896ba322c0 | ||
|
|
9173c3b93a | ||
|
|
f2c69587ee | ||
|
|
324cd5dddc |
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"dark": {
|
"dark": {
|
||||||
"background": "#404040",
|
"background": "#303030",
|
||||||
"foreground": "#EAEAEA",
|
"foreground": "#EAEAEA",
|
||||||
"highlight": "#FA1504",
|
"highlight": "#FA1504",
|
||||||
"subdued": "#fda052"
|
"subdued": "#fda052"
|
||||||
},
|
},
|
||||||
"bright": {
|
"bright": {
|
||||||
"background": "#fda052",
|
"background": "#fda052",
|
||||||
"foreground": "#202020",
|
"foreground": "#303030",
|
||||||
"highlight": "#df2100",
|
"highlight": "#df2100",
|
||||||
"subdued": "#EAEAEA"
|
"subdued": "#EAEAEA"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
||||||
|
|
||||||
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEBUG MODE - Set True for development without GPS hardware
|
||||||
|
# When True: skips gpsd entirely, generates realistic mock data
|
||||||
|
# When False: connects to real gpsd (requires GPS device)
|
||||||
|
# ============================================================================
|
||||||
|
_GPS_DEBUG = True
|
||||||
|
|
||||||
# gpsdclient is a modern, simple gpsd client
|
# gpsdclient is a modern, simple gpsd client
|
||||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||||
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
||||||
@@ -32,6 +40,10 @@ class GPSService:
|
|||||||
# Callback for push-based updates
|
# Callback for push-based updates
|
||||||
self._on_data_callback = None
|
self._on_data_callback = None
|
||||||
|
|
||||||
|
# Periodic status logging
|
||||||
|
self._last_status_log = 0.0
|
||||||
|
self._fix_count = 0
|
||||||
|
|
||||||
def set_on_data(self, callback):
|
def set_on_data(self, callback):
|
||||||
"""Set callback for new GPS fix. Called with fix dict."""
|
"""Set callback for new GPS fix. Called with fix dict."""
|
||||||
self._on_data_callback = callback
|
self._on_data_callback = callback
|
||||||
@@ -57,6 +69,7 @@ class GPSService:
|
|||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
print("[GPS] Service started")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop background reader."""
|
"""Stop background reader."""
|
||||||
@@ -66,6 +79,7 @@ class GPSService:
|
|||||||
|
|
||||||
def _reader_loop(self):
|
def _reader_loop(self):
|
||||||
"""Main reader loop with reconnection logic."""
|
"""Main reader loop with reconnection logic."""
|
||||||
|
print("[GPS] Reader thread running")
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
self._connect_and_read()
|
self._connect_and_read()
|
||||||
@@ -76,23 +90,40 @@ class GPSService:
|
|||||||
|
|
||||||
def _connect_and_read(self):
|
def _connect_and_read(self):
|
||||||
"""Connect to gpsd and read data."""
|
"""Connect to gpsd and read data."""
|
||||||
|
# Debug mode: skip gpsd entirely, use stub data
|
||||||
|
if _GPS_DEBUG:
|
||||||
|
print("[GPS] Debug mode enabled, using stub data")
|
||||||
|
self._stub_mode()
|
||||||
|
return
|
||||||
|
|
||||||
if GPSDClient is None:
|
if GPSDClient is None:
|
||||||
# Stub mode - no gpsd client installed
|
|
||||||
print("[GPS] gpsdclient not installed, running in stub mode")
|
print("[GPS] gpsdclient not installed, running in stub mode")
|
||||||
self._stub_mode()
|
self._stub_mode()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Quick check if gpsd is reachable before attempting connection
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection((self.host, self.port), timeout=2.0)
|
||||||
|
sock.close()
|
||||||
|
except (socket.timeout, socket.error, OSError) as e:
|
||||||
|
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
|
||||||
|
raise ConnectionError(f"gpsd not reachable: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = GPSDClient(host=self.host, port=self.port)
|
client = GPSDClient(host=self.host, port=self.port)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode")
|
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
|
||||||
self._stub_mode()
|
raise ConnectionError(f"gpsd connection failed: {e}")
|
||||||
return
|
|
||||||
|
|
||||||
with client:
|
with client:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||||
|
|
||||||
|
self._last_status_log = time.time()
|
||||||
|
self._fix_count = 0
|
||||||
|
first_fix_timeout = time.time() + 5.0 # 5s to get first fix
|
||||||
|
|
||||||
for result in client.dict_stream(filter=["TPV"]):
|
for result in client.dict_stream(filter=["TPV"]):
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
@@ -106,8 +137,20 @@ class GPSService:
|
|||||||
"speed": result.get("speed"), # m/s
|
"speed": result.get("speed"), # m/s
|
||||||
"track": result.get("track"), # heading in degrees
|
"track": result.get("track"), # heading in degrees
|
||||||
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
||||||
|
"satellites": result.get("satellites"), # from SKY messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if this is a real fix (has position) or just empty TPV
|
||||||
|
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
|
||||||
|
# No real data yet, check timeout
|
||||||
|
if time.time() > first_fix_timeout:
|
||||||
|
print("[GPS] No GPS fix after 5s, will retry connection")
|
||||||
|
raise ConnectionError("No GPS fix within timeout")
|
||||||
|
continue # Skip empty fixes
|
||||||
|
|
||||||
|
# Got real data, disable timeout
|
||||||
|
first_fix_timeout = float('inf')
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = fix
|
self._latest = fix
|
||||||
if fix.get("lat") is not None:
|
if fix.get("lat") is not None:
|
||||||
@@ -117,27 +160,110 @@ class GPSService:
|
|||||||
if self._on_data_callback:
|
if self._on_data_callback:
|
||||||
self._on_data_callback(fix)
|
self._on_data_callback(fix)
|
||||||
|
|
||||||
|
# Periodic status log (every 5s)
|
||||||
|
self._fix_count += 1
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_status_log >= 5.0:
|
||||||
|
elapsed = now - self._last_status_log
|
||||||
|
fps = self._fix_count / elapsed
|
||||||
|
speed = fix.get('speed', 0) or 0
|
||||||
|
track = fix.get('track', 0) or 0
|
||||||
|
mode = fix.get('mode', 0) or 0
|
||||||
|
sats = fix.get('satellites', '?')
|
||||||
|
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
|
||||||
|
self._last_status_log = now
|
||||||
|
self._fix_count = 0
|
||||||
|
|
||||||
def _stub_mode(self):
|
def _stub_mode(self):
|
||||||
"""Fake data for testing without gpsd."""
|
"""Generate realistic mock GPS data for development/testing.
|
||||||
import random
|
|
||||||
|
Simulates:
|
||||||
|
- Normal 3D fix with satellites
|
||||||
|
- Occasional signal loss (~3% chance per second, lasts ~5s)
|
||||||
|
- Wandering position near Tokyo
|
||||||
|
"""
|
||||||
|
self._last_status_log = time.time()
|
||||||
|
self._fix_count = 0
|
||||||
|
|
||||||
|
# Signal loss state
|
||||||
|
signal_lost = False
|
||||||
|
signal_lost_until = 0.0
|
||||||
|
|
||||||
|
# Base position (Tokyo area)
|
||||||
|
base_lat = 35.6762
|
||||||
|
base_lon = 139.6503
|
||||||
|
base_alt = 40.0
|
||||||
|
|
||||||
|
# Smoothly varying heading/speed
|
||||||
|
heading = random.uniform(0, 360)
|
||||||
|
speed = random.uniform(5, 15)
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
fix = {
|
now = time.time()
|
||||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
||||||
"lat": 35.6762 + random.uniform(-0.001, 0.001),
|
# Check for signal loss simulation
|
||||||
"lon": 139.6503 + random.uniform(-0.001, 0.001),
|
if signal_lost:
|
||||||
"alt": 40.0 + random.uniform(-5, 5),
|
if now >= signal_lost_until:
|
||||||
"speed": random.uniform(0, 30),
|
signal_lost = False
|
||||||
"track": random.uniform(0, 360),
|
print("[GPS] Signal recovered (stub)")
|
||||||
"mode": 3,
|
else:
|
||||||
}
|
# ~30% chance per second to lose signal
|
||||||
|
if random.random() < 0.3:
|
||||||
|
signal_lost = True
|
||||||
|
signal_lost_until = now + 2 # fixed 2s loss
|
||||||
|
print("[GPS] Signal loss simulation (stub)")
|
||||||
|
|
||||||
|
if signal_lost:
|
||||||
|
# No fix - mode 1, no satellites, no track
|
||||||
|
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
|
||||||
|
fix = {
|
||||||
|
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
"alt": None,
|
||||||
|
"speed": None,
|
||||||
|
"track": None,
|
||||||
|
"mode": 1,
|
||||||
|
"satellites": 0,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Smoothly vary heading and speed
|
||||||
|
heading = (heading + random.uniform(1, 3)) % 360
|
||||||
|
speed = max(0, min(30, speed + random.uniform(-2, 2)))
|
||||||
|
|
||||||
|
fix = {
|
||||||
|
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"lat": base_lat + random.uniform(-0.001, 0.001),
|
||||||
|
"lon": base_lon + random.uniform(-0.001, 0.001),
|
||||||
|
"alt": base_alt + random.uniform(-5, 5),
|
||||||
|
"speed": speed,
|
||||||
|
"track": heading,
|
||||||
|
"mode": 3,
|
||||||
|
"satellites": random.randint(6, 12),
|
||||||
|
}
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = fix
|
self._latest = fix
|
||||||
self._buffer.append(fix)
|
if fix.get("lat") is not None:
|
||||||
|
self._buffer.append(fix)
|
||||||
|
|
||||||
# Invoke callback with new fix
|
# Invoke callback with new fix
|
||||||
if self._on_data_callback:
|
if self._on_data_callback:
|
||||||
self._on_data_callback(fix)
|
self._on_data_callback(fix)
|
||||||
|
|
||||||
|
# Periodic status log (every 5s)
|
||||||
|
self._fix_count += 1
|
||||||
|
if now - self._last_status_log >= 5.0:
|
||||||
|
elapsed = now - self._last_status_log
|
||||||
|
fps = self._fix_count / elapsed
|
||||||
|
speed_val = fix.get('speed') or 0
|
||||||
|
track_val = fix.get('track')
|
||||||
|
track_str = f"{track_val:.0f}" if track_val is not None else "---"
|
||||||
|
mode = fix.get('mode', 0)
|
||||||
|
sats = fix.get('satellites', 0)
|
||||||
|
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
|
||||||
|
self._last_status_log = now
|
||||||
|
self._fix_count = 0
|
||||||
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ All services use singleton pattern with `ServiceName.instance`.
|
|||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `NavigatorWidget` | Animated character with emotion states (images precached at startup) |
|
| `NavigatorWidget` | Animated character with emotion states (images precached at startup) |
|
||||||
| `AccelGraph` | Real-time accelerometer visualization with gravity compensation |
|
| `AccelGraph` | Real-time accelerometer visualization with gravity compensation |
|
||||||
|
| `GpsCompass` | GPS heading compass with rotating navigation icon and degree readout |
|
||||||
| `WhiskeyMark` | Gimbal-style horizon indicator using IMU roll/pitch |
|
| `WhiskeyMark` | Gimbal-style horizon indicator using IMU roll/pitch |
|
||||||
| `SystemBar` | Top status bar (time, connection, Pi temp) |
|
| `SystemBar` | Top status bar (time, connection, Pi temp) |
|
||||||
| `StatBox` | Reusable metric display box |
|
| `StatBox` | Reusable metric display box |
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class AppRoot extends StatefulWidget {
|
|||||||
class _AppRootState extends State<AppRoot> {
|
class _AppRootState extends State<AppRoot> {
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
bool _overheatTriggered = false;
|
bool _overheatTriggered = false;
|
||||||
String _initStatus = 'Starting...';
|
final Map<String, String> _initStatuses = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,27 +33,32 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateStatus(String key, String value) {
|
||||||
|
setState(() => _initStatuses[key] = value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runInitSequence() async {
|
Future<void> _runInitSequence() async {
|
||||||
// Load config first
|
// Show all items from the start so the row doesn't jump around
|
||||||
setState(() => _initStatus = 'Loading config...');
|
_updateStatus('Config', '...');
|
||||||
|
_updateStatus('UART', '...');
|
||||||
|
_updateStatus('Navigator', '...');
|
||||||
|
|
||||||
|
// Config must load first (everything else depends on it)
|
||||||
|
_updateStatus('Config', 'Loading');
|
||||||
await ConfigService.instance.load();
|
await ConfigService.instance.load();
|
||||||
|
_updateStatus('Config', 'Ready');
|
||||||
|
|
||||||
setState(() => _initStatus = 'Checking systems...');
|
// UART health check and navigator image preload run truly in parallel
|
||||||
|
_updateStatus('UART', 'Connecting');
|
||||||
|
_updateStatus('Navigator', 'Loading');
|
||||||
|
await Future.wait([
|
||||||
|
_waitForUart(),
|
||||||
|
_preloadNavigatorImages(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Let the user see the all-ready state for a moment
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
// Check UART connection via backend health endpoint
|
|
||||||
// Also preload navigator images in parallel (usually UART is the bottleneck)
|
|
||||||
setState(() => _initStatus = 'UART: connecting...');
|
|
||||||
final imagePreloadFuture = _preloadNavigatorImages();
|
|
||||||
await _waitForUart();
|
|
||||||
await imagePreloadFuture;
|
|
||||||
|
|
||||||
setState(() => _initStatus = 'GPS: standby');
|
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
|
||||||
|
|
||||||
setState(() => _initStatus = 'Ready');
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
|
|
||||||
// Start overheat monitoring
|
// Start overheat monitoring
|
||||||
OverheatMonitor.instance.start(
|
OverheatMonitor.instance.start(
|
||||||
onOverheat: () {
|
onOverheat: () {
|
||||||
@@ -78,11 +83,8 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final arduinoOk = data['arduino_connected'] == true;
|
if (data['arduino_connected'] == true) {
|
||||||
|
_updateStatus('UART', 'Ready');
|
||||||
if (arduinoOk) {
|
|
||||||
setState(() => _initStatus = 'UART: OK');
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,28 +92,24 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
// Backend not reachable yet - keep trying
|
// Backend not reachable yet - keep trying
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not connected yet
|
_updateStatus('UART', 'Waiting');
|
||||||
setState(() => _initStatus = 'UART: waiting...');
|
|
||||||
await Future.delayed(retryDelay);
|
await Future.delayed(retryDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout - proceed anyway (UI will show stale data indicators)
|
// Timeout - proceed anyway (UI will show stale data indicators)
|
||||||
setState(() => _initStatus = 'UART: timeout');
|
_updateStatus('UART', 'Timeout');
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preload navigator images into Flutter's image cache
|
/// Preload navigator images into Flutter's image cache
|
||||||
///
|
///
|
||||||
/// Scans for all PNGs in the navigator folder and precaches them.
|
/// Scans for all PNGs in the navigator folder and precaches them.
|
||||||
/// Runs silently - no status updates (meant to run parallel with UART).
|
|
||||||
Future<void> _preloadNavigatorImages() async {
|
Future<void> _preloadNavigatorImages() async {
|
||||||
final images = await ConfigService.instance.getNavigatorImages();
|
final images = await ConfigService.instance.getNavigatorImages();
|
||||||
for (final file in images) {
|
for (final file in images) {
|
||||||
// precacheImage needs a context, but we're in initState territory
|
|
||||||
// Use the root context via a post-frame callback workaround
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await precacheImage(FileImage(file), context);
|
await precacheImage(FileImage(file), context);
|
||||||
}
|
}
|
||||||
|
_updateStatus('Navigator', 'Ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -121,7 +119,7 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
if (_overheatTriggered) {
|
if (_overheatTriggered) {
|
||||||
child = const OverheatScreen(key: ValueKey('overheat'));
|
child = const OverheatScreen(key: ValueKey('overheat'));
|
||||||
} else if (!_initialized) {
|
} else if (!_initialized) {
|
||||||
child = SplashScreen(key: const ValueKey('splash'), status: _initStatus);
|
child = SplashScreen(key: const ValueKey('splash'), statuses: _initStatuses);
|
||||||
} else {
|
} else {
|
||||||
child = const DashboardScreen(key: ValueKey('dashboard'));
|
child = const DashboardScreen(key: ValueKey('dashboard'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../widgets/system_bar.dart';
|
|||||||
import '../widgets/debug_console.dart';
|
import '../widgets/debug_console.dart';
|
||||||
import '../widgets/whiskey_mark.dart';
|
import '../widgets/whiskey_mark.dart';
|
||||||
import '../widgets/accel_graph.dart';
|
import '../widgets/accel_graph.dart';
|
||||||
|
import '../widgets/gps_compass.dart';
|
||||||
|
|
||||||
// test service for triggers
|
// test service for triggers
|
||||||
import '../services/test_flipflop_service.dart';
|
import '../services/test_flipflop_service.dart';
|
||||||
@@ -55,6 +56,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
|
|
||||||
// From backend - GPS data
|
// From backend - GPS data
|
||||||
double? _gpsSpeed;
|
double? _gpsSpeed;
|
||||||
|
double? _gpsTrack;
|
||||||
|
|
||||||
// Placeholder values for system bar
|
// Placeholder values for system bar
|
||||||
int? _gpsSatellites;
|
int? _gpsSatellites;
|
||||||
@@ -105,8 +107,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_gpsSpeed = data.speed;
|
_gpsSpeed = data.speed;
|
||||||
// Derive satellites from mode (placeholder logic)
|
_gpsTrack = data.track;
|
||||||
_gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0);
|
_gpsSatellites = data.satellites;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +142,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
final cachedGps = WebSocketService.instance.latestGps;
|
final cachedGps = WebSocketService.instance.latestGps;
|
||||||
if (cachedGps != null) {
|
if (cachedGps != null) {
|
||||||
_gpsSpeed = cachedGps.speed;
|
_gpsSpeed = cachedGps.speed;
|
||||||
_gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0);
|
_gpsTrack = cachedGps.track;
|
||||||
|
_gpsSatellites = cachedGps.satellites;
|
||||||
}
|
}
|
||||||
|
|
||||||
_wsState = WebSocketService.instance.connectionState;
|
_wsState = WebSocketService.instance.connectionState;
|
||||||
@@ -203,11 +206,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
wsState: _wsState,
|
wsState: _wsState,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 2),
|
||||||
|
|
||||||
// Main content area - big stat boxes
|
// Main content area - big stat boxes
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 8,
|
flex: 7,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Attitude indicator (whiskey mark)
|
// Attitude indicator (whiskey mark)
|
||||||
@@ -231,11 +234,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
|
|
||||||
// Bottom stats row
|
// Bottom stats row
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 3,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||||
|
GpsCompass(heading: _gpsTrack),
|
||||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Splash screen - shown during initialization
|
/// Splash screen - shown during initialization
|
||||||
|
///
|
||||||
|
/// Displays parallel status items that independently flip to "Ready".
|
||||||
class SplashScreen extends StatelessWidget {
|
class SplashScreen extends StatelessWidget {
|
||||||
final String status;
|
final Map<String, String> statuses;
|
||||||
|
|
||||||
const SplashScreen({super.key, required this.status});
|
const SplashScreen({super.key, required this.statuses});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -33,13 +35,19 @@ class SplashScreen extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 32),
|
||||||
Text(
|
Row(
|
||||||
status,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
children: statuses.entries.map((entry) {
|
||||||
fontSize: 80,
|
final isReady = entry.value == 'Ready';
|
||||||
color: theme.subdued,
|
return Text(
|
||||||
),
|
'${entry.key}: ${entry.value}',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
fontSize: 48,
|
||||||
|
color: isReady ? theme.foreground : theme.subdued,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ class GpsData {
|
|||||||
final double? alt;
|
final double? alt;
|
||||||
final double? track;
|
final double? track;
|
||||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
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});
|
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites});
|
||||||
|
|
||||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||||
return GpsData(
|
return GpsData(
|
||||||
@@ -50,6 +51,7 @@ class GpsData {
|
|||||||
alt: (json['alt'] as num?)?.toDouble(),
|
alt: (json['alt'] as num?)?.toDouble(),
|
||||||
track: (json['track'] as num?)?.toDouble(),
|
track: (json['track'] as num?)?.toDouble(),
|
||||||
mode: (json['mode'] as num?)?.toInt(),
|
mode: (json['mode'] as num?)?.toInt(),
|
||||||
|
satellites: (json['satellites'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class WebSocketService {
|
|||||||
final gps = GpsData.fromJson(data);
|
final gps = GpsData.fromJson(data);
|
||||||
_latestGps = gps;
|
_latestGps = gps;
|
||||||
_gpsController.add(gps);
|
_gpsController.add(gps);
|
||||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s mode${gps.mode ?? "-"}');
|
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
71
pi/ui/lib/widgets/gps_compass.dart
Normal file
71
pi/ui/lib/widgets/gps_compass.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
class GpsCompass extends StatelessWidget {
|
||||||
|
final double? heading;
|
||||||
|
|
||||||
|
const GpsCompass({super.key, this.heading});
|
||||||
|
|
||||||
|
bool get _hasSignal => heading != null;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _compassDirection {
|
||||||
|
if (!_hasSignal) return '';
|
||||||
|
final directions = [
|
||||||
|
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||||
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
||||||
|
];
|
||||||
|
final index = ((heading! % 360) / 22.5).round() % 16;
|
||||||
|
return directions[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = AppTheme.of(context);
|
||||||
|
|
||||||
|
// No signal = subdued color, valid = foreground
|
||||||
|
final iconColour = _hasSignal ? theme.foreground : theme.highlight;
|
||||||
|
|
||||||
|
// Convert to radians, 0 = no rotation when no signal
|
||||||
|
final angle = _hasSignal ? (heading! * math.pi / 180.0) : 0.0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 3,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _hasSignal ? angle : 0,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Icon(
|
||||||
|
_hasSignal ? Icons.navigation : Icons.navigation_outlined,
|
||||||
|
size: 120,
|
||||||
|
color: iconColour,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: Text(
|
||||||
|
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 80,
|
||||||
|
color: theme.subdued,
|
||||||
|
fontFamily: 'DIN1451',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user