From 47b3427e63300f623380a85670c0d10896493089 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Mon, 9 Feb 2026 02:36:03 +0900 Subject: [PATCH] lte service (backend) and ui handling --- pi/backend/lte_service.py | 178 ++++++++++++++++++++++ pi/backend/main.py | 32 +++- pi/ui/lib/screens/dashboard_screen.dart | 16 +- pi/ui/lib/services/backend_service.dart | 19 +++ pi/ui/lib/services/websocket_service.dart | 19 +++ 5 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 pi/backend/lte_service.py diff --git a/pi/backend/lte_service.py b/pi/backend/lte_service.py new file mode 100644 index 0000000..5067926 --- /dev/null +++ b/pi/backend/lte_service.py @@ -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", + } diff --git a/pi/backend/main.py b/pi/backend/main.py index d84056e..d6b7a76 100644 --- a/pi/backend/main.py +++ b/pi/backend/main.py @@ -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(): @@ -147,6 +154,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 +187,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 +199,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 +223,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 +251,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 +270,7 @@ def main(): arduino.stop() gps.stop() gpio.stop() + lte.stop() if __name__ == "__main__": diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 14db07b..bcd4d76 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -37,6 +37,7 @@ class _DashboardScreenState extends State { // WebSocket stream subscriptions StreamSubscription? _arduinoSub; StreamSubscription? _gpsSub; + StreamSubscription? _lteSub; StreamSubscription? _connectionSub; // Pi temperature - direct file read (safety critical) @@ -114,6 +115,13 @@ class _DashboardScreenState extends State { }); }); + // 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 { _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 { _piTempTimer?.cancel(); _arduinoSub?.cancel(); _gpsSub?.cancel(); + _lteSub?.cancel(); _connectionSub?.cancel(); TestFlipFlopService.instance.stop(); super.dispose(); diff --git a/pi/ui/lib/services/backend_service.dart b/pi/ui/lib/services/backend_service.dart index f312de5..e1291d8 100644 --- a/pi/ui/lib/services/backend_service.dart +++ b/pi/ui/lib/services/backend_service.dart @@ -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 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. diff --git a/pi/ui/lib/services/websocket_service.dart b/pi/ui/lib/services/websocket_service.dart index ada5f80..06df2d7 100644 --- a/pi/ui/lib/services/websocket_service.dart +++ b/pi/ui/lib/services/websocket_service.dart @@ -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 _arduinoController; late StreamController _gpsController; + late StreamController _lteController; late StreamController _statusController; late StreamController _ackController; late StreamController _alertController; @@ -83,6 +85,7 @@ class WebSocketService { void _setupStreams() { _arduinoController = StreamController.broadcast(); _gpsController = StreamController.broadcast(); + _lteController = StreamController.broadcast(); _statusController = StreamController.broadcast(); _ackController = StreamController.broadcast(); _alertController = StreamController.broadcast(); @@ -107,6 +110,9 @@ class WebSocketService { /// Stream of GPS updates Stream get gpsStream => _gpsController.stream; + /// Stream of LTE status updates + Stream get lteStream => _lteController.stream; + /// Stream of backend status updates Stream 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) { + 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) { final status = BackendStatus( @@ -323,6 +341,7 @@ class WebSocketService { disconnect(); _arduinoController.close(); _gpsController.close(); + _lteController.close(); _statusController.close(); _ackController.close(); _alertController.close();