Compare commits

...

4 Commits

Author SHA1 Message Date
Mikkeli Matlock
a46496d688 gps related minor fixes 2026-02-09 17:48:38 +09:00
Mikkeli Matlock
47b3427e63 lte service (backend) and ui handling 2026-02-09 02:36:03 +09:00
Mikkeli Matlock
12a0d58800 switched backend gps service to real mode 2026-02-09 02:35:48 +09:00
629c735eec gps manipulations tailored to sim7600h hat 2026-02-09 02:11:55 +09:00
21 changed files with 2776 additions and 2356 deletions

View File

@@ -1,5 +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)
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.

4
arduino/.gitignore vendored
View File

@@ -1,3 +1,3 @@
# arduino test files
# arduino test files
test/

View File

@@ -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.

View File

@@ -1,308 +1,308 @@
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
import json
import math
import re
import threading
import time
from collections import deque
from typing import Any
# pyserial for UART communication
try:
import serial
except ImportError:
serial = None # Allow import without pyserial for testing structure
class ArduinoService:
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
# TSV field names (order per PROTOCOL.md)
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
# Regex patterns for legacy text protocol (backwards compatibility)
PATTERNS = {
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
}
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
def __init__(
self,
port: str = "/dev/serial0",
baudrate: int = 115200,
buffer_size: int = 100,
):
self.port = port
self.baudrate = baudrate
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Callbacks for push-based updates
self._on_data_callback = None
self._on_ack_callback = None
# Serial port handle for sending commands
self._serial: Any = None
self._serial_lock = threading.Lock()
# Periodic status logging
self._last_status_log = 0.0
self._frame_count = 0
def set_on_data(self, callback):
"""Set callback for new telemetry data. Called with data dict."""
self._on_data_callback = callback
def set_on_ack(self, callback):
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
self._on_ack_callback = callback
def send_command(self, cmd: str, params: dict | None = None) -> bool:
"""Send a command to Arduino via serial.
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
Args:
cmd: Command name (e.g., "HORN", "LIGHT")
params: Optional parameters dict
Returns:
True if sent successfully, False if serial unavailable
"""
with self._serial_lock:
if self._serial is None or not self._connected:
print(f"[Arduino] Cannot send command, not connected")
return False
try:
# Build command string
parts = ["CMD", cmd.upper()]
if params:
for key, val in params.items():
parts.append(f"{key}={val}")
line = ":".join(parts) + "\n"
self._serial.write(line.encode("utf-8"))
self._serial.flush()
print(f"[Arduino] Sent: {line.strip()}")
return True
except Exception as e:
print(f"[Arduino] Failed to send command: {e}")
return False
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent telemetry values."""
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"}
def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered telemetry history."""
with self._lock:
return list(self._buffer)
def start(self):
"""Start background serial reader thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop background reader."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
time.sleep(5)
def _connect_and_read(self):
"""Connect to Arduino serial and read data."""
if serial is None:
print("[Arduino] pyserial not installed, cannot connect")
return # Will retry via _reader_loop after 5s
try:
ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1.0,
)
except serial.SerialException as e:
print(f"[Arduino] Cannot open {self.port}: {e}")
return # Will retry via _reader_loop after 5s
try:
# Store serial handle for send_command()
with self._serial_lock:
self._serial = ser
self._connected = True
self._last_status_log = time.time()
self._frame_count = 0
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
while self._running:
try:
# Read null-terminated line (TSV protocol)
line = self._read_null_terminated(ser)
if not line:
continue
# Check for ACK responses first (legacy newline-terminated)
ack_match = self.ACK_PATTERN.match(line)
if ack_match:
cmd, status, extra = ack_match.groups()
if self._on_ack_callback:
self._on_ack_callback(cmd, status, extra)
continue
data = self._parse_line(line)
if data:
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with self._lock:
# Merge new values into latest (preserve old values for partial updates)
for key, val in data.items():
if val is not None and not (isinstance(val, float) and math.isnan(val)):
self._latest[key] = val
self._latest["time"] = data["time"]
self._buffer.append(self._latest.copy())
# Invoke callback with new data
if self._on_data_callback:
self._on_data_callback(self._latest.copy())
# Periodic status log (every 5s)
self._frame_count += 1
now = time.time()
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._frame_count / elapsed
v = self._latest.get('voltage', 0)
rpm = self._latest.get('rpm', 0)
gear = self._latest.get('gear', 0)
roll = self._latest.get('roll', 0)
print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
self._last_status_log = now
self._frame_count = 0
except serial.SerialException as e:
print(f"[Arduino] Serial error: {e}")
break
finally:
self._connected = False
with self._serial_lock:
self._serial = None
ser.close()
def _read_null_terminated(self, ser) -> str:
"""Read bytes until null terminator or newline (fallback for legacy)."""
buf = bytearray()
while self._running:
byte = ser.read(1)
if not byte:
# Timeout
if buf:
# Return partial buffer if we have data
return buf.decode("utf-8", errors="ignore").strip()
return ""
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
# End of frame
if buf:
return buf.decode("utf-8", errors="ignore").strip()
# Skip empty lines / consecutive terminators
continue
buf.append(byte[0])
# Safety limit
if len(buf) > 256:
return buf.decode("utf-8", errors="ignore").strip()
def _parse_line(self, line: str) -> dict[str, Any] | None:
"""Parse a line from Arduino - TSV first, then JSON, fallback to regex.
TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md)
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
Legacy text: V_bat: 12.45V
"""
# Try TSV first (new protocol)
if '\t' in line:
return self._parse_tsv(line)
# Try JSON (may still be used for special messages)
try:
obj = json.loads(line)
return {
"voltage": obj.get("v"),
"rpm": obj.get("rpm"),
"eng_temp": obj.get("eng"),
"gear": obj.get("gear"),
}
except json.JSONDecodeError:
pass
# Fallback to regex for legacy text protocol
result = {}
for key, pattern in self.PATTERNS.items():
match = pattern.search(line)
if match:
val = match.group(1)
result[key] = float(val) if "." in val else int(val)
return result if result else None
def _parse_tsv(self, line: str) -> dict[str, Any] | None:
"""Parse TSV telemetry frame per PROTOCOL.md.
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
Empty fields (stale IMU) become NaN.
"""
fields = line.split('\t')
if len(fields) != len(self.TSV_FIELDS):
# Wrong field count - might be debug output or malformed
return None
result = {}
for i, name in enumerate(self.TSV_FIELDS):
val_str = fields[i].strip()
if val_str == '':
# Empty field = stale/missing data
result[name] = float('nan')
else:
try:
result[name] = float(val_str)
except ValueError:
result[name] = float('nan')
# IMU axis correction for mounting orientation
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
if 'pitch' in result and not math.isnan(result['pitch']):
result['pitch'] = -result['pitch']
if 'yaw' in result and not math.isnan(result['yaw']):
result['yaw'] = -result['yaw']
return result
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
import json
import math
import re
import threading
import time
from collections import deque
from typing import Any
# pyserial for UART communication
try:
import serial
except ImportError:
serial = None # Allow import without pyserial for testing structure
class ArduinoService:
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
# TSV field names (order per PROTOCOL.md)
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
# Regex patterns for legacy text protocol (backwards compatibility)
PATTERNS = {
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
}
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
def __init__(
self,
port: str = "/dev/serial0",
baudrate: int = 115200,
buffer_size: int = 100,
):
self.port = port
self.baudrate = baudrate
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Callbacks for push-based updates
self._on_data_callback = None
self._on_ack_callback = None
# Serial port handle for sending commands
self._serial: Any = None
self._serial_lock = threading.Lock()
# Periodic status logging
self._last_status_log = 0.0
self._frame_count = 0
def set_on_data(self, callback):
"""Set callback for new telemetry data. Called with data dict."""
self._on_data_callback = callback
def set_on_ack(self, callback):
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
self._on_ack_callback = callback
def send_command(self, cmd: str, params: dict | None = None) -> bool:
"""Send a command to Arduino via serial.
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
Args:
cmd: Command name (e.g., "HORN", "LIGHT")
params: Optional parameters dict
Returns:
True if sent successfully, False if serial unavailable
"""
with self._serial_lock:
if self._serial is None or not self._connected:
print(f"[Arduino] Cannot send command, not connected")
return False
try:
# Build command string
parts = ["CMD", cmd.upper()]
if params:
for key, val in params.items():
parts.append(f"{key}={val}")
line = ":".join(parts) + "\n"
self._serial.write(line.encode("utf-8"))
self._serial.flush()
print(f"[Arduino] Sent: {line.strip()}")
return True
except Exception as e:
print(f"[Arduino] Failed to send command: {e}")
return False
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent telemetry values."""
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"}
def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered telemetry history."""
with self._lock:
return list(self._buffer)
def start(self):
"""Start background serial reader thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start()
def stop(self):
"""Stop background reader."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
time.sleep(5)
def _connect_and_read(self):
"""Connect to Arduino serial and read data."""
if serial is None:
print("[Arduino] pyserial not installed, cannot connect")
return # Will retry via _reader_loop after 5s
try:
ser = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=1.0,
)
except serial.SerialException as e:
print(f"[Arduino] Cannot open {self.port}: {e}")
return # Will retry via _reader_loop after 5s
try:
# Store serial handle for send_command()
with self._serial_lock:
self._serial = ser
self._connected = True
self._last_status_log = time.time()
self._frame_count = 0
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
while self._running:
try:
# Read null-terminated line (TSV protocol)
line = self._read_null_terminated(ser)
if not line:
continue
# Check for ACK responses first (legacy newline-terminated)
ack_match = self.ACK_PATTERN.match(line)
if ack_match:
cmd, status, extra = ack_match.groups()
if self._on_ack_callback:
self._on_ack_callback(cmd, status, extra)
continue
data = self._parse_line(line)
if data:
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with self._lock:
# Merge new values into latest (preserve old values for partial updates)
for key, val in data.items():
if val is not None and not (isinstance(val, float) and math.isnan(val)):
self._latest[key] = val
self._latest["time"] = data["time"]
self._buffer.append(self._latest.copy())
# Invoke callback with new data
if self._on_data_callback:
self._on_data_callback(self._latest.copy())
# Periodic status log (every 5s)
self._frame_count += 1
now = time.time()
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._frame_count / elapsed
v = self._latest.get('voltage', 0)
rpm = self._latest.get('rpm', 0)
gear = self._latest.get('gear', 0)
roll = self._latest.get('roll', 0)
print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
self._last_status_log = now
self._frame_count = 0
except serial.SerialException as e:
print(f"[Arduino] Serial error: {e}")
break
finally:
self._connected = False
with self._serial_lock:
self._serial = None
ser.close()
def _read_null_terminated(self, ser) -> str:
"""Read bytes until null terminator or newline (fallback for legacy)."""
buf = bytearray()
while self._running:
byte = ser.read(1)
if not byte:
# Timeout
if buf:
# Return partial buffer if we have data
return buf.decode("utf-8", errors="ignore").strip()
return ""
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
# End of frame
if buf:
return buf.decode("utf-8", errors="ignore").strip()
# Skip empty lines / consecutive terminators
continue
buf.append(byte[0])
# Safety limit
if len(buf) > 256:
return buf.decode("utf-8", errors="ignore").strip()
def _parse_line(self, line: str) -> dict[str, Any] | None:
"""Parse a line from Arduino - TSV first, then JSON, fallback to regex.
TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md)
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
Legacy text: V_bat: 12.45V
"""
# Try TSV first (new protocol)
if '\t' in line:
return self._parse_tsv(line)
# Try JSON (may still be used for special messages)
try:
obj = json.loads(line)
return {
"voltage": obj.get("v"),
"rpm": obj.get("rpm"),
"eng_temp": obj.get("eng"),
"gear": obj.get("gear"),
}
except json.JSONDecodeError:
pass
# Fallback to regex for legacy text protocol
result = {}
for key, pattern in self.PATTERNS.items():
match = pattern.search(line)
if match:
val = match.group(1)
result[key] = float(val) if "." in val else int(val)
return result if result else None
def _parse_tsv(self, line: str) -> dict[str, Any] | None:
"""Parse TSV telemetry frame per PROTOCOL.md.
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
Empty fields (stale IMU) become NaN.
"""
fields = line.split('\t')
if len(fields) != len(self.TSV_FIELDS):
# Wrong field count - might be debug output or malformed
return None
result = {}
for i, name in enumerate(self.TSV_FIELDS):
val_str = fields[i].strip()
if val_str == '':
# Empty field = stale/missing data
result[name] = float('nan')
else:
try:
result[name] = float(val_str)
except ValueError:
result[name] = float('nan')
# IMU axis correction for mounting orientation
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
if 'pitch' in result and not math.isnan(result['pitch']):
result['pitch'] = -result['pitch']
if 'yaw' in result and not math.isnan(result['yaw']):
result['yaw'] = -result['yaw']
return result

View File

@@ -1,269 +1,315 @@
"""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 = True
# 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)
try:
from gpsdclient import GPSDClient
except ImportError:
GPSDClient = None # Allow import without gpsd for testing structure
class GPSService:
"""Threaded GPS reader with buffering and auto-reconnect."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self.host = host
self.port = port
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Callback for push-based updates
self._on_data_callback = None
# Periodic status logging
self._last_status_log = 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
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"}
def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered GPS history."""
with self._lock:
return list(self._buffer)
def start(self):
"""Start background GPS reader thread."""
if self._running:
return
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."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
print("[GPS] Reader thread running")
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5)
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:
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}")
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
first_fix_timeout = time.time() + 5.0 # 5s to get first fix
for result in client.dict_stream(filter=["TPV"]):
if not self._running:
break
# TPV = Time-Position-Velocity report
fix = {
"time": result.get("time"),
"lat": result.get("lat"),
"lon": result.get("lon"),
"alt": result.get("alt"),
"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
}
# 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() > first_fix_timeout:
print("[GPS] No GPS fix after 5s, will retry connection")
raise ConnectionError("No GPS fix within timeout")
continue # Skip empty fixes
# Got real data, disable timeout
first_fix_timeout = float('inf')
with self._lock:
self._latest = 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
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):
"""Generate realistic mock GPS data for development/testing.
Simulates:
- Normal 3D fix with satellites
- Occasional signal loss (~3% chance per second, lasts ~5s)
- 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
# 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
now = time.time()
# Check for signal loss simulation
if signal_lost:
if now >= signal_lost_until:
signal_lost = False
print("[GPS] Signal recovered (stub)")
else:
# ~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)))
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),
}
with self._lock:
self._latest = 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)
"""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)
try:
from gpsdclient import GPSDClient
except ImportError:
GPSDClient = None # Allow import without gpsd for testing structure
class GPSService:
"""Threaded GPS reader with buffering and auto-reconnect."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self.host = host
self.port = port
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# 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
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
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:
return list(self._buffer)
def start(self):
"""Start background GPS reader thread."""
if self._running:
return
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."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
print("[GPS] Reader thread running")
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5)
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:
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}")
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
# TPV = Time-Position-Velocity report
fix = {
"time": result.get("time"),
"lat": result.get("lat"),
"lon": result.get("lon"),
"alt": result.get("alt"),
"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:
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
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):
"""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
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
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
View 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",
}

