gps manipulations tailored to sim7600h hat

This commit is contained in:
2026-02-09 02:11:55 +09:00
parent 992270ed00
commit 629c735eec
20 changed files with 2503 additions and 2355 deletions

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,305 @@
"""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 = 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
# 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._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")
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
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 (~3s)
acquiring_until = time.time() + 3.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)

View File

@@ -1,245 +1,247 @@
"""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 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."""
gps_latest = gps.get_latest()
return jsonify({
"status": "ok",
"gps_connected": gps.connected,
"gps_state": gps_latest.get("gps_state", "acquiring"),
"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()

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,286 @@
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<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 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;
// 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,
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,163 @@
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?,
);
}
}
/// 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,332 @@
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;
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();
}
}

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,7 +58,7 @@ 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,

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',
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