Compare commits
2 Commits
b7cf38c649
...
477fd698dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477fd698dc | ||
|
|
7301149c47 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// No window configured - clear history to save memory
|
||||||
|
_history.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupGhostTimer() {
|
|
||||||
_ghostResetTimer?.cancel();
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user