Compare commits

..

6 Commits

Author SHA1 Message Date
Mikkeli Matlock
0c342d7989 slight Rei colour theme tweaks 2026-02-08 03:07:09 +09:00
Mikkeli Matlock
58a523aab2 ui: gps compass widget layout changes
- angular direction (0-359) + 16ths compassrose
- documented in ui README
2026-02-08 03:06:52 +09:00
Mikkeli Matlock
896ba322c0 gps: debug stub mode with satellites field and signal loss simulation
- _GPS_DEBUG flag for development without hardware
- stub mode: realistic mock data with occasional signal loss
- satellites field in backend and UI data models
- periodic status logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 03:04:44 +09:00
Mikkeli Matlock
9173c3b93a new startup screen logic 2026-02-08 02:56:32 +09:00
Mikkeli Matlock
f2c69587ee ui: gps compass widget visual update 2026-02-08 02:27:21 +09:00
Mikkeli Matlock
324cd5dddc ui: gps compass widget 2026-02-08 00:26:59 +09:00
9 changed files with 275 additions and 65 deletions

View File

@@ -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"
} }

View File

@@ -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,6 +137,110 @@ 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:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
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):
"""Generate realistic mock GPS data for development/testing.
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:
self._connected = True
now = time.time()
# Check for signal loss simulation
if signal_lost:
if now >= signal_lost_until:
signal_lost = False
print("[GPS] Signal recovered (stub)")
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:
@@ -117,27 +252,18 @@ class GPSService:
if self._on_data_callback: if self._on_data_callback:
self._on_data_callback(fix) self._on_data_callback(fix)
def _stub_mode(self): # Periodic status log (every 5s)
"""Fake data for testing without gpsd.""" self._fix_count += 1
import random if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
while self._running: fps = self._fix_count / elapsed
self._connected = True speed_val = fix.get('speed') or 0
fix = { track_val = fix.get('track')
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), track_str = f"{track_val:.0f}" if track_val is not None else "---"
"lat": 35.6762 + random.uniform(-0.001, 0.001), mode = fix.get('mode', 0)
"lon": 139.6503 + random.uniform(-0.001, 0.001), sats = fix.get('satellites', 0)
"alt": 40.0 + random.uniform(-5, 5), print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
"speed": random.uniform(0, 30), self._last_status_log = now
"track": random.uniform(0, 360), self._fix_count = 0
"mode": 3,
}
with self._lock:
self._latest = fix
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
time.sleep(1) time.sleep(1)

View File

@@ -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 |

View File

@@ -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'));
} }

View File

@@ -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'),
], ],
), ),

View File

@@ -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,
children: statuses.entries.map((entry) {
final isReady = entry.value == 'Ready';
return Text(
'${entry.key}: ${entry.value}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: 80, fontSize: 48,
color: theme.subdued, color: isReady ? theme.foreground : theme.subdued,
), ),
);
}).toList(),
), ),
], ],
), ),

View File

@@ -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(),
); );
} }
} }

View File

@@ -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 ?? "-"}');
} }
}); });

View 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',
),
),
),
),
],
);
}
}