Compare commits

...

2 Commits

Author SHA1 Message Date
Mikkeli Matlock
477fd698dc ui: multiple visual upgrades:
- attitude indicator: pitch ladders and little triangle crosshair
- accelerometer: G trace and various other stuff
- navigator: bigger surprise
2026-02-05 00:55:18 +09:00
Mikkeli Matlock
7301149c47 README updates and minor visual fixes 2026-02-05 00:13:01 +09:00
7 changed files with 162 additions and 59 deletions

View File

@@ -30,6 +30,8 @@ uv run flask --app main run --host 0.0.0.0 --port 5000 --reload
## API ## API
### HTTP Endpoints
| Endpoint | Description | | Endpoint | Description |
|----------|-------------| |----------|-------------|
| `GET /health` | Health check, shows gpsd and Arduino connection status | | `GET /health` | Health check, shows gpsd and Arduino connection status |
@@ -38,6 +40,34 @@ uv run flask --app main run --host 0.0.0.0 --port 5000 --reload
| `GET /arduino` | Latest Arduino telemetry (voltage, rpm, eng_temp, gear) | | `GET /arduino` | Latest Arduino telemetry (voltage, rpm, eng_temp, gear) |
| `GET /arduino/history` | Last 100 buffered Arduino readings | | `GET /arduino/history` | Last 100 buffered Arduino readings |
### WebSocket Events (socket.io)
Real-time data is pushed over WebSocket. The UI connects once and receives streams.
**Server → Client:**
| Event | Description |
|-------|-------------|
| `arduino` | Real-time telemetry (voltage, rpm, roll, pitch, accel, etc.) |
| `gps` | GPS position updates |
| `status` | Connection status + `theme_switch` signal from GPIO |
| `alert` | System alerts |
| `ack` | Command acknowledgments |
**Client → Server:**
| Event | Description |
|-------|-------------|
| `button` | UI button presses (horn, light, indicators, hazard) |
| `emergency` | Emergency signal |
### Throttling
WebSocket data is rate-limited to prevent flooding:
- **Arduino data**: 20Hz max
- **GPS data**: 1Hz max
## Test from SSH ## Test from SSH
```bash ```bash

View File

@@ -22,11 +22,29 @@ All services use singleton pattern with `ServiceName.instance`.
| Service | Role | | Service | Role |
|---------|------| |---------|------|
| `ConfigService` | Loads `config.json`, exposes settings | | `ConfigService` | Loads `config.json`, exposes settings |
| `PiIO` | Pi hardware interface (CPU temp, future GPIO) | | `WebSocketService` | socket.io client, streams for arduino/gps/connection/debug, auto-reconnect |
| `PiIO` | Pi hardware interface (CPU temp) |
| `OverheatMonitor` | Polls temp, fires callback when threshold exceeded | | `OverheatMonitor` | Polls temp, fires callback when threshold exceeded |
| `ThemeService` | Dark/bright mode state, notifies listeners | | `ThemeService` | Dark/bright mode state, notifies listeners |
| `TestFlipFlopService` | Debug: toggles theme + navigator emotion every 2s | | `TestFlipFlopService` | Debug: toggles theme + navigator emotion every 2s |
## Key Widgets
| Widget | Purpose |
|--------|---------|
| `NavigatorWidget` | Animated character with emotion states (images precached at startup) |
| `AccelGraph` | Real-time accelerometer visualization with gravity compensation |
| `WhiskeyMark` | Gimbal-style horizon indicator using IMU roll/pitch |
| `SystemBar` | Top status bar (time, connection, Pi temp) |
| `StatBox` | Reusable metric display box |
| `DebugConsole` | Scrolling log overlay for diagnostics |
## Notes
- **Gravity compensation**: Accelerometer display subtracts 1g from Z-axis to show deviation from vertical
- **Navigator precaching**: All navigator images are loaded during splash screen to prevent frame drops
- **Theme switching**: Backend sends `theme_switch` via WebSocket status events (triggered by GPIO)
## Theme System ## Theme System
- `AppColors` — static color constants (dark/bright variants), auto-generated from JSON - `AppColors` — static color constants (dark/bright variants), auto-generated from JSON