View File

@@ -1,245 +1,281 @@
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
from gevent import monkey
monkey.patch_all() # Must be at the very top before other imports
from flask import Flask, jsonify
from flask_socketio import SocketIO, emit
from gps_service import GPSService
from arduino_service import ArduinoService
from gpio_service import GPIOService
from throttle import Throttle
app = Flask(__name__)
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
# SocketIO with gevent async mode (eventlet is deprecated)
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
# Services
gps = GPSService()
arduino = ArduinoService()
gpio = GPIOService()
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS)
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
# Track connected clients
connected_clients = set()
# -----------------------------------------------------------------------------
# WebSocket Event Handlers
# -----------------------------------------------------------------------------
@socketio.on("connect")
def handle_connect():
"""Client connected."""
client_id = id(socketio) # Simple identifier
connected_clients.add(client_id)
print(f"[WS] Client connected ({len(connected_clients)} total)")
# Send current status immediately
emit("status", {
"gps_connected": gps.connected,
"arduino_connected": arduino.connected,
"theme_switch": gpio.theme_switch,
})
# Send latest data if available
arduino_data = arduino.get_latest()
if "error" not in arduino_data:
emit("arduino", arduino_data)
gps_data = gps.get_latest()
if "error" not in gps_data:
emit("gps", gps_data)
@socketio.on("disconnect")
def handle_disconnect():
"""Client disconnected."""
client_id = id(socketio)
connected_clients.discard(client_id)
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
@socketio.on("button")
def handle_button(data):
"""Handle button press from UI.
Expected data: {"id": "horn", "action": "press", ...params}
"""
btn_id = data.get("id", "unknown")
action = data.get("action", "press")
params = {k: v for k, v in data.items() if k not in ("id", "action")}
print(f"[WS] Button: {btn_id} {action} {params}")
# Map button ID to Arduino command
cmd_map = {
"horn": "HORN",
"light": "LIGHT",
"indicator_left": "IND_L",
"indicator_right": "IND_R",
"hazard": "HAZARD",
}
cmd = cmd_map.get(btn_id)
if cmd:
# Add action to params (e.g., ON/OFF based on press/release)
params["state"] = "ON" if action == "press" else "OFF"
success = arduino.send_command(cmd, params)
# Send immediate ack for the attempt
emit("ack", {
"id": btn_id,
"status": "sent" if success else "failed",
"error": None if success else "arduino not connected",
})
else:
emit("ack", {
"id": btn_id,
"status": "error",
"error": f"unknown button: {btn_id}",
})
@socketio.on("emergency")
def handle_emergency(data):
"""Handle emergency signal from UI."""
etype = data.get("type", "stop")
print(f"[WS] EMERGENCY: {etype}")
# Send emergency command to Arduino
arduino.send_command("EMERGENCY", {"type": etype})
# Broadcast alert to all clients
socketio.emit("alert", {
"type": "emergency",
"message": f"Emergency {etype} triggered",
})
# -----------------------------------------------------------------------------
# Service Callbacks (push data to WebSocket)
# -----------------------------------------------------------------------------
def on_arduino_data(data):
"""Called by ArduinoService when new telemetry arrives."""
# Always include current GPIO state (UI dedupes)
data = dict(data) # Don't mutate original
data["theme_switch"] = gpio.theme_switch
def emit_fn(d):
socketio.emit("arduino", d)
arduino_throttle.maybe_emit(data, emit_fn)
def on_gps_data(data):
"""Called by GPSService when new fix arrives."""
def emit_fn(d):
socketio.emit("gps", d)
gps_throttle.maybe_emit(data, emit_fn)
def on_arduino_ack(cmd, status, extra):
"""Called by ArduinoService when ACK received from Arduino."""
socketio.emit("ack", {
"id": cmd.lower(),
"status": status.lower(),
"extra": extra,
})
# -----------------------------------------------------------------------------
# Background task to flush pending throttled data
# -----------------------------------------------------------------------------
def throttle_flusher():
"""Periodically flush pending throttled data."""
import gevent
while True:
gevent.sleep(0.05) # 20Hz flush rate
if arduino_throttle.has_pending:
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
if gps_throttle.has_pending:
gps_throttle.flush(lambda d: socketio.emit("gps", d))
# -----------------------------------------------------------------------------
# REST API (backward compatibility)
# -----------------------------------------------------------------------------
@app.route("/health")
def health():
"""Health check endpoint."""
return jsonify({
"status": "ok",
"gps_connected": gps.connected,
"arduino_connected": arduino.connected,
"ws_clients": len(connected_clients),
})
@app.route("/gps")
def gps_data():
"""Current GPS data."""
return jsonify(gps.get_latest())
@app.route("/gps/history")
def gps_history():
"""Buffered GPS history."""
return jsonify(gps.get_buffer())
@app.route("/arduino")
def arduino_data():
"""Current Arduino telemetry (voltage, rpm, etc)."""
return jsonify(arduino.get_latest())
@app.route("/arduino/history")
def arduino_history():
"""Buffered Arduino telemetry history."""
return jsonify(arduino.get_buffer())
# -----------------------------------------------------------------------------
# Main Entry Point
# -----------------------------------------------------------------------------
def main():
"""Entry point."""
# Wire up callbacks
arduino.set_on_data(on_arduino_data)
arduino.set_on_ack(on_arduino_ack)
gps.set_on_data(on_gps_data)
# Start services
gps.start()
arduino.start()
gpio.start()
# Start throttle flusher in background
socketio.start_background_task(throttle_flusher)
try:
# Use socketio.run() instead of app.run() for WebSocket support
print("[Backend] Starting on http://0.0.0.0:5000")
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
finally:
arduino.stop()
gps.stop()
gpio.stop()
if __name__ == "__main__":
main()
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
from gevent import monkey
monkey.patch_all() # Must be at the very top before other imports
from flask import Flask, jsonify
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__)
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
# SocketIO with gevent async mode (eventlet is deprecated)
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
# Services
gps = GPSService()
arduino = ArduinoService()
gpio = GPIOService()
lte = LteService()
# 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()
# -----------------------------------------------------------------------------
# WebSocket Event Handlers
# -----------------------------------------------------------------------------
@socketio.on("connect")
def handle_connect():
"""Client connected."""
client_id = id(socketio) # Simple identifier
connected_clients.add(client_id)
print(f"[WS] Client connected ({len(connected_clients)} total)")
# Send current status immediately
emit("status", {
"gps_connected": gps.connected,
"arduino_connected": arduino.connected,
"theme_switch": gpio.theme_switch,
})
# Send latest data if available
arduino_data = arduino.get_latest()
if "error" not in arduino_data:
emit("arduino", arduino_data)
gps_data = gps.get_latest()
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():
"""Client disconnected."""
client_id = id(socketio)
connected_clients.discard(client_id)
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
@socketio.on("button")
def handle_button(data):
"""Handle button press from UI.
Expected data: {"id": "horn", "action": "press", ...params}
"""
btn_id = data.get("id", "unknown")
action = data.get("action", "press")
params = {k: v for k, v in data.items() if k not in ("id", "action")}
print(f"[WS] Button: {btn_id} {action} {params}")
# Map button ID to Arduino command
cmd_map = {
"horn": "HORN",
"light": "LIGHT",
"indicator_left": "IND_L",
"indicator_right": "IND_R",
"hazard": "HAZARD",
}
cmd = cmd_map.get(btn_id)
if cmd:
# Add action to params (e.g., ON/OFF based on press/release)
params["state"] = "ON" if action == "press" else "OFF"
success = arduino.send_command(cmd, params)
# Send immediate ack for the attempt
emit("ack", {
"id": btn_id,
"status": "sent" if success else "failed",
"error": None if success else "arduino not connected",
})
else:
emit("ack", {
"id": btn_id,
"status": "error",
"error": f"unknown button: {btn_id}",
})
@socketio.on("emergency")
def handle_emergency(data):
"""Handle emergency signal from UI."""
etype = data.get("type", "stop")
print(f"[WS] EMERGENCY: {etype}")
# Send emergency command to Arduino
arduino.send_command("EMERGENCY", {"type": etype})
# Broadcast alert to all clients
socketio.emit("alert", {
"type": "emergency",
"message": f"Emergency {etype} triggered",
})
# -----------------------------------------------------------------------------
# Service Callbacks (push data to WebSocket)
# -----------------------------------------------------------------------------
def on_arduino_data(data):
"""Called by ArduinoService when new telemetry arrives."""
# Always include current GPIO state (UI dedupes)
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)
arduino_throttle.maybe_emit(data, emit_fn)
def on_gps_data(data):
"""Called by GPSService when new fix arrives."""
def emit_fn(d):
socketio.emit("gps", d)
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", {
"id": cmd.lower(),
"status": status.lower(),
"extra": extra,
})
# -----------------------------------------------------------------------------
# Background task to flush pending throttled data
# -----------------------------------------------------------------------------
def throttle_flusher():
"""Periodically flush pending throttled data."""
import gevent
while True:
gevent.sleep(0.05) # 20Hz flush rate
if arduino_throttle.has_pending:
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
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)
# -----------------------------------------------------------------------------
@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),
})
@app.route("/gps")
def gps_data():
"""Current GPS data."""
return jsonify(gps.get_latest())
@app.route("/gps/history")
def gps_history():
"""Buffered 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)."""
return jsonify(arduino.get_latest())
@app.route("/arduino/history")
def arduino_history():
"""Buffered Arduino telemetry history."""
return jsonify(arduino.get_buffer())
# -----------------------------------------------------------------------------
# Main Entry Point
# -----------------------------------------------------------------------------
def main():
"""Entry point."""
# Wire up callbacks
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)
try:
# Use socketio.run() instead of app.run() for WebSocket support
print("[Backend] Starting on http://0.0.0.0:5000")
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
finally:
arduino.stop()
gps.stop()
gpio.stop()
lte.stop()
if __name__ == "__main__":
main()

