initial switch to websocket
This commit is contained in:
@@ -25,6 +25,9 @@ class ArduinoService:
|
||||
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
|
||||
}
|
||||
|
||||
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
|
||||
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str = "/dev/ttyUSB0",
|
||||
@@ -42,6 +45,55 @@ class ArduinoService:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callbacks for push-based updates
|
||||
self._on_data_callback: callable | None = None
|
||||
self._on_ack_callback: callable | None = None
|
||||
|
||||
# Serial port handle for sending commands
|
||||
self._serial: Any = None
|
||||
self._serial_lock = threading.Lock()
|
||||
|
||||
def set_on_data(self, callback: callable | None):
|
||||
"""Set callback for new telemetry data. Called with data dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
def set_on_ack(self, callback: callable | None):
|
||||
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
|
||||
self._on_ack_callback = callback
|
||||
|
||||
def send_command(self, cmd: str, params: dict | None = None) -> bool:
|
||||
"""Send a command to Arduino via serial.
|
||||
|
||||
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
|
||||
|
||||
Args:
|
||||
cmd: Command name (e.g., "HORN", "LIGHT")
|
||||
params: Optional parameters dict
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False if serial unavailable
|
||||
"""
|
||||
with self._serial_lock:
|
||||
if self._serial is None or not self._connected:
|
||||
print(f"[Arduino] Cannot send command, not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Build command string
|
||||
parts = ["CMD", cmd.upper()]
|
||||
if params:
|
||||
for key, val in params.items():
|
||||
parts.append(f"{key}={val}")
|
||||
line = ":".join(parts) + "\n"
|
||||
|
||||
self._serial.write(line.encode("utf-8"))
|
||||
self._serial.flush()
|
||||
print(f"[Arduino] Sent: {line.strip()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Arduino] Failed to send command: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
@@ -100,6 +152,9 @@ class ArduinoService:
|
||||
return
|
||||
|
||||
try:
|
||||
# Store serial handle for send_command()
|
||||
with self._serial_lock:
|
||||
self._serial = ser
|
||||
self._connected = True
|
||||
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
|
||||
|
||||
@@ -109,6 +164,14 @@ class ArduinoService:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for ACK responses first
|
||||
ack_match = self.ACK_PATTERN.match(line)
|
||||
if ack_match:
|
||||
cmd, status, extra = ack_match.groups()
|
||||
if self._on_ack_callback:
|
||||
self._on_ack_callback(cmd, status, extra)
|
||||
continue
|
||||
|
||||
data = self._parse_line(line)
|
||||
if data:
|
||||
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
@@ -120,12 +183,18 @@ class ArduinoService:
|
||||
self._latest["time"] = data["time"]
|
||||
self._buffer.append(self._latest.copy())
|
||||
|
||||
# Invoke callback with new data
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(self._latest.copy())
|
||||
|
||||
except serial.SerialException as e:
|
||||
print(f"[Arduino] Serial error: {e}")
|
||||
break
|
||||
|
||||
finally:
|
||||
self._connected = False
|
||||
with self._serial_lock:
|
||||
self._serial = None
|
||||
ser.close()
|
||||
|
||||
def _parse_line(self, line: str) -> dict[str, Any] | None:
|
||||
@@ -172,4 +241,9 @@ class ArduinoService:
|
||||
with self._lock:
|
||||
self._latest = data
|
||||
self._buffer.append(data)
|
||||
|
||||
# Invoke callback with new data
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(data)
|
||||
|
||||
time.sleep(0.5) # 2Hz stub updates
|
||||
|
||||
@@ -29,6 +29,13 @@ class GPSService:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback: callable | None = None
|
||||
|
||||
def set_on_data(self, callback: callable | None):
|
||||
"""Set callback for new GPS fix. Called with fix dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
@@ -75,7 +82,14 @@ class GPSService:
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
with GPSDClient(host=self.host, port=self.port) as client:
|
||||
try:
|
||||
client = GPSDClient(host=self.host, port=self.port)
|
||||
except Exception as e:
|
||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
with client:
|
||||
self._connected = True
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||
|
||||
@@ -99,6 +113,10 @@ class GPSService:
|
||||
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)
|
||||
|
||||
def _stub_mode(self):
|
||||
"""Fake data for testing without gpsd."""
|
||||
import random
|
||||
@@ -117,4 +135,9 @@ class GPSService:
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
self._buffer.append(fix)
|
||||
|
||||
# Invoke callback with new fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
@@ -1,13 +1,174 @@
|
||||
"""Smart Serow Backend - GPS and Arduino services with HTTP API."""
|
||||
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
|
||||
|
||||
from gevent import monkey
|
||||
monkey.patch_all() # Must be at the very top before other imports
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
from gps_service import GPSService
|
||||
from arduino_service import ArduinoService
|
||||
from throttle import Throttle
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
|
||||
|
||||
# SocketIO with gevent async mode (eventlet is deprecated)
|
||||
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
|
||||
|
||||
# Services
|
||||
gps = GPSService()
|
||||
arduino = ArduinoService()
|
||||
|
||||
# Throttles for emission rate limiting (2Hz for arduino, 1Hz for GPS)
|
||||
arduino_throttle = Throttle(min_interval=0.5) # 2Hz max
|
||||
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
||||
|
||||
# Track connected clients
|
||||
connected_clients = set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WebSocket Event Handlers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
"""Client connected."""
|
||||
client_id = id(socketio) # Simple identifier
|
||||
connected_clients.add(client_id)
|
||||
print(f"[WS] Client connected ({len(connected_clients)} total)")
|
||||
|
||||
# Send current status immediately
|
||||
emit("status", {
|
||||
"gps_connected": gps.connected,
|
||||
"arduino_connected": arduino.connected,
|
||||
})
|
||||
|
||||
# Send latest data if available
|
||||
arduino_data = arduino.get_latest()
|
||||
if "error" not in arduino_data:
|
||||
emit("arduino", arduino_data)
|
||||
|
||||
gps_data = gps.get_latest()
|
||||
if "error" not in gps_data:
|
||||
emit("gps", gps_data)
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
"""Client disconnected."""
|
||||
client_id = id(socketio)
|
||||
connected_clients.discard(client_id)
|
||||
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
|
||||
|
||||
|
||||
@socketio.on("button")
|
||||
def handle_button(data):
|
||||
"""Handle button press from UI.
|
||||
|
||||
Expected data: {"id": "horn", "action": "press", ...params}
|
||||
"""
|
||||
btn_id = data.get("id", "unknown")
|
||||
action = data.get("action", "press")
|
||||
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
||||
|
||||
print(f"[WS] Button: {btn_id} {action} {params}")
|
||||
|
||||
# Map button ID to Arduino command
|
||||
cmd_map = {
|
||||
"horn": "HORN",
|
||||
"light": "LIGHT",
|
||||
"indicator_left": "IND_L",
|
||||
"indicator_right": "IND_R",
|
||||
"hazard": "HAZARD",
|
||||
}
|
||||
|
||||
cmd = cmd_map.get(btn_id)
|
||||
if cmd:
|
||||
# Add action to params (e.g., ON/OFF based on press/release)
|
||||
params["state"] = "ON" if action == "press" else "OFF"
|
||||
success = arduino.send_command(cmd, params)
|
||||
|
||||
# Send immediate ack for the attempt
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "sent" if success else "failed",
|
||||
"error": None if success else "arduino not connected",
|
||||
})
|
||||
else:
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "error",
|
||||
"error": f"unknown button: {btn_id}",
|
||||
})
|
||||
|
||||
|
||||
@socketio.on("emergency")
|
||||
def handle_emergency(data):
|
||||
"""Handle emergency signal from UI."""
|
||||
etype = data.get("type", "stop")
|
||||
print(f"[WS] EMERGENCY: {etype}")
|
||||
|
||||
# Send emergency command to Arduino
|
||||
arduino.send_command("EMERGENCY", {"type": etype})
|
||||
|
||||
# Broadcast alert to all clients
|
||||
socketio.emit("alert", {
|
||||
"type": "emergency",
|
||||
"message": f"Emergency {etype} triggered",
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Service Callbacks (push data to WebSocket)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def on_arduino_data(data):
|
||||
"""Called by ArduinoService when new telemetry arrives."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("arduino", d)
|
||||
|
||||
arduino_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_gps_data(data):
|
||||
"""Called by GPSService when new fix arrives."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("gps", d)
|
||||
|
||||
gps_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_arduino_ack(cmd, status, extra):
|
||||
"""Called by ArduinoService when ACK received from Arduino."""
|
||||
socketio.emit("ack", {
|
||||
"id": cmd.lower(),
|
||||
"status": status.lower(),
|
||||
"extra": extra,
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Background task to flush pending throttled data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def throttle_flusher():
|
||||
"""Periodically flush pending throttled data."""
|
||||
import gevent
|
||||
while True:
|
||||
gevent.sleep(0.5)
|
||||
|
||||
if arduino_throttle.has_pending:
|
||||
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
|
||||
|
||||
if gps_throttle.has_pending:
|
||||
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REST API (backward compatibility)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
@@ -16,6 +177,7 @@ def health():
|
||||
"status": "ok",
|
||||
"gps_connected": gps.connected,
|
||||
"arduino_connected": arduino.connected,
|
||||
"ws_clients": len(connected_clients),
|
||||
})
|
||||
|
||||
|
||||
@@ -43,13 +205,28 @@ def arduino_history():
|
||||
return jsonify(arduino.get_buffer())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Entry Point
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Entry point."""
|
||||
# Wire up callbacks
|
||||
arduino.set_on_data(on_arduino_data)
|
||||
arduino.set_on_ack(on_arduino_ack)
|
||||
gps.set_on_data(on_gps_data)
|
||||
|
||||
# Start services
|
||||
gps.start()
|
||||
arduino.start()
|
||||
|
||||
# Start throttle flusher in background
|
||||
socketio.start_background_task(throttle_flusher)
|
||||
|
||||
try:
|
||||
# Host 0.0.0.0 for access from Flutter app
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
# 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()
|
||||
|
||||
@@ -5,6 +5,9 @@ description = "GPS and Arduino telemetry service for Smart Serow"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flask>=3.0",
|
||||
"flask-socketio>=5.3.0",
|
||||
"gevent>=24.0",
|
||||
"gevent-websocket>=0.10",
|
||||
"gpsdclient>=1.3",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
61
pi/backend/throttle.py
Normal file
61
pi/backend/throttle.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Throttle layer for rate-limiting telemetry emissions."""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class Throttle:
|
||||
"""Rate limiter for WebSocket emissions.
|
||||
|
||||
Coalesces rapid updates - only emits at most once per min_interval.
|
||||
If multiple updates arrive within the interval, the latest value wins.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float = 0.5):
|
||||
"""
|
||||
Args:
|
||||
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
|
||||
"""
|
||||
self._last_emit: float = 0
|
||||
self._min_interval = min_interval
|
||||
self._pending: Any = None
|
||||
|
||||
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit if interval has passed, otherwise store as pending.
|
||||
|
||||
Args:
|
||||
data: Data to emit
|
||||
emit_fn: Function to call with data when emitting
|
||||
|
||||
Returns:
|
||||
True if emitted, False if stored as pending
|
||||
"""
|
||||
now = time.time()
|
||||
if now - self._last_emit >= self._min_interval:
|
||||
emit_fn(data)
|
||||
self._last_emit = now
|
||||
self._pending = None
|
||||
return True
|
||||
else:
|
||||
self._pending = data # Latest value wins
|
||||
return False
|
||||
|
||||
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit pending data if any.
|
||||
|
||||
Call this periodically to ensure pending data gets sent.
|
||||
|
||||
Returns:
|
||||
True if pending data was emitted, False if nothing pending
|
||||
"""
|
||||
if self._pending is not None:
|
||||
emit_fn(self._pending)
|
||||
self._last_emit = time.time()
|
||||
self._pending = None
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""Check if there's pending data waiting to be emitted."""
|
||||
return self._pending is not None
|
||||
Reference in New Issue
Block a user