gps manipulations tailored to sim7600h hat

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

View File

@@ -178,6 +178,19 @@ The Pi's internal pull-down (~50kΩ) will overpower high-value external resistor
Physical switches/connectors need debouncing. Current implementation requires 15 consecutive identical readings (~750ms at 20Hz) before accepting a state change. Tune `required_consecutive` in `gpio_service.py` as needed.
## Utilities
Standalone tools live in `pi/utils/` (not part of the backend service):
| Tool | Description |
|------|-------------|
| `at_terminal.py` | Interactive AT command terminal for SIM7600 (pyserial). Default port: `/dev/ttyUSB2` |
```bash
python pi/utils/at_terminal.py # default /dev/ttyUSB2
python pi/utils/at_terminal.py /dev/ttyUSB3 # specify port
```
## Deploy
TODO: Add to `scripts/deploy.py` as second target + systemd service.

View File

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

View File

@@ -1,269 +1,305 @@
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
import random
import threading
import time
from collections import deque
from typing import Any
# ============================================================================
# DEBUG MODE - Set True for development without GPS hardware
# When True: skips gpsd entirely, generates realistic mock data
# When False: connects to real gpsd (requires GPS device)
# ============================================================================
_GPS_DEBUG = True
# gpsdclient is a modern, simple gpsd client
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
try:
from gpsdclient import GPSDClient
except ImportError:
GPSDClient = None # Allow import without gpsd for testing structure
class GPSService:
"""Threaded GPS reader with buffering and auto-reconnect."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self.host = host
self.port = port
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Callback for push-based updates
self._on_data_callback = None
# Periodic status logging
self._last_status_log = 0.0
self._fix_count = 0
def set_on_data(self, callback):
"""Set callback for new GPS fix. Called with fix dict."""
self._on_data_callback = callback
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"}
def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered GPS history."""
with self._lock:
return list(self._buffer)
def start(self):
"""Start background GPS reader thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start()
print("[GPS] Service started")
def stop(self):
"""Stop background reader."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
print("[GPS] Reader thread running")
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5)
def _connect_and_read(self):
"""Connect to gpsd and read data."""
# Debug mode: skip gpsd entirely, use stub data
if _GPS_DEBUG:
print("[GPS] Debug mode enabled, using stub data")
self._stub_mode()
return
if GPSDClient is None:
print("[GPS] gpsdclient not installed, running in stub mode")
self._stub_mode()
return
# Quick check if gpsd is reachable before attempting connection
import socket
try:
sock = socket.create_connection((self.host, self.port), timeout=2.0)
sock.close()
except (socket.timeout, socket.error, OSError) as e:
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
raise ConnectionError(f"gpsd not reachable: {e}")
try:
client = GPSDClient(host=self.host, port=self.port)
except Exception as e:
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
raise ConnectionError(f"gpsd connection failed: {e}")
with client:
self._connected = True
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
self._last_status_log = time.time()
self._fix_count = 0
first_fix_timeout = time.time() + 5.0 # 5s to get first fix
for result in client.dict_stream(filter=["TPV"]):
if not self._running:
break
# TPV = Time-Position-Velocity report
fix = {
"time": result.get("time"),
"lat": result.get("lat"),
"lon": result.get("lon"),
"alt": result.get("alt"),
"speed": result.get("speed"), # m/s
"track": result.get("track"), # heading in degrees
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
"satellites": result.get("satellites"), # from SKY messages
}
# Check if this is a real fix (has position) or just empty TPV
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
# No real data yet, check timeout
if time.time() > first_fix_timeout:
print("[GPS] No GPS fix after 5s, will retry connection")
raise ConnectionError("No GPS fix within timeout")
continue # Skip empty fixes
# Got real data, disable timeout
first_fix_timeout = float('inf')
with self._lock:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
# Periodic status log (every 5s)
self._fix_count += 1
now = time.time()
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._fix_count / elapsed
speed = fix.get('speed', 0) or 0
track = fix.get('track', 0) or 0
mode = fix.get('mode', 0) or 0
sats = fix.get('satellites', '?')
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
self._last_status_log = now
self._fix_count = 0
def _stub_mode(self):
"""Generate realistic mock GPS data for development/testing.
Simulates:
- Normal 3D fix with satellites
- Occasional signal loss (~3% chance per second, lasts ~5s)
- Wandering position near Tokyo
"""
self._last_status_log = time.time()
self._fix_count = 0
# Signal loss state
signal_lost = False
signal_lost_until = 0.0
# Base position (Tokyo area)
base_lat = 35.6762
base_lon = 139.6503
base_alt = 40.0
# Smoothly varying heading/speed
heading = random.uniform(0, 360)
speed = random.uniform(5, 15)
while self._running:
self._connected = True
now = time.time()
# Check for signal loss simulation
if signal_lost:
if now >= signal_lost_until:
signal_lost = False
print("[GPS] Signal recovered (stub)")
else:
# ~30% chance per second to lose signal
if random.random() < 0.3:
signal_lost = True
signal_lost_until = now + 2 # fixed 2s loss
print("[GPS] Signal loss simulation (stub)")
if signal_lost:
# No fix - mode 1, no satellites, no track
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
fix = {
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": None,
"lon": None,
"alt": None,
"speed": None,
"track": None,
"mode": 1,
"satellites": 0,
}
else:
# Smoothly vary heading and speed
heading = (heading + random.uniform(1, 3)) % 360
speed = max(0, min(30, speed + random.uniform(-2, 2)))
fix = {
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": base_lat + random.uniform(-0.001, 0.001),
"lon": base_lon + random.uniform(-0.001, 0.001),
"alt": base_alt + random.uniform(-5, 5),
"speed": speed,
"track": heading,
"mode": 3,
"satellites": random.randint(6, 12),
}
with self._lock:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
# Periodic status log (every 5s)
self._fix_count += 1
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._fix_count / elapsed
speed_val = fix.get('speed') or 0
track_val = fix.get('track')
track_str = f"{track_val:.0f}" if track_val is not None else "---"
mode = fix.get('mode', 0)
sats = fix.get('satellites', 0)
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
self._last_status_log = now
self._fix_count = 0
time.sleep(1)
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
import random
import threading
import time
from collections import deque
from typing import Any
# ============================================================================
# DEBUG MODE - Set True for development without GPS hardware
# When True: skips gpsd entirely, generates realistic mock data
# When False: connects to real gpsd (requires GPS device)
# ============================================================================
_GPS_DEBUG = True
# gpsdclient is a modern, simple gpsd client
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
try:
from gpsdclient import GPSDClient
except ImportError:
GPSDClient = None # Allow import without gpsd for testing structure
class GPSService:
"""Threaded GPS reader with buffering and auto-reconnect."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self.host = host
self.port = port
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
self._connected = False
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
# Callback for push-based updates
self._on_data_callback = None
# GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
self._has_ever_fixed = False # True after first valid fix this session
# Periodic status logging
self._last_status_log = 0.0
self._fix_count = 0
def set_on_data(self, callback):
"""Set callback for new GPS fix. Called with fix dict."""
self._on_data_callback = callback
@property
def connected(self) -> bool:
return self._connected
def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"}
def _gps_state(self, fix: dict) -> str:
"""Determine GPS state: acquiring, fix, or lost.
NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
report mode 1 with no position. We track it ourselves.
"""
has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
if has_fix:
return "fix"
return "lost" if self._has_ever_fixed else "acquiring"
def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered GPS history."""
with self._lock:
return list(self._buffer)
def start(self):
"""Start background GPS reader thread."""
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start()
print("[GPS] Service started")
def stop(self):
"""Stop background reader."""
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
def _reader_loop(self):
"""Main reader loop with reconnection logic."""
print("[GPS] Reader thread running")
while self._running:
try:
self._connect_and_read()
except Exception as e:
self._connected = False
print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5)
def _connect_and_read(self):
"""Connect to gpsd and read data."""
# Debug mode: skip gpsd entirely, use stub data
if _GPS_DEBUG:
print("[GPS] Debug mode enabled, using stub data")
self._stub_mode()
return
if GPSDClient is None:
print("[GPS] gpsdclient not installed, running in stub mode")
self._stub_mode()
return
# Quick check if gpsd is reachable before attempting connection
import socket
try:
sock = socket.create_connection((self.host, self.port), timeout=2.0)
sock.close()
except (socket.timeout, socket.error, OSError) as e:
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
raise ConnectionError(f"gpsd not reachable: {e}")
try:
client = GPSDClient(host=self.host, port=self.port)
except Exception as e:
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
raise ConnectionError(f"gpsd connection failed: {e}")
with client:
self._connected = True
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
self._last_status_log = time.time()
self._fix_count = 0
# 120s for initial cold fix, 10s for signal loss after first fix
fix_timeout = time.time() + 120.0
for result in client.dict_stream(filter=["TPV"]):
if not self._running:
break
# TPV = Time-Position-Velocity report
fix = {
"time": result.get("time"),
"lat": result.get("lat"),
"lon": result.get("lon"),
"alt": result.get("alt"),
"speed": result.get("speed"), # m/s
"track": result.get("track"), # heading in degrees
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
"satellites": result.get("satellites"), # from SKY messages
}
# Compute state and attach to fix
fix["gps_state"] = self._gps_state(fix)
# Check if this is a real fix (has position) or just empty TPV
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
# No real data yet, check timeout
if time.time() > fix_timeout:
timeout_s = "120s" if not self._has_ever_fixed else "10s"
print(f"[GPS] No GPS fix after {timeout_s}, will retry connection")
raise ConnectionError("No GPS fix within timeout")
continue # Skip empty fixes
# Got real data — mark first fix, reset timeout to shorter window
if not self._has_ever_fixed:
self._has_ever_fixed = True
print("[GPS] First fix acquired")
fix_timeout = time.time() + 10.0 # 10s timeout for signal loss
with self._lock:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
# Periodic status log (every 5s)
self._fix_count += 1
now = time.time()
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._fix_count / elapsed
speed = fix.get('speed', 0) or 0
track = fix.get('track', 0) or 0
mode = fix.get('mode', 0) or 0
sats = fix.get('satellites', '?')
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
self._last_status_log = now
self._fix_count = 0
def _stub_mode(self):
"""Generate realistic mock GPS data for development/testing.
Simulates:
- Initial acquisition delay (~3s before first fix)
- Normal 3D fix with satellites
- Occasional signal loss (~30% chance per second, lasts ~2s)
- Wandering position near Tokyo
"""
self._last_status_log = time.time()
self._fix_count = 0
# Signal loss state
signal_lost = False
signal_lost_until = 0.0
# Simulate cold start acquisition (~3s)
acquiring_until = time.time() + 3.0
# Base position (Tokyo area)
base_lat = 35.6762
base_lon = 139.6503
base_alt = 40.0
# Smoothly varying heading/speed
heading = random.uniform(0, 360)
speed = random.uniform(5, 15)
while self._running:
self._connected = True
now = time.time()
# Simulate initial acquisition period
if now < acquiring_until:
signal_lost = True # No fix yet
elif signal_lost and now >= signal_lost_until:
signal_lost = False
if self._has_ever_fixed:
print("[GPS] Signal recovered (stub)")
else:
print("[GPS] First fix acquired (stub)")
elif not signal_lost:
# ~30% chance per second to lose signal
if random.random() < 0.3:
signal_lost = True
signal_lost_until = now + 2 # fixed 2s loss
print("[GPS] Signal loss simulation (stub)")
if signal_lost:
# No fix - mode 1, no satellites, no track
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
fix = {
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": None,
"lon": None,
"alt": None,
"speed": None,
"track": None,
"mode": 1,
"satellites": 0,
}
else:
# Smoothly vary heading and speed
heading = (heading + random.uniform(1, 3)) % 360
speed = max(0, min(30, speed + random.uniform(-2, 2)))
if not self._has_ever_fixed:
self._has_ever_fixed = True
fix = {
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": base_lat + random.uniform(-0.001, 0.001),
"lon": base_lon + random.uniform(-0.001, 0.001),
"alt": base_alt + random.uniform(-5, 5),
"speed": speed,
"track": heading,
"mode": 3,
"satellites": random.randint(6, 12),
}
# Attach state — same logic as real GPS path
fix["gps_state"] = self._gps_state(fix)
with self._lock:
self._latest = fix
if fix.get("lat") is not None:
self._buffer.append(fix)
# Invoke callback with new fix
if self._on_data_callback:
self._on_data_callback(fix)
# Periodic status log (every 5s)
self._fix_count += 1
if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
fps = self._fix_count / elapsed
speed_val = fix.get('speed') or 0
track_val = fix.get('track')
track_str = f"{track_val:.0f}" if track_val is not None else "---"
mode = fix.get('mode', 0)
sats = fix.get('satellites', 0)
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
self._last_status_log = now
self._fix_count = 0
time.sleep(1)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
"""Quick AT command terminal - minicom but less hostile.
Usage:
python at_terminal.py [port]
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
"""
import sys
import serial
import threading
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
BAUD = 115200
def reader(ser):
"""Background thread: print everything the modem sends."""
while True:
try:
data = ser.read(ser.in_waiting or 1)
if data:
sys.stdout.write(data.decode("utf-8", errors="replace"))
sys.stdout.flush()
except Exception:
break
def main():
print(f"Opening {PORT} @ {BAUD} baud")
print("Type AT commands. Ctrl+C to quit.\n")
ser = serial.Serial(PORT, BAUD, timeout=0.1)
t = threading.Thread(target=reader, args=(ser,), daemon=True)
t.start()
try:
while True:
line = input()
ser.write((line + "\r\n").encode())
except (KeyboardInterrupt, EOFError):
print("\nBye.")
finally:
ser.close()
if __name__ == "__main__":
main()