View File

@@ -1,26 +1,26 @@
[project]
name = "smartserow-backend"
version = "0.1.0"
description = "GPS and Arduino telemetry service for Smart Serow"
requires-python = ">=3.11"
dependencies = [
"flask>=3.0",
"flask-socketio>=5.3.0",
"gevent>=24.0",
"gevent-websocket>=0.10",
"gpsdclient>=1.3",
"pyserial>=3.5",
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
# Not listed here because pip versions require compilation
]
[project.optional-dependencies]
dev = [
"ruff",
]
[project.scripts]
smartserow-backend = "main:main"
[tool.ruff]
line-length = 100
[project]
name = "smartserow-backend"
version = "0.1.0"
description = "GPS and Arduino telemetry service for Smart Serow"
requires-python = ">=3.11"
dependencies = [
"flask>=3.0",
"flask-socketio>=5.3.0",
"gevent>=24.0",
"gevent-websocket>=0.10",
"gpsdclient>=1.3",
"pyserial>=3.5",
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
# Not listed here because pip versions require compilation
]
[project.optional-dependencies]
dev = [
"ruff",
]
[project.scripts]
smartserow-backend = "main:main"
[tool.ruff]
line-length = 100

View File

@@ -1,61 +1,61 @@
"""Throttle layer for rate-limiting telemetry emissions."""
import time
from typing import Any, Callable
class Throttle:
"""Rate limiter for WebSocket emissions.
Coalesces rapid updates - only emits at most once per min_interval.
If multiple updates arrive within the interval, the latest value wins.
"""
def __init__(self, min_interval: float = 0.5):
"""
Args:
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
"""
self._last_emit: float = 0
self._min_interval = min_interval
self._pending: Any = None
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
"""Emit if interval has passed, otherwise store as pending.
Args:
data: Data to emit
emit_fn: Function to call with data when emitting
Returns:
True if emitted, False if stored as pending
"""
now = time.time()
if now - self._last_emit >= self._min_interval:
emit_fn(data)
self._last_emit = now
self._pending = None
return True
else:
self._pending = data # Latest value wins
return False
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
"""Emit pending data if any.
Call this periodically to ensure pending data gets sent.
Returns:
True if pending data was emitted, False if nothing pending
"""
if self._pending is not None:
emit_fn(self._pending)
self._last_emit = time.time()
self._pending = None
return True
return False
@property
def has_pending(self) -> bool:
"""Check if there's pending data waiting to be emitted."""
return self._pending is not None
"""Throttle layer for rate-limiting telemetry emissions."""
import time
from typing import Any, Callable
class Throttle:
"""Rate limiter for WebSocket emissions.
Coalesces rapid updates - only emits at most once per min_interval.
If multiple updates arrive within the interval, the latest value wins.
"""
def __init__(self, min_interval: float = 0.5):
"""
Args:
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
"""
self._last_emit: float = 0
self._min_interval = min_interval
self._pending: Any = None
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
"""Emit if interval has passed, otherwise store as pending.
Args:
data: Data to emit
emit_fn: Function to call with data when emitting
Returns:
True if emitted, False if stored as pending
"""
now = time.time()
if now - self._last_emit >= self._min_interval:
emit_fn(data)
self._last_emit = now
self._pending = None
return True
else:
self._pending = data # Latest value wins
return False
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
"""Emit pending data if any.
Call this periodically to ensure pending data gets sent.
Returns:
True if pending data was emitted, False if nothing pending
"""
if self._pending is not None:
emit_fn(self._pending)
self._last_emit = time.time()
self._pending = None
return True
return False
@property
def has_pending(self) -> bool:
"""Check if there's pending data waiting to be emitted."""
return self._pending is not None

View 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()

View File

@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
// 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)
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
await ConfigService.instance.load();
_updateStatus('Config', 'Ready');
// UART health check and navigator image preload run truly in parallel
// 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(),
]);
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
_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.

View File

