Compare commits
13 Commits
477fd698dc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46496d688 | ||
|
|
47b3427e63 | ||
|
|
12a0d58800 | ||
| 629c735eec | |||
|
|
992270ed00 | ||
|
|
83af09b47c | ||
|
|
0c342d7989 | ||
|
|
58a523aab2 | ||
|
|
896ba322c0 | ||
|
|
9173c3b93a | ||
|
|
f2c69587ee | ||
|
|
324cd5dddc | ||
|
|
bc53bd7e82 |
5
IDEAS.md
Normal file
5
IDEAS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Note to keep inspirations lest I forget.
|
||||
|
||||
# Things to do, but not really urgent
|
||||
- Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am)
|
||||
- Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps.
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"dark": {
|
||||
"background": "#404040",
|
||||
"background": "#303030",
|
||||
"foreground": "#EAEAEA",
|
||||
"highlight": "#FA1504",
|
||||
"subdued": "#fda052"
|
||||
},
|
||||
"bright": {
|
||||
"background": "#fda052",
|
||||
"foreground": "#202020",
|
||||
"foreground": "#303030",
|
||||
"highlight": "#df2100",
|
||||
"subdued": "#EAEAEA"
|
||||
}
|
||||
|
||||
@@ -178,6 +178,19 @@ The Pi's internal pull-down (~50kΩ) will overpower high-value external resistor
|
||||
|
||||
Physical switches/connectors need debouncing. Current implementation requires 15 consecutive identical readings (~750ms at 20Hz) before accepting a state change. Tune `required_consecutive` in `gpio_service.py` as needed.
|
||||
|
||||
## Utilities
|
||||
|
||||
Standalone tools live in `pi/utils/` (not part of the backend service):
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `at_terminal.py` | Interactive AT command terminal for SIM7600 (pyserial). Default port: `/dev/ttyUSB2` |
|
||||
|
||||
```bash
|
||||
python pi/utils/at_terminal.py # default /dev/ttyUSB2
|
||||
python pi/utils/at_terminal.py /dev/ttyUSB3 # specify port
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
TODO: Add to `scripts/deploy.py` as second target + systemd service.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
||||
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
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 = False
|
||||
|
||||
# gpsdclient is a modern, simple gpsd client
|
||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
||||
@@ -32,6 +40,14 @@ class GPSService:
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback = None
|
||||
|
||||
# GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
|
||||
self._has_ever_fixed = False # True after first valid fix this session
|
||||
|
||||
# Periodic status logging
|
||||
self._last_status_log = 0.0
|
||||
self._last_state_emit = 0.0
|
||||
self._fix_count = 0
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new GPS fix. Called with fix dict."""
|
||||
self._on_data_callback = callback
|
||||
@@ -45,6 +61,17 @@ class GPSService:
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def _gps_state(self, fix: dict) -> str:
|
||||
"""Determine GPS state: acquiring, fix, or lost.
|
||||
|
||||
NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
|
||||
report mode 1 with no position. We track it ourselves.
|
||||
"""
|
||||
has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
|
||||
if has_fix:
|
||||
return "fix"
|
||||
return "lost" if self._has_ever_fixed else "acquiring"
|
||||
|
||||
def get_buffer(self) -> list[dict[str, Any]]:
|
||||
"""Get buffered GPS history."""
|
||||
with self._lock:
|
||||
@@ -57,6 +84,7 @@ class GPSService:
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
print("[GPS] Service started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background reader."""
|
||||
@@ -66,6 +94,7 @@ class GPSService:
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Main reader loop with reconnection logic."""
|
||||
print("[GPS] Reader thread running")
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_read()
|
||||
@@ -76,23 +105,41 @@ class GPSService:
|
||||
|
||||
def _connect_and_read(self):
|
||||
"""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:
|
||||
# Stub mode - no gpsd client installed
|
||||
print("[GPS] gpsdclient not installed, running in stub mode")
|
||||
self._stub_mode()
|
||||
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:
|
||||
client = GPSDClient(host=self.host, port=self.port)
|
||||
except Exception as e:
|
||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
raise ConnectionError(f"gpsd connection failed: {e}")
|
||||
|
||||
with client:
|
||||
self._connected = True
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||
|
||||
self._last_status_log = time.time()
|
||||
self._fix_count = 0
|
||||
# 120s for initial cold fix, 10s for signal loss after first fix
|
||||
fix_timeout = time.time() + 120.0
|
||||
|
||||
for result in client.dict_stream(filter=["TPV"]):
|
||||
if not self._running:
|
||||
break
|
||||
@@ -106,8 +153,36 @@ class GPSService:
|
||||
"speed": result.get("speed"), # m/s
|
||||
"track": result.get("track"), # heading in degrees
|
||||
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
||||
"satellites": result.get("satellites"), # from SKY messages
|
||||
}
|
||||
|
||||
# Compute state and attach to fix
|
||||
fix["gps_state"] = self._gps_state(fix)
|
||||
|
||||
# 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() > fix_timeout:
|
||||
timeout_s = "120s" if not self._has_ever_fixed else "10s"
|
||||
print(f"[GPS] No GPS fix after {timeout_s}, will retry connection")
|
||||
raise ConnectionError("No GPS fix within timeout")
|
||||
# Emit state periodically so UI knows we're alive
|
||||
now = time.time()
|
||||
if now - self._last_state_emit >= 5.0:
|
||||
self._last_state_emit = now
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
continue # Skip empty fixes
|
||||
|
||||
# Got real data — mark first fix, reset timeout to shorter window
|
||||
if not self._has_ever_fixed:
|
||||
self._has_ever_fixed = True
|
||||
self._last_state_emit = 0.0 # Force immediate emit on transition
|
||||
print("[GPS] First fix acquired")
|
||||
fix_timeout = time.time() + 10.0 # 10s timeout for signal loss
|
||||
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if fix.get("lat") is not None:
|
||||
@@ -117,27 +192,124 @@ class GPSService:
|
||||
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):
|
||||
"""Fake data for testing without gpsd."""
|
||||
import random
|
||||
"""Generate realistic mock GPS data for development/testing.
|
||||
|
||||
Simulates:
|
||||
- Initial acquisition delay (~3s before first fix)
|
||||
- Normal 3D fix with satellites
|
||||
- Occasional signal loss (~30% chance per second, lasts ~2s)
|
||||
- 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
|
||||
|
||||
# Simulate cold start acquisition (~30s)
|
||||
acquiring_until = time.time() + 30.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
|
||||
fix = {
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"lat": 35.6762 + random.uniform(-0.001, 0.001),
|
||||
"lon": 139.6503 + random.uniform(-0.001, 0.001),
|
||||
"alt": 40.0 + random.uniform(-5, 5),
|
||||
"speed": random.uniform(0, 30),
|
||||
"track": random.uniform(0, 360),
|
||||
"mode": 3,
|
||||
}
|
||||
now = time.time()
|
||||
|
||||
# Simulate initial acquisition period
|
||||
if now < acquiring_until:
|
||||
signal_lost = True # No fix yet
|
||||
elif signal_lost and now >= signal_lost_until:
|
||||
signal_lost = False
|
||||
if self._has_ever_fixed:
|
||||
print("[GPS] Signal recovered (stub)")
|
||||
else:
|
||||
print("[GPS] First fix acquired (stub)")
|
||||
elif not signal_lost:
|
||||
# ~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)))
|
||||
|
||||
if not self._has_ever_fixed:
|
||||
self._has_ever_fixed = True
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
# Attach state — same logic as real GPS path
|
||||
fix["gps_state"] = self._gps_state(fix)
|
||||
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
self._buffer.append(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
|
||||
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)
|
||||
|
||||
178
pi/backend/lte_service.py
Normal file
178
pi/backend/lte_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""LTE service - polls ModemManager for signal quality and connection state."""
|
||||
|
||||
import random
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# ============================================================================
|
||||
# DEBUG MODE - Set True for development without modem hardware
|
||||
# When True: skips mmcli entirely, generates mock LTE data
|
||||
# When False: polls real ModemManager via mmcli
|
||||
# ============================================================================
|
||||
_LTE_DEBUG = True
|
||||
|
||||
|
||||
class LteService:
|
||||
"""Threaded LTE modem status poller.
|
||||
|
||||
Polls `mmcli -m 0` every 5 seconds, parses signal quality, connection
|
||||
state, operator, and access technology. Emits dict via callback.
|
||||
|
||||
No history buffer — historical signal strength isn't useful.
|
||||
"""
|
||||
|
||||
def __init__(self, poll_interval: float = 5.0):
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False # True when mmcli responds (modem alive)
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback = None
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new LTE data. Called with data dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Whether the modem is reachable via mmcli."""
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent LTE status."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def start(self):
|
||||
"""Start background LTE poller thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
print("[LTE] Service started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background poller."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main poll loop."""
|
||||
print("[LTE] Poller thread running")
|
||||
while self._running:
|
||||
try:
|
||||
if _LTE_DEBUG:
|
||||
data = self._stub_poll()
|
||||
else:
|
||||
data = self._real_poll()
|
||||
|
||||
with self._lock:
|
||||
self._latest = data
|
||||
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LTE] Poll error: {e}")
|
||||
self._connected = False
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _real_poll(self) -> dict[str, Any]:
|
||||
"""Poll mmcli -m 0 and parse output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mmcli", "-m", "0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self._connected = False
|
||||
return {
|
||||
"connected": False,
|
||||
"signal": 0,
|
||||
"operator": None,
|
||||
"access_tech": None,
|
||||
}
|
||||
|
||||
output = result.stdout
|
||||
self._connected = True
|
||||
|
||||
# Parse signal quality: "signal quality: 68 (recent)"
|
||||
signal = 0
|
||||
m = re.search(r"signal quality:\s*(\d+)", output)
|
||||
if m:
|
||||
signal = int(m.group(1))
|
||||
|
||||
# Parse state: "state: connected" / "registered" / "searching" etc.
|
||||
state = None
|
||||
m = re.search(r"^\s*state:\s*(\S+)", output, re.MULTILINE)
|
||||
if m:
|
||||
state = m.group(1).strip("'\"")
|
||||
|
||||
# Parse operator: "operator name: KDDI KDDI"
|
||||
operator = None
|
||||
m = re.search(r"operator name:\s*(.+)", output)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
|
||||
# Parse access tech: "access tech: lte"
|
||||
access_tech = None
|
||||
m = re.search(r"access tech:\s*(\S+)", output)
|
||||
if m:
|
||||
access_tech = m.group(1).strip("'\"")
|
||||
|
||||
network_connected = state in ("connected", "registered")
|
||||
|
||||
return {
|
||||
"connected": network_connected,
|
||||
"signal": signal,
|
||||
"operator": operator,
|
||||
"access_tech": access_tech,
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[LTE] mmcli timed out")
|
||||
self._connected = False
|
||||
return {
|
||||
"connected": False,
|
||||
"signal": 0,
|
||||
"operator": None,
|
||||
"access_tech": None,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
print("[LTE] mmcli not found, falling back to stub mode")
|
||||
self._connected = False
|
||||
return self._stub_poll()
|
||||
|
||||
# Stub state lives across polls
|
||||
_stub_signal: float = 70.0
|
||||
|
||||
def _stub_poll(self) -> dict[str, Any]:
|
||||
"""Generate mock LTE data for development.
|
||||
|
||||
Simulates connected state with signal wandering 60-80.
|
||||
"""
|
||||
self._connected = True
|
||||
|
||||
# Random walk signal, clamped to 60-80
|
||||
self._stub_signal += random.uniform(-3, 3)
|
||||
self._stub_signal = max(60, min(80, self._stub_signal))
|
||||
|
||||
return {
|
||||
"connected": True,
|
||||
"signal": int(self._stub_signal),
|
||||
"operator": "STUB",
|
||||
"access_tech": "lte",
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from flask_socketio import SocketIO, emit
|
||||
from gps_service import GPSService
|
||||
from arduino_service import ArduinoService
|
||||
from gpio_service import GPIOService
|
||||
from lte_service import LteService
|
||||
from throttle import Throttle
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -21,10 +22,12 @@ socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
|
||||
gps = GPSService()
|
||||
arduino = ArduinoService()
|
||||
gpio = GPIOService()
|
||||
lte = LteService()
|
||||
|
||||
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS)
|
||||
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS, 5s for LTE)
|
||||
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
|
||||
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
||||
lte_throttle = Throttle(min_interval=5.0) # Every 5s — signal doesn't need real-time
|
||||
|
||||
# Track connected clients
|
||||
connected_clients = set()
|
||||
@@ -57,6 +60,10 @@ def handle_connect():
|
||||
if "error" not in gps_data:
|
||||
emit("gps", gps_data)
|
||||
|
||||
lte_data = lte.get_latest()
|
||||
if "error" not in lte_data:
|
||||
emit("lte", lte_data)
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
@@ -133,6 +140,10 @@ def on_arduino_data(data):
|
||||
data = dict(data) # Don't mutate original
|
||||
data["theme_switch"] = gpio.theme_switch
|
||||
|
||||
# backend voltage offset correction
|
||||
if "voltage" in data:
|
||||
data["voltage"] += 0.2 # Calibration offset
|
||||
|
||||
def emit_fn(d):
|
||||
socketio.emit("arduino", d)
|
||||
|
||||
@@ -147,6 +158,14 @@ def on_gps_data(data):
|
||||
gps_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_lte_data(data):
|
||||
"""Called by LteService when new status polled."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("lte", d)
|
||||
|
||||
lte_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_arduino_ack(cmd, status, extra):
|
||||
"""Called by ArduinoService when ACK received from Arduino."""
|
||||
socketio.emit("ack", {
|
||||
@@ -172,6 +191,9 @@ def throttle_flusher():
|
||||
if gps_throttle.has_pending:
|
||||
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
||||
|
||||
if lte_throttle.has_pending:
|
||||
lte_throttle.flush(lambda d: socketio.emit("lte", d))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REST API (backward compatibility)
|
||||
@@ -180,10 +202,15 @@ def throttle_flusher():
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
gps_latest = gps.get_latest()
|
||||
lte_latest = lte.get_latest()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"gps_connected": gps.connected,
|
||||
"gps_state": gps_latest.get("gps_state", "acquiring"),
|
||||
"arduino_connected": arduino.connected,
|
||||
"lte_connected": lte.connected,
|
||||
"lte_signal": lte_latest.get("signal"),
|
||||
"ws_clients": len(connected_clients),
|
||||
})
|
||||
|
||||
@@ -200,6 +227,12 @@ def gps_history():
|
||||
return jsonify(gps.get_buffer())
|
||||
|
||||
|
||||
@app.route("/lte")
|
||||
def lte_data():
|
||||
"""Current LTE modem status."""
|
||||
return jsonify(lte.get_latest())
|
||||
|
||||
|
||||
@app.route("/arduino")
|
||||
def arduino_data():
|
||||
"""Current Arduino telemetry (voltage, rpm, etc)."""
|
||||
@@ -222,11 +255,13 @@ def main():
|
||||
arduino.set_on_data(on_arduino_data)
|
||||
arduino.set_on_ack(on_arduino_ack)
|
||||
gps.set_on_data(on_gps_data)
|
||||
lte.set_on_data(on_lte_data)
|
||||
|
||||
# Start services
|
||||
gps.start()
|
||||
arduino.start()
|
||||
gpio.start()
|
||||
lte.start()
|
||||
|
||||
# Start throttle flusher in background
|
||||
socketio.start_background_task(throttle_flusher)
|
||||
@@ -239,6 +274,7 @@ def main():
|
||||
arduino.stop()
|
||||
gps.stop()
|
||||
gpio.stop()
|
||||
lte.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
49
pi/backend/utils/at_terminal.py
Normal file
49
pi/backend/utils/at_terminal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Quick AT command terminal - minicom but less hostile.
|
||||
|
||||
Usage:
|
||||
python at_terminal.py [port]
|
||||
|
||||
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import serial
|
||||
import threading
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def reader(ser):
|
||||
"""Background thread: print everything the modem sends."""
|
||||
while True:
|
||||
try:
|
||||
data = ser.read(ser.in_waiting or 1)
|
||||
if data:
|
||||
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Opening {PORT} @ {BAUD} baud")
|
||||
print("Type AT commands. Ctrl+C to quit.\n")
|
||||
|
||||
ser = serial.Serial(PORT, BAUD, timeout=0.1)
|
||||
|
||||
t = threading.Thread(target=reader, args=(ser,), daemon=True)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = input()
|
||||
ser.write((line + "\r\n").encode())
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nBye.")
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -34,6 +34,7 @@ All services use singleton pattern with `ServiceName.instance`.
|
||||
|--------|---------|
|
||||
| `NavigatorWidget` | Animated character with emotion states (images precached at startup) |
|
||||
| `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 |
|
||||
| `SystemBar` | Top status bar (time, connection, Pi temp) |
|
||||
| `StatBox` | Reusable metric display box |
|
||||
|
||||
@@ -19,7 +19,7 @@ class AppRoot extends StatefulWidget {
|
||||
class _AppRootState extends State<AppRoot> {
|
||||
bool _initialized = false;
|
||||
bool _overheatTriggered = false;
|
||||
String _initStatus = 'Starting...';
|
||||
final Map<String, String> _initStatuses = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,27 +33,35 @@ class _AppRootState extends State<AppRoot> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateStatus(String key, String value) {
|
||||
setState(() => _initStatuses[key] = value);
|
||||
}
|
||||
|
||||
Future<void> _runInitSequence() async {
|
||||
// Load config first
|
||||
setState(() => _initStatus = 'Loading config...');
|
||||
// Show all items from the start so the row doesn't jump around
|
||||
_updateStatus('Config', '...');
|
||||
_updateStatus('UART', '...');
|
||||
_updateStatus('GPS', '...');
|
||||
_updateStatus('Navigator', '...');
|
||||
|
||||
// Config must load first (everything else depends on it)
|
||||
_updateStatus('Config', 'Loading');
|
||||
await ConfigService.instance.load();
|
||||
_updateStatus('Config', 'Ready');
|
||||
|
||||
setState(() => _initStatus = 'Checking systems...');
|
||||
// UART, GPS, and navigator image preload run truly in parallel
|
||||
_updateStatus('UART', 'Connecting');
|
||||
_updateStatus('GPS', 'Waiting');
|
||||
_updateStatus('Navigator', 'Loading');
|
||||
await Future.wait([
|
||||
_waitForUart(),
|
||||
_waitForGps(),
|
||||
_preloadNavigatorImages(),
|
||||
]);
|
||||
|
||||
// Let the user see the all-ready state for a moment
|
||||
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
|
||||
OverheatMonitor.instance.start(
|
||||
onOverheat: () {
|
||||
@@ -78,11 +86,8 @@ class _AppRootState extends State<AppRoot> {
|
||||
|
||||
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));
|
||||
if (data['arduino_connected'] == true) {
|
||||
_updateStatus('UART', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -90,28 +95,57 @@ class _AppRootState extends State<AppRoot> {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
// Not connected yet
|
||||
setState(() => _initStatus = 'UART: waiting...');
|
||||
_updateStatus('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));
|
||||
_updateStatus('UART', 'Timeout');
|
||||
}
|
||||
|
||||
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
|
||||
Future<void> _waitForGps() async {
|
||||
final backendUrl = ConfigService.instance.backendUrl;
|
||||
const bailOut = Duration(milliseconds: 7500);
|
||||
const retryDelay = Duration(seconds: 1);
|
||||
final deadline = DateTime.now().add(bailOut);
|
||||
|
||||
_updateStatus('GPS', 'Acquiring');
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
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>;
|
||||
if (data['gps_state'] == 'fix') {
|
||||
_updateStatus('GPS', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
|
||||
// Bail out - dashboard will show live GPS state when it arrives
|
||||
_updateStatus('GPS', 'Timeout');
|
||||
}
|
||||
|
||||
/// Preload navigator images into Flutter's image cache
|
||||
///
|
||||
/// 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 {
|
||||
final images = await ConfigService.instance.getNavigatorImages();
|
||||
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;
|
||||
await precacheImage(FileImage(file), context);
|
||||
}
|
||||
_updateStatus('Navigator', 'Ready');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -121,7 +155,7 @@ class _AppRootState extends State<AppRoot> {
|
||||
if (_overheatTriggered) {
|
||||
child = const OverheatScreen(key: ValueKey('overheat'));
|
||||
} else if (!_initialized) {
|
||||
child = SplashScreen(key: const ValueKey('splash'), status: _initStatus);
|
||||
child = SplashScreen(key: const ValueKey('splash'), statuses: _initStatuses);
|
||||
} else {
|
||||
child = const DashboardScreen(key: ValueKey('dashboard'));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../widgets/system_bar.dart';
|
||||
import '../widgets/debug_console.dart';
|
||||
import '../widgets/whiskey_mark.dart';
|
||||
import '../widgets/accel_graph.dart';
|
||||
import '../widgets/gps_compass.dart';
|
||||
|
||||
// test service for triggers
|
||||
import '../services/test_flipflop_service.dart';
|
||||
@@ -26,7 +27,7 @@ class DashboardScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
static const _surpriseThreshold = 0.3; // G threshold for navigator surprise
|
||||
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
|
||||
@@ -36,6 +37,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
// WebSocket stream subscriptions
|
||||
StreamSubscription<ArduinoData>? _arduinoSub;
|
||||
StreamSubscription<GpsData>? _gpsSub;
|
||||
StreamSubscription<LteData>? _lteSub;
|
||||
StreamSubscription<WsConnectionState>? _connectionSub;
|
||||
|
||||
// Pi temperature - direct file read (safety critical)
|
||||
@@ -55,9 +57,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
double? _gpsTrack;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
String? _gpsState;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
@@ -105,8 +109,16 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
// Derive satellites from mode (placeholder logic)
|
||||
_gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0);
|
||||
_gpsTrack = data.track;
|
||||
_gpsSatellites = data.satellites;
|
||||
_gpsState = data.gpsState;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to LTE data stream
|
||||
_lteSub = WebSocketService.instance.lteStream.listen((data) {
|
||||
setState(() {
|
||||
_lteSignal = data.signal;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,13 +152,18 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0);
|
||||
_gpsTrack = cachedGps.track;
|
||||
_gpsSatellites = cachedGps.satellites;
|
||||
_gpsState = cachedGps.gpsState;
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
||||
_lteSignal = null;
|
||||
// Init from cached LTE data
|
||||
final cachedLte = WebSocketService.instance.latestLte;
|
||||
if (cachedLte != null) {
|
||||
_lteSignal = cachedLte.signal;
|
||||
}
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
@@ -157,6 +174,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_lteSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
@@ -197,17 +215,16 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
gpsState: _gpsState,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
const SizedBox(height: 5),
|
||||
|
||||
// Main content area - big stat boxes
|
||||
// Main content area - big widgets
|
||||
Expanded(
|
||||
flex: 8,
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
// Attitude indicator (whiskey mark)
|
||||
@@ -231,11 +248,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
// Bottom stats row
|
||||
Expanded(
|
||||
flex: 2,
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||
GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Splash screen - shown during initialization
|
||||
///
|
||||
/// Displays parallel status items that independently flip to "Ready".
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -33,13 +35,19 @@ class SplashScreen extends StatelessWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
status,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
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(
|
||||
fontSize: 48,
|
||||
color: isReady ? theme.foreground : theme.subdued,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -39,8 +39,10 @@ class GpsData {
|
||||
final double? alt;
|
||||
final double? track;
|
||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||
final int? satellites;
|
||||
final String? gpsState; // "acquiring", "fix", or "lost"
|
||||
|
||||
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, this.gpsState});
|
||||
|
||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||
return GpsData(
|
||||
@@ -50,6 +52,27 @@ class GpsData {
|
||||
alt: (json['alt'] as num?)?.toDouble(),
|
||||
track: (json['track'] as num?)?.toDouble(),
|
||||
mode: (json['mode'] as num?)?.toInt(),
|
||||
satellites: (json['satellites'] as num?)?.toInt(),
|
||||
gpsState: json['gps_state'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data from LTE modem (signal quality, connection state)
|
||||
class LteData {
|
||||
final bool? connected;
|
||||
final int? signal; // 0-100 percent
|
||||
final String? operator_;
|
||||
final String? accessTech;
|
||||
|
||||
LteData({this.connected, this.signal, this.operator_, this.accessTech});
|
||||
|
||||
factory LteData.fromJson(Map<String, dynamic> json) {
|
||||
return LteData(
|
||||
connected: json['connected'] as bool?,
|
||||
signal: (json['signal'] as num?)?.toInt(),
|
||||
operator_: json['operator'] as String?,
|
||||
accessTech: json['access_tech'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +65,13 @@ class WebSocketService {
|
||||
// Latest values for sync access (backward compat)
|
||||
ArduinoData? _latestArduino;
|
||||
GpsData? _latestGps;
|
||||
LteData? _latestLte;
|
||||
BackendStatus? _latestStatus;
|
||||
|
||||
// Stream controllers
|
||||
late StreamController<ArduinoData> _arduinoController;
|
||||
late StreamController<GpsData> _gpsController;
|
||||
late StreamController<LteData> _lteController;
|
||||
late StreamController<BackendStatus> _statusController;
|
||||
late StreamController<CommandAck> _ackController;
|
||||
late StreamController<BackendAlert> _alertController;
|
||||
@@ -83,6 +85,7 @@ class WebSocketService {
|
||||
void _setupStreams() {
|
||||
_arduinoController = StreamController<ArduinoData>.broadcast();
|
||||
_gpsController = StreamController<GpsData>.broadcast();
|
||||
_lteController = StreamController<LteData>.broadcast();
|
||||
_statusController = StreamController<BackendStatus>.broadcast();
|
||||
_ackController = StreamController<CommandAck>.broadcast();
|
||||
_alertController = StreamController<BackendAlert>.broadcast();
|
||||
@@ -107,6 +110,9 @@ class WebSocketService {
|
||||
/// Stream of GPS updates
|
||||
Stream<GpsData> get gpsStream => _gpsController.stream;
|
||||
|
||||
/// Stream of LTE status updates
|
||||
Stream<LteData> get lteStream => _lteController.stream;
|
||||
|
||||
/// Stream of backend status updates
|
||||
Stream<BackendStatus> get statusStream => _statusController.stream;
|
||||
|
||||
@@ -139,6 +145,9 @@ class WebSocketService {
|
||||
/// Latest GPS data (may be null if not yet received)
|
||||
GpsData? get latestGps => _latestGps;
|
||||
|
||||
/// Latest LTE data (may be null if not yet received)
|
||||
LteData? get latestLte => _latestLte;
|
||||
|
||||
/// Latest backend status
|
||||
BackendStatus? get latestStatus => _latestStatus;
|
||||
|
||||
@@ -204,7 +213,16 @@ class WebSocketService {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = 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 ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('lte', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final lte = LteData.fromJson(data);
|
||||
_latestLte = lte;
|
||||
_lteController.add(lte);
|
||||
_log('lte: ${lte.signal ?? "-"}% ${lte.operator_ ?? "-"} ${lte.accessTech ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -323,6 +341,7 @@ class WebSocketService {
|
||||
disconnect();
|
||||
_arduinoController.close();
|
||||
_gpsController.close();
|
||||
_lteController.close();
|
||||
_statusController.close();
|
||||
_ackController.close();
|
||||
_alertController.close();
|
||||
|
||||
73
pi/ui/lib/widgets/gps_compass.dart
Normal file
73
pi/ui/lib/widgets/gps_compass.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class GpsCompass extends StatelessWidget {
|
||||
final double? heading;
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
|
||||
const GpsCompass({super.key, this.heading, this.gpsState});
|
||||
|
||||
bool get _hasSignal => heading != null;
|
||||
bool get _isAcquiring => gpsState == 'acquiring';
|
||||
|
||||
String get _displayHeading {
|
||||
if (!_hasSignal) return 'N/A';
|
||||
return '${(heading! % 360).round()}';
|
||||
}
|
||||
|
||||
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}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued, // less emphasis on text, let the icon have semantic colour
|
||||
fontFamily: 'DIN1451',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../theme/app_theme.dart';
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
@@ -15,6 +16,7 @@ class SystemBar extends StatelessWidget {
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.gpsState,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
@@ -49,14 +51,6 @@ class SystemBar extends StatelessWidget {
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.subdued.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -73,8 +67,10 @@ class SystemBar extends StatelessWidget {
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsSatellites?.toString() ?? 'N/A',
|
||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
||||
value: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix' || gpsSatellites == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
@@ -106,7 +102,7 @@ class SystemBar extends StatelessWidget {
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.9,
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
|
||||
Reference in New Issue
Block a user