Compare commits
4 Commits
992270ed00
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46496d688 | ||
|
|
47b3427e63 | ||
|
|
12a0d58800 | ||
| 629c735eec |
8
IDEAS.md
8
IDEAS.md
@@ -1,5 +1,5 @@
|
|||||||
Note to keep inspirations lest I forget.
|
Note to keep inspirations lest I forget.
|
||||||
|
|
||||||
# Things to do, but not really urgent
|
# Things to do, but not really urgent
|
||||||
- Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am)
|
- Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am)
|
||||||
- Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps.
|
- Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps.
|
||||||
4
arduino/.gitignore
vendored
4
arduino/.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
# arduino test files
|
# arduino test files
|
||||||
|
|
||||||
test/
|
test/
|
||||||
@@ -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.
|
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
|
## Deploy
|
||||||
|
|
||||||
TODO: Add to `scripts/deploy.py` as second target + systemd service.
|
TODO: Add to `scripts/deploy.py` as second target + systemd service.
|
||||||
|
|||||||
@@ -1,308 +1,308 @@
|
|||||||
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
|
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# pyserial for UART communication
|
# pyserial for UART communication
|
||||||
try:
|
try:
|
||||||
import serial
|
import serial
|
||||||
except ImportError:
|
except ImportError:
|
||||||
serial = None # Allow import without pyserial for testing structure
|
serial = None # Allow import without pyserial for testing structure
|
||||||
|
|
||||||
|
|
||||||
class ArduinoService:
|
class ArduinoService:
|
||||||
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
|
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
|
||||||
|
|
||||||
# TSV field names (order per PROTOCOL.md)
|
# TSV field names (order per PROTOCOL.md)
|
||||||
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
|
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
|
||||||
|
|
||||||
# Regex patterns for legacy text protocol (backwards compatibility)
|
# Regex patterns for legacy text protocol (backwards compatibility)
|
||||||
PATTERNS = {
|
PATTERNS = {
|
||||||
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
|
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
|
||||||
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
|
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
|
||||||
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
|
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
|
||||||
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
|
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
|
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
|
||||||
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
|
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
port: str = "/dev/serial0",
|
port: str = "/dev/serial0",
|
||||||
baudrate: int = 115200,
|
baudrate: int = 115200,
|
||||||
buffer_size: int = 100,
|
buffer_size: int = 100,
|
||||||
):
|
):
|
||||||
self.port = port
|
self.port = port
|
||||||
self.baudrate = baudrate
|
self.baudrate = baudrate
|
||||||
self.buffer_size = buffer_size
|
self.buffer_size = buffer_size
|
||||||
|
|
||||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||||
self._latest: dict[str, Any] = {}
|
self._latest: dict[str, Any] = {}
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# Callbacks for push-based updates
|
# Callbacks for push-based updates
|
||||||
self._on_data_callback = None
|
self._on_data_callback = None
|
||||||
self._on_ack_callback = None
|
self._on_ack_callback = None
|
||||||
|
|
||||||
# Serial port handle for sending commands
|
# Serial port handle for sending commands
|
||||||
self._serial: Any = None
|
self._serial: Any = None
|
||||||
self._serial_lock = threading.Lock()
|
self._serial_lock = threading.Lock()
|
||||||
|
|
||||||
# Periodic status logging
|
# Periodic status logging
|
||||||
self._last_status_log = 0.0
|
self._last_status_log = 0.0
|
||||||
self._frame_count = 0
|
self._frame_count = 0
|
||||||
|
|
||||||
def set_on_data(self, callback):
|
def set_on_data(self, callback):
|
||||||
"""Set callback for new telemetry data. Called with data dict."""
|
"""Set callback for new telemetry data. Called with data dict."""
|
||||||
self._on_data_callback = callback
|
self._on_data_callback = callback
|
||||||
|
|
||||||
def set_on_ack(self, callback):
|
def set_on_ack(self, callback):
|
||||||
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
|
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
|
||||||
self._on_ack_callback = callback
|
self._on_ack_callback = callback
|
||||||
|
|
||||||
def send_command(self, cmd: str, params: dict | None = None) -> bool:
|
def send_command(self, cmd: str, params: dict | None = None) -> bool:
|
||||||
"""Send a command to Arduino via serial.
|
"""Send a command to Arduino via serial.
|
||||||
|
|
||||||
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
|
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cmd: Command name (e.g., "HORN", "LIGHT")
|
cmd: Command name (e.g., "HORN", "LIGHT")
|
||||||
params: Optional parameters dict
|
params: Optional parameters dict
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if sent successfully, False if serial unavailable
|
True if sent successfully, False if serial unavailable
|
||||||
"""
|
"""
|
||||||
with self._serial_lock:
|
with self._serial_lock:
|
||||||
if self._serial is None or not self._connected:
|
if self._serial is None or not self._connected:
|
||||||
print(f"[Arduino] Cannot send command, not connected")
|
print(f"[Arduino] Cannot send command, not connected")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build command string
|
# Build command string
|
||||||
parts = ["CMD", cmd.upper()]
|
parts = ["CMD", cmd.upper()]
|
||||||
if params:
|
if params:
|
||||||
for key, val in params.items():
|
for key, val in params.items():
|
||||||
parts.append(f"{key}={val}")
|
parts.append(f"{key}={val}")
|
||||||
line = ":".join(parts) + "\n"
|
line = ":".join(parts) + "\n"
|
||||||
|
|
||||||
self._serial.write(line.encode("utf-8"))
|
self._serial.write(line.encode("utf-8"))
|
||||||
self._serial.flush()
|
self._serial.flush()
|
||||||
print(f"[Arduino] Sent: {line.strip()}")
|
print(f"[Arduino] Sent: {line.strip()}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Arduino] Failed to send command: {e}")
|
print(f"[Arduino] Failed to send command: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
def get_latest(self) -> dict[str, Any]:
|
def get_latest(self) -> dict[str, Any]:
|
||||||
"""Get most recent telemetry values."""
|
"""Get most recent telemetry values."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||||
|
|
||||||
def get_buffer(self) -> list[dict[str, Any]]:
|
def get_buffer(self) -> list[dict[str, Any]]:
|
||||||
"""Get buffered telemetry history."""
|
"""Get buffered telemetry history."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return list(self._buffer)
|
return list(self._buffer)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start background serial reader thread."""
|
"""Start background serial reader thread."""
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
self._running = True
|
self._running = True
|
||||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop background reader."""
|
"""Stop background reader."""
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=2.0)
|
self._thread.join(timeout=2.0)
|
||||||
|
|
||||||
def _reader_loop(self):
|
def _reader_loop(self):
|
||||||
"""Main reader loop with reconnection logic."""
|
"""Main reader loop with reconnection logic."""
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
self._connect_and_read()
|
self._connect_and_read()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
|
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
def _connect_and_read(self):
|
def _connect_and_read(self):
|
||||||
"""Connect to Arduino serial and read data."""
|
"""Connect to Arduino serial and read data."""
|
||||||
if serial is None:
|
if serial is None:
|
||||||
print("[Arduino] pyserial not installed, cannot connect")
|
print("[Arduino] pyserial not installed, cannot connect")
|
||||||
return # Will retry via _reader_loop after 5s
|
return # Will retry via _reader_loop after 5s
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ser = serial.Serial(
|
ser = serial.Serial(
|
||||||
port=self.port,
|
port=self.port,
|
||||||
baudrate=self.baudrate,
|
baudrate=self.baudrate,
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
)
|
)
|
||||||
except serial.SerialException as e:
|
except serial.SerialException as e:
|
||||||
print(f"[Arduino] Cannot open {self.port}: {e}")
|
print(f"[Arduino] Cannot open {self.port}: {e}")
|
||||||
return # Will retry via _reader_loop after 5s
|
return # Will retry via _reader_loop after 5s
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Store serial handle for send_command()
|
# Store serial handle for send_command()
|
||||||
with self._serial_lock:
|
with self._serial_lock:
|
||||||
self._serial = ser
|
self._serial = ser
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._last_status_log = time.time()
|
self._last_status_log = time.time()
|
||||||
self._frame_count = 0
|
self._frame_count = 0
|
||||||
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
|
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
# Read null-terminated line (TSV protocol)
|
# Read null-terminated line (TSV protocol)
|
||||||
line = self._read_null_terminated(ser)
|
line = self._read_null_terminated(ser)
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for ACK responses first (legacy newline-terminated)
|
# Check for ACK responses first (legacy newline-terminated)
|
||||||
ack_match = self.ACK_PATTERN.match(line)
|
ack_match = self.ACK_PATTERN.match(line)
|
||||||
if ack_match:
|
if ack_match:
|
||||||
cmd, status, extra = ack_match.groups()
|
cmd, status, extra = ack_match.groups()
|
||||||
if self._on_ack_callback:
|
if self._on_ack_callback:
|
||||||
self._on_ack_callback(cmd, status, extra)
|
self._on_ack_callback(cmd, status, extra)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = self._parse_line(line)
|
data = self._parse_line(line)
|
||||||
if data:
|
if data:
|
||||||
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# Merge new values into latest (preserve old values for partial updates)
|
# Merge new values into latest (preserve old values for partial updates)
|
||||||
for key, val in data.items():
|
for key, val in data.items():
|
||||||
if val is not None and not (isinstance(val, float) and math.isnan(val)):
|
if val is not None and not (isinstance(val, float) and math.isnan(val)):
|
||||||
self._latest[key] = val
|
self._latest[key] = val
|
||||||
self._latest["time"] = data["time"]
|
self._latest["time"] = data["time"]
|
||||||
self._buffer.append(self._latest.copy())
|
self._buffer.append(self._latest.copy())
|
||||||
|
|
||||||
# Invoke callback with new data
|
# Invoke callback with new data
|
||||||
if self._on_data_callback:
|
if self._on_data_callback:
|
||||||
self._on_data_callback(self._latest.copy())
|
self._on_data_callback(self._latest.copy())
|
||||||
|
|
||||||
# Periodic status log (every 5s)
|
# Periodic status log (every 5s)
|
||||||
self._frame_count += 1
|
self._frame_count += 1
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_status_log >= 5.0:
|
if now - self._last_status_log >= 5.0:
|
||||||
elapsed = now - self._last_status_log
|
elapsed = now - self._last_status_log
|
||||||
fps = self._frame_count / elapsed
|
fps = self._frame_count / elapsed
|
||||||
v = self._latest.get('voltage', 0)
|
v = self._latest.get('voltage', 0)
|
||||||
rpm = self._latest.get('rpm', 0)
|
rpm = self._latest.get('rpm', 0)
|
||||||
gear = self._latest.get('gear', 0)
|
gear = self._latest.get('gear', 0)
|
||||||
roll = self._latest.get('roll', 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}°")
|
print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
|
||||||
self._last_status_log = now
|
self._last_status_log = now
|
||||||
self._frame_count = 0
|
self._frame_count = 0
|
||||||
|
|
||||||
except serial.SerialException as e:
|
except serial.SerialException as e:
|
||||||
print(f"[Arduino] Serial error: {e}")
|
print(f"[Arduino] Serial error: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
with self._serial_lock:
|
with self._serial_lock:
|
||||||
self._serial = None
|
self._serial = None
|
||||||
ser.close()
|
ser.close()
|
||||||
|
|
||||||
def _read_null_terminated(self, ser) -> str:
|
def _read_null_terminated(self, ser) -> str:
|
||||||
"""Read bytes until null terminator or newline (fallback for legacy)."""
|
"""Read bytes until null terminator or newline (fallback for legacy)."""
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
while self._running:
|
while self._running:
|
||||||
byte = ser.read(1)
|
byte = ser.read(1)
|
||||||
if not byte:
|
if not byte:
|
||||||
# Timeout
|
# Timeout
|
||||||
if buf:
|
if buf:
|
||||||
# Return partial buffer if we have data
|
# Return partial buffer if we have data
|
||||||
return buf.decode("utf-8", errors="ignore").strip()
|
return buf.decode("utf-8", errors="ignore").strip()
|
||||||
return ""
|
return ""
|
||||||
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
|
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
|
||||||
# End of frame
|
# End of frame
|
||||||
if buf:
|
if buf:
|
||||||
return buf.decode("utf-8", errors="ignore").strip()
|
return buf.decode("utf-8", errors="ignore").strip()
|
||||||
# Skip empty lines / consecutive terminators
|
# Skip empty lines / consecutive terminators
|
||||||
continue
|
continue
|
||||||
buf.append(byte[0])
|
buf.append(byte[0])
|
||||||
# Safety limit
|
# Safety limit
|
||||||
if len(buf) > 256:
|
if len(buf) > 256:
|
||||||
return buf.decode("utf-8", errors="ignore").strip()
|
return buf.decode("utf-8", errors="ignore").strip()
|
||||||
|
|
||||||
def _parse_line(self, line: str) -> dict[str, Any] | None:
|
def _parse_line(self, line: str) -> dict[str, Any] | None:
|
||||||
"""Parse a line from Arduino - TSV first, then JSON, fallback to regex.
|
"""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)
|
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}
|
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
|
||||||
Legacy text: V_bat: 12.45V
|
Legacy text: V_bat: 12.45V
|
||||||
"""
|
"""
|
||||||
# Try TSV first (new protocol)
|
# Try TSV first (new protocol)
|
||||||
if '\t' in line:
|
if '\t' in line:
|
||||||
return self._parse_tsv(line)
|
return self._parse_tsv(line)
|
||||||
|
|
||||||
# Try JSON (may still be used for special messages)
|
# Try JSON (may still be used for special messages)
|
||||||
try:
|
try:
|
||||||
obj = json.loads(line)
|
obj = json.loads(line)
|
||||||
return {
|
return {
|
||||||
"voltage": obj.get("v"),
|
"voltage": obj.get("v"),
|
||||||
"rpm": obj.get("rpm"),
|
"rpm": obj.get("rpm"),
|
||||||
"eng_temp": obj.get("eng"),
|
"eng_temp": obj.get("eng"),
|
||||||
"gear": obj.get("gear"),
|
"gear": obj.get("gear"),
|
||||||
}
|
}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallback to regex for legacy text protocol
|
# Fallback to regex for legacy text protocol
|
||||||
result = {}
|
result = {}
|
||||||
for key, pattern in self.PATTERNS.items():
|
for key, pattern in self.PATTERNS.items():
|
||||||
match = pattern.search(line)
|
match = pattern.search(line)
|
||||||
if match:
|
if match:
|
||||||
val = match.group(1)
|
val = match.group(1)
|
||||||
result[key] = float(val) if "." in val else int(val)
|
result[key] = float(val) if "." in val else int(val)
|
||||||
|
|
||||||
return result if result else None
|
return result if result else None
|
||||||
|
|
||||||
def _parse_tsv(self, line: str) -> dict[str, Any] | None:
|
def _parse_tsv(self, line: str) -> dict[str, Any] | None:
|
||||||
"""Parse TSV telemetry frame per PROTOCOL.md.
|
"""Parse TSV telemetry frame per PROTOCOL.md.
|
||||||
|
|
||||||
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
|
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
|
||||||
Empty fields (stale IMU) become NaN.
|
Empty fields (stale IMU) become NaN.
|
||||||
"""
|
"""
|
||||||
fields = line.split('\t')
|
fields = line.split('\t')
|
||||||
if len(fields) != len(self.TSV_FIELDS):
|
if len(fields) != len(self.TSV_FIELDS):
|
||||||
# Wrong field count - might be debug output or malformed
|
# Wrong field count - might be debug output or malformed
|
||||||
return None
|
return None
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
for i, name in enumerate(self.TSV_FIELDS):
|
for i, name in enumerate(self.TSV_FIELDS):
|
||||||
val_str = fields[i].strip()
|
val_str = fields[i].strip()
|
||||||
if val_str == '':
|
if val_str == '':
|
||||||
# Empty field = stale/missing data
|
# Empty field = stale/missing data
|
||||||
result[name] = float('nan')
|
result[name] = float('nan')
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
result[name] = float(val_str)
|
result[name] = float(val_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
result[name] = float('nan')
|
result[name] = float('nan')
|
||||||
|
|
||||||
# IMU axis correction for mounting orientation
|
# IMU axis correction for mounting orientation
|
||||||
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
|
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
|
||||||
if 'pitch' in result and not math.isnan(result['pitch']):
|
if 'pitch' in result and not math.isnan(result['pitch']):
|
||||||
result['pitch'] = -result['pitch']
|
result['pitch'] = -result['pitch']
|
||||||
if 'yaw' in result and not math.isnan(result['yaw']):
|
if 'yaw' in result and not math.isnan(result['yaw']):
|
||||||
result['yaw'] = -result['yaw']
|
result['yaw'] = -result['yaw']
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -1,269 +1,315 @@
|
|||||||
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DEBUG MODE - Set True for development without GPS hardware
|
# DEBUG MODE - Set True for development without GPS hardware
|
||||||
# When True: skips gpsd entirely, generates realistic mock data
|
# When True: skips gpsd entirely, generates realistic mock data
|
||||||
# When False: connects to real gpsd (requires GPS device)
|
# When False: connects to real gpsd (requires GPS device)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
_GPS_DEBUG = True
|
_GPS_DEBUG = False
|
||||||
|
|
||||||
# gpsdclient is a modern, simple gpsd client
|
# gpsdclient is a modern, simple gpsd client
|
||||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||||
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
||||||
try:
|
try:
|
||||||
from gpsdclient import GPSDClient
|
from gpsdclient import GPSDClient
|
||||||
except ImportError:
|
except ImportError:
|
||||||
GPSDClient = None # Allow import without gpsd for testing structure
|
GPSDClient = None # Allow import without gpsd for testing structure
|
||||||
|
|
||||||
|
|
||||||
class GPSService:
|
class GPSService:
|
||||||
"""Threaded GPS reader with buffering and auto-reconnect."""
|
"""Threaded GPS reader with buffering and auto-reconnect."""
|
||||||
|
|
||||||
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
|
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.buffer_size = buffer_size
|
self.buffer_size = buffer_size
|
||||||
|
|
||||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||||
self._latest: dict[str, Any] = {}
|
self._latest: dict[str, Any] = {}
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._running = False
|
self._running = False
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# Callback for push-based updates
|
# Callback for push-based updates
|
||||||
self._on_data_callback = None
|
self._on_data_callback = None
|
||||||
|
|
||||||
# Periodic status logging
|
# GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
|
||||||
self._last_status_log = 0.0
|
self._has_ever_fixed = False # True after first valid fix this session
|
||||||
self._fix_count = 0
|
|
||||||
|
# Periodic status logging
|
||||||
def set_on_data(self, callback):
|
self._last_status_log = 0.0
|
||||||
"""Set callback for new GPS fix. Called with fix dict."""
|
self._last_state_emit = 0.0
|
||||||
self._on_data_callback = callback
|
self._fix_count = 0
|
||||||
|
|
||||||
@property
|
def set_on_data(self, callback):
|
||||||
def connected(self) -> bool:
|
"""Set callback for new GPS fix. Called with fix dict."""
|
||||||
return self._connected
|
self._on_data_callback = callback
|
||||||
|
|
||||||
def get_latest(self) -> dict[str, Any]:
|
@property
|
||||||
"""Get most recent GPS fix."""
|
def connected(self) -> bool:
|
||||||
with self._lock:
|
return self._connected
|
||||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
|
||||||
|
def get_latest(self) -> dict[str, Any]:
|
||||||
def get_buffer(self) -> list[dict[str, Any]]:
|
"""Get most recent GPS fix."""
|
||||||
"""Get buffered GPS history."""
|
with self._lock:
|
||||||
with self._lock:
|
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||||
return list(self._buffer)
|
|
||||||
|
def _gps_state(self, fix: dict) -> str:
|
||||||
def start(self):
|
"""Determine GPS state: acquiring, fix, or lost.
|
||||||
"""Start background GPS reader thread."""
|
|
||||||
if self._running:
|
NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
|
||||||
return
|
report mode 1 with no position. We track it ourselves.
|
||||||
self._running = True
|
"""
|
||||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
|
||||||
self._thread.start()
|
if has_fix:
|
||||||
print("[GPS] Service started")
|
return "fix"
|
||||||
|
return "lost" if self._has_ever_fixed else "acquiring"
|
||||||
def stop(self):
|
|
||||||
"""Stop background reader."""
|
def get_buffer(self) -> list[dict[str, Any]]:
|
||||||
self._running = False
|
"""Get buffered GPS history."""
|
||||||
if self._thread:
|
with self._lock:
|
||||||
self._thread.join(timeout=2.0)
|
return list(self._buffer)
|
||||||
|
|
||||||
def _reader_loop(self):
|
def start(self):
|
||||||
"""Main reader loop with reconnection logic."""
|
"""Start background GPS reader thread."""
|
||||||
print("[GPS] Reader thread running")
|
if self._running:
|
||||||
while self._running:
|
return
|
||||||
try:
|
self._running = True
|
||||||
self._connect_and_read()
|
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||||
except Exception as e:
|
self._thread.start()
|
||||||
self._connected = False
|
print("[GPS] Service started")
|
||||||
print(f"[GPS] Connection error: {e}, retrying in 5s...")
|
|
||||||
time.sleep(5)
|
def stop(self):
|
||||||
|
"""Stop background reader."""
|
||||||
def _connect_and_read(self):
|
self._running = False
|
||||||
"""Connect to gpsd and read data."""
|
if self._thread:
|
||||||
# Debug mode: skip gpsd entirely, use stub data
|
self._thread.join(timeout=2.0)
|
||||||
if _GPS_DEBUG:
|
|
||||||
print("[GPS] Debug mode enabled, using stub data")
|
def _reader_loop(self):
|
||||||
self._stub_mode()
|
"""Main reader loop with reconnection logic."""
|
||||||
return
|
print("[GPS] Reader thread running")
|
||||||
|
while self._running:
|
||||||
if GPSDClient is None:
|
try:
|
||||||
print("[GPS] gpsdclient not installed, running in stub mode")
|
self._connect_and_read()
|
||||||
self._stub_mode()
|
except Exception as e:
|
||||||
return
|
self._connected = False
|
||||||
|
print(f"[GPS] Connection error: {e}, retrying in 5s...")
|
||||||
# Quick check if gpsd is reachable before attempting connection
|
time.sleep(5)
|
||||||
import socket
|
|
||||||
try:
|
def _connect_and_read(self):
|
||||||
sock = socket.create_connection((self.host, self.port), timeout=2.0)
|
"""Connect to gpsd and read data."""
|
||||||
sock.close()
|
# Debug mode: skip gpsd entirely, use stub data
|
||||||
except (socket.timeout, socket.error, OSError) as e:
|
if _GPS_DEBUG:
|
||||||
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
|
print("[GPS] Debug mode enabled, using stub data")
|
||||||
raise ConnectionError(f"gpsd not reachable: {e}")
|
self._stub_mode()
|
||||||
|
return
|
||||||
try:
|
|
||||||
client = GPSDClient(host=self.host, port=self.port)
|
if GPSDClient is None:
|
||||||
except Exception as e:
|
print("[GPS] gpsdclient not installed, running in stub mode")
|
||||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
|
self._stub_mode()
|
||||||
raise ConnectionError(f"gpsd connection failed: {e}")
|
return
|
||||||
|
|
||||||
with client:
|
# Quick check if gpsd is reachable before attempting connection
|
||||||
self._connected = True
|
import socket
|
||||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
try:
|
||||||
|
sock = socket.create_connection((self.host, self.port), timeout=2.0)
|
||||||
self._last_status_log = time.time()
|
sock.close()
|
||||||
self._fix_count = 0
|
except (socket.timeout, socket.error, OSError) as e:
|
||||||
first_fix_timeout = time.time() + 5.0 # 5s to get first fix
|
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
|
||||||
|
raise ConnectionError(f"gpsd not reachable: {e}")
|
||||||
for result in client.dict_stream(filter=["TPV"]):
|
|
||||||
if not self._running:
|
try:
|
||||||
break
|
client = GPSDClient(host=self.host, port=self.port)
|
||||||
|
except Exception as e:
|
||||||
# TPV = Time-Position-Velocity report
|
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
|
||||||
fix = {
|
raise ConnectionError(f"gpsd connection failed: {e}")
|
||||||
"time": result.get("time"),
|
|
||||||
"lat": result.get("lat"),
|
with client:
|
||||||
"lon": result.get("lon"),
|
self._connected = True
|
||||||
"alt": result.get("alt"),
|
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||||
"speed": result.get("speed"), # m/s
|
|
||||||
"track": result.get("track"), # heading in degrees
|
self._last_status_log = time.time()
|
||||||
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
self._fix_count = 0
|
||||||
"satellites": result.get("satellites"), # from SKY messages
|
# 120s for initial cold fix, 10s for signal loss after first fix
|
||||||
}
|
fix_timeout = time.time() + 120.0
|
||||||
|
|
||||||
# Check if this is a real fix (has position) or just empty TPV
|
for result in client.dict_stream(filter=["TPV"]):
|
||||||
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
|
if not self._running:
|
||||||
# No real data yet, check timeout
|
break
|
||||||
if time.time() > first_fix_timeout:
|
|
||||||
print("[GPS] No GPS fix after 5s, will retry connection")
|
# TPV = Time-Position-Velocity report
|
||||||
raise ConnectionError("No GPS fix within timeout")
|
fix = {
|
||||||
continue # Skip empty fixes
|
"time": result.get("time"),
|
||||||
|
"lat": result.get("lat"),
|
||||||
# Got real data, disable timeout
|
"lon": result.get("lon"),
|
||||||
first_fix_timeout = float('inf')
|
"alt": result.get("alt"),
|
||||||
|
"speed": result.get("speed"), # m/s
|
||||||
with self._lock:
|
"track": result.get("track"), # heading in degrees
|
||||||
self._latest = fix
|
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
||||||
if fix.get("lat") is not None:
|
"satellites": result.get("satellites"), # from SKY messages
|
||||||
self._buffer.append(fix)
|
}
|
||||||
|
|
||||||
# Invoke callback with new fix
|
# Compute state and attach to fix
|
||||||
if self._on_data_callback:
|
fix["gps_state"] = self._gps_state(fix)
|
||||||
self._on_data_callback(fix)
|
|
||||||
|
# Check if this is a real fix (has position) or just empty TPV
|
||||||
# Periodic status log (every 5s)
|
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
|
||||||
self._fix_count += 1
|
# No real data yet, check timeout
|
||||||
now = time.time()
|
if time.time() > fix_timeout:
|
||||||
if now - self._last_status_log >= 5.0:
|
timeout_s = "120s" if not self._has_ever_fixed else "10s"
|
||||||
elapsed = now - self._last_status_log
|
print(f"[GPS] No GPS fix after {timeout_s}, will retry connection")
|
||||||
fps = self._fix_count / elapsed
|
raise ConnectionError("No GPS fix within timeout")
|
||||||
speed = fix.get('speed', 0) or 0
|
# Emit state periodically so UI knows we're alive
|
||||||
track = fix.get('track', 0) or 0
|
now = time.time()
|
||||||
mode = fix.get('mode', 0) or 0
|
if now - self._last_state_emit >= 5.0:
|
||||||
sats = fix.get('satellites', '?')
|
self._last_state_emit = now
|
||||||
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
|
with self._lock:
|
||||||
self._last_status_log = now
|
self._latest = fix
|
||||||
self._fix_count = 0
|
if self._on_data_callback:
|
||||||
|
self._on_data_callback(fix)
|
||||||
def _stub_mode(self):
|
continue # Skip empty fixes
|
||||||
"""Generate realistic mock GPS data for development/testing.
|
|
||||||
|
# Got real data — mark first fix, reset timeout to shorter window
|
||||||
Simulates:
|
if not self._has_ever_fixed:
|
||||||
- Normal 3D fix with satellites
|
self._has_ever_fixed = True
|
||||||
- Occasional signal loss (~3% chance per second, lasts ~5s)
|
self._last_state_emit = 0.0 # Force immediate emit on transition
|
||||||
- Wandering position near Tokyo
|
print("[GPS] First fix acquired")
|
||||||
"""
|
fix_timeout = time.time() + 10.0 # 10s timeout for signal loss
|
||||||
self._last_status_log = time.time()
|
|
||||||
self._fix_count = 0
|
with self._lock:
|
||||||
|
self._latest = fix
|
||||||
# Signal loss state
|
if fix.get("lat") is not None:
|
||||||
signal_lost = False
|
self._buffer.append(fix)
|
||||||
signal_lost_until = 0.0
|
|
||||||
|
# Invoke callback with new fix
|
||||||
# Base position (Tokyo area)
|
if self._on_data_callback:
|
||||||
base_lat = 35.6762
|
self._on_data_callback(fix)
|
||||||
base_lon = 139.6503
|
|
||||||
base_alt = 40.0
|
# Periodic status log (every 5s)
|
||||||
|
self._fix_count += 1
|
||||||
# Smoothly varying heading/speed
|
now = time.time()
|
||||||
heading = random.uniform(0, 360)
|
if now - self._last_status_log >= 5.0:
|
||||||
speed = random.uniform(5, 15)
|
elapsed = now - self._last_status_log
|
||||||
|
fps = self._fix_count / elapsed
|
||||||
while self._running:
|
speed = fix.get('speed', 0) or 0
|
||||||
self._connected = True
|
track = fix.get('track', 0) or 0
|
||||||
now = time.time()
|
mode = fix.get('mode', 0) or 0
|
||||||
|
sats = fix.get('satellites', '?')
|
||||||
# Check for signal loss simulation
|
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
|
||||||
if signal_lost:
|
self._last_status_log = now
|
||||||
if now >= signal_lost_until:
|
self._fix_count = 0
|
||||||
signal_lost = False
|
|
||||||
print("[GPS] Signal recovered (stub)")
|
def _stub_mode(self):
|
||||||
else:
|
"""Generate realistic mock GPS data for development/testing.
|
||||||
# ~30% chance per second to lose signal
|
|
||||||
if random.random() < 0.3:
|
Simulates:
|
||||||
signal_lost = True
|
- Initial acquisition delay (~3s before first fix)
|
||||||
signal_lost_until = now + 2 # fixed 2s loss
|
- Normal 3D fix with satellites
|
||||||
print("[GPS] Signal loss simulation (stub)")
|
- Occasional signal loss (~30% chance per second, lasts ~2s)
|
||||||
|
- Wandering position near Tokyo
|
||||||
if signal_lost:
|
"""
|
||||||
# No fix - mode 1, no satellites, no track
|
self._last_status_log = time.time()
|
||||||
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
|
self._fix_count = 0
|
||||||
fix = {
|
|
||||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
# Signal loss state
|
||||||
"lat": None,
|
signal_lost = False
|
||||||
"lon": None,
|
signal_lost_until = 0.0
|
||||||
"alt": None,
|
|
||||||
"speed": None,
|
# Simulate cold start acquisition (~30s)
|
||||||
"track": None,
|
acquiring_until = time.time() + 30.0
|
||||||
"mode": 1,
|
|
||||||
"satellites": 0,
|
# Base position (Tokyo area)
|
||||||
}
|
base_lat = 35.6762
|
||||||
else:
|
base_lon = 139.6503
|
||||||
# Smoothly vary heading and speed
|
base_alt = 40.0
|
||||||
heading = (heading + random.uniform(1, 3)) % 360
|
|
||||||
speed = max(0, min(30, speed + random.uniform(-2, 2)))
|
# Smoothly varying heading/speed
|
||||||
|
heading = random.uniform(0, 360)
|
||||||
fix = {
|
speed = random.uniform(5, 15)
|
||||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
||||||
"lat": base_lat + random.uniform(-0.001, 0.001),
|
while self._running:
|
||||||
"lon": base_lon + random.uniform(-0.001, 0.001),
|
self._connected = True
|
||||||
"alt": base_alt + random.uniform(-5, 5),
|
now = time.time()
|
||||||
"speed": speed,
|
|
||||||
"track": heading,
|
# Simulate initial acquisition period
|
||||||
"mode": 3,
|
if now < acquiring_until:
|
||||||
"satellites": random.randint(6, 12),
|
signal_lost = True # No fix yet
|
||||||
}
|
elif signal_lost and now >= signal_lost_until:
|
||||||
|
signal_lost = False
|
||||||
with self._lock:
|
if self._has_ever_fixed:
|
||||||
self._latest = fix
|
print("[GPS] Signal recovered (stub)")
|
||||||
if fix.get("lat") is not None:
|
else:
|
||||||
self._buffer.append(fix)
|
print("[GPS] First fix acquired (stub)")
|
||||||
|
elif not signal_lost:
|
||||||
# Invoke callback with new fix
|
# ~30% chance per second to lose signal
|
||||||
if self._on_data_callback:
|
if random.random() < 0.3:
|
||||||
self._on_data_callback(fix)
|
signal_lost = True
|
||||||
|
signal_lost_until = now + 2 # fixed 2s loss
|
||||||
# Periodic status log (every 5s)
|
print("[GPS] Signal loss simulation (stub)")
|
||||||
self._fix_count += 1
|
|
||||||
if now - self._last_status_log >= 5.0:
|
if signal_lost:
|
||||||
elapsed = now - self._last_status_log
|
# No fix - mode 1, no satellites, no track
|
||||||
fps = self._fix_count / elapsed
|
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
|
||||||
speed_val = fix.get('speed') or 0
|
fix = {
|
||||||
track_val = fix.get('track')
|
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
track_str = f"{track_val:.0f}" if track_val is not None else "---"
|
"lat": None,
|
||||||
mode = fix.get('mode', 0)
|
"lon": None,
|
||||||
sats = fix.get('satellites', 0)
|
"alt": None,
|
||||||
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
|
"speed": None,
|
||||||
self._last_status_log = now
|
"track": None,
|
||||||
self._fix_count = 0
|
"mode": 1,
|
||||||
|
"satellites": 0,
|
||||||
time.sleep(1)
|
}
|
||||||
|
else:
|
||||||
|
# Smoothly vary heading and speed
|
||||||
|
heading = (heading + random.uniform(1, 3)) % 360
|
||||||
|
speed = max(0, min(30, speed + random.uniform(-2, 2)))
|
||||||
|
|
||||||
|
if not self._has_ever_fixed:
|
||||||
|
self._has_ever_fixed = True
|
||||||
|
|
||||||
|
fix = {
|
||||||
|
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"lat": base_lat + random.uniform(-0.001, 0.001),
|
||||||
|
"lon": base_lon + random.uniform(-0.001, 0.001),
|
||||||
|
"alt": base_alt + random.uniform(-5, 5),
|
||||||
|
"speed": speed,
|
||||||
|
"track": heading,
|
||||||
|
"mode": 3,
|
||||||
|
"satellites": random.randint(6, 12),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attach state — same logic as real GPS path
|
||||||
|
fix["gps_state"] = self._gps_state(fix)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._latest = fix
|
||||||
|
if fix.get("lat") is not None:
|
||||||
|
self._buffer.append(fix)
|
||||||
|
|
||||||
|
# Invoke callback with new fix
|
||||||
|
if self._on_data_callback:
|
||||||
|
self._on_data_callback(fix)
|
||||||
|
|
||||||
|
# Periodic status log (every 5s)
|
||||||
|
self._fix_count += 1
|
||||||
|
if now - self._last_status_log >= 5.0:
|
||||||
|
elapsed = now - self._last_status_log
|
||||||
|
fps = self._fix_count / elapsed
|
||||||
|
speed_val = fix.get('speed') or 0
|
||||||
|
track_val = fix.get('track')
|
||||||
|
track_str = f"{track_val:.0f}" if track_val is not None else "---"
|
||||||
|
mode = fix.get('mode', 0)
|
||||||
|
sats = fix.get('satellites', 0)
|
||||||
|
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
|
||||||
|
self._last_status_log = now
|
||||||
|
self._fix_count = 0
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|||||||
178
pi/backend/lte_service.py
Normal file
178
pi/backend/lte_service.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""LTE service - polls ModemManager for signal quality and connection state."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DEBUG MODE - Set True for development without modem hardware
|
||||||
|
# When True: skips mmcli entirely, generates mock LTE data
|
||||||
|
# When False: polls real ModemManager via mmcli
|
||||||
|
# ============================================================================
|
||||||
|
_LTE_DEBUG = True
|
||||||
|
|
||||||
|
|
||||||
|
class LteService:
|
||||||
|
"""Threaded LTE modem status poller.
|
||||||
|
|
||||||
|
Polls `mmcli -m 0` every 5 seconds, parses signal quality, connection
|
||||||
|
state, operator, and access technology. Emits dict via callback.
|
||||||
|
|
||||||
|
No history buffer — historical signal strength isn't useful.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, poll_interval: float = 5.0):
|
||||||
|
self.poll_interval = poll_interval
|
||||||
|
|
||||||
|
self._latest: dict[str, Any] = {}
|
||||||
|
self._connected = False # True when mmcli responds (modem alive)
|
||||||
|
self._running = False
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# Callback for push-based updates
|
||||||
|
self._on_data_callback = None
|
||||||
|
|
||||||
|
def set_on_data(self, callback):
|
||||||
|
"""Set callback for new LTE data. Called with data dict."""
|
||||||
|
self._on_data_callback = callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
"""Whether the modem is reachable via mmcli."""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def get_latest(self) -> dict[str, Any]:
|
||||||
|
"""Get most recent LTE status."""
|
||||||
|
with self._lock:
|
||||||
|
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start background LTE poller thread."""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
print("[LTE] Service started")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop background poller."""
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
def _poll_loop(self):
|
||||||
|
"""Main poll loop."""
|
||||||
|
print("[LTE] Poller thread running")
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
if _LTE_DEBUG:
|
||||||
|
data = self._stub_poll()
|
||||||
|
else:
|
||||||
|
data = self._real_poll()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._latest = data
|
||||||
|
|
||||||
|
if self._on_data_callback:
|
||||||
|
self._on_data_callback(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LTE] Poll error: {e}")
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
time.sleep(self.poll_interval)
|
||||||
|
|
||||||
|
def _real_poll(self) -> dict[str, Any]:
|
||||||
|
"""Poll mmcli -m 0 and parse output."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["mmcli", "-m", "0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
self._connected = False
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"signal": 0,
|
||||||
|
"operator": None,
|
||||||
|
"access_tech": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
output = result.stdout
|
||||||
|
self._connected = True
|
||||||
|
|
||||||
|
# Parse signal quality: "signal quality: 68 (recent)"
|
||||||
|
signal = 0
|
||||||
|
m = re.search(r"signal quality:\s*(\d+)", output)
|
||||||
|
if m:
|
||||||
|
signal = int(m.group(1))
|
||||||
|
|
||||||
|
# Parse state: "state: connected" / "registered" / "searching" etc.
|
||||||
|
state = None
|
||||||
|
m = re.search(r"^\s*state:\s*(\S+)", output, re.MULTILINE)
|
||||||
|
if m:
|
||||||
|
state = m.group(1).strip("'\"")
|
||||||
|
|
||||||
|
# Parse operator: "operator name: KDDI KDDI"
|
||||||
|
operator = None
|
||||||
|
m = re.search(r"operator name:\s*(.+)", output)
|
||||||
|
if m:
|
||||||
|
operator = m.group(1).strip()
|
||||||
|
|
||||||
|
# Parse access tech: "access tech: lte"
|
||||||
|
access_tech = None
|
||||||
|
m = re.search(r"access tech:\s*(\S+)", output)
|
||||||
|
if m:
|
||||||
|
access_tech = m.group(1).strip("'\"")
|
||||||
|
|
||||||
|
network_connected = state in ("connected", "registered")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": network_connected,
|
||||||
|
"signal": signal,
|
||||||
|
"operator": operator,
|
||||||
|
"access_tech": access_tech,
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[LTE] mmcli timed out")
|
||||||
|
self._connected = False
|
||||||
|
return {
|
||||||
|
"connected": False,
|
||||||
|
"signal": 0,
|
||||||
|
"operator": None,
|
||||||
|
"access_tech": None,
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("[LTE] mmcli not found, falling back to stub mode")
|
||||||
|
self._connected = False
|
||||||
|
return self._stub_poll()
|
||||||
|
|
||||||
|
# Stub state lives across polls
|
||||||
|
_stub_signal: float = 70.0
|
||||||
|
|
||||||
|
def _stub_poll(self) -> dict[str, Any]:
|
||||||
|
"""Generate mock LTE data for development.
|
||||||
|
|
||||||
|
Simulates connected state with signal wandering 60-80.
|
||||||
|
"""
|
||||||
|
self._connected = True
|
||||||
|
|
||||||
|
# Random walk signal, clamped to 60-80
|
||||||
|
self._stub_signal += random.uniform(-3, 3)
|
||||||
|
self._stub_signal = max(60, min(80, self._stub_signal))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"connected": True,
|
||||||
|
"signal": int(self._stub_signal),
|
||||||
|
"operator": "STUB",
|
||||||
|
"access_tech": "lte",
|
||||||
|
}
|
||||||
@@ -1,245 +1,281 @@
|
|||||||
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
|
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
|
||||||
|
|
||||||
from gevent import monkey
|
from gevent import monkey
|
||||||
monkey.patch_all() # Must be at the very top before other imports
|
monkey.patch_all() # Must be at the very top before other imports
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
from flask_socketio import SocketIO, emit
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
from gps_service import GPSService
|
from gps_service import GPSService
|
||||||
from arduino_service import ArduinoService
|
from arduino_service import ArduinoService
|
||||||
from gpio_service import GPIOService
|
from gpio_service import GPIOService
|
||||||
from throttle import Throttle
|
from lte_service import LteService
|
||||||
|
from throttle import Throttle
|
||||||
app = Flask(__name__)
|
|
||||||
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
|
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="*")
|
# SocketIO with gevent async mode (eventlet is deprecated)
|
||||||
|
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
|
||||||
# Services
|
|
||||||
gps = GPSService()
|
# Services
|
||||||
arduino = ArduinoService()
|
gps = GPSService()
|
||||||
gpio = GPIOService()
|
arduino = ArduinoService()
|
||||||
|
gpio = GPIOService()
|
||||||
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS)
|
lte = LteService()
|
||||||
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
|
|
||||||
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS, 5s for LTE)
|
||||||
|
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
|
||||||
# Track connected clients
|
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
||||||
connected_clients = set()
|
lte_throttle = Throttle(min_interval=5.0) # Every 5s — signal doesn't need real-time
|
||||||
|
|
||||||
|
# Track connected clients
|
||||||
# -----------------------------------------------------------------------------
|
connected_clients = set()
|
||||||
# WebSocket Event Handlers
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
@socketio.on("connect")
|
# WebSocket Event Handlers
|
||||||
def handle_connect():
|
# -----------------------------------------------------------------------------
|
||||||
"""Client connected."""
|
|
||||||
client_id = id(socketio) # Simple identifier
|
@socketio.on("connect")
|
||||||
connected_clients.add(client_id)
|
def handle_connect():
|
||||||
print(f"[WS] Client connected ({len(connected_clients)} total)")
|
"""Client connected."""
|
||||||
|
client_id = id(socketio) # Simple identifier
|
||||||
# Send current status immediately
|
connected_clients.add(client_id)
|
||||||
emit("status", {
|
print(f"[WS] Client connected ({len(connected_clients)} total)")
|
||||||
"gps_connected": gps.connected,
|
|
||||||
"arduino_connected": arduino.connected,
|
# Send current status immediately
|
||||||
"theme_switch": gpio.theme_switch,
|
emit("status", {
|
||||||
})
|
"gps_connected": gps.connected,
|
||||||
|
"arduino_connected": arduino.connected,
|
||||||
# Send latest data if available
|
"theme_switch": gpio.theme_switch,
|
||||||
arduino_data = arduino.get_latest()
|
})
|
||||||
if "error" not in arduino_data:
|
|
||||||
emit("arduino", arduino_data)
|
# Send latest data if available
|
||||||
|
arduino_data = arduino.get_latest()
|
||||||
gps_data = gps.get_latest()
|
if "error" not in arduino_data:
|
||||||
if "error" not in gps_data:
|
emit("arduino", arduino_data)
|
||||||
emit("gps", gps_data)
|
|
||||||
|
gps_data = gps.get_latest()
|
||||||
|
if "error" not in gps_data:
|
||||||
@socketio.on("disconnect")
|
emit("gps", gps_data)
|
||||||
def handle_disconnect():
|
|
||||||
"""Client disconnected."""
|
lte_data = lte.get_latest()
|
||||||
client_id = id(socketio)
|
if "error" not in lte_data:
|
||||||
connected_clients.discard(client_id)
|
emit("lte", lte_data)
|
||||||
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
|
|
||||||
|
|
||||||
|
@socketio.on("disconnect")
|
||||||
@socketio.on("button")
|
def handle_disconnect():
|
||||||
def handle_button(data):
|
"""Client disconnected."""
|
||||||
"""Handle button press from UI.
|
client_id = id(socketio)
|
||||||
|
connected_clients.discard(client_id)
|
||||||
Expected data: {"id": "horn", "action": "press", ...params}
|
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
|
||||||
"""
|
|
||||||
btn_id = data.get("id", "unknown")
|
|
||||||
action = data.get("action", "press")
|
@socketio.on("button")
|
||||||
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
def handle_button(data):
|
||||||
|
"""Handle button press from UI.
|
||||||
print(f"[WS] Button: {btn_id} {action} {params}")
|
|
||||||
|
Expected data: {"id": "horn", "action": "press", ...params}
|
||||||
# Map button ID to Arduino command
|
"""
|
||||||
cmd_map = {
|
btn_id = data.get("id", "unknown")
|
||||||
"horn": "HORN",
|
action = data.get("action", "press")
|
||||||
"light": "LIGHT",
|
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
||||||
"indicator_left": "IND_L",
|
|
||||||
"indicator_right": "IND_R",
|
print(f"[WS] Button: {btn_id} {action} {params}")
|
||||||
"hazard": "HAZARD",
|
|
||||||
}
|
# Map button ID to Arduino command
|
||||||
|
cmd_map = {
|
||||||
cmd = cmd_map.get(btn_id)
|
"horn": "HORN",
|
||||||
if cmd:
|
"light": "LIGHT",
|
||||||
# Add action to params (e.g., ON/OFF based on press/release)
|
"indicator_left": "IND_L",
|
||||||
params["state"] = "ON" if action == "press" else "OFF"
|
"indicator_right": "IND_R",
|
||||||
success = arduino.send_command(cmd, params)
|
"hazard": "HAZARD",
|
||||||
|
}
|
||||||
# Send immediate ack for the attempt
|
|
||||||
emit("ack", {
|
cmd = cmd_map.get(btn_id)
|
||||||
"id": btn_id,
|
if cmd:
|
||||||
"status": "sent" if success else "failed",
|
# Add action to params (e.g., ON/OFF based on press/release)
|
||||||
"error": None if success else "arduino not connected",
|
params["state"] = "ON" if action == "press" else "OFF"
|
||||||
})
|
success = arduino.send_command(cmd, params)
|
||||||
else:
|
|
||||||
emit("ack", {
|
# Send immediate ack for the attempt
|
||||||
"id": btn_id,
|
emit("ack", {
|
||||||
"status": "error",
|
"id": btn_id,
|
||||||
"error": f"unknown button: {btn_id}",
|
"status": "sent" if success else "failed",
|
||||||
})
|
"error": None if success else "arduino not connected",
|
||||||
|
})
|
||||||
|
else:
|
||||||
@socketio.on("emergency")
|
emit("ack", {
|
||||||
def handle_emergency(data):
|
"id": btn_id,
|
||||||
"""Handle emergency signal from UI."""
|
"status": "error",
|
||||||
etype = data.get("type", "stop")
|
"error": f"unknown button: {btn_id}",
|
||||||
print(f"[WS] EMERGENCY: {etype}")
|
})
|
||||||
|
|
||||||
# Send emergency command to Arduino
|
|
||||||
arduino.send_command("EMERGENCY", {"type": etype})
|
@socketio.on("emergency")
|
||||||
|
def handle_emergency(data):
|
||||||
# Broadcast alert to all clients
|
"""Handle emergency signal from UI."""
|
||||||
socketio.emit("alert", {
|
etype = data.get("type", "stop")
|
||||||
"type": "emergency",
|
print(f"[WS] EMERGENCY: {etype}")
|
||||||
"message": f"Emergency {etype} triggered",
|
|
||||||
})
|
# Send emergency command to Arduino
|
||||||
|
arduino.send_command("EMERGENCY", {"type": etype})
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# Broadcast alert to all clients
|
||||||
# Service Callbacks (push data to WebSocket)
|
socketio.emit("alert", {
|
||||||
# -----------------------------------------------------------------------------
|
"type": "emergency",
|
||||||
|
"message": f"Emergency {etype} triggered",
|
||||||
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
|
# Service Callbacks (push data to WebSocket)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def emit_fn(d):
|
|
||||||
socketio.emit("arduino", d)
|
def on_arduino_data(data):
|
||||||
|
"""Called by ArduinoService when new telemetry arrives."""
|
||||||
arduino_throttle.maybe_emit(data, emit_fn)
|
# Always include current GPIO state (UI dedupes)
|
||||||
|
data = dict(data) # Don't mutate original
|
||||||
|
data["theme_switch"] = gpio.theme_switch
|
||||||
def on_gps_data(data):
|
|
||||||
"""Called by GPSService when new fix arrives."""
|
# backend voltage offset correction
|
||||||
def emit_fn(d):
|
if "voltage" in data:
|
||||||
socketio.emit("gps", d)
|
data["voltage"] += 0.2 # Calibration offset
|
||||||
|
|
||||||
gps_throttle.maybe_emit(data, emit_fn)
|
def emit_fn(d):
|
||||||
|
socketio.emit("arduino", d)
|
||||||
|
|
||||||
def on_arduino_ack(cmd, status, extra):
|
arduino_throttle.maybe_emit(data, emit_fn)
|
||||||
"""Called by ArduinoService when ACK received from Arduino."""
|
|
||||||
socketio.emit("ack", {
|
|
||||||
"id": cmd.lower(),
|
def on_gps_data(data):
|
||||||
"status": status.lower(),
|
"""Called by GPSService when new fix arrives."""
|
||||||
"extra": extra,
|
def emit_fn(d):
|
||||||
})
|
socketio.emit("gps", d)
|
||||||
|
|
||||||
|
gps_throttle.maybe_emit(data, emit_fn)
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Background task to flush pending throttled data
|
|
||||||
# -----------------------------------------------------------------------------
|
def on_lte_data(data):
|
||||||
|
"""Called by LteService when new status polled."""
|
||||||
def throttle_flusher():
|
def emit_fn(d):
|
||||||
"""Periodically flush pending throttled data."""
|
socketio.emit("lte", d)
|
||||||
import gevent
|
|
||||||
while True:
|
lte_throttle.maybe_emit(data, emit_fn)
|
||||||
gevent.sleep(0.05) # 20Hz flush rate
|
|
||||||
|
|
||||||
if arduino_throttle.has_pending:
|
def on_arduino_ack(cmd, status, extra):
|
||||||
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
|
"""Called by ArduinoService when ACK received from Arduino."""
|
||||||
|
socketio.emit("ack", {
|
||||||
if gps_throttle.has_pending:
|
"id": cmd.lower(),
|
||||||
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
"status": status.lower(),
|
||||||
|
"extra": extra,
|
||||||
|
})
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# REST API (backward compatibility)
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
# Background task to flush pending throttled data
|
||||||
@app.route("/health")
|
# -----------------------------------------------------------------------------
|
||||||
def health():
|
|
||||||
"""Health check endpoint."""
|
def throttle_flusher():
|
||||||
return jsonify({
|
"""Periodically flush pending throttled data."""
|
||||||
"status": "ok",
|
import gevent
|
||||||
"gps_connected": gps.connected,
|
while True:
|
||||||
"arduino_connected": arduino.connected,
|
gevent.sleep(0.05) # 20Hz flush rate
|
||||||
"ws_clients": len(connected_clients),
|
|
||||||
})
|
if arduino_throttle.has_pending:
|
||||||
|
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
|
||||||
|
|
||||||
@app.route("/gps")
|
if gps_throttle.has_pending:
|
||||||
def gps_data():
|
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
||||||
"""Current GPS data."""
|
|
||||||
return jsonify(gps.get_latest())
|
if lte_throttle.has_pending:
|
||||||
|
lte_throttle.flush(lambda d: socketio.emit("lte", d))
|
||||||
|
|
||||||
@app.route("/gps/history")
|
|
||||||
def gps_history():
|
# -----------------------------------------------------------------------------
|
||||||
"""Buffered GPS history."""
|
# REST API (backward compatibility)
|
||||||
return jsonify(gps.get_buffer())
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
@app.route("/arduino")
|
def health():
|
||||||
def arduino_data():
|
"""Health check endpoint."""
|
||||||
"""Current Arduino telemetry (voltage, rpm, etc)."""
|
gps_latest = gps.get_latest()
|
||||||
return jsonify(arduino.get_latest())
|
lte_latest = lte.get_latest()
|
||||||
|
return jsonify({
|
||||||
|
"status": "ok",
|
||||||
@app.route("/arduino/history")
|
"gps_connected": gps.connected,
|
||||||
def arduino_history():
|
"gps_state": gps_latest.get("gps_state", "acquiring"),
|
||||||
"""Buffered Arduino telemetry history."""
|
"arduino_connected": arduino.connected,
|
||||||
return jsonify(arduino.get_buffer())
|
"lte_connected": lte.connected,
|
||||||
|
"lte_signal": lte_latest.get("signal"),
|
||||||
|
"ws_clients": len(connected_clients),
|
||||||
# -----------------------------------------------------------------------------
|
})
|
||||||
# Main Entry Point
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
@app.route("/gps")
|
||||||
def main():
|
def gps_data():
|
||||||
"""Entry point."""
|
"""Current GPS data."""
|
||||||
# Wire up callbacks
|
return jsonify(gps.get_latest())
|
||||||
arduino.set_on_data(on_arduino_data)
|
|
||||||
arduino.set_on_ack(on_arduino_ack)
|
|
||||||
gps.set_on_data(on_gps_data)
|
@app.route("/gps/history")
|
||||||
|
def gps_history():
|
||||||
# Start services
|
"""Buffered GPS history."""
|
||||||
gps.start()
|
return jsonify(gps.get_buffer())
|
||||||
arduino.start()
|
|
||||||
gpio.start()
|
|
||||||
|
@app.route("/lte")
|
||||||
# Start throttle flusher in background
|
def lte_data():
|
||||||
socketio.start_background_task(throttle_flusher)
|
"""Current LTE modem status."""
|
||||||
|
return jsonify(lte.get_latest())
|
||||||
try:
|
|
||||||
# Use socketio.run() instead of app.run() for WebSocket support
|
|
||||||
print("[Backend] Starting on http://0.0.0.0:5000")
|
@app.route("/arduino")
|
||||||
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
|
def arduino_data():
|
||||||
finally:
|
"""Current Arduino telemetry (voltage, rpm, etc)."""
|
||||||
arduino.stop()
|
return jsonify(arduino.get_latest())
|
||||||
gps.stop()
|
|
||||||
gpio.stop()
|
|
||||||
|
@app.route("/arduino/history")
|
||||||
|
def arduino_history():
|
||||||
if __name__ == "__main__":
|
"""Buffered Arduino telemetry history."""
|
||||||
main()
|
return jsonify(arduino.get_buffer())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Main Entry Point
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point."""
|
||||||
|
# Wire up callbacks
|
||||||
|
arduino.set_on_data(on_arduino_data)
|
||||||
|
arduino.set_on_ack(on_arduino_ack)
|
||||||
|
gps.set_on_data(on_gps_data)
|
||||||
|
lte.set_on_data(on_lte_data)
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
gps.start()
|
||||||
|
arduino.start()
|
||||||
|
gpio.start()
|
||||||
|
lte.start()
|
||||||
|
|
||||||
|
# Start throttle flusher in background
|
||||||
|
socketio.start_background_task(throttle_flusher)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use socketio.run() instead of app.run() for WebSocket support
|
||||||
|
print("[Backend] Starting on http://0.0.0.0:5000")
|
||||||
|
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
|
||||||
|
finally:
|
||||||
|
arduino.stop()
|
||||||
|
gps.stop()
|
||||||
|
gpio.stop()
|
||||||
|
lte.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "smartserow-backend"
|
name = "smartserow-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "GPS and Arduino telemetry service for Smart Serow"
|
description = "GPS and Arduino telemetry service for Smart Serow"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.0",
|
"flask>=3.0",
|
||||||
"flask-socketio>=5.3.0",
|
"flask-socketio>=5.3.0",
|
||||||
"gevent>=24.0",
|
"gevent>=24.0",
|
||||||
"gevent-websocket>=0.10",
|
"gevent-websocket>=0.10",
|
||||||
"gpsdclient>=1.3",
|
"gpsdclient>=1.3",
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
|
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
|
||||||
# Not listed here because pip versions require compilation
|
# Not listed here because pip versions require compilation
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff",
|
"ruff",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
smartserow-backend = "main:main"
|
smartserow-backend = "main:main"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
"""Throttle layer for rate-limiting telemetry emissions."""
|
"""Throttle layer for rate-limiting telemetry emissions."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
class Throttle:
|
class Throttle:
|
||||||
"""Rate limiter for WebSocket emissions.
|
"""Rate limiter for WebSocket emissions.
|
||||||
|
|
||||||
Coalesces rapid updates - only emits at most once per min_interval.
|
Coalesces rapid updates - only emits at most once per min_interval.
|
||||||
If multiple updates arrive within the interval, the latest value wins.
|
If multiple updates arrive within the interval, the latest value wins.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, min_interval: float = 0.5):
|
def __init__(self, min_interval: float = 0.5):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
|
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
|
||||||
"""
|
"""
|
||||||
self._last_emit: float = 0
|
self._last_emit: float = 0
|
||||||
self._min_interval = min_interval
|
self._min_interval = min_interval
|
||||||
self._pending: Any = None
|
self._pending: Any = None
|
||||||
|
|
||||||
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
|
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
|
||||||
"""Emit if interval has passed, otherwise store as pending.
|
"""Emit if interval has passed, otherwise store as pending.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Data to emit
|
data: Data to emit
|
||||||
emit_fn: Function to call with data when emitting
|
emit_fn: Function to call with data when emitting
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if emitted, False if stored as pending
|
True if emitted, False if stored as pending
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_emit >= self._min_interval:
|
if now - self._last_emit >= self._min_interval:
|
||||||
emit_fn(data)
|
emit_fn(data)
|
||||||
self._last_emit = now
|
self._last_emit = now
|
||||||
self._pending = None
|
self._pending = None
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self._pending = data # Latest value wins
|
self._pending = data # Latest value wins
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
|
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
|
||||||
"""Emit pending data if any.
|
"""Emit pending data if any.
|
||||||
|
|
||||||
Call this periodically to ensure pending data gets sent.
|
Call this periodically to ensure pending data gets sent.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if pending data was emitted, False if nothing pending
|
True if pending data was emitted, False if nothing pending
|
||||||
"""
|
"""
|
||||||
if self._pending is not None:
|
if self._pending is not None:
|
||||||
emit_fn(self._pending)
|
emit_fn(self._pending)
|
||||||
self._last_emit = time.time()
|
self._last_emit = time.time()
|
||||||
self._pending = None
|
self._pending = None
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_pending(self) -> bool:
|
def has_pending(self) -> bool:
|
||||||
"""Check if there's pending data waiting to be emitted."""
|
"""Check if there's pending data waiting to be emitted."""
|
||||||
return self._pending is not None
|
return self._pending is not None
|
||||||
|
|||||||
49
pi/backend/utils/at_terminal.py
Normal file
49
pi/backend/utils/at_terminal.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Quick AT command terminal - minicom but less hostile.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python at_terminal.py [port]
|
||||||
|
|
||||||
|
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import serial
|
||||||
|
import threading
|
||||||
|
|
||||||
|
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
|
||||||
|
BAUD = 115200
|
||||||
|
|
||||||
|
|
||||||
|
def reader(ser):
|
||||||
|
"""Background thread: print everything the modem sends."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = ser.read(ser.in_waiting or 1)
|
||||||
|
if data:
|
||||||
|
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Opening {PORT} @ {BAUD} baud")
|
||||||
|
print("Type AT commands. Ctrl+C to quit.\n")
|
||||||
|
|
||||||
|
ser = serial.Serial(PORT, BAUD, timeout=0.1)
|
||||||
|
|
||||||
|
t = threading.Thread(target=reader, args=(ser,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = input()
|
||||||
|
ser.write((line + "\r\n").encode())
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print("\nBye.")
|
||||||
|
finally:
|
||||||
|
ser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
// Show all items from the start so the row doesn't jump around
|
// Show all items from the start so the row doesn't jump around
|
||||||
_updateStatus('Config', '...');
|
_updateStatus('Config', '...');
|
||||||
_updateStatus('UART', '...');
|
_updateStatus('UART', '...');
|
||||||
|
_updateStatus('GPS', '...');
|
||||||
_updateStatus('Navigator', '...');
|
_updateStatus('Navigator', '...');
|
||||||
|
|
||||||
// Config must load first (everything else depends on it)
|
// Config must load first (everything else depends on it)
|
||||||
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
await ConfigService.instance.load();
|
await ConfigService.instance.load();
|
||||||
_updateStatus('Config', 'Ready');
|
_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('UART', 'Connecting');
|
||||||
|
_updateStatus('GPS', 'Waiting');
|
||||||
_updateStatus('Navigator', 'Loading');
|
_updateStatus('Navigator', 'Loading');
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
_waitForUart(),
|
_waitForUart(),
|
||||||
|
_waitForGps(),
|
||||||
_preloadNavigatorImages(),
|
_preloadNavigatorImages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
_updateStatus('UART', 'Timeout');
|
_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
|
/// Preload navigator images into Flutter's image cache
|
||||||
///
|
///
|
||||||
/// Scans for all PNGs in the navigator folder and precaches them.
|
/// Scans for all PNGs in the navigator folder and precaches them.
|
||||||
|
|||||||
@@ -1,282 +1,298 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' show sqrt, sin, cos, pi;
|
import 'dart:math' show sqrt, sin, cos, pi;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../services/backend_service.dart';
|
import '../services/backend_service.dart';
|
||||||
import '../services/websocket_service.dart';
|
import '../services/websocket_service.dart';
|
||||||
import '../services/pi_io.dart';
|
import '../services/pi_io.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/navigator_widget.dart';
|
import '../widgets/navigator_widget.dart';
|
||||||
import '../widgets/stat_box.dart';
|
import '../widgets/stat_box.dart';
|
||||||
import '../widgets/stat_box_main.dart';
|
import '../widgets/stat_box_main.dart';
|
||||||
import '../widgets/system_bar.dart';
|
import '../widgets/system_bar.dart';
|
||||||
import '../widgets/debug_console.dart';
|
import '../widgets/debug_console.dart';
|
||||||
import '../widgets/whiskey_mark.dart';
|
import '../widgets/whiskey_mark.dart';
|
||||||
import '../widgets/accel_graph.dart';
|
import '../widgets/accel_graph.dart';
|
||||||
import '../widgets/gps_compass.dart';
|
import '../widgets/gps_compass.dart';
|
||||||
|
|
||||||
// test service for triggers
|
// test service for triggers
|
||||||
import '../services/test_flipflop_service.dart';
|
import '../services/test_flipflop_service.dart';
|
||||||
|
|
||||||
/// Main dashboard - displays Pi vitals and placeholder stats
|
/// Main dashboard - displays Pi vitals and placeholder stats
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DashboardScreenState extends State<DashboardScreen> {
|
class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
||||||
|
|
||||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||||
|
|
||||||
// Timer for Pi temp only (safety critical, direct file read)
|
// Timer for Pi temp only (safety critical, direct file read)
|
||||||
Timer? _piTempTimer;
|
Timer? _piTempTimer;
|
||||||
|
|
||||||
// WebSocket stream subscriptions
|
// WebSocket stream subscriptions
|
||||||
StreamSubscription<ArduinoData>? _arduinoSub;
|
StreamSubscription<ArduinoData>? _arduinoSub;
|
||||||
StreamSubscription<GpsData>? _gpsSub;
|
StreamSubscription<GpsData>? _gpsSub;
|
||||||
StreamSubscription<WsConnectionState>? _connectionSub;
|
StreamSubscription<LteData>? _lteSub;
|
||||||
|
StreamSubscription<WsConnectionState>? _connectionSub;
|
||||||
// Pi temperature - direct file read (safety critical)
|
|
||||||
double? _piTemp;
|
// Pi temperature - direct file read (safety critical)
|
||||||
|
double? _piTemp;
|
||||||
// From backend - Arduino data
|
|
||||||
int? _rpm;
|
// From backend - Arduino data
|
||||||
double? _voltage;
|
int? _rpm;
|
||||||
int? _engineTemp;
|
double? _voltage;
|
||||||
int? _gear;
|
int? _engineTemp;
|
||||||
double? _roll;
|
int? _gear;
|
||||||
double? _pitch;
|
double? _roll;
|
||||||
double? _ax;
|
double? _pitch;
|
||||||
double? _ay;
|
double? _ax;
|
||||||
double? _dynamicAx; // Gravity-compensated
|
double? _ay;
|
||||||
double? _dynamicAy;
|
double? _dynamicAx; // Gravity-compensated
|
||||||
|
double? _dynamicAy;
|
||||||
// From backend - GPS data
|
|
||||||
double? _gpsSpeed;
|
// From backend - GPS data
|
||||||
double? _gpsTrack;
|
double? _gpsSpeed;
|
||||||
|
double? _gpsTrack;
|
||||||
// Placeholder values for system bar
|
|
||||||
int? _gpsSatellites;
|
// Placeholder values for system bar
|
||||||
int? _lteSignal;
|
int? _gpsSatellites;
|
||||||
|
String? _gpsState;
|
||||||
// WebSocket connection state
|
int? _lteSignal;
|
||||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
|
||||||
|
// WebSocket connection state
|
||||||
@override
|
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||||
void initState() {
|
|
||||||
super.initState();
|
@override
|
||||||
|
void initState() {
|
||||||
// Connect to WebSocket
|
super.initState();
|
||||||
WebSocketService.instance.connect();
|
|
||||||
|
// Connect to WebSocket
|
||||||
// Subscribe to Arduino data stream
|
WebSocketService.instance.connect();
|
||||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
|
||||||
// Gravity-compensated acceleration
|
// Subscribe to Arduino data stream
|
||||||
// When tilted, gravity "leaks" into horizontal axes - subtract it out
|
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||||
final rollRad = (data.roll ?? 0) * pi / 180;
|
// Gravity-compensated acceleration
|
||||||
final pitchRad = (data.pitch ?? 0) * pi / 180;
|
// When tilted, gravity "leaks" into horizontal axes - subtract it out
|
||||||
|
final rollRad = (data.roll ?? 0) * pi / 180;
|
||||||
// Subtract gravity leakage from measured acceleration
|
final pitchRad = (data.pitch ?? 0) * pi / 180;
|
||||||
// Axes swapped for IMU mounting orientation
|
|
||||||
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
|
// Subtract gravity leakage from measured acceleration
|
||||||
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
|
// Axes swapped for IMU mounting orientation
|
||||||
|
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
|
||||||
setState(() {
|
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
|
||||||
_voltage = data.voltage;
|
|
||||||
_rpm = data.rpm;
|
setState(() {
|
||||||
_engineTemp = data.engTemp;
|
_voltage = data.voltage;
|
||||||
_gear = data.gear;
|
_rpm = data.rpm;
|
||||||
_roll = data.roll;
|
_engineTemp = data.engTemp;
|
||||||
_pitch = data.pitch;
|
_gear = data.gear;
|
||||||
_ax = data.ax;
|
_roll = data.roll;
|
||||||
_ay = data.ay;
|
_pitch = data.pitch;
|
||||||
_dynamicAx = dynamicAx;
|
_ax = data.ax;
|
||||||
_dynamicAy = dynamicAy;
|
_ay = data.ay;
|
||||||
});
|
_dynamicAx = dynamicAx;
|
||||||
|
_dynamicAy = dynamicAy;
|
||||||
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
|
});
|
||||||
if (gMagnitude > _surpriseThreshold) {
|
|
||||||
_navigatorKey.currentState?.setEmotion('surprise');
|
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(() {
|
// Subscribe to GPS data stream
|
||||||
_gpsSpeed = data.speed;
|
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||||
_gpsTrack = data.track;
|
setState(() {
|
||||||
_gpsSatellites = data.satellites;
|
_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;
|
// Subscribe to LTE data stream
|
||||||
});
|
_lteSub = WebSocketService.instance.lteStream.listen((data) {
|
||||||
});
|
setState(() {
|
||||||
|
_lteSignal = data.signal;
|
||||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
});
|
||||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
});
|
||||||
setState(() {
|
|
||||||
_piTemp = PiIO.instance.getTemperature();
|
// Subscribe to connection state
|
||||||
});
|
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||||
});
|
setState(() {
|
||||||
|
_wsState = state;
|
||||||
// Initialize with any cached data from WebSocketService
|
});
|
||||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
});
|
||||||
if (cachedArduino != null) {
|
|
||||||
_voltage = cachedArduino.voltage;
|
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||||
_rpm = cachedArduino.rpm;
|
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||||
_engineTemp = cachedArduino.engTemp;
|
setState(() {
|
||||||
_gear = cachedArduino.gear;
|
_piTemp = PiIO.instance.getTemperature();
|
||||||
_roll = cachedArduino.roll;
|
});
|
||||||
_pitch = cachedArduino.pitch;
|
});
|
||||||
_ax = cachedArduino.ax;
|
|
||||||
_ay = cachedArduino.ay;
|
// Initialize with any cached data from WebSocketService
|
||||||
}
|
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||||
|
if (cachedArduino != null) {
|
||||||
final cachedGps = WebSocketService.instance.latestGps;
|
_voltage = cachedArduino.voltage;
|
||||||
if (cachedGps != null) {
|
_rpm = cachedArduino.rpm;
|
||||||
_gpsSpeed = cachedGps.speed;
|
_engineTemp = cachedArduino.engTemp;
|
||||||
_gpsTrack = cachedGps.track;
|
_gear = cachedArduino.gear;
|
||||||
_gpsSatellites = cachedGps.satellites;
|
_roll = cachedArduino.roll;
|
||||||
}
|
_pitch = cachedArduino.pitch;
|
||||||
|
_ax = cachedArduino.ax;
|
||||||
_wsState = WebSocketService.instance.connectionState;
|
_ay = cachedArduino.ay;
|
||||||
|
}
|
||||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
|
||||||
_lteSignal = null;
|
final cachedGps = WebSocketService.instance.latestGps;
|
||||||
|
if (cachedGps != null) {
|
||||||
// DEBUG: flip-flop theme + navigator every 2s
|
_gpsSpeed = cachedGps.speed;
|
||||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
_gpsTrack = cachedGps.track;
|
||||||
}
|
_gpsSatellites = cachedGps.satellites;
|
||||||
|
_gpsState = cachedGps.gpsState;
|
||||||
@override
|
}
|
||||||
void dispose() {
|
|
||||||
_piTempTimer?.cancel();
|
_wsState = WebSocketService.instance.connectionState;
|
||||||
_arduinoSub?.cancel();
|
|
||||||
_gpsSub?.cancel();
|
// Init from cached LTE data
|
||||||
_connectionSub?.cancel();
|
final cachedLte = WebSocketService.instance.latestLte;
|
||||||
TestFlipFlopService.instance.stop();
|
if (cachedLte != null) {
|
||||||
super.dispose();
|
_lteSignal = cachedLte.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
// DEBUG: flip-flop theme + navigator every 2s
|
||||||
String _formatGear(int? gear) {
|
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||||
if (gear == null) return '—';
|
}
|
||||||
if (gear == 0) return 'N';
|
|
||||||
return gear.toString();
|
@override
|
||||||
}
|
void dispose() {
|
||||||
|
_piTempTimer?.cancel();
|
||||||
/// Format nullable int for display
|
_arduinoSub?.cancel();
|
||||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
_gpsSub?.cancel();
|
||||||
|
_lteSub?.cancel();
|
||||||
/// Format nullable double for display with decimal places
|
_connectionSub?.cancel();
|
||||||
String _formatDouble(double? value, [int decimals = 1]) {
|
TestFlipFlopService.instance.stop();
|
||||||
if (value == null) return '—';
|
super.dispose();
|
||||||
return value.toStringAsFixed(decimals);
|
}
|
||||||
}
|
|
||||||
|
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||||
@override
|
String _formatGear(int? gear) {
|
||||||
Widget build(BuildContext context) {
|
if (gear == null) return '—';
|
||||||
final theme = AppTheme.of(context);
|
if (gear == 0) return 'N';
|
||||||
|
return gear.toString();
|
||||||
return Scaffold(
|
}
|
||||||
backgroundColor: theme.background,
|
|
||||||
body: Padding(
|
/// Format nullable int for display
|
||||||
padding: const EdgeInsets.all(16),
|
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||||
child: Row(
|
|
||||||
children: [
|
/// Format nullable double for display with decimal places
|
||||||
// Left side: All dashboard widgets (flex: 2)
|
String _formatDouble(double? value, [int decimals = 1]) {
|
||||||
Expanded(
|
if (value == null) return '—';
|
||||||
flex: 2,
|
return value.toStringAsFixed(decimals);
|
||||||
child: Column(
|
}
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
@override
|
||||||
// System status bar
|
Widget build(BuildContext context) {
|
||||||
SystemBar(
|
final theme = AppTheme.of(context);
|
||||||
gpsSatellites: _gpsSatellites,
|
|
||||||
lteSignal: _lteSignal,
|
return Scaffold(
|
||||||
piTemp: _piTemp,
|
backgroundColor: theme.background,
|
||||||
voltage: _voltage,
|
body: Padding(
|
||||||
wsState: _wsState,
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: Row(
|
||||||
|
children: [
|
||||||
// Main content area - big widgets
|
// Left side: All dashboard widgets (flex: 2)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 7,
|
flex: 2,
|
||||||
child: Row(
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
// Attitude indicator (whiskey mark)
|
children: [
|
||||||
Expanded(
|
// System status bar
|
||||||
child: WhiskeyMark(
|
SystemBar(
|
||||||
roll: _roll,
|
gpsSatellites: _gpsSatellites,
|
||||||
pitch: _pitch,
|
gpsState: _gpsState,
|
||||||
),
|
lteSignal: _lteSignal,
|
||||||
),
|
piTemp: _piTemp,
|
||||||
Expanded(
|
voltage: _voltage,
|
||||||
child: AccelGraph(
|
wsState: _wsState,
|
||||||
ax: _dynamicAx, // Gravity-compensated lateral
|
),
|
||||||
ay: _dynamicAy, // Gravity-compensated longitudinal
|
|
||||||
maxG: 0.8,
|
// Main content area - big widgets
|
||||||
ghostTrackPeriod: const Duration(seconds: 4),
|
Expanded(
|
||||||
),
|
flex: 7,
|
||||||
)
|
child: Row(
|
||||||
],
|
children: [
|
||||||
),
|
// Attitude indicator (whiskey mark)
|
||||||
),
|
Expanded(
|
||||||
|
child: WhiskeyMark(
|
||||||
// Bottom stats row
|
roll: _roll,
|
||||||
Expanded(
|
pitch: _pitch,
|
||||||
flex: 3,
|
),
|
||||||
child: Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
Expanded(
|
||||||
children: [
|
child: AccelGraph(
|
||||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
ax: _dynamicAx, // Gravity-compensated lateral
|
||||||
GpsCompass(heading: _gpsTrack),
|
ay: _dynamicAy, // Gravity-compensated longitudinal
|
||||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
maxG: 0.8,
|
||||||
],
|
ghostTrackPeriod: const Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 32),
|
// Bottom stats row
|
||||||
|
Expanded(
|
||||||
// Right side: Navigator on top, debug console below
|
flex: 3,
|
||||||
Expanded(
|
child: Row(
|
||||||
flex: 1,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||||
// Navigator
|
GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
|
||||||
Expanded(
|
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||||
flex: 3,
|
],
|
||||||
child: Center(
|
),
|
||||||
child: NavigatorWidget(key: _navigatorKey),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
// Debug console
|
),
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
const SizedBox(width: 32),
|
||||||
child:
|
|
||||||
DebugConsole(
|
// Right side: Navigator on top, debug console below
|
||||||
messageStream: WebSocketService.instance.debugStream,
|
Expanded(
|
||||||
initialMessages: WebSocketService.instance.debugMessages,
|
flex: 1,
|
||||||
maxLines: 6,
|
child: Column(
|
||||||
title: 'WebSocket messages',
|
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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,161 +1,182 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
|
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
|
||||||
class ArduinoData {
|
class ArduinoData {
|
||||||
final double? voltage;
|
final double? voltage;
|
||||||
final int? rpm;
|
final int? rpm;
|
||||||
final int? engTemp;
|
final int? engTemp;
|
||||||
final int? gear; // 0 = neutral, 1-6 = gear
|
final int? gear; // 0 = neutral, 1-6 = gear
|
||||||
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
||||||
final double? pitch; // Euler angle in degrees (negative = nose down)
|
final double? pitch; // Euler angle in degrees (negative = nose down)
|
||||||
final double? ax; // Lateral acceleration (g)
|
final double? ax; // Lateral acceleration (g)
|
||||||
final double? ay; // Longitudinal acceleration (g)
|
final double? ay; // Longitudinal acceleration (g)
|
||||||
final double? az; // Vertical 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});
|
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) {
|
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||||
return ArduinoData(
|
return ArduinoData(
|
||||||
voltage: (json['voltage'] as num?)?.toDouble(),
|
voltage: (json['voltage'] as num?)?.toDouble(),
|
||||||
rpm: (json['rpm'] as num?)?.toInt(),
|
rpm: (json['rpm'] as num?)?.toInt(),
|
||||||
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
||||||
gear: (json['gear'] as num?)?.toInt(),
|
gear: (json['gear'] as num?)?.toInt(),
|
||||||
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
||||||
pitch: (json['pitch'] as num?)?.toDouble(),
|
pitch: (json['pitch'] as num?)?.toDouble(),
|
||||||
ax: (json['ax'] as num?)?.toDouble(),
|
ax: (json['ax'] as num?)?.toDouble(),
|
||||||
ay: (json['ay'] as num?)?.toDouble(),
|
ay: (json['ay'] as num?)?.toDouble(),
|
||||||
az: (json['az'] as num?)?.toDouble(),
|
az: (json['az'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data from GPS
|
/// Data from GPS
|
||||||
class GpsData {
|
class GpsData {
|
||||||
final double? lat;
|
final double? lat;
|
||||||
final double? lon;
|
final double? lon;
|
||||||
final double? speed; // m/s
|
final double? speed; // m/s
|
||||||
final double? alt;
|
final double? alt;
|
||||||
final double? track;
|
final double? track;
|
||||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||||
final int? satellites;
|
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});
|
|
||||||
|
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(
|
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||||
lat: (json['lat'] as num?)?.toDouble(),
|
return GpsData(
|
||||||
lon: (json['lon'] as num?)?.toDouble(),
|
lat: (json['lat'] as num?)?.toDouble(),
|
||||||
speed: (json['speed'] as num?)?.toDouble(),
|
lon: (json['lon'] as num?)?.toDouble(),
|
||||||
alt: (json['alt'] as num?)?.toDouble(),
|
speed: (json['speed'] as num?)?.toDouble(),
|
||||||
track: (json['track'] as num?)?.toDouble(),
|
alt: (json['alt'] as num?)?.toDouble(),
|
||||||
mode: (json['mode'] as num?)?.toInt(),
|
track: (json['track'] as num?)?.toDouble(),
|
||||||
satellites: (json['satellites'] as num?)?.toInt(),
|
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.
|
/// Data from LTE modem (signal quality, connection state)
|
||||||
class BackendService {
|
class LteData {
|
||||||
BackendService._() {
|
final bool? connected;
|
||||||
// Kick off initial fetches
|
final int? signal; // 0-100 percent
|
||||||
_refreshArduino();
|
final String? operator_;
|
||||||
_refreshGps();
|
final String? accessTech;
|
||||||
}
|
|
||||||
static final instance = BackendService._();
|
LteData({this.connected, this.signal, this.operator_, this.accessTech});
|
||||||
|
|
||||||
static const _baseUrl = 'http://127.0.0.1:5000';
|
factory LteData.fromJson(Map<String, dynamic> json) {
|
||||||
static const _timeout = Duration(seconds: 2);
|
return LteData(
|
||||||
|
connected: json['connected'] as bool?,
|
||||||
// Caches
|
signal: (json['signal'] as num?)?.toInt(),
|
||||||
ArduinoData? _arduinoCache;
|
operator_: json['operator'] as String?,
|
||||||
GpsData? _gpsCache;
|
accessTech: json['access_tech'] as String?,
|
||||||
bool _connected = false;
|
);
|
||||||
|
}
|
||||||
// In-progress flags (prevent duplicate requests)
|
}
|
||||||
bool _arduinoFetchInProgress = false;
|
|
||||||
bool _gpsFetchInProgress = false;
|
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||||
|
///
|
||||||
/// Whether backend is reachable
|
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||||
bool get isConnected => _connected;
|
class BackendService {
|
||||||
|
BackendService._() {
|
||||||
/// Get Arduino data (sync, returns cached value)
|
// Kick off initial fetches
|
||||||
ArduinoData? getArduinoData() {
|
_refreshArduino();
|
||||||
if (!_arduinoFetchInProgress) {
|
_refreshGps();
|
||||||
_refreshArduino();
|
}
|
||||||
}
|
static final instance = BackendService._();
|
||||||
return _arduinoCache;
|
|
||||||
}
|
static const _baseUrl = 'http://127.0.0.1:5000';
|
||||||
|
static const _timeout = Duration(seconds: 2);
|
||||||
/// Get GPS data (sync, returns cached value)
|
|
||||||
GpsData? getGpsData() {
|
// Caches
|
||||||
if (!_gpsFetchInProgress) {
|
ArduinoData? _arduinoCache;
|
||||||
_refreshGps();
|
GpsData? _gpsCache;
|
||||||
}
|
bool _connected = false;
|
||||||
return _gpsCache;
|
|
||||||
}
|
// In-progress flags (prevent duplicate requests)
|
||||||
|
bool _arduinoFetchInProgress = false;
|
||||||
/// Background fetch for Arduino data
|
bool _gpsFetchInProgress = false;
|
||||||
Future<void> _refreshArduino() async {
|
|
||||||
if (_arduinoFetchInProgress) return;
|
/// Whether backend is reachable
|
||||||
_arduinoFetchInProgress = true;
|
bool get isConnected => _connected;
|
||||||
|
|
||||||
try {
|
/// Get Arduino data (sync, returns cached value)
|
||||||
final response = await http
|
ArduinoData? getArduinoData() {
|
||||||
.get(Uri.parse('$_baseUrl/arduino'))
|
if (!_arduinoFetchInProgress) {
|
||||||
.timeout(_timeout);
|
_refreshArduino();
|
||||||
|
}
|
||||||
if (response.statusCode == 200) {
|
return _arduinoCache;
|
||||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
}
|
||||||
// Skip if backend returns error (no data yet) - keep cached value
|
|
||||||
if (!json.containsKey('error')) {
|
/// Get GPS data (sync, returns cached value)
|
||||||
_arduinoCache = ArduinoData.fromJson(json);
|
GpsData? getGpsData() {
|
||||||
}
|
if (!_gpsFetchInProgress) {
|
||||||
_connected = true;
|
_refreshGps();
|
||||||
}
|
}
|
||||||
// Non-200: keep cached data, just mark disconnected
|
return _gpsCache;
|
||||||
} catch (e) {
|
}
|
||||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
|
||||||
_connected = false;
|
/// Background fetch for Arduino data
|
||||||
} finally {
|
Future<void> _refreshArduino() async {
|
||||||
_arduinoFetchInProgress = false;
|
if (_arduinoFetchInProgress) return;
|
||||||
}
|
_arduinoFetchInProgress = true;
|
||||||
}
|
|
||||||
|
try {
|
||||||
/// Background fetch for GPS data
|
final response = await http
|
||||||
Future<void> _refreshGps() async {
|
.get(Uri.parse('$_baseUrl/arduino'))
|
||||||
if (_gpsFetchInProgress) return;
|
.timeout(_timeout);
|
||||||
_gpsFetchInProgress = true;
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
try {
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final response = await http
|
// Skip if backend returns error (no data yet) - keep cached value
|
||||||
.get(Uri.parse('$_baseUrl/gps'))
|
if (!json.containsKey('error')) {
|
||||||
.timeout(_timeout);
|
_arduinoCache = ArduinoData.fromJson(json);
|
||||||
|
}
|
||||||
if (response.statusCode == 200) {
|
_connected = true;
|
||||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
}
|
||||||
// Skip if backend returns error (no data yet) - keep cached value
|
// Non-200: keep cached data, just mark disconnected
|
||||||
if (!json.containsKey('error')) {
|
} catch (e) {
|
||||||
_gpsCache = GpsData.fromJson(json);
|
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||||
}
|
_connected = false;
|
||||||
_connected = true;
|
} finally {
|
||||||
}
|
_arduinoFetchInProgress = false;
|
||||||
// Non-200: keep cached data, just mark disconnected
|
}
|
||||||
} catch (e) {
|
}
|
||||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
|
||||||
_connected = false;
|
/// Background fetch for GPS data
|
||||||
} finally {
|
Future<void> _refreshGps() async {
|
||||||
_gpsFetchInProgress = false;
|
if (_gpsFetchInProgress) return;
|
||||||
}
|
_gpsFetchInProgress = true;
|
||||||
}
|
|
||||||
|
try {
|
||||||
/// Force clear all caches
|
final response = await http
|
||||||
void clearCache() {
|
.get(Uri.parse('$_baseUrl/gps'))
|
||||||
_arduinoCache = null;
|
.timeout(_timeout);
|
||||||
_gpsCache = null;
|
|
||||||
_connected = false;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,332 +1,351 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||||
|
|
||||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||||
import 'theme_service.dart';
|
import 'theme_service.dart';
|
||||||
|
|
||||||
/// Connection state for WebSocket
|
/// Connection state for WebSocket
|
||||||
enum WsConnectionState {
|
enum WsConnectionState {
|
||||||
disconnected,
|
disconnected,
|
||||||
connecting,
|
connecting,
|
||||||
connected,
|
connected,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acknowledgment from backend for a command
|
/// Acknowledgment from backend for a command
|
||||||
class CommandAck {
|
class CommandAck {
|
||||||
final String id;
|
final String id;
|
||||||
final String status;
|
final String status;
|
||||||
final String? error;
|
final String? error;
|
||||||
final String? extra;
|
final String? extra;
|
||||||
|
|
||||||
CommandAck({
|
CommandAck({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.error,
|
this.error,
|
||||||
this.extra,
|
this.extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isSuccess => status == 'ok' || status == 'sent';
|
bool get isSuccess => status == 'ok' || status == 'sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alert from backend
|
/// Alert from backend
|
||||||
class BackendAlert {
|
class BackendAlert {
|
||||||
final String type;
|
final String type;
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
BackendAlert({required this.type, required this.message});
|
BackendAlert({required this.type, required this.message});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backend status (connection states of GPS/Arduino)
|
/// Backend status (connection states of GPS/Arduino)
|
||||||
class BackendStatus {
|
class BackendStatus {
|
||||||
final bool gpsConnected;
|
final bool gpsConnected;
|
||||||
final bool arduinoConnected;
|
final bool arduinoConnected;
|
||||||
|
|
||||||
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
|
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket service for real-time data from backend.
|
/// WebSocket service for real-time data from backend.
|
||||||
///
|
///
|
||||||
/// Replaces HTTP polling with push-based updates.
|
/// Replaces HTTP polling with push-based updates.
|
||||||
/// Maintains dual logical channels:
|
/// Maintains dual logical channels:
|
||||||
/// - Telemetry: arduino/gps data streams (throttled by backend)
|
/// - Telemetry: arduino/gps data streams (throttled by backend)
|
||||||
/// - Control: button commands and acknowledgments
|
/// - Control: button commands and acknowledgments
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
WebSocketService._() {
|
WebSocketService._() {
|
||||||
_setupStreams();
|
_setupStreams();
|
||||||
}
|
}
|
||||||
static final instance = WebSocketService._();
|
static final instance = WebSocketService._();
|
||||||
|
|
||||||
static const _serverUrl = 'http://127.0.0.1:5000';
|
static const _serverUrl = 'http://127.0.0.1:5000';
|
||||||
|
|
||||||
io.Socket? _socket;
|
io.Socket? _socket;
|
||||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
|
|
||||||
// Latest values for sync access (backward compat)
|
// Latest values for sync access (backward compat)
|
||||||
ArduinoData? _latestArduino;
|
ArduinoData? _latestArduino;
|
||||||
GpsData? _latestGps;
|
GpsData? _latestGps;
|
||||||
BackendStatus? _latestStatus;
|
LteData? _latestLte;
|
||||||
|
BackendStatus? _latestStatus;
|
||||||
// Stream controllers
|
|
||||||
late StreamController<ArduinoData> _arduinoController;
|
// Stream controllers
|
||||||
late StreamController<GpsData> _gpsController;
|
late StreamController<ArduinoData> _arduinoController;
|
||||||
late StreamController<BackendStatus> _statusController;
|
late StreamController<GpsData> _gpsController;
|
||||||
late StreamController<CommandAck> _ackController;
|
late StreamController<LteData> _lteController;
|
||||||
late StreamController<BackendAlert> _alertController;
|
late StreamController<BackendStatus> _statusController;
|
||||||
late StreamController<WsConnectionState> _connectionController;
|
late StreamController<CommandAck> _ackController;
|
||||||
late StreamController<String> _debugController;
|
late StreamController<BackendAlert> _alertController;
|
||||||
|
late StreamController<WsConnectionState> _connectionController;
|
||||||
// Debug message buffer
|
late StreamController<String> _debugController;
|
||||||
static const int _maxDebugMessages = 50;
|
|
||||||
final List<String> _debugMessages = [];
|
// Debug message buffer
|
||||||
|
static const int _maxDebugMessages = 50;
|
||||||
void _setupStreams() {
|
final List<String> _debugMessages = [];
|
||||||
_arduinoController = StreamController<ArduinoData>.broadcast();
|
|
||||||
_gpsController = StreamController<GpsData>.broadcast();
|
void _setupStreams() {
|
||||||
_statusController = StreamController<BackendStatus>.broadcast();
|
_arduinoController = StreamController<ArduinoData>.broadcast();
|
||||||
_ackController = StreamController<CommandAck>.broadcast();
|
_gpsController = StreamController<GpsData>.broadcast();
|
||||||
_alertController = StreamController<BackendAlert>.broadcast();
|
_lteController = StreamController<LteData>.broadcast();
|
||||||
_connectionController = StreamController<WsConnectionState>.broadcast();
|
_statusController = StreamController<BackendStatus>.broadcast();
|
||||||
_debugController = StreamController<String>.broadcast();
|
_ackController = StreamController<CommandAck>.broadcast();
|
||||||
}
|
_alertController = StreamController<BackendAlert>.broadcast();
|
||||||
|
_connectionController = StreamController<WsConnectionState>.broadcast();
|
||||||
/// Log a debug message (adds to buffer and stream)
|
_debugController = StreamController<String>.broadcast();
|
||||||
void _log(String message) {
|
}
|
||||||
_debugMessages.add(message);
|
|
||||||
if (_debugMessages.length > _maxDebugMessages) {
|
/// Log a debug message (adds to buffer and stream)
|
||||||
_debugMessages.removeAt(0);
|
void _log(String message) {
|
||||||
}
|
_debugMessages.add(message);
|
||||||
_debugController.add(message);
|
if (_debugMessages.length > _maxDebugMessages) {
|
||||||
}
|
_debugMessages.removeAt(0);
|
||||||
|
}
|
||||||
// --- Public API: Streams ---
|
_debugController.add(message);
|
||||||
|
}
|
||||||
/// Stream of Arduino telemetry updates
|
|
||||||
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
|
// --- Public API: Streams ---
|
||||||
|
|
||||||
/// Stream of GPS updates
|
/// Stream of Arduino telemetry updates
|
||||||
Stream<GpsData> get gpsStream => _gpsController.stream;
|
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
|
||||||
|
|
||||||
/// Stream of backend status updates
|
/// Stream of GPS updates
|
||||||
Stream<BackendStatus> get statusStream => _statusController.stream;
|
Stream<GpsData> get gpsStream => _gpsController.stream;
|
||||||
|
|
||||||
/// Stream of command acknowledgments
|
/// Stream of LTE status updates
|
||||||
Stream<CommandAck> get ackStream => _ackController.stream;
|
Stream<LteData> get lteStream => _lteController.stream;
|
||||||
|
|
||||||
/// Stream of alerts from backend
|
/// Stream of backend status updates
|
||||||
Stream<BackendAlert> get alertStream => _alertController.stream;
|
Stream<BackendStatus> get statusStream => _statusController.stream;
|
||||||
|
|
||||||
/// Stream of connection state changes
|
/// Stream of command acknowledgments
|
||||||
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
|
Stream<CommandAck> get ackStream => _ackController.stream;
|
||||||
|
|
||||||
/// Stream of debug log messages
|
/// Stream of alerts from backend
|
||||||
Stream<String> get debugStream => _debugController.stream;
|
Stream<BackendAlert> get alertStream => _alertController.stream;
|
||||||
|
|
||||||
/// Current debug message buffer (for initial display)
|
/// Stream of connection state changes
|
||||||
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
|
||||||
|
|
||||||
// --- Public API: Sync getters (backward compat) ---
|
/// Stream of debug log messages
|
||||||
|
Stream<String> get debugStream => _debugController.stream;
|
||||||
/// Current connection state
|
|
||||||
WsConnectionState get connectionState => _connectionState;
|
/// Current debug message buffer (for initial display)
|
||||||
|
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
||||||
/// Whether connected to backend
|
|
||||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
// --- Public API: Sync getters (backward compat) ---
|
||||||
|
|
||||||
/// Latest Arduino data (may be null if not yet received)
|
/// Current connection state
|
||||||
ArduinoData? get latestArduino => _latestArduino;
|
WsConnectionState get connectionState => _connectionState;
|
||||||
|
|
||||||
/// Latest GPS data (may be null if not yet received)
|
/// Whether connected to backend
|
||||||
GpsData? get latestGps => _latestGps;
|
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||||
|
|
||||||
/// Latest backend status
|
/// Latest Arduino data (may be null if not yet received)
|
||||||
BackendStatus? get latestStatus => _latestStatus;
|
ArduinoData? get latestArduino => _latestArduino;
|
||||||
|
|
||||||
// --- Public API: Connection ---
|
/// Latest GPS data (may be null if not yet received)
|
||||||
|
GpsData? get latestGps => _latestGps;
|
||||||
/// Connect to backend WebSocket
|
|
||||||
void connect() {
|
/// Latest LTE data (may be null if not yet received)
|
||||||
if (_socket != null) return; // Already connected or connecting
|
LteData? get latestLte => _latestLte;
|
||||||
|
|
||||||
_setConnectionState(WsConnectionState.connecting);
|
/// Latest backend status
|
||||||
|
BackendStatus? get latestStatus => _latestStatus;
|
||||||
_socket = io.io(_serverUrl, <String, dynamic>{
|
|
||||||
'transports': ['websocket'],
|
// --- Public API: Connection ---
|
||||||
'autoConnect': true,
|
|
||||||
'reconnection': false, // We handle reconnection ourselves
|
/// Connect to backend WebSocket
|
||||||
});
|
void connect() {
|
||||||
|
if (_socket != null) return; // Already connected or connecting
|
||||||
_socket!.onConnect((_) {
|
|
||||||
_log('connected');
|
_setConnectionState(WsConnectionState.connecting);
|
||||||
_setConnectionState(WsConnectionState.connected);
|
|
||||||
_cancelReconnect();
|
_socket = io.io(_serverUrl, <String, dynamic>{
|
||||||
});
|
'transports': ['websocket'],
|
||||||
|
'autoConnect': true,
|
||||||
_socket!.onDisconnect((_) {
|
'reconnection': false, // We handle reconnection ourselves
|
||||||
_log('disconnected');
|
});
|
||||||
_setConnectionState(WsConnectionState.disconnected);
|
|
||||||
_scheduleReconnect();
|
_socket!.onConnect((_) {
|
||||||
});
|
_log('connected');
|
||||||
|
_setConnectionState(WsConnectionState.connected);
|
||||||
_socket!.onConnectError((error) {
|
_cancelReconnect();
|
||||||
_log('error: $error');
|
});
|
||||||
_setConnectionState(WsConnectionState.disconnected);
|
|
||||||
_scheduleReconnect();
|
_socket!.onDisconnect((_) {
|
||||||
});
|
_log('disconnected');
|
||||||
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
_socket!.onError((error) {
|
_scheduleReconnect();
|
||||||
_log('error: $error');
|
});
|
||||||
});
|
|
||||||
|
_socket!.onConnectError((error) {
|
||||||
// --- Telemetry Events ---
|
_log('error: $error');
|
||||||
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
_socket!.on('arduino', (data) {
|
_scheduleReconnect();
|
||||||
if (data is Map<String, dynamic>) {
|
});
|
||||||
final arduino = ArduinoData.fromJson(data);
|
|
||||||
_latestArduino = arduino;
|
_socket!.onError((error) {
|
||||||
_arduinoController.add(arduino);
|
_log('error: $error');
|
||||||
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' : '';
|
// --- Telemetry Events ---
|
||||||
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
|
|
||||||
|
_socket!.on('arduino', (data) {
|
||||||
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
|
if (data is Map<String, dynamic>) {
|
||||||
if (data.containsKey('theme_switch')) {
|
final arduino = ArduinoData.fromJson(data);
|
||||||
final isDark = data['theme_switch'] as bool;
|
_latestArduino = arduino;
|
||||||
ThemeService.instance.setDarkMode(isDark);
|
_arduinoController.add(arduino);
|
||||||
_log('theme: ${isDark ? "dark" : "light"}');
|
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');
|
||||||
|
|
||||||
_socket!.on('gps', (data) {
|
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
|
||||||
if (data is Map<String, dynamic>) {
|
if (data.containsKey('theme_switch')) {
|
||||||
final gps = GpsData.fromJson(data);
|
final isDark = data['theme_switch'] as bool;
|
||||||
_latestGps = gps;
|
ThemeService.instance.setDarkMode(isDark);
|
||||||
_gpsController.add(gps);
|
_log('theme: ${isDark ? "dark" : "light"}');
|
||||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
_socket!.on('status', (data) {
|
_socket!.on('gps', (data) {
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
final status = BackendStatus(
|
final gps = GpsData.fromJson(data);
|
||||||
gpsConnected: data['gps_connected'] ?? false,
|
_latestGps = gps;
|
||||||
arduinoConnected: data['arduino_connected'] ?? false,
|
_gpsController.add(gps);
|
||||||
);
|
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
||||||
_latestStatus = status;
|
}
|
||||||
_statusController.add(status);
|
});
|
||||||
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
|
||||||
|
_socket!.on('lte', (data) {
|
||||||
// Initial theme state comes with status on connect
|
if (data is Map<String, dynamic>) {
|
||||||
if (data.containsKey('theme_switch')) {
|
final lte = LteData.fromJson(data);
|
||||||
final isDark = data['theme_switch'] as bool;
|
_latestLte = lte;
|
||||||
ThemeService.instance.setDarkMode(isDark);
|
_lteController.add(lte);
|
||||||
_log('theme: ${isDark ? "dark" : "light"} (initial)');
|
_log('lte: ${lte.signal ?? "-"}% ${lte.operator_ ?? "-"} ${lte.accessTech ?? "-"}');
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
_socket!.on('status', (data) {
|
||||||
// --- Control Events ---
|
if (data is Map<String, dynamic>) {
|
||||||
|
final status = BackendStatus(
|
||||||
_socket!.on('ack', (data) {
|
gpsConnected: data['gps_connected'] ?? false,
|
||||||
if (data is Map<String, dynamic>) {
|
arduinoConnected: data['arduino_connected'] ?? false,
|
||||||
final ack = CommandAck(
|
);
|
||||||
id: data['id'] ?? 'unknown',
|
_latestStatus = status;
|
||||||
status: data['status'] ?? 'unknown',
|
_statusController.add(status);
|
||||||
error: data['error'],
|
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
||||||
extra: data['extra'],
|
|
||||||
);
|
// Initial theme state comes with status on connect
|
||||||
_ackController.add(ack);
|
if (data.containsKey('theme_switch')) {
|
||||||
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
final isDark = data['theme_switch'] as bool;
|
||||||
}
|
ThemeService.instance.setDarkMode(isDark);
|
||||||
});
|
_log('theme: ${isDark ? "dark" : "light"} (initial)');
|
||||||
|
}
|
||||||
_socket!.on('alert', (data) {
|
}
|
||||||
if (data is Map<String, dynamic>) {
|
});
|
||||||
final alert = BackendAlert(
|
|
||||||
type: data['type'] ?? 'unknown',
|
// --- Control Events ---
|
||||||
message: data['message'] ?? '',
|
|
||||||
);
|
_socket!.on('ack', (data) {
|
||||||
_alertController.add(alert);
|
if (data is Map<String, dynamic>) {
|
||||||
_log('alert: [${alert.type}] ${alert.message}');
|
final ack = CommandAck(
|
||||||
}
|
id: data['id'] ?? 'unknown',
|
||||||
});
|
status: data['status'] ?? 'unknown',
|
||||||
|
error: data['error'],
|
||||||
_socket!.connect();
|
extra: data['extra'],
|
||||||
}
|
);
|
||||||
|
_ackController.add(ack);
|
||||||
/// Disconnect from backend
|
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
||||||
void disconnect() {
|
}
|
||||||
_cancelReconnect();
|
});
|
||||||
_socket?.disconnect();
|
|
||||||
_socket?.dispose();
|
_socket!.on('alert', (data) {
|
||||||
_socket = null;
|
if (data is Map<String, dynamic>) {
|
||||||
_setConnectionState(WsConnectionState.disconnected);
|
final alert = BackendAlert(
|
||||||
}
|
type: data['type'] ?? 'unknown',
|
||||||
|
message: data['message'] ?? '',
|
||||||
// --- Public API: Commands ---
|
);
|
||||||
|
_alertController.add(alert);
|
||||||
/// Send button event to backend
|
_log('alert: [${alert.type}] ${alert.message}');
|
||||||
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
|
}
|
||||||
if (_socket == null || !isConnected) {
|
});
|
||||||
print('[WS] Cannot send button, not connected');
|
|
||||||
return;
|
_socket!.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = <String, dynamic>{
|
/// Disconnect from backend
|
||||||
'id': id,
|
void disconnect() {
|
||||||
'action': action,
|
_cancelReconnect();
|
||||||
...?params,
|
_socket?.disconnect();
|
||||||
};
|
_socket?.dispose();
|
||||||
|
_socket = null;
|
||||||
_socket!.emit('button', data);
|
_setConnectionState(WsConnectionState.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send emergency signal to backend
|
// --- Public API: Commands ---
|
||||||
void sendEmergency(String type) {
|
|
||||||
if (_socket == null) {
|
/// Send button event to backend
|
||||||
print('[WS] Cannot send emergency, not connected');
|
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
|
||||||
return;
|
if (_socket == null || !isConnected) {
|
||||||
}
|
print('[WS] Cannot send button, not connected');
|
||||||
|
return;
|
||||||
// Emergency should be sent even if not fully connected
|
}
|
||||||
_socket!.emit('emergency', {'type': type});
|
|
||||||
}
|
final data = <String, dynamic>{
|
||||||
|
'id': id,
|
||||||
// --- Private ---
|
'action': action,
|
||||||
|
...?params,
|
||||||
void _setConnectionState(WsConnectionState state) {
|
};
|
||||||
if (_connectionState != state) {
|
|
||||||
_connectionState = state;
|
_socket!.emit('button', data);
|
||||||
_connectionController.add(state);
|
}
|
||||||
}
|
|
||||||
}
|
/// Send emergency signal to backend
|
||||||
|
void sendEmergency(String type) {
|
||||||
void _scheduleReconnect() {
|
if (_socket == null) {
|
||||||
_cancelReconnect();
|
print('[WS] Cannot send emergency, not connected');
|
||||||
_reconnectTimer = Timer(const Duration(seconds: 3), () {
|
return;
|
||||||
print('[WS] Attempting reconnect...');
|
}
|
||||||
_socket?.dispose();
|
|
||||||
_socket = null;
|
// Emergency should be sent even if not fully connected
|
||||||
connect();
|
_socket!.emit('emergency', {'type': type});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
// --- Private ---
|
||||||
void _cancelReconnect() {
|
|
||||||
_reconnectTimer?.cancel();
|
void _setConnectionState(WsConnectionState state) {
|
||||||
_reconnectTimer = null;
|
if (_connectionState != state) {
|
||||||
}
|
_connectionState = state;
|
||||||
|
_connectionController.add(state);
|
||||||
/// Dispose all resources (call on app shutdown)
|
}
|
||||||
void dispose() {
|
}
|
||||||
disconnect();
|
|
||||||
_arduinoController.close();
|
void _scheduleReconnect() {
|
||||||
_gpsController.close();
|
_cancelReconnect();
|
||||||
_statusController.close();
|
_reconnectTimer = Timer(const Duration(seconds: 3), () {
|
||||||
_ackController.close();
|
print('[WS] Attempting reconnect...');
|
||||||
_alertController.close();
|
_socket?.dispose();
|
||||||
_connectionController.close();
|
_socket = null;
|
||||||
_debugController.close();
|
connect();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _cancelReconnect() {
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose all resources (call on app shutdown)
|
||||||
|
void dispose() {
|
||||||
|
disconnect();
|
||||||
|
_arduinoController.close();
|
||||||
|
_gpsController.close();
|
||||||
|
_lteController.close();
|
||||||
|
_statusController.close();
|
||||||
|
_ackController.close();
|
||||||
|
_alertController.close();
|
||||||
|
_connectionController.close();
|
||||||
|
_debugController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,119 +1,119 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Generic debug console that displays streaming log messages.
|
/// Generic debug console that displays streaming log messages.
|
||||||
///
|
///
|
||||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||||
class DebugConsole extends StatefulWidget {
|
class DebugConsole extends StatefulWidget {
|
||||||
/// Stream of new messages to display
|
/// Stream of new messages to display
|
||||||
final Stream<String> messageStream;
|
final Stream<String> messageStream;
|
||||||
|
|
||||||
/// Initial messages to populate (e.g., from a buffer)
|
/// Initial messages to populate (e.g., from a buffer)
|
||||||
final List<String> initialMessages;
|
final List<String> initialMessages;
|
||||||
|
|
||||||
/// Maximum lines to display
|
/// Maximum lines to display
|
||||||
final int maxLines;
|
final int maxLines;
|
||||||
|
|
||||||
/// Optional title for the console (shown in title bar)
|
/// Optional title for the console (shown in title bar)
|
||||||
final String? title;
|
final String? title;
|
||||||
|
|
||||||
const DebugConsole({
|
const DebugConsole({
|
||||||
super.key,
|
super.key,
|
||||||
required this.messageStream,
|
required this.messageStream,
|
||||||
this.initialMessages = const [],
|
this.initialMessages = const [],
|
||||||
this.maxLines = 8,
|
this.maxLines = 8,
|
||||||
this.title,
|
this.title,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DebugConsole> createState() => _DebugConsoleState();
|
State<DebugConsole> createState() => _DebugConsoleState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DebugConsoleState extends State<DebugConsole> {
|
class _DebugConsoleState extends State<DebugConsole> {
|
||||||
final List<String> _messages = [];
|
final List<String> _messages = [];
|
||||||
StreamSubscription<String>? _sub;
|
StreamSubscription<String>? _sub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Initialize with existing buffer
|
// Initialize with existing buffer
|
||||||
_messages.addAll(widget.initialMessages);
|
_messages.addAll(widget.initialMessages);
|
||||||
_trimMessages();
|
_trimMessages();
|
||||||
|
|
||||||
// Subscribe to new messages
|
// Subscribe to new messages
|
||||||
_sub = widget.messageStream.listen((msg) {
|
_sub = widget.messageStream.listen((msg) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages.add(msg);
|
_messages.add(msg);
|
||||||
_trimMessages();
|
_trimMessages();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _trimMessages() {
|
void _trimMessages() {
|
||||||
while (_messages.length > widget.maxLines) {
|
while (_messages.length > widget.maxLines) {
|
||||||
_messages.removeAt(0);
|
_messages.removeAt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.of(context);
|
final theme = AppTheme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.background.withAlpha(64),
|
color: theme.background.withAlpha(64),
|
||||||
border: Border.all(color: theme.subdued, width: 2),
|
border: Border.all(color: theme.subdued, width: 2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Title bar (optional)
|
// Title bar (optional)
|
||||||
if (widget.title != null)
|
if (widget.title != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title!,
|
widget.title!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: theme.subdued,
|
color: theme.subdued,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Console content
|
// Console content
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Text(
|
child: Text(
|
||||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 30,
|
fontSize: 30,
|
||||||
color: theme.foreground,
|
color: theme.foreground,
|
||||||
height: 1.0,
|
height: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
|
|||||||
|
|
||||||
class GpsCompass extends StatelessWidget {
|
class GpsCompass extends StatelessWidget {
|
||||||
final double? heading;
|
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 _hasSignal => heading != null;
|
||||||
|
bool get _isAcquiring => gpsState == 'acquiring';
|
||||||
|
|
||||||
String get _displayHeading {
|
String get _displayHeading {
|
||||||
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal
|
if (!_hasSignal) return 'N/A';
|
||||||
return '${(heading! % 360).round()}'; // No need for the degree symbol
|
return '${(heading! % 360).round()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _compassDirection {
|
String get _compassDirection {
|
||||||
@@ -56,10 +58,10 @@ class GpsCompass extends StatelessWidget {
|
|||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
child: Text(
|
child: Text(
|
||||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
|
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 80,
|
fontSize: 80,
|
||||||
color: theme.subdued,
|
color: theme.subdued, // less emphasis on text, let the icon have semantic colour
|
||||||
fontFamily: 'DIN1451',
|
fontFamily: 'DIN1451',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/config_service.dart';
|
import '../services/config_service.dart';
|
||||||
|
|
||||||
/// Displays the navigator character with emotion support.
|
/// Displays the navigator character with emotion support.
|
||||||
///
|
///
|
||||||
/// Use a GlobalKey to control emotions from parent:
|
/// Use a GlobalKey to control emotions from parent:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||||
/// NavigatorWidget(key: _navigatorKey)
|
/// NavigatorWidget(key: _navigatorKey)
|
||||||
/// // Later:
|
/// // Later:
|
||||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||||
/// ```
|
/// ```
|
||||||
class NavigatorWidget extends StatefulWidget {
|
class NavigatorWidget extends StatefulWidget {
|
||||||
const NavigatorWidget({super.key});
|
const NavigatorWidget({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
String _emotion = 'default';
|
String _emotion = 'default';
|
||||||
late AnimationController _shakeController;
|
late AnimationController _shakeController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_shakeController = AnimationController(
|
_shakeController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
// Auto-reset to default after surprise animation completes
|
// Auto-reset to default after surprise animation completes
|
||||||
_shakeController.addStatusListener((status) {
|
_shakeController.addStatusListener((status) {
|
||||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||||
setState(() => _emotion = 'default');
|
setState(() => _emotion = 'default');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_shakeController.dispose();
|
_shakeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the displayed emotion.
|
/// Change the displayed emotion.
|
||||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||||
void setEmotion(String emotion) {
|
void setEmotion(String emotion) {
|
||||||
if (emotion != _emotion) {
|
if (emotion != _emotion) {
|
||||||
setState(() => _emotion = emotion);
|
setState(() => _emotion = emotion);
|
||||||
if (emotion == 'surprise') {
|
if (emotion == 'surprise') {
|
||||||
_shakeController.forward(from: 0);
|
_shakeController.forward(from: 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset to default emotion
|
/// Reset to default emotion
|
||||||
void reset() => setEmotion('default');
|
void reset() => setEmotion('default');
|
||||||
|
|
||||||
/// Current emotion
|
/// Current emotion
|
||||||
String get emotion => _emotion;
|
String get emotion => _emotion;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = ConfigService.instance;
|
final config = ConfigService.instance;
|
||||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||||
|
|
||||||
final image = Image.file(
|
final image = Image.file(
|
||||||
File('$basePath/$_emotion.png'),
|
File('$basePath/$_emotion.png'),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// Fallback: try default.png if specific emotion missing
|
// Fallback: try default.png if specific emotion missing
|
||||||
if (_emotion != 'default') {
|
if (_emotion != 'default') {
|
||||||
return Image.file(
|
return Image.file(
|
||||||
File('$basePath/default.png'),
|
File('$basePath/default.png'),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shake animation for surprise
|
// Shake animation for surprise
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _shakeController,
|
animation: _shakeController,
|
||||||
child: image,
|
child: image,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(shake, 0),
|
offset: Offset(shake, 0),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +1,174 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../services/websocket_service.dart';
|
import '../services/websocket_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
/// Android-style persistent status bar for system indicators.
|
/// Android-style persistent status bar for system indicators.
|
||||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||||
class SystemBar extends StatelessWidget {
|
class SystemBar extends StatelessWidget {
|
||||||
final int? gpsSatellites; // null = disconnected
|
final int? gpsSatellites; // null = disconnected
|
||||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
final String? gpsState; // "acquiring", "fix", "lost"
|
||||||
final double? piTemp; // null = unavailable
|
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||||
final double? voltage; // null = Arduino disconnected
|
final double? piTemp; // null = unavailable
|
||||||
final WsConnectionState? wsState; // WebSocket connection state
|
final double? voltage; // null = Arduino disconnected
|
||||||
|
final WsConnectionState? wsState; // WebSocket connection state
|
||||||
const SystemBar({
|
|
||||||
super.key,
|
const SystemBar({
|
||||||
this.gpsSatellites,
|
super.key,
|
||||||
this.lteSignal,
|
this.gpsSatellites,
|
||||||
this.piTemp,
|
this.gpsState,
|
||||||
this.voltage,
|
this.lteSignal,
|
||||||
this.wsState,
|
this.piTemp,
|
||||||
});
|
this.voltage,
|
||||||
|
this.wsState,
|
||||||
/// Get WebSocket status text and abnormal flag
|
});
|
||||||
(String, bool) _wsStatus() {
|
|
||||||
switch (wsState) {
|
/// Get WebSocket status text and abnormal flag
|
||||||
case WsConnectionState.connected:
|
(String, bool) _wsStatus() {
|
||||||
return ('OK', false);
|
switch (wsState) {
|
||||||
case WsConnectionState.connecting:
|
case WsConnectionState.connected:
|
||||||
return ('...', true);
|
return ('OK', false);
|
||||||
case WsConnectionState.disconnected:
|
case WsConnectionState.connecting:
|
||||||
case null:
|
return ('...', true);
|
||||||
return ('OFF', true);
|
case WsConnectionState.disconnected:
|
||||||
}
|
case null:
|
||||||
}
|
return ('OFF', true);
|
||||||
|
}
|
||||||
@override
|
}
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = AppTheme.of(context);
|
@override
|
||||||
final (wsText, wsAbnormal) = _wsStatus();
|
Widget build(BuildContext context) {
|
||||||
|
final theme = AppTheme.of(context);
|
||||||
return Expanded(
|
final (wsText, wsAbnormal) = _wsStatus();
|
||||||
flex: 1,
|
|
||||||
child: LayoutBuilder(
|
return Expanded(
|
||||||
builder: (context, constraints) {
|
flex: 1,
|
||||||
// Font sizes relative to bar height
|
child: LayoutBuilder(
|
||||||
final labelSize = constraints.maxHeight * 0.5;
|
builder: (context, constraints) {
|
||||||
final valueSize = constraints.maxHeight * 0.5;
|
// Font sizes relative to bar height
|
||||||
|
final labelSize = constraints.maxHeight * 0.5;
|
||||||
return Container(
|
final valueSize = constraints.maxHeight * 0.5;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
child: Row(
|
return Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
children: [
|
child: Row(
|
||||||
// Left group: WS, GPS, LTE
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
_Indicator(
|
children: [
|
||||||
label: 'WS',
|
// Left group: WS, GPS, LTE
|
||||||
value: wsText,
|
_Indicator(
|
||||||
isAbnormal: wsAbnormal,
|
label: 'WS',
|
||||||
alignment: Alignment.centerLeft,
|
value: wsText,
|
||||||
labelSize: labelSize,
|
isAbnormal: wsAbnormal,
|
||||||
valueSize: valueSize,
|
alignment: Alignment.centerLeft,
|
||||||
flex: 2,
|
labelSize: labelSize,
|
||||||
theme: theme,
|
valueSize: valueSize,
|
||||||
),
|
flex: 2,
|
||||||
_Indicator(
|
theme: theme,
|
||||||
label: 'GPS',
|
),
|
||||||
value: gpsSatellites?.toString() ?? 'N/A',
|
_Indicator(
|
||||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
label: 'GPS',
|
||||||
alignment: Alignment.centerLeft,
|
value: gpsState == 'acquiring' ? 'ACQ'
|
||||||
labelSize: labelSize,
|
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||||
valueSize: valueSize,
|
: '0', // lost or unknown
|
||||||
flex: 2,
|
isAbnormal: gpsState != 'fix' || gpsSatellites == null,
|
||||||
theme: theme,
|
alignment: Alignment.centerLeft,
|
||||||
),
|
labelSize: labelSize,
|
||||||
_Indicator(
|
valueSize: valueSize,
|
||||||
label: 'LTE',
|
flex: 2,
|
||||||
value: lteSignal?.toString() ?? 'N/A',
|
theme: theme,
|
||||||
isAbnormal: lteSignal == null,
|
),
|
||||||
alignment: Alignment.centerLeft,
|
_Indicator(
|
||||||
labelSize: labelSize,
|
label: 'LTE',
|
||||||
valueSize: valueSize,
|
value: lteSignal?.toString() ?? 'N/A',
|
||||||
flex: 2,
|
isAbnormal: lteSignal == null,
|
||||||
theme: theme,
|
alignment: Alignment.centerLeft,
|
||||||
),
|
labelSize: labelSize,
|
||||||
|
valueSize: valueSize,
|
||||||
// Right group: Pi, Chassis
|
flex: 2,
|
||||||
_Indicator(
|
theme: theme,
|
||||||
label: 'Pi',
|
),
|
||||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
|
||||||
isAbnormal: piTemp == null || piTemp! > 80,
|
// Right group: Pi, Chassis
|
||||||
alignment: Alignment.centerLeft,
|
_Indicator(
|
||||||
labelSize: labelSize,
|
label: 'Pi',
|
||||||
valueSize: valueSize,
|
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||||
flex: 2,
|
isAbnormal: piTemp == null || piTemp! > 80,
|
||||||
theme: theme,
|
alignment: Alignment.centerLeft,
|
||||||
),
|
labelSize: labelSize,
|
||||||
_Indicator(
|
valueSize: valueSize,
|
||||||
label: 'Mains',
|
flex: 2,
|
||||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
theme: theme,
|
||||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
),
|
||||||
alignment: Alignment.centerLeft,
|
_Indicator(
|
||||||
labelSize: labelSize,
|
label: 'Mains',
|
||||||
valueSize: valueSize,
|
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||||
flex: 3,
|
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||||
theme: theme,
|
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;
|
/// Single status indicator in a fixed-width flex slot.
|
||||||
final Alignment alignment;
|
class _Indicator extends StatelessWidget {
|
||||||
final double labelSize;
|
final String label;
|
||||||
final double valueSize;
|
final String value;
|
||||||
final int flex;
|
final bool isAbnormal;
|
||||||
final AppTheme theme;
|
final Alignment alignment;
|
||||||
|
final double labelSize;
|
||||||
const _Indicator({
|
final double valueSize;
|
||||||
required this.label,
|
final int flex;
|
||||||
required this.value,
|
final AppTheme theme;
|
||||||
required this.isAbnormal,
|
|
||||||
required this.alignment,
|
const _Indicator({
|
||||||
required this.labelSize,
|
required this.label,
|
||||||
required this.valueSize,
|
required this.value,
|
||||||
required this.flex,
|
required this.isAbnormal,
|
||||||
required this.theme,
|
required this.alignment,
|
||||||
});
|
required this.labelSize,
|
||||||
|
required this.valueSize,
|
||||||
@override
|
required this.flex,
|
||||||
Widget build(BuildContext context) {
|
required this.theme,
|
||||||
return Expanded(
|
});
|
||||||
flex: flex,
|
|
||||||
child: Align(
|
@override
|
||||||
alignment: alignment,
|
Widget build(BuildContext context) {
|
||||||
child: Row(
|
return Expanded(
|
||||||
mainAxisSize: MainAxisSize.min,
|
flex: flex,
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
child: Align(
|
||||||
textBaseline: TextBaseline.alphabetic,
|
alignment: alignment,
|
||||||
children: [
|
child: Row(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'$label ',
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
style: TextStyle(
|
textBaseline: TextBaseline.alphabetic,
|
||||||
fontSize: labelSize,
|
children: [
|
||||||
color: theme.subdued,
|
Text(
|
||||||
),
|
'$label ',
|
||||||
),
|
style: TextStyle(
|
||||||
Text(
|
fontSize: labelSize,
|
||||||
value,
|
color: theme.subdued,
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: valueSize,
|
),
|
||||||
fontFeatures: const [FontFeature.tabularFigures()],
|
Text(
|
||||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
value,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontSize: valueSize,
|
||||||
],
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
),
|
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
],
|
||||||
}
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
name: smartserow_ui
|
name: smartserow_ui
|
||||||
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
socket_io_client: ^2.0.3+1
|
socket_io_client: ^2.0.3+1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: DIN1451
|
- family: DIN1451
|
||||||
fonts:
|
fonts:
|
||||||
- asset: assets/fonts/din1451alt.ttf
|
- asset: assets/fonts/din1451alt.ttf
|
||||||
|
|||||||
@@ -1,88 +1,88 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""One-click build and deploy for Smart Serow.
|
"""One-click build and deploy for Smart Serow.
|
||||||
|
|
||||||
Combines build.py and deploy.py with sensible defaults.
|
Combines build.py and deploy.py with sensible defaults.
|
||||||
Defaults to --restart since that's usually what you want.
|
Defaults to --restart since that's usually what you want.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Import sibling modules
|
# Import sibling modules
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
from build import build
|
from build import build
|
||||||
from deploy import deploy
|
from deploy import deploy
|
||||||
from deploy_backend import deploy as deploy_backend
|
from deploy_backend import deploy as deploy_backend
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Build and deploy Smart Serow in one step",
|
description="Build and deploy Smart Serow in one step",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--clean", "-c",
|
"--clean", "-c",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Clean CMake cache before building",
|
help="Clean CMake cache before building",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-restart",
|
"--no-restart",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Don't restart service after deploy (default: restart)",
|
help="Don't restart service after deploy (default: restart)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--build-only",
|
"--build-only",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Only build, don't deploy",
|
help="Only build, don't deploy",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--deploy-only",
|
"--deploy-only",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Only deploy, don't build",
|
help="Only deploy, don't build",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ui",
|
"--ui",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Build/deploy UI only (no backend)",
|
help="Build/deploy UI only (no backend)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Deploy backend only (no UI, no build)",
|
help="Deploy backend only (no UI, no build)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Default: both UI and backend if neither flag specified
|
# Default: both UI and backend if neither flag specified
|
||||||
do_ui = args.ui or not args.backend
|
do_ui = args.ui or not args.backend
|
||||||
do_backend = args.backend or not args.ui
|
do_backend = args.backend or not args.ui
|
||||||
|
|
||||||
restart = not args.no_restart
|
restart = not args.no_restart
|
||||||
|
|
||||||
# Build UI (only if doing UI and not deploy-only)
|
# Build UI (only if doing UI and not deploy-only)
|
||||||
if do_ui and not args.deploy_only:
|
if do_ui and not args.deploy_only:
|
||||||
print()
|
print()
|
||||||
if not build(clean=args.clean):
|
if not build(clean=args.clean):
|
||||||
print("UI build failed!")
|
print("UI build failed!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Deploy backend FIRST (no build step needed - it's Python)
|
# Deploy backend FIRST (no build step needed - it's Python)
|
||||||
# Backend must be up before UI connects to WebSocket
|
# Backend must be up before UI connects to WebSocket
|
||||||
if do_backend and not args.build_only:
|
if do_backend and not args.build_only:
|
||||||
print()
|
print()
|
||||||
if not deploy_backend(restart=restart):
|
if not deploy_backend(restart=restart):
|
||||||
print("Backend deploy failed!")
|
print("Backend deploy failed!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Deploy UI after backend is ready
|
# Deploy UI after backend is ready
|
||||||
if do_ui and not args.build_only:
|
if do_ui and not args.build_only:
|
||||||
print()
|
print()
|
||||||
if not deploy(restart=restart):
|
if not deploy(restart=restart):
|
||||||
print("UI deploy failed!")
|
print("UI deploy failed!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== All done! ===")
|
print("=== All done! ===")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,157 +1,157 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Deploy script for Smart Serow Python backend.
|
"""Deploy script for Smart Serow Python backend.
|
||||||
|
|
||||||
Pushes backend source to Pi and optionally restarts service.
|
Pushes backend source to Pi and optionally restarts service.
|
||||||
Completely independent from UI deploy.
|
Completely independent from UI deploy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||||
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
||||||
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
|
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
|
||||||
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
|
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
||||||
"""Run a command."""
|
"""Run a command."""
|
||||||
print(f" → {' '.join(cmd)}")
|
print(f" → {' '.join(cmd)}")
|
||||||
return subprocess.run(cmd, check=check, **kwargs)
|
return subprocess.run(cmd, check=check, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
"""Load deploy target configuration."""
|
"""Load deploy target configuration."""
|
||||||
if not CONFIG_FILE.exists():
|
if not CONFIG_FILE.exists():
|
||||||
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
||||||
print("Create it based on deploy_target.sample.json")
|
print("Create it based on deploy_target.sample.json")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(CONFIG_FILE) as f:
|
with open(CONFIG_FILE) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def deploy(restart: bool = False) -> bool:
|
def deploy(restart: bool = False) -> bool:
|
||||||
"""Deploy backend to Pi. Returns True on success."""
|
"""Deploy backend to Pi. Returns True on success."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
pi_user = config["user"]
|
pi_user = config["user"]
|
||||||
pi_host = config["host"]
|
pi_host = config["host"]
|
||||||
|
|
||||||
# Backend-specific config (with defaults)
|
# Backend-specific config (with defaults)
|
||||||
remote_path = config.get("backend_path", "/opt/smartserow-backend")
|
remote_path = config.get("backend_path", "/opt/smartserow-backend")
|
||||||
service_name = config.get("backend_service", "smartserow-backend")
|
service_name = config.get("backend_service", "smartserow-backend")
|
||||||
|
|
||||||
ssh_target = f"{pi_user}@{pi_host}"
|
ssh_target = f"{pi_user}@{pi_host}"
|
||||||
|
|
||||||
print("=== Smart Serow Backend Deploy ===")
|
print("=== Smart Serow Backend Deploy ===")
|
||||||
print(f"Target: {ssh_target}:{remote_path}")
|
print(f"Target: {ssh_target}:{remote_path}")
|
||||||
print(f"Source: {BACKEND_DIR}")
|
print(f"Source: {BACKEND_DIR}")
|
||||||
|
|
||||||
if not BACKEND_DIR.exists():
|
if not BACKEND_DIR.exists():
|
||||||
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
|
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Ensure remote directory exists
|
# Ensure remote directory exists
|
||||||
print()
|
print()
|
||||||
print("Ensuring remote directory...")
|
print("Ensuring remote directory...")
|
||||||
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
|
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
|
||||||
|
|
||||||
# Sync backend source to Pi
|
# Sync backend source to Pi
|
||||||
# Exclude __pycache__, .venv, etc.
|
# Exclude __pycache__, .venv, etc.
|
||||||
print()
|
print()
|
||||||
print("Syncing files...")
|
print("Syncing files...")
|
||||||
run([
|
run([
|
||||||
"rsync", "-avz", "--delete",
|
"rsync", "-avz", "--delete",
|
||||||
"--exclude", "__pycache__",
|
"--exclude", "__pycache__",
|
||||||
"--exclude", "*.pyc",
|
"--exclude", "*.pyc",
|
||||||
"--exclude", ".venv",
|
"--exclude", ".venv",
|
||||||
"--exclude", ".ruff_cache",
|
"--exclude", ".ruff_cache",
|
||||||
"--exclude", "uv.lock", # Let Pi generate its own lockfile
|
"--exclude", "uv.lock", # Let Pi generate its own lockfile
|
||||||
f"{BACKEND_DIR}/",
|
f"{BACKEND_DIR}/",
|
||||||
f"{ssh_target}:{remote_path}/",
|
f"{ssh_target}:{remote_path}/",
|
||||||
])
|
])
|
||||||
|
|
||||||
# Ensure system GPIO package is installed (pip version needs compilation)
|
# Ensure system GPIO package is installed (pip version needs compilation)
|
||||||
print()
|
print()
|
||||||
print("Ensuring system GPIO package...")
|
print("Ensuring system GPIO package...")
|
||||||
run(
|
run(
|
||||||
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
|
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create venv with system-site-packages if it doesn't exist
|
# Create venv with system-site-packages if it doesn't exist
|
||||||
# This allows access to apt-installed packages like python3-rpi.gpio
|
# This allows access to apt-installed packages like python3-rpi.gpio
|
||||||
print()
|
print()
|
||||||
print("Ensuring venv with system-site-packages...")
|
print("Ensuring venv with system-site-packages...")
|
||||||
run(
|
run(
|
||||||
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
|
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run uv sync to install/update dependencies
|
# Run uv sync to install/update dependencies
|
||||||
# Use full path since non-interactive SSH doesn't load .bashrc
|
# Use full path since non-interactive SSH doesn't load .bashrc
|
||||||
print()
|
print()
|
||||||
print("Running uv sync...")
|
print("Running uv sync...")
|
||||||
result = run(
|
result = run(
|
||||||
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
|
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("WARNING: uv sync failed - dependencies may be out of date")
|
print("WARNING: uv sync failed - dependencies may be out of date")
|
||||||
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||||
|
|
||||||
# Deploy service file if it exists
|
# Deploy service file if it exists
|
||||||
if SERVICE_FILE.exists():
|
if SERVICE_FILE.exists():
|
||||||
print()
|
print()
|
||||||
print("Deploying systemd service file...")
|
print("Deploying systemd service file...")
|
||||||
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
|
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
|
||||||
run([
|
run([
|
||||||
"ssh", ssh_target,
|
"ssh", ssh_target,
|
||||||
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
|
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
|
||||||
])
|
])
|
||||||
|
|
||||||
# Restart service if requested
|
# Restart service if requested
|
||||||
if restart:
|
if restart:
|
||||||
print()
|
print()
|
||||||
print(f"Restarting service: {service_name}")
|
print(f"Restarting service: {service_name}")
|
||||||
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
|
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
|
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
|
||||||
else:
|
else:
|
||||||
print()
|
print()
|
||||||
print("Deploy complete. To restart service, run:")
|
print("Deploy complete. To restart service, run:")
|
||||||
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
|
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
|
||||||
print()
|
print()
|
||||||
print("Or run this script with --restart flag")
|
print("Or run this script with --restart flag")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Note: First-time setup on Pi requires:")
|
print("Note: First-time setup on Pi requires:")
|
||||||
print(f" ssh {ssh_target}")
|
print(f" ssh {ssh_target}")
|
||||||
print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
|
print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
|
||||||
print(" sudo apt install python3-rpi.gpio # GPIO support")
|
print(" sudo apt install python3-rpi.gpio # GPIO support")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
|
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--restart", "-r",
|
"--restart", "-r",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Restart the systemd service after deploy",
|
help="Restart the systemd service after deploy",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
success = deploy(restart=args.restart)
|
success = deploy(restart=args.restart)
|
||||||
sys.exit(0 if success else 1)
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user