@@ -1,282 +1,298 @@
import 'dart:async';
import 'dart:math' show sqrt, sin, cos, pi;
import 'package:flutter/material.dart';
import '../services/backend_service.dart';
import '../services/websocket_service.dart';
import '../services/pi_io.dart';
import '../theme/app_theme.dart';
import '../widgets/navigator_widget.dart';
import '../widgets/stat_box.dart';
import '../widgets/stat_box_main.dart';
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';
/// Main dashboard - displays Pi vitals and placeholder stats
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
// Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer;
// WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub;
StreamSubscription<GpsData>? _gpsSub;
StreamSubscription<WsConnectionState>? _connectionSub;
// Pi temperature - direct file read (safety critical)
double? _piTemp;
// From backend - Arduino data
int? _rpm;
double? _voltage;
int? _engineTemp;
int? _gear;
double? _roll;
double? _pitch;
double? _ax;
double? _ay;
double? _dynamicAx; // Gravity-compensated
double? _dynamicAy;
// From backend - GPS data
double? _gpsSpeed;
double? _gpsTrack;
// Placeholder values for system bar
int? _gpsSatellites;
int? _lteSignal;
// WebSocket connection state
WsConnectionState _wsState = WsConnectionState.disconnected;
@override
void initState() {
super.initState();
// Connect to WebSocket
WebSocketService.instance.connect();
// Subscribe to Arduino data stream
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
// Gravity-compensated acceleration
// When tilted, gravity "leaks" into horizontal axes - subtract it out
final rollRad = (data.roll ?? 0) * pi / 180;
final pitchRad = (data.pitch ?? 0) * pi / 180;
// Subtract gravity leakage from measured acceleration
// Axes swapped for IMU mounting orientation
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
setState(() {
_voltage = data.voltage;
_rpm = data.rpm;
_engineTemp = data.engTemp;
_gear = data.gear;
_roll = data.roll;
_pitch = data.pitch;
_ax = data.ax;
_ay = data.ay;
_dynamicAx = dynamicAx;
_dynamicAy = dynamicAy;
});
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
if (gMagnitude > _surpriseThreshold) {
_navigatorKey.currentState?.setEmotion('surprise');
}
});
// Subscribe to GPS data stream
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
setState(() {
_gpsSpeed = data.speed;
_gpsTrack = data.track;
_gpsSatellites = data.satellites;
});
});
// Subscribe to connection state
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
setState(() {
_wsState = state;
});
});
// Timer for Pi temp only (safety critical - bypasses backend)
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
setState(() {
_piTemp = PiIO.instance.getTemperature();
});
});
// Initialize with any cached data from WebSocketService
final cachedArduino = WebSocketService.instance.latestArduino;
if (cachedArduino != null) {
_voltage = cachedArduino.voltage;
_rpm = cachedArduino.rpm;
_engineTemp = cachedArduino.engTemp;
_gear = cachedArduino.gear;
_roll = cachedArduino.roll;
_pitch = cachedArduino.pitch;
_ax = cachedArduino.ax;
_ay = cachedArduino.ay;
}
final cachedGps = WebSocketService.instance.latestGps;
if (cachedGps != null) {
_gpsSpeed = cachedGps.speed;
_gpsTrack = cachedGps.track;
_gpsSatellites = cachedGps.satellites;
}
_wsState = WebSocketService.instance.connectionState;
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
_lteSignal = null;
// DEBUG: flip-flop theme + navigator every 2s
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
}
@override
void dispose() {
_piTempTimer?.cancel();
_arduinoSub?.cancel();
_gpsSub?.cancel();
_connectionSub?.cancel();
TestFlipFlopService.instance.stop();
super.dispose();
}
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
String _formatGear(int? gear) {
if (gear == null) return '';
if (gear == 0) return 'N';
return gear.toString();
}
/// Format nullable int for display
String _formatInt(int? value) => value?.toString() ?? '';
/// Format nullable double for display with decimal places
String _formatDouble(double? value, [int decimals = 1]) {
if (value == null) return '';
return value.toStringAsFixed(decimals);
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Scaffold(
backgroundColor: theme.background,
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Left side: All dashboard widgets (flex: 2)
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// System status bar
SystemBar(
gpsSatellites: _gpsSatellites,
lteSignal: _lteSignal,
piTemp: _piTemp,
voltage: _voltage,
wsState: _wsState,
),
// Main content area - big widgets
Expanded(
flex: 7,
child: Row(
children: [
// Attitude indicator (whiskey mark)
Expanded(
child: WhiskeyMark(
roll: _roll,
pitch: _pitch,
),
),
Expanded(
child: AccelGraph(
ax: _dynamicAx, // Gravity-compensated lateral
ay: _dynamicAy, // Gravity-compensated longitudinal
maxG: 0.8,
ghostTrackPeriod: const Duration(seconds: 4),
),
)
],
),
),
// Bottom stats row
Expanded(
flex: 3,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
GpsCompass(heading: _gpsTrack),
StatBox(value: _formatGear(_gear), label: 'GEAR'),
],
),
),
],
),
),
const SizedBox(width: 32),
// Right side: Navigator on top, debug console below
Expanded(
flex: 1,
child: Column(
children: [
// Navigator
Expanded(
flex: 3,
child: Center(
child: NavigatorWidget(key: _navigatorKey),
),
),
// Debug console
Expanded(
flex: 1,
child:
DebugConsole(
messageStream: WebSocketService.instance.debugStream,
initialMessages: WebSocketService.instance.debugMessages,
maxLines: 6,
title: 'WebSocket messages',
),
),
],
),
),
],
),
),
);
}
}
import 'dart:async';
import 'dart:math' show sqrt, sin, cos, pi;
import 'package:flutter/material.dart';
import '../services/backend_service.dart';
import '../services/websocket_service.dart';
import '../services/pi_io.dart';
import '../theme/app_theme.dart';
import '../widgets/navigator_widget.dart';
import '../widgets/stat_box.dart';
import '../widgets/stat_box_main.dart';
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';
/// Main dashboard - displays Pi vitals and placeholder stats
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
// Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer;
// WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub;
StreamSubscription<GpsData>? _gpsSub;
StreamSubscription<LteData>? _lteSub;
StreamSubscription<WsConnectionState>? _connectionSub;
// Pi temperature - direct file read (safety critical)
double? _piTemp;
// From backend - Arduino data
int? _rpm;
double? _voltage;
int? _engineTemp;
int? _gear;
double? _roll;
double? _pitch;
double? _ax;
double? _ay;
double? _dynamicAx; // Gravity-compensated
double? _dynamicAy;
// From backend - GPS data
double? _gpsSpeed;
double? _gpsTrack;
// Placeholder values for system bar
int? _gpsSatellites;
String? _gpsState;
int? _lteSignal;
// WebSocket connection state
WsConnectionState _wsState = WsConnectionState.disconnected;
@override
void initState() {
super.initState();
// Connect to WebSocket
WebSocketService.instance.connect();
// Subscribe to Arduino data stream
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
// Gravity-compensated acceleration
// When tilted, gravity "leaks" into horizontal axes - subtract it out
final rollRad = (data.roll ?? 0) * pi / 180;
final pitchRad = (data.pitch ?? 0) * pi / 180;
// Subtract gravity leakage from measured acceleration
// Axes swapped for IMU mounting orientation
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
setState(() {
_voltage = data.voltage;
_rpm = data.rpm;
_engineTemp = data.engTemp;
_gear = data.gear;
_roll = data.roll;
_pitch = data.pitch;
_ax = data.ax;
_ay = data.ay;
_dynamicAx = dynamicAx;
_dynamicAy = dynamicAy;
});
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
if (gMagnitude > _surpriseThreshold) {
_navigatorKey.currentState?.setEmotion('surprise');
}
});
// Subscribe to GPS data stream
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
setState(() {
_gpsSpeed = data.speed;
_gpsTrack = data.track;
_gpsSatellites = data.satellites;
_gpsState = data.gpsState;
});
});
// 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(() {
_wsState = state;
});
});
// Timer for Pi temp only (safety critical - bypasses backend)
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
setState(() {
_piTemp = PiIO.instance.getTemperature();
});
});
// Initialize with any cached data from WebSocketService
final cachedArduino = WebSocketService.instance.latestArduino;
if (cachedArduino != null) {
_voltage = cachedArduino.voltage;
_rpm = cachedArduino.rpm;
_engineTemp = cachedArduino.engTemp;
_gear = cachedArduino.gear;
_roll = cachedArduino.roll;
_pitch = cachedArduino.pitch;
_ax = cachedArduino.ax;
_ay = cachedArduino.ay;
}
final cachedGps = WebSocketService.instance.latestGps;
if (cachedGps != null) {
_gpsSpeed = cachedGps.speed;
_gpsTrack = cachedGps.track;
_gpsSatellites = cachedGps.satellites;
_gpsState = cachedGps.gpsState;
}
_wsState = WebSocketService.instance.connectionState;
// 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);
}
@override
void dispose() {
_piTempTimer?.cancel();
_arduinoSub?.cancel();
_gpsSub?.cancel();
_lteSub?.cancel();
_connectionSub?.cancel();
TestFlipFlopService.instance.stop();
super.dispose();
}
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
String _formatGear(int? gear) {
if (gear == null) return '';
if (gear == 0) return 'N';
return gear.toString();
}
/// Format nullable int for display
String _formatInt(int? value) => value?.toString() ?? '';
/// Format nullable double for display with decimal places
String _formatDouble(double? value, [int decimals = 1]) {
if (value == null) return '';
return value.toStringAsFixed(decimals);
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Scaffold(
backgroundColor: theme.background,
body: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Left side: All dashboard widgets (flex: 2)
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// System status bar
SystemBar(
gpsSatellites: _gpsSatellites,
gpsState: _gpsState,
lteSignal: _lteSignal,
piTemp: _piTemp,
voltage: _voltage,
wsState: _wsState,
),
// Main content area - big widgets
Expanded(
flex: 7,
child: Row(
children: [
// Attitude indicator (whiskey mark)
Expanded(
child: WhiskeyMark(
roll: _roll,
pitch: _pitch,
),
),
Expanded(
child: AccelGraph(
ax: _dynamicAx, // Gravity-compensated lateral
ay: _dynamicAy, // Gravity-compensated longitudinal
maxG: 0.8,
ghostTrackPeriod: const Duration(seconds: 4),
),
)
],
),
),
// Bottom stats row
Expanded(
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'),
],
),
),
],
),
),
const SizedBox(width: 32),
// Right side: Navigator on top, debug console below
Expanded(
flex: 1,
child: Column(
children: [
// Navigator
Expanded(
flex: 3,
child: Center(
child: NavigatorWidget(key: _navigatorKey),
),
),
// Debug console
Expanded(
flex: 1,
child:
DebugConsole(
messageStream: WebSocketService.instance.debugStream,
initialMessages: WebSocketService.instance.debugMessages,
maxLines: 6,
title: 'WebSocket messages',
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -1,161 +1,182 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
class ArduinoData {
final double? voltage;
final int? rpm;
final int? engTemp;
final int? gear; // 0 = neutral, 1-6 = gear
final double? roll; // Euler angle in degrees (negative = left, positive = right)
final double? pitch; // Euler angle in degrees (negative = nose down)
final double? ax; // Lateral acceleration (g)
final double? ay; // Longitudinal acceleration (g)
final double? az; // Vertical acceleration (g)
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
factory ArduinoData.fromJson(Map<String, dynamic> json) {
return ArduinoData(
voltage: (json['voltage'] as num?)?.toDouble(),
rpm: (json['rpm'] as num?)?.toInt(),
engTemp: (json['eng_temp'] as num?)?.toInt(),
gear: (json['gear'] as num?)?.toInt(),
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
pitch: (json['pitch'] as num?)?.toDouble(),
ax: (json['ax'] as num?)?.toDouble(),
ay: (json['ay'] as num?)?.toDouble(),
az: (json['az'] as num?)?.toDouble(),
);
}
}
/// Data from GPS
class GpsData {
final double? lat;
final double? lon;
final double? speed; // m/s
final double? alt;
final double? track;
final int? mode; // 0=no fix, 2=2D, 3=3D
final int? satellites;
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites});
factory GpsData.fromJson(Map<String, dynamic> json) {
return GpsData(
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
speed: (json['speed'] as num?)?.toDouble(),
alt: (json['alt'] as num?)?.toDouble(),
track: (json['track'] as num?)?.toDouble(),
mode: (json['mode'] as num?)?.toInt(),
satellites: (json['satellites'] as num?)?.toInt(),
);
}
}
/// 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.
class BackendService {
BackendService._() {
// Kick off initial fetches
_refreshArduino();
_refreshGps();
}
static final instance = BackendService._();
static const _baseUrl = 'http://127.0.0.1:5000';
static const _timeout = Duration(seconds: 2);
// Caches
ArduinoData? _arduinoCache;
GpsData? _gpsCache;
bool _connected = false;
// In-progress flags (prevent duplicate requests)
bool _arduinoFetchInProgress = false;
bool _gpsFetchInProgress = false;
/// Whether backend is reachable
bool get isConnected => _connected;
/// Get Arduino data (sync, returns cached value)
ArduinoData? getArduinoData() {
if (!_arduinoFetchInProgress) {
_refreshArduino();
}
return _arduinoCache;
}
/// Get GPS data (sync, returns cached value)
GpsData? getGpsData() {
if (!_gpsFetchInProgress) {
_refreshGps();
}
return _gpsCache;
}
/// Background fetch for Arduino data
Future<void> _refreshArduino() async {
if (_arduinoFetchInProgress) return;
_arduinoFetchInProgress = true;
try {
final response = await http
.get(Uri.parse('$_baseUrl/arduino'))
.timeout(_timeout);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value
if (!json.containsKey('error')) {
_arduinoCache = ArduinoData.fromJson(json);
}
_connected = true;
}
// Non-200: keep cached data, just mark disconnected
} catch (e) {
// Network error, timeout, etc - keep cached data for transient hiccups
_connected = false;
} finally {
_arduinoFetchInProgress = false;
}
}
/// Background fetch for GPS data
Future<void> _refreshGps() async {
if (_gpsFetchInProgress) return;
_gpsFetchInProgress = true;
try {
final response = await http
.get(Uri.parse('$_baseUrl/gps'))
.timeout(_timeout);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value
if (!json.containsKey('error')) {
_gpsCache = GpsData.fromJson(json);
}
_connected = true;
}
// Non-200: keep cached data, just mark disconnected
} catch (e) {
// Network error, timeout, etc - keep cached data for transient hiccups
_connected = false;
} finally {
_gpsFetchInProgress = false;
}
}
/// Force clear all caches
void clearCache() {
_arduinoCache = null;
_gpsCache = null;
_connected = false;
}
}
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
class ArduinoData {
final double? voltage;
final int? rpm;
final int? engTemp;
final int? gear; // 0 = neutral, 1-6 = gear
final double? roll; // Euler angle in degrees (negative = left, positive = right)
final double? pitch; // Euler angle in degrees (negative = nose down)
final double? ax; // Lateral acceleration (g)
final double? ay; // Longitudinal acceleration (g)
final double? az; // Vertical acceleration (g)
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
factory ArduinoData.fromJson(Map<String, dynamic> json) {
return ArduinoData(
voltage: (json['voltage'] as num?)?.toDouble(),
rpm: (json['rpm'] as num?)?.toInt(),
engTemp: (json['eng_temp'] as num?)?.toInt(),
gear: (json['gear'] as num?)?.toInt(),
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
pitch: (json['pitch'] as num?)?.toDouble(),
ax: (json['ax'] as num?)?.toDouble(),
ay: (json['ay'] as num?)?.toDouble(),
az: (json['az'] as num?)?.toDouble(),
);
}
}
/// Data from GPS
class GpsData {
final double? lat;
final double? lon;
final double? speed; // m/s
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, this.satellites, this.gpsState});
factory GpsData.fromJson(Map<String, dynamic> json) {
return GpsData(
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
speed: (json['speed'] as num?)?.toDouble(),
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?,
);
}
}
/// 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.
class BackendService {
BackendService._() {
// Kick off initial fetches
_refreshArduino();
_refreshGps();
}
static final instance = BackendService._();
static const _baseUrl = 'http://127.0.0.1:5000';
static const _timeout = Duration(seconds: 2);
// Caches
ArduinoData? _arduinoCache;
GpsData? _gpsCache;
bool _connected = false;
// In-progress flags (prevent duplicate requests)
bool _arduinoFetchInProgress = false;
bool _gpsFetchInProgress = false;
/// Whether backend is reachable
bool get isConnected => _connected;
/// Get Arduino data (sync, returns cached value)
ArduinoData? getArduinoData() {
if (!_arduinoFetchInProgress) {
_refreshArduino();
}
return _arduinoCache;
}
/// Get GPS data (sync, returns cached value)
GpsData? getGpsData() {
if (!_gpsFetchInProgress) {
_refreshGps();
}
return _gpsCache;
}
/// Background fetch for Arduino data
Future<void> _refreshArduino() async {
if (_arduinoFetchInProgress) return;
_arduinoFetchInProgress = true;
try {
final response = await http
.get(Uri.parse('$_baseUrl/arduino'))
.timeout(_timeout);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value
if (!json.containsKey('error')) {
_arduinoCache = ArduinoData.fromJson(json);
}
_connected = true;
}
// Non-200: keep cached data, just mark disconnected
} catch (e) {
// Network error, timeout, etc - keep cached data for transient hiccups
_connected = false;
} finally {
_arduinoFetchInProgress = false;
}
}
/// Background fetch for GPS data
Future<void> _refreshGps() async {
if (_gpsFetchInProgress) return;
_gpsFetchInProgress = true;
try {
final response = await http
.get(Uri.parse('$_baseUrl/gps'))
.timeout(_timeout);
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
// Skip if backend returns error (no data yet) - keep cached value
if (!json.containsKey('error')) {
_gpsCache = GpsData.fromJson(json);
}
_connected = true;
}
// Non-200: keep cached data, just mark disconnected
} catch (e) {
// Network error, timeout, etc - keep cached data for transient hiccups
_connected = false;
} finally {
_gpsFetchInProgress = false;
}
}
/// Force clear all caches
void clearCache() {
_arduinoCache = null;
_gpsCache = null;
_connected = false;
}
}

