gps manipulations tailored to sim7600h hat
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user