Compare commits
3 Commits
629c735eec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46496d688 | ||
|
|
47b3427e63 | ||
|
|
12a0d58800 |
@@ -11,7 +11,7 @@ from typing import Any
|
||||
# When True: skips gpsd entirely, generates realistic mock data
|
||||
# When False: connects to real gpsd (requires GPS device)
|
||||
# ============================================================================
|
||||
_GPS_DEBUG = True
|
||||
_GPS_DEBUG = False
|
||||
|
||||
# gpsdclient is a modern, simple gpsd client
|
||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||
@@ -45,6 +45,7 @@ class GPSService:
|
||||
|
||||
# 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):
|
||||
@@ -165,11 +166,20 @@ class GPSService:
|
||||
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
|
||||
|
||||
@@ -212,8 +222,8 @@ class GPSService:
|
||||
signal_lost = False
|
||||
signal_lost_until = 0.0
|
||||
|
||||
# Simulate cold start acquisition (~3s)
|
||||
acquiring_until = time.time() + 3.0
|
||||
# Simulate cold start acquisition (~30s)
|
||||
acquiring_until = time.time() + 30.0
|
||||
|
||||
# Base position (Tokyo area)
|
||||
base_lat = 35.6762
|
||||
|
||||
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)
|
||||
@@ -181,11 +203,14 @@ def throttle_flusher():
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -202,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)."""
|
||||
@@ -224,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)
|
||||
@@ -241,6 +274,7 @@ def main():
|
||||
arduino.stop()
|
||||
gps.stop()
|
||||
gpio.stop()
|
||||
lte.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -37,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)
|
||||
@@ -114,6 +115,13 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to LTE data stream
|
||||
_lteSub = WebSocketService.instance.lteStream.listen((data) {
|
||||
setState(() {
|
||||
_lteSignal = data.signal;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
@@ -151,8 +159,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
_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);
|
||||
@@ -163,6 +174,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_lteSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
|
||||
@@ -58,6 +58,25 @@ class GpsData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||
///
|
||||
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -208,6 +217,15 @@ class WebSocketService {
|
||||
}
|
||||
});
|
||||
|
||||
_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 ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('status', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final status = BackendStatus(
|
||||
@@ -323,6 +341,7 @@ class WebSocketService {
|
||||
disconnect();
|
||||
_arduinoController.close();
|
||||
_gpsController.close();
|
||||
_lteController.close();
|
||||
_statusController.close();
|
||||
_ackController.close();
|
||||
_alertController.close();
|
||||
|
||||
@@ -61,7 +61,7 @@ class GpsCompass extends StatelessWidget {
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
color: theme.subdued, // less emphasis on text, let the icon have semantic colour
|
||||
fontFamily: 'DIN1451',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -70,7 +70,7 @@ class SystemBar extends StatelessWidget {
|
||||
value: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix',
|
||||
isAbnormal: gpsState != 'fix' || gpsSatellites == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
|
||||
Reference in New Issue
Block a user