View File

@@ -1,332 +1,351 @@
import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io;
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
import 'theme_service.dart';
/// Connection state for WebSocket
enum WsConnectionState {
disconnected,
connecting,
connected,
}
/// Acknowledgment from backend for a command
class CommandAck {
final String id;
final String status;
final String? error;
final String? extra;
CommandAck({
required this.id,
required this.status,
this.error,
this.extra,
});
bool get isSuccess => status == 'ok' || status == 'sent';
}
/// Alert from backend
class BackendAlert {
final String type;
final String message;
BackendAlert({required this.type, required this.message});
}
/// Backend status (connection states of GPS/Arduino)
class BackendStatus {
final bool gpsConnected;
final bool arduinoConnected;
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
}
/// WebSocket service for real-time data from backend.
///
/// Replaces HTTP polling with push-based updates.
/// Maintains dual logical channels:
/// - Telemetry: arduino/gps data streams (throttled by backend)
/// - Control: button commands and acknowledgments
class WebSocketService {
WebSocketService._() {
_setupStreams();
}
static final instance = WebSocketService._();
static const _serverUrl = 'http://127.0.0.1:5000';
io.Socket? _socket;
WsConnectionState _connectionState = WsConnectionState.disconnected;
Timer? _reconnectTimer;
// Latest values for sync access (backward compat)
ArduinoData? _latestArduino;
GpsData? _latestGps;
BackendStatus? _latestStatus;
// Stream controllers
late StreamController<ArduinoData> _arduinoController;
late StreamController<GpsData> _gpsController;
late StreamController<BackendStatus> _statusController;
late StreamController<CommandAck> _ackController;
late StreamController<BackendAlert> _alertController;
late StreamController<WsConnectionState> _connectionController;
late StreamController<String> _debugController;
// Debug message buffer
static const int _maxDebugMessages = 50;
final List<String> _debugMessages = [];
void _setupStreams() {
_arduinoController = StreamController<ArduinoData>.broadcast();
_gpsController = StreamController<GpsData>.broadcast();
_statusController = StreamController<BackendStatus>.broadcast();
_ackController = StreamController<CommandAck>.broadcast();
_alertController = StreamController<BackendAlert>.broadcast();
_connectionController = StreamController<WsConnectionState>.broadcast();
_debugController = StreamController<String>.broadcast();
}
/// Log a debug message (adds to buffer and stream)
void _log(String message) {
_debugMessages.add(message);
if (_debugMessages.length > _maxDebugMessages) {
_debugMessages.removeAt(0);
}
_debugController.add(message);
}
// --- Public API: Streams ---
/// Stream of Arduino telemetry updates
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
/// Stream of GPS updates
Stream<GpsData> get gpsStream => _gpsController.stream;
/// Stream of backend status updates
Stream<BackendStatus> get statusStream => _statusController.stream;
/// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream;
/// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream;
/// Stream of connection state changes
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
/// Stream of debug log messages
Stream<String> get debugStream => _debugController.stream;
/// Current debug message buffer (for initial display)
List<String> get debugMessages => List.unmodifiable(_debugMessages);
// --- Public API: Sync getters (backward compat) ---
/// Current connection state
WsConnectionState get connectionState => _connectionState;
/// Whether connected to backend
bool get isConnected => _connectionState == WsConnectionState.connected;
/// Latest Arduino data (may be null if not yet received)
ArduinoData? get latestArduino => _latestArduino;
/// Latest GPS data (may be null if not yet received)
GpsData? get latestGps => _latestGps;
/// Latest backend status
BackendStatus? get latestStatus => _latestStatus;
// --- Public API: Connection ---
/// Connect to backend WebSocket
void connect() {
if (_socket != null) return; // Already connected or connecting
_setConnectionState(WsConnectionState.connecting);
_socket = io.io(_serverUrl, <String, dynamic>{
'transports': ['websocket'],
'autoConnect': true,
'reconnection': false, // We handle reconnection ourselves
});
_socket!.onConnect((_) {
_log('connected');
_setConnectionState(WsConnectionState.connected);
_cancelReconnect();
});
_socket!.onDisconnect((_) {
_log('disconnected');
_setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect();
});
_socket!.onConnectError((error) {
_log('error: $error');
_setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect();
});
_socket!.onError((error) {
_log('error: $error');
});
// --- Telemetry Events ---
_socket!.on('arduino', (data) {
if (data is Map<String, dynamic>) {
final arduino = ArduinoData.fromJson(data);
_latestArduino = arduino;
_arduinoController.add(arduino);
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"}');
}
}
});
_socket!.on('gps', (data) {
if (data is Map<String, dynamic>) {
final gps = GpsData.fromJson(data);
_latestGps = gps;
_gpsController.add(gps);
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
}
});
_socket!.on('status', (data) {
if (data is Map<String, dynamic>) {
final status = BackendStatus(
gpsConnected: data['gps_connected'] ?? false,
arduinoConnected: data['arduino_connected'] ?? false,
);
_latestStatus = status;
_statusController.add(status);
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
// Initial theme state comes with status on connect
if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"} (initial)');
}
}
});
// --- Control Events ---
_socket!.on('ack', (data) {
if (data is Map<String, dynamic>) {
final ack = CommandAck(
id: data['id'] ?? 'unknown',
status: data['status'] ?? 'unknown',
error: data['error'],
extra: data['extra'],
);
_ackController.add(ack);
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
}
});
_socket!.on('alert', (data) {
if (data is Map<String, dynamic>) {
final alert = BackendAlert(
type: data['type'] ?? 'unknown',
message: data['message'] ?? '',
);
_alertController.add(alert);
_log('alert: [${alert.type}] ${alert.message}');
}
});
_socket!.connect();
}
/// Disconnect from backend
void disconnect() {
_cancelReconnect();
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_setConnectionState(WsConnectionState.disconnected);
}
// --- Public API: Commands ---
/// Send button event to backend
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
if (_socket == null || !isConnected) {
print('[WS] Cannot send button, not connected');
return;
}
final data = <String, dynamic>{
'id': id,
'action': action,
...?params,
};
_socket!.emit('button', data);
}
/// Send emergency signal to backend
void sendEmergency(String type) {
if (_socket == null) {
print('[WS] Cannot send emergency, not connected');
return;
}
// Emergency should be sent even if not fully connected
_socket!.emit('emergency', {'type': type});
}
// --- Private ---
void _setConnectionState(WsConnectionState state) {
if (_connectionState != state) {
_connectionState = state;
_connectionController.add(state);
}
}
void _scheduleReconnect() {
_cancelReconnect();
_reconnectTimer = Timer(const Duration(seconds: 3), () {
print('[WS] Attempting reconnect...');
_socket?.dispose();
_socket = null;
connect();
});
}
void _cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
}
/// Dispose all resources (call on app shutdown)
void dispose() {
disconnect();
_arduinoController.close();
_gpsController.close();
_statusController.close();
_ackController.close();
_alertController.close();
_connectionController.close();
_debugController.close();
}
}
import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io;
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
import 'theme_service.dart';
/// Connection state for WebSocket
enum WsConnectionState {
disconnected,
connecting,
connected,
}
/// Acknowledgment from backend for a command
class CommandAck {
final String id;
final String status;
final String? error;
final String? extra;
CommandAck({
required this.id,
required this.status,
this.error,
this.extra,
});
bool get isSuccess => status == 'ok' || status == 'sent';
}
/// Alert from backend
class BackendAlert {
final String type;
final String message;
BackendAlert({required this.type, required this.message});
}
/// Backend status (connection states of GPS/Arduino)
class BackendStatus {
final bool gpsConnected;
final bool arduinoConnected;
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
}
/// WebSocket service for real-time data from backend.
///
/// Replaces HTTP polling with push-based updates.
/// Maintains dual logical channels:
/// - Telemetry: arduino/gps data streams (throttled by backend)
/// - Control: button commands and acknowledgments
class WebSocketService {
WebSocketService._() {
_setupStreams();
}
static final instance = WebSocketService._();
static const _serverUrl = 'http://127.0.0.1:5000';
io.Socket? _socket;
WsConnectionState _connectionState = WsConnectionState.disconnected;
Timer? _reconnectTimer;
// 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;
late StreamController<WsConnectionState> _connectionController;
late StreamController<String> _debugController;
// Debug message buffer
static const int _maxDebugMessages = 50;
final List<String> _debugMessages = [];
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();
_connectionController = StreamController<WsConnectionState>.broadcast();
_debugController = StreamController<String>.broadcast();
}
/// Log a debug message (adds to buffer and stream)
void _log(String message) {
_debugMessages.add(message);
if (_debugMessages.length > _maxDebugMessages) {
_debugMessages.removeAt(0);
}
_debugController.add(message);
}
// --- Public API: Streams ---
/// Stream of Arduino telemetry updates
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
/// 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;
/// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream;
/// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream;
/// Stream of connection state changes
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
/// Stream of debug log messages
Stream<String> get debugStream => _debugController.stream;
/// Current debug message buffer (for initial display)
List<String> get debugMessages => List.unmodifiable(_debugMessages);
// --- Public API: Sync getters (backward compat) ---
/// Current connection state
WsConnectionState get connectionState => _connectionState;
/// Whether connected to backend
bool get isConnected => _connectionState == WsConnectionState.connected;
/// Latest Arduino data (may be null if not yet received)
ArduinoData? get latestArduino => _latestArduino;
/// 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;
// --- Public API: Connection ---
/// Connect to backend WebSocket
void connect() {
if (_socket != null) return; // Already connected or connecting
_setConnectionState(WsConnectionState.connecting);
_socket = io.io(_serverUrl, <String, dynamic>{
'transports': ['websocket'],
'autoConnect': true,
'reconnection': false, // We handle reconnection ourselves
});
_socket!.onConnect((_) {
_log('connected');
_setConnectionState(WsConnectionState.connected);
_cancelReconnect();
});
_socket!.onDisconnect((_) {
_log('disconnected');
_setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect();
});
_socket!.onConnectError((error) {
_log('error: $error');
_setConnectionState(WsConnectionState.disconnected);
_scheduleReconnect();
});
_socket!.onError((error) {
_log('error: $error');
});
// --- Telemetry Events ---
_socket!.on('arduino', (data) {
if (data is Map<String, dynamic>) {
final arduino = ArduinoData.fromJson(data);
_latestArduino = arduino;
_arduinoController.add(arduino);
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"}');
}
}
});
_socket!.on('gps', (data) {
if (data is Map<String, dynamic>) {
final gps = GpsData.fromJson(data);
_latestGps = gps;
_gpsController.add(gps);
_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 ?? "-"}');
}
});
_socket!.on('status', (data) {
if (data is Map<String, dynamic>) {
final status = BackendStatus(
gpsConnected: data['gps_connected'] ?? false,
arduinoConnected: data['arduino_connected'] ?? false,
);
_latestStatus = status;
_statusController.add(status);
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
// Initial theme state comes with status on connect
if (data.containsKey('theme_switch')) {
final isDark = data['theme_switch'] as bool;
ThemeService.instance.setDarkMode(isDark);
_log('theme: ${isDark ? "dark" : "light"} (initial)');
}
}
});
// --- Control Events ---
_socket!.on('ack', (data) {
if (data is Map<String, dynamic>) {
final ack = CommandAck(
id: data['id'] ?? 'unknown',
status: data['status'] ?? 'unknown',
error: data['error'],
extra: data['extra'],
);
_ackController.add(ack);
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
}
});
_socket!.on('alert', (data) {
if (data is Map<String, dynamic>) {
final alert = BackendAlert(
type: data['type'] ?? 'unknown',
message: data['message'] ?? '',
);
_alertController.add(alert);
_log('alert: [${alert.type}] ${alert.message}');
}
});
_socket!.connect();
}
/// Disconnect from backend
void disconnect() {
_cancelReconnect();
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_setConnectionState(WsConnectionState.disconnected);
}
// --- Public API: Commands ---
/// Send button event to backend
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
if (_socket == null || !isConnected) {
print('[WS] Cannot send button, not connected');
return;
}
final data = <String, dynamic>{
'id': id,
'action': action,
...?params,
};
_socket!.emit('button', data);
}
/// Send emergency signal to backend
void sendEmergency(String type) {
if (_socket == null) {
print('[WS] Cannot send emergency, not connected');
return;
}
// Emergency should be sent even if not fully connected
_socket!.emit('emergency', {'type': type});
}
// --- Private ---
void _setConnectionState(WsConnectionState state) {
if (_connectionState != state) {
_connectionState = state;
_connectionController.add(state);
}
}
void _scheduleReconnect() {
_cancelReconnect();
_reconnectTimer = Timer(const Duration(seconds: 3), () {
print('[WS] Attempting reconnect...');
_socket?.dispose();
_socket = null;
connect();
});
}
void _cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
}
/// Dispose all resources (call on app shutdown)
void dispose() {
disconnect();
_arduinoController.close();
_gpsController.close();
_lteController.close();
_statusController.close();
_ackController.close();
_alertController.close();
_connectionController.close();
_debugController.close();
}
}