View File

@@ -221,8 +221,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: AccelGraph( child: AccelGraph(
ax: _dynamicAx, // Gravity-compensated lateral ax: _dynamicAx, // Gravity-compensated lateral
ay: _dynamicAy, // Gravity-compensated longitudinal ay: _dynamicAy, // Gravity-compensated longitudinal
maxG: 1.0, maxG: 0.8,
ghostTrackPeriod: const Duration(seconds: 3), ghostTrackPeriod: const Duration(seconds: 4),
), ),
) )
], ],

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -38,55 +37,46 @@ class _AccelGraphState extends State<AccelGraph> {
double _ghostAx = 0; double _ghostAx = 0;
double _ghostAy = 0; double _ghostAy = 0;
double _ghostMagnitude = 0; double _ghostMagnitude = 0;
Timer? _ghostResetTimer;
@override // Timestamped history for sliding window
void initState() { List<({DateTime time, double ax, double ay})> _history = [];
super.initState();
_setupGhostTimer();
}
@override @override
void didUpdateWidget(AccelGraph oldWidget) { void didUpdateWidget(AccelGraph oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Update ghost position if current magnitude exceeds previous peak
final currentAx = widget.ax ?? 0; final currentAx = widget.ax ?? 0;
final currentAy = widget.ay ?? 0; final currentAy = widget.ay ?? 0;
final currentMag = math.sqrt(currentAx * currentAx + currentAy * currentAy); final now = DateTime.now();
if (currentMag > _ghostMagnitude) { // Only track history when ghostTrackPeriod is configured
if (widget.ghostTrackPeriod != null) {
// Add current reading to history
_history.add((time: now, ax: currentAx, ay: currentAy));
// Prune entries outside the window
final cutoff = now.subtract(widget.ghostTrackPeriod!);
_history.removeWhere((e) => e.time.isBefore(cutoff));
// Recalculate ghost as max magnitude from current window
_ghostAx = currentAx; _ghostAx = currentAx;
_ghostAy = currentAy; _ghostAy = currentAy;
_ghostMagnitude = currentMag; _ghostMagnitude = 0;
}
// Restart timer if period changed for (final entry in _history) {
if (oldWidget.ghostTrackPeriod != widget.ghostTrackPeriod) { final mag = math.sqrt(entry.ax * entry.ax + entry.ay * entry.ay);
_setupGhostTimer(); if (mag > _ghostMagnitude) {
_ghostAx = entry.ax;
_ghostAy = entry.ay;
_ghostMagnitude = mag;
} }
} }
} else {
void _setupGhostTimer() { // No window configured - clear history to save memory
_ghostResetTimer?.cancel(); _history.clear();
if (widget.ghostTrackPeriod != null) {
_ghostResetTimer = Timer.periodic(widget.ghostTrackPeriod!, (_) {
setState(() {
// Reset ghost to current position
_ghostAx = widget.ax ?? 0;
_ghostAy = widget.ay ?? 0;
_ghostMagnitude = math.sqrt(_ghostAx * _ghostAx + _ghostAy * _ghostAy);
});
});
} }
} }
@override
void dispose() {
_ghostResetTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
@@ -117,6 +107,7 @@ class _AccelGraphState extends State<AccelGraph> {
subdued: theme.subdued, subdued: theme.subdued,
background: theme.background, background: theme.background,
strokeWeight: strokeSize, strokeWeight: strokeSize,
traceBuffer: _history.map((e) => Offset(e.ax, e.ay)).toList(),
), ),
), ),
), ),
@@ -151,7 +142,7 @@ class _AccelGraphState extends State<AccelGraph> {
// Label // Label
Text( Text(
'ACCEL', 'Acceleration',
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.8, fontSize: fontSize * 0.8,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -167,7 +158,9 @@ class _AccelGraphState extends State<AccelGraph> {
String _formatAccel(double? force) { String _formatAccel(double? force) {
if (force == null) return '—°'; if (force == null) return '—°';
return '${force.toStringAsFixed(1)}G'; return '${
force.toStringAsFixed(1) == '-0.0' ? '0.0' : force.toStringAsFixed(1)
}G';
} }
} }
@@ -183,6 +176,7 @@ class _AccelGraphPainter extends CustomPainter {
final Color subdued; final Color subdued;
final Color background; final Color background;
final double strokeWeight; final double strokeWeight;
final List<Offset> traceBuffer;
_AccelGraphPainter({ _AccelGraphPainter({
required this.ax, required this.ax,
@@ -195,6 +189,7 @@ class _AccelGraphPainter extends CustomPainter {
required this.subdued, required this.subdued,
required this.background, required this.background,
required this.strokeWeight, required this.strokeWeight,
required this.traceBuffer,
}); });
@override @override
@@ -207,13 +202,13 @@ class _AccelGraphPainter extends CustomPainter {
// No rectangular border // No rectangular border
// Grid lines at 0.5G intervals // Grid lines at 0.25G intervals
final gridPaint = Paint() final gridPaint = Paint()
..color = subdued ..color = subdued
..strokeWidth = strokeWeight * 0.6 ..strokeWidth = strokeWeight * 0.4
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
final gStep = 0.5; final gStep = 0.25;
for (double g = gStep; g < maxG; g += gStep) { for (double g = gStep; g < maxG; g += gStep) {
final offset = (g / maxG) * halfSize; final offset = (g / maxG) * halfSize;
@@ -261,17 +256,40 @@ class _AccelGraphPainter extends CustomPainter {
axisPaint, axisPaint,
); );
// G-ring markers (circles at 1G and 2G for quick reference) // G-ring markers (circles at every 0.5G for quick reference)
final ringPaint = Paint() final ringPaint = Paint()
..color = subdued ..color = subdued
..strokeWidth = strokeWeight ..strokeWidth = strokeWeight * 0.5
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
for (double g = 1.0; g <= maxG; g += 1.0) { for (double g = 0.5; g <= maxG; g += 0.5) {
final radius = (g / maxG) * halfSize; final radius = (g / maxG) * halfSize;
canvas.drawCircle(center, radius, ringPaint); canvas.drawCircle(center, radius, ringPaint);
} }
// Trace line
if (traceBuffer.length >= 2) {
final tracePaint = Paint()
..color = foreground
..strokeWidth = strokeWeight * 0.4
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path();
for (int i = 0; i < traceBuffer.length; i++) {
final pt = traceBuffer[i];
final x = center.dx + (pt.dx.clamp(-maxG, maxG) / maxG) * halfSize;
final y = center.dy - (pt.dy.clamp(-maxG, maxG) / maxG) * halfSize;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, tracePaint);
}
// Ghost dot (if enabled and has data) // Ghost dot (if enabled and has data)
if (showGhost) { if (showGhost) {
final ghostX = center.dx + (ghostAx / maxG) * halfSize; final ghostX = center.dx + (ghostAx / maxG) * halfSize;
@@ -279,7 +297,7 @@ class _AccelGraphPainter extends CustomPainter {
final ghostRadius = halfSize * 0.08; final ghostRadius = halfSize * 0.08;
final ghostPaint = Paint() final ghostPaint = Paint()
..color = subdued.withValues(alpha: 0.5) ..color = subdued
..strokeWidth = strokeWeight ..strokeWidth = strokeWeight
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
@@ -309,6 +327,7 @@ class _AccelGraphPainter extends CustomPainter {
showGhost != oldDelegate.showGhost || showGhost != oldDelegate.showGhost ||
maxG != oldDelegate.maxG || maxG != oldDelegate.maxG ||
foreground != oldDelegate.foreground || foreground != oldDelegate.foreground ||
subdued != oldDelegate.subdued; subdued != oldDelegate.subdued ||
traceBuffer != oldDelegate.traceBuffer;
} }
} }

View File

@@ -88,8 +88,8 @@ class NavigatorWidgetState extends State<NavigatorWidget>
animation: _shakeController, animation: _shakeController,
child: image, child: image,
builder: (context, child) { builder: (context, child) {
final shake = sin(_shakeController.value * pi * 6) * 10 * final shake = sin(_shakeController.value * pi * 6) * 25 *
(1 - _shakeController.value); // 6 oscillations, 4px amplitude, decay (1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
return Transform.translate( return Transform.translate(
offset: Offset(shake, 0), offset: Offset(shake, 0),
child: child, child: child,

View File

@@ -85,7 +85,7 @@ class WhiskeyMark extends StatelessWidget {
// Label // Label
Text( Text(
'ATTITUDE', 'Attitude',
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.8, fontSize: fontSize * 0.8,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -180,7 +180,7 @@ class _HorizonPainter extends CustomPainter {
// Horizon line // Horizon line
final linePaint = Paint() final linePaint = Paint()
..color = lineColor ..color = lineColor
..strokeWidth = 2 ..strokeWidth = borderWeight * 0.1
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawLine( canvas.drawLine(
@@ -189,12 +189,34 @@ class _HorizonPainter extends CustomPainter {
linePaint, linePaint,
); );
// Pitch ladder lines (15° intervals)
final ladderPaint = Paint()
..color = lineColor
..strokeWidth = borderWeight * 0.4
..style = PaintingStyle.stroke;
for (int deg = -75; deg <= 75; deg += 15) {
if (deg == 0) continue; // Skip horizon (already drawn)
final ladderY = horizonY - (deg / 90) * radius;
final double widthMod = (100 - (deg < 0 ? -deg : deg)) / 100;
final ladderWidth = radius * 0.7 * widthMod; // longer ladder if close to horizon
canvas.drawLine(
Offset(center.dx - ladderWidth, ladderY),
Offset(center.dx + ladderWidth, ladderY),
ladderPaint,
);
}
canvas.restore(); canvas.restore();
// Draw circle border // Draw circle border
final borderPaint = Paint() final borderPaint = Paint()
..color = lineColor.withValues(alpha: 0.5) ..color = lineColor
..strokeWidth = borderWeight ..strokeWidth = borderWeight * 1.1
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
canvas.drawCircle(center, radius - 1, borderPaint); canvas.drawCircle(center, radius - 1, borderPaint);
@@ -202,7 +224,7 @@ class _HorizonPainter extends CustomPainter {
// Draw center reference mark (fixed, doesn't rotate) // Draw center reference mark (fixed, doesn't rotate)
final refPaint = Paint() final refPaint = Paint()
..color = lineColor ..color = lineColor
..strokeWidth = borderWeight * 0.8 ..strokeWidth = borderWeight
..style = PaintingStyle.stroke; ..style = PaintingStyle.stroke;
// Small wings // Small wings
@@ -216,12 +238,24 @@ class _HorizonPainter extends CustomPainter {
Offset(center.dx + radius * 0.3, center.dy), Offset(center.dx + radius * 0.3, center.dy),
refPaint, refPaint,
); );
// Center vertical line
// Center arrow
final refTipPaint = Paint()
..color = lineColor
..strokeWidth = borderWeight * 0.8
..style = PaintingStyle.stroke;
canvas.drawLine( canvas.drawLine(
Offset(center.dx, center.dy - radius * 0.05), Offset(center.dx, center.dy),
Offset(center.dx, center.dy + radius * 0.1), Offset(center.dx + radius * 0.07, center.dy + radius * 0.1),
refPaint, refTipPaint,
); );
canvas.drawLine(
Offset(center.dx, center.dy),
Offset(center.dx - radius * 0.07, center.dy + radius * 0.1),
refTipPaint,
);
canvas.restore(); canvas.restore();
} }

View File

@@ -46,6 +46,8 @@ Called automatically by `build.py`. Looks for theme matching `navigator` in `con
| `pi_setup_backend.sh` | First-time Pi config for backend (uv, gpsd, systemd) | | `pi_setup_backend.sh` | First-time Pi config for backend (uv, gpsd, systemd) |
| `smartserow-ui.service.sample` | UI systemd unit template | | `smartserow-ui.service.sample` | UI systemd unit template |
| `smartserow-backend.service.sample` | Backend systemd unit template | | `smartserow-backend.service.sample` | Backend systemd unit template |
| `smartserow-ui.service` | Production UI systemd unit (gitignored, Pi-specific) |
| `smartserow-backend.service` | Production backend systemd unit (gitignored, Pi-specific) |
```bash ```bash
# On the Pi - UI setup # On the Pi - UI setup