View File

@@ -1,119 +1,119 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Generic debug console that displays streaming log messages.
///
/// Can be wired to any message source via [messageStream] and [initialMessages].
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
class DebugConsole extends StatefulWidget {
/// Stream of new messages to display
final Stream<String> messageStream;
/// Initial messages to populate (e.g., from a buffer)
final List<String> initialMessages;
/// Maximum lines to display
final int maxLines;
/// Optional title for the console (shown in title bar)
final String? title;
const DebugConsole({
super.key,
required this.messageStream,
this.initialMessages = const [],
this.maxLines = 8,
this.title,
});
@override
State<DebugConsole> createState() => _DebugConsoleState();
}
class _DebugConsoleState extends State<DebugConsole> {
final List<String> _messages = [];
StreamSubscription<String>? _sub;
@override
void initState() {
super.initState();
// Initialize with existing buffer
_messages.addAll(widget.initialMessages);
_trimMessages();
// Subscribe to new messages
_sub = widget.messageStream.listen((msg) {
setState(() {
_messages.add(msg);
_trimMessages();
});
});
}
void _trimMessages() {
while (_messages.length > widget.maxLines) {
_messages.removeAt(0);
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.background.withAlpha(64),
border: Border.all(color: theme.subdued, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title bar (optional)
if (widget.title != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.subdued, width: 1),
),
),
child: Text(
widget.title!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 24,
color: theme.subdued,
),
),
),
// Console content
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 30,
color: theme.foreground,
height: 1.0,
),
),
),
),
],
),
);
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Generic debug console that displays streaming log messages.
///
/// Can be wired to any message source via [messageStream] and [initialMessages].
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
class DebugConsole extends StatefulWidget {
/// Stream of new messages to display
final Stream<String> messageStream;
/// Initial messages to populate (e.g., from a buffer)
final List<String> initialMessages;
/// Maximum lines to display
final int maxLines;
/// Optional title for the console (shown in title bar)
final String? title;
const DebugConsole({
super.key,
required this.messageStream,
this.initialMessages = const [],
this.maxLines = 8,
this.title,
});
@override
State<DebugConsole> createState() => _DebugConsoleState();
}
class _DebugConsoleState extends State<DebugConsole> {
final List<String> _messages = [];
StreamSubscription<String>? _sub;
@override
void initState() {
super.initState();
// Initialize with existing buffer
_messages.addAll(widget.initialMessages);
_trimMessages();
// Subscribe to new messages
_sub = widget.messageStream.listen((msg) {
setState(() {
_messages.add(msg);
_trimMessages();
});
});
}
void _trimMessages() {
while (_messages.length > widget.maxLines) {
_messages.removeAt(0);
}
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.background.withAlpha(64),
border: Border.all(color: theme.subdued, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title bar (optional)
if (widget.title != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.subdued, width: 1),
),
),
child: Text(
widget.title!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 24,
color: theme.subdued,
),
),
),
// Console content
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 30,
color: theme.foreground,
height: 1.0,
),
),
),
),
],
),
);
}
}

View File

@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
class GpsCompass extends StatelessWidget {
final double? heading;
final String? gpsState; // "acquiring", "fix", "lost"
const GpsCompass({super.key, this.heading});
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'; // Just make it clear; redundant anyways, this only gets called when _hasSignal
return '${(heading! % 360).round()}'; // No need for the degree symbol
if (!_hasSignal) return 'N/A';
return '${(heading! % 360).round()}';
}
String get _compassDirection {
@@ -56,10 +58,10 @@ class GpsCompass extends StatelessWidget {
child: FittedBox(
fit: BoxFit.contain,
child: Text(
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
_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',
),
),

View File

@@ -1,100 +1,100 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import '../services/config_service.dart';
/// Displays the navigator character with emotion support.
///
/// Use a GlobalKey to control emotions from parent:
/// ```dart
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
/// NavigatorWidget(key: _navigatorKey)
/// // Later:
/// _navigatorKey.currentState?.setEmotion('happy');
/// ```
class NavigatorWidget extends StatefulWidget {
const NavigatorWidget({super.key});
@override
State<NavigatorWidget> createState() => NavigatorWidgetState();
}
class NavigatorWidgetState extends State<NavigatorWidget>
with SingleTickerProviderStateMixin {
String _emotion = 'default';
late AnimationController _shakeController;
@override
void initState() {
super.initState();
_shakeController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
// Auto-reset to default after surprise animation completes
_shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed && _emotion == 'surprise') {
setState(() => _emotion = 'default');
}
});
}
@override
void dispose() {
_shakeController.dispose();
super.dispose();
}
/// Change the displayed emotion.
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
void setEmotion(String emotion) {
if (emotion != _emotion) {
setState(() => _emotion = emotion);
if (emotion == 'surprise') {
_shakeController.forward(from: 0);
}
}
}
/// Reset to default emotion
void reset() => setEmotion('default');
/// Current emotion
String get emotion => _emotion;
@override
Widget build(BuildContext context) {
final config = ConfigService.instance;
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
final image = Image.file(
File('$basePath/$_emotion.png'),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Fallback: try default.png if specific emotion missing
if (_emotion != 'default') {
return Image.file(
File('$basePath/default.png'),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
}
return const SizedBox.shrink();
},
);
// Shake animation for surprise
return AnimatedBuilder(
animation: _shakeController,
child: image,
builder: (context, child) {
final shake = sin(_shakeController.value * pi * 6) * 25 *
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
return Transform.translate(
offset: Offset(shake, 0),
child: child,
);
},
);
}
}
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import '../services/config_service.dart';
/// Displays the navigator character with emotion support.
///
/// Use a GlobalKey to control emotions from parent:
/// ```dart
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
/// NavigatorWidget(key: _navigatorKey)
/// // Later:
/// _navigatorKey.currentState?.setEmotion('happy');
/// ```
class NavigatorWidget extends StatefulWidget {
const NavigatorWidget({super.key});
@override
State<NavigatorWidget> createState() => NavigatorWidgetState();
}
class NavigatorWidgetState extends State<NavigatorWidget>
with SingleTickerProviderStateMixin {
String _emotion = 'default';
late AnimationController _shakeController;
@override
void initState() {
super.initState();
_shakeController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
// Auto-reset to default after surprise animation completes
_shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed && _emotion == 'surprise') {
setState(() => _emotion = 'default');
}
});
}
@override
void dispose() {
_shakeController.dispose();
super.dispose();
}
/// Change the displayed emotion.
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
void setEmotion(String emotion) {
if (emotion != _emotion) {
setState(() => _emotion = emotion);
if (emotion == 'surprise') {
_shakeController.forward(from: 0);
}
}
}
/// Reset to default emotion
void reset() => setEmotion('default');
/// Current emotion
String get emotion => _emotion;
@override
Widget build(BuildContext context) {
final config = ConfigService.instance;
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
final image = Image.file(
File('$basePath/$_emotion.png'),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Fallback: try default.png if specific emotion missing
if (_emotion != 'default') {
return Image.file(
File('$basePath/default.png'),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
);
}
return const SizedBox.shrink();
},
);
// Shake animation for surprise
return AnimatedBuilder(
animation: _shakeController,
child: image,
builder: (context, child) {
final shake = sin(_shakeController.value * pi * 6) * 25 *
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
return Transform.translate(
offset: Offset(shake, 0),
child: child,
);
},
);
}
}

View File

@@ -1,170 +1,174 @@
import 'package:flutter/material.dart';
import '../services/websocket_service.dart';
import '../theme/app_theme.dart';
/// Android-style persistent status bar for system indicators.
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
class SystemBar extends StatelessWidget {
final int? gpsSatellites; // null = disconnected
final int? lteSignal; // null = disconnected, 0-4 bars
final double? piTemp; // null = unavailable
final double? voltage; // null = Arduino disconnected
final WsConnectionState? wsState; // WebSocket connection state
const SystemBar({
super.key,
this.gpsSatellites,
this.lteSignal,
this.piTemp,
this.voltage,
this.wsState,
});
/// Get WebSocket status text and abnormal flag
(String, bool) _wsStatus() {
switch (wsState) {
case WsConnectionState.connected:
return ('OK', false);
case WsConnectionState.connecting:
return ('...', true);
case WsConnectionState.disconnected:
case null:
return ('OFF', true);
}
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
final (wsText, wsAbnormal) = _wsStatus();
return Expanded(
flex: 1,
child: LayoutBuilder(
builder: (context, constraints) {
// Font sizes relative to bar height
final labelSize = constraints.maxHeight * 0.5;
final valueSize = constraints.maxHeight * 0.5;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left group: WS, GPS, LTE
_Indicator(
label: 'WS',
value: wsText,
isAbnormal: wsAbnormal,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
_Indicator(
label: 'GPS',
value: gpsSatellites?.toString() ?? 'N/A',
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
_Indicator(
label: 'LTE',
value: lteSignal?.toString() ?? 'N/A',
isAbnormal: lteSignal == null,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
// Right group: Pi, Chassis
_Indicator(
label: 'Pi',
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
isAbnormal: piTemp == null || piTemp! > 80,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
_Indicator(
label: 'Mains',
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 3,
theme: theme,
),
],
),
);
},
),
);
}
}
/// Single status indicator in a fixed-width flex slot.
class _Indicator extends StatelessWidget {
final String label;
final String value;
final bool isAbnormal;
final Alignment alignment;
final double labelSize;
final double valueSize;
final int flex;
final AppTheme theme;
const _Indicator({
required this.label,
required this.value,
required this.isAbnormal,
required this.alignment,
required this.labelSize,
required this.valueSize,
required this.flex,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: Align(
alignment: alignment,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$label ',
style: TextStyle(
fontSize: labelSize,
color: theme.subdued,
),
),
Text(
value,
style: TextStyle(
fontSize: valueSize,
fontFeatures: const [FontFeature.tabularFigures()],
color: isAbnormal ? theme.highlight : theme.foreground,
),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import '../services/websocket_service.dart';
import '../theme/app_theme.dart';
/// Android-style persistent status bar for system indicators.
/// 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
final WsConnectionState? wsState; // WebSocket connection state
const SystemBar({
super.key,
this.gpsSatellites,
this.gpsState,
this.lteSignal,
this.piTemp,
this.voltage,
this.wsState,
});
/// Get WebSocket status text and abnormal flag
(String, bool) _wsStatus() {
switch (wsState) {
case WsConnectionState.connected:
return ('OK', false);
case WsConnectionState.connecting:
return ('...', true);
case WsConnectionState.disconnected:
case null:
return ('OFF', true);
}
}
@override
Widget build(BuildContext context) {
final theme = AppTheme.of(context);
final (wsText, wsAbnormal) = _wsStatus();
return Expanded(
flex: 1,
child: LayoutBuilder(
builder: (context, constraints) {
// Font sizes relative to bar height
final labelSize = constraints.maxHeight * 0.5;
final valueSize = constraints.maxHeight * 0.5;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left group: WS, GPS, LTE
_Indicator(
label: 'WS',
value: wsText,
isAbnormal: wsAbnormal,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
_Indicator(
label: 'GPS',
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,
flex: 2,
theme: theme,
),
_Indicator(
label: 'LTE',
value: lteSignal?.toString() ?? 'N/A',
isAbnormal: lteSignal == null,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
// Right group: Pi, Chassis
_Indicator(
label: 'Pi',
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
isAbnormal: piTemp == null || piTemp! > 80,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 2,
theme: theme,
),
_Indicator(
label: 'Mains',
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
alignment: Alignment.centerLeft,
labelSize: labelSize,
valueSize: valueSize,
flex: 3,
theme: theme,
),
],
),
);
},
),
);
}
}
/// Single status indicator in a fixed-width flex slot.
class _Indicator extends StatelessWidget {
final String label;
final String value;
final bool isAbnormal;
final Alignment alignment;
final double labelSize;
final double valueSize;
final int flex;
final AppTheme theme;
const _Indicator({
required this.label,
required this.value,
required this.isAbnormal,
required this.alignment,
required this.labelSize,
required this.valueSize,
required this.flex,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: Align(
alignment: alignment,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$label ',
style: TextStyle(
fontSize: labelSize,
color: theme.subdued,
),
),
Text(
value,
style: TextStyle(
fontSize: valueSize,
fontFeatures: const [FontFeature.tabularFigures()],
color: isAbnormal ? theme.highlight : theme.foreground,
),
),
],
),
),
);
}
}

View File

@@ -1,26 +1,26 @@
name: smartserow_ui
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
socket_io_client: ^2.0.3+1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
fonts:
- family: DIN1451
fonts:
- asset: assets/fonts/din1451alt.ttf
name: smartserow_ui
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
socket_io_client: ^2.0.3+1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
fonts:
- family: DIN1451
fonts:
- asset: assets/fonts/din1451alt.ttf

View File

@@ -1,88 +1,88 @@
#!/usr/bin/env python3
"""One-click build and deploy for Smart Serow.
Combines build.py and deploy.py with sensible defaults.
Defaults to --restart since that's usually what you want.
"""
import argparse
import sys
from pathlib import Path
# Import sibling modules
sys.path.insert(0, str(Path(__file__).parent))
from build import build
from deploy import deploy
from deploy_backend import deploy as deploy_backend
def main():
parser = argparse.ArgumentParser(
description="Build and deploy Smart Serow in one step",
)
parser.add_argument(
"--clean", "-c",
action="store_true",
help="Clean CMake cache before building",
)
parser.add_argument(
"--no-restart",
action="store_true",
help="Don't restart service after deploy (default: restart)",
)
parser.add_argument(
"--build-only",
action="store_true",
help="Only build, don't deploy",
)
parser.add_argument(
"--deploy-only",
action="store_true",
help="Only deploy, don't build",
)
parser.add_argument(
"--ui",
action="store_true",
help="Build/deploy UI only (no backend)",
)
parser.add_argument(
"--backend",
action="store_true",
help="Deploy backend only (no UI, no build)",
)
args = parser.parse_args()
# Default: both UI and backend if neither flag specified
do_ui = args.ui or not args.backend
do_backend = args.backend or not args.ui
restart = not args.no_restart
# Build UI (only if doing UI and not deploy-only)
if do_ui and not args.deploy_only:
print()
if not build(clean=args.clean):
print("UI build failed!")
sys.exit(1)
# Deploy backend FIRST (no build step needed - it's Python)
# Backend must be up before UI connects to WebSocket
if do_backend and not args.build_only:
print()
if not deploy_backend(restart=restart):
print("Backend deploy failed!")
sys.exit(1)
# Deploy UI after backend is ready
if do_ui and not args.build_only:
print()
if not deploy(restart=restart):
print("UI deploy failed!")
sys.exit(1)
print()
print("=== All done! ===")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""One-click build and deploy for Smart Serow.
Combines build.py and deploy.py with sensible defaults.
Defaults to --restart since that's usually what you want.
"""
import argparse
import sys
from pathlib import Path
# Import sibling modules
sys.path.insert(0, str(Path(__file__).parent))
from build import build
from deploy import deploy
from deploy_backend import deploy as deploy_backend
def main():
parser = argparse.ArgumentParser(
description="Build and deploy Smart Serow in one step",
)
parser.add_argument(
"--clean", "-c",
action="store_true",
help="Clean CMake cache before building",
)
parser.add_argument(
"--no-restart",
action="store_true",
help="Don't restart service after deploy (default: restart)",
)
parser.add_argument(
"--build-only",
action="store_true",
help="Only build, don't deploy",
)
parser.add_argument(
"--deploy-only",
action="store_true",
help="Only deploy, don't build",
)
parser.add_argument(
"--ui",
action="store_true",
help="Build/deploy UI only (no backend)",
)
parser.add_argument(
"--backend",
action="store_true",
help="Deploy backend only (no UI, no build)",
)
args = parser.parse_args()
# Default: both UI and backend if neither flag specified
do_ui = args.ui or not args.backend
do_backend = args.backend or not args.ui
restart = not args.no_restart
# Build UI (only if doing UI and not deploy-only)
if do_ui and not args.deploy_only:
print()
if not build(clean=args.clean):
print("UI build failed!")
sys.exit(1)
# Deploy backend FIRST (no build step needed - it's Python)
# Backend must be up before UI connects to WebSocket
if do_backend and not args.build_only:
print()
if not deploy_backend(restart=restart):
print("Backend deploy failed!")
sys.exit(1)
# Deploy UI after backend is ready
if do_ui and not args.build_only:
print()
if not deploy(restart=restart):
print("UI deploy failed!")
sys.exit(1)
print()
print("=== All done! ===")
if __name__ == "__main__":
main()

View File

@@ -1,157 +1,157 @@
#!/usr/bin/env python3
"""Deploy script for Smart Serow Python backend.
Pushes backend source to Pi and optionally restarts service.
Completely independent from UI deploy.
"""
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
"""Run a command."""
print(f"{' '.join(cmd)}")
return subprocess.run(cmd, check=check, **kwargs)
def load_config() -> dict:
"""Load deploy target configuration."""
if not CONFIG_FILE.exists():
print(f"ERROR: Config file not found: {CONFIG_FILE}")
print("Create it based on deploy_target.sample.json")
sys.exit(1)
with open(CONFIG_FILE) as f:
return json.load(f)
def deploy(restart: bool = False) -> bool:
"""Deploy backend to Pi. Returns True on success."""
config = load_config()
pi_user = config["user"]
pi_host = config["host"]
# Backend-specific config (with defaults)
remote_path = config.get("backend_path", "/opt/smartserow-backend")
service_name = config.get("backend_service", "smartserow-backend")
ssh_target = f"{pi_user}@{pi_host}"
print("=== Smart Serow Backend Deploy ===")
print(f"Target: {ssh_target}:{remote_path}")
print(f"Source: {BACKEND_DIR}")
if not BACKEND_DIR.exists():
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
return False
# Ensure remote directory exists
print()
print("Ensuring remote directory...")
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
# Sync backend source to Pi
# Exclude __pycache__, .venv, etc.
print()
print("Syncing files...")
run([
"rsync", "-avz", "--delete",
"--exclude", "__pycache__",
"--exclude", "*.pyc",
"--exclude", ".venv",
"--exclude", ".ruff_cache",
"--exclude", "uv.lock", # Let Pi generate its own lockfile
f"{BACKEND_DIR}/",
f"{ssh_target}:{remote_path}/",
])
# Ensure system GPIO package is installed (pip version needs compilation)
print()
print("Ensuring system GPIO package...")
run(
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
check=False,
)
# Create venv with system-site-packages if it doesn't exist
# This allows access to apt-installed packages like python3-rpi.gpio
print()
print("Ensuring venv with system-site-packages...")
run(
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
check=False,
)
# Run uv sync to install/update dependencies
# Use full path since non-interactive SSH doesn't load .bashrc
print()
print("Running uv sync...")
result = run(
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
check=False,
)
if result.returncode != 0:
print("WARNING: uv sync failed - dependencies may be out of date")
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
# Deploy service file if it exists
if SERVICE_FILE.exists():
print()
print("Deploying systemd service file...")
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
run([
"ssh", ssh_target,
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
])
# Restart service if requested
if restart:
print()
print(f"Restarting service: {service_name}")
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
time.sleep(2)
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
else:
print()
print("Deploy complete. To restart service, run:")
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
print()
print("Or run this script with --restart flag")
print()
print("Note: First-time setup on Pi requires:")
print(f" ssh {ssh_target}")
print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
print(" sudo apt install python3-rpi.gpio # GPIO support")
return True
def main():
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
parser.add_argument(
"--restart", "-r",
action="store_true",
help="Restart the systemd service after deploy",
)
args = parser.parse_args()
success = deploy(restart=args.restart)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Deploy script for Smart Serow Python backend.
Pushes backend source to Pi and optionally restarts service.
Completely independent from UI deploy.
"""
import argparse
import json
import subprocess
import sys
import time
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
"""Run a command."""
print(f"{' '.join(cmd)}")
return subprocess.run(cmd, check=check, **kwargs)
def load_config() -> dict:
"""Load deploy target configuration."""
if not CONFIG_FILE.exists():
print(f"ERROR: Config file not found: {CONFIG_FILE}")
print("Create it based on deploy_target.sample.json")
sys.exit(1)
with open(CONFIG_FILE) as f:
return json.load(f)
def deploy(restart: bool = False) -> bool:
"""Deploy backend to Pi. Returns True on success."""
config = load_config()
pi_user = config["user"]
pi_host = config["host"]
# Backend-specific config (with defaults)
remote_path = config.get("backend_path", "/opt/smartserow-backend")
service_name = config.get("backend_service", "smartserow-backend")
ssh_target = f"{pi_user}@{pi_host}"
print("=== Smart Serow Backend Deploy ===")
print(f"Target: {ssh_target}:{remote_path}")
print(f"Source: {BACKEND_DIR}")
if not BACKEND_DIR.exists():
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
return False
# Ensure remote directory exists
print()
print("Ensuring remote directory...")
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
# Sync backend source to Pi
# Exclude __pycache__, .venv, etc.
print()
print("Syncing files...")
run([
"rsync", "-avz", "--delete",
"--exclude", "__pycache__",
"--exclude", "*.pyc",
"--exclude", ".venv",
"--exclude", ".ruff_cache",
"--exclude", "uv.lock", # Let Pi generate its own lockfile
f"{BACKEND_DIR}/",
f"{ssh_target}:{remote_path}/",
])
# Ensure system GPIO package is installed (pip version needs compilation)
print()
print("Ensuring system GPIO package...")
run(
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
check=False,
)
# Create venv with system-site-packages if it doesn't exist
# This allows access to apt-installed packages like python3-rpi.gpio
print()
print("Ensuring venv with system-site-packages...")
run(
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
check=False,
)
# Run uv sync to install/update dependencies
# Use full path since non-interactive SSH doesn't load .bashrc
print()
print("Running uv sync...")
result = run(
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
check=False,
)
if result.returncode != 0:
print("WARNING: uv sync failed - dependencies may be out of date")
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
# Deploy service file if it exists
if SERVICE_FILE.exists():
print()
print("Deploying systemd service file...")
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
run([
"ssh", ssh_target,
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
])
# Restart service if requested
if restart:
print()
print(f"Restarting service: {service_name}")
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
time.sleep(2)
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
else:
print()
print("Deploy complete. To restart service, run:")
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
print()
print("Or run this script with --restart flag")
print()
print("Note: First-time setup on Pi requires:")
print(f" ssh {ssh_target}")
print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
print(" sudo apt install python3-rpi.gpio # GPIO support")
return True
def main():
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
parser.add_argument(
"--restart", "-r",
action="store_true",
help="Restart the systemd service after deploy",
)
args = parser.parse_args()
success = deploy(restart=args.restart)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()