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.
|
||||
|
||||
@@ -40,6 +40,9 @@ class GPSService:
|
||||
# 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
|
||||
@@ -57,6 +60,17 @@ class GPSService:
|
||||
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:
|
||||
@@ -122,7 +136,8 @@ class GPSService:
|
||||
|
||||
self._last_status_log = time.time()
|
||||
self._fix_count = 0
|
||||
first_fix_timeout = time.time() + 5.0 # 5s to get first fix
|
||||
# 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:
|
||||
@@ -140,16 +155,23 @@ class GPSService:
|
||||
"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() > first_fix_timeout:
|
||||
print("[GPS] No GPS fix after 5s, will retry connection")
|
||||
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, disable timeout
|
||||
first_fix_timeout = float('inf')
|
||||
# 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
|
||||
@@ -178,8 +200,9 @@ class GPSService:
|
||||
"""Generate realistic mock GPS data for development/testing.
|
||||
|
||||
Simulates:
|
||||
- Initial acquisition delay (~3s before first fix)
|
||||
- Normal 3D fix with satellites
|
||||
- Occasional signal loss (~3% chance per second, lasts ~5s)
|
||||
- Occasional signal loss (~30% chance per second, lasts ~2s)
|
||||
- Wandering position near Tokyo
|
||||
"""
|
||||
self._last_status_log = time.time()
|
||||
@@ -189,6 +212,9 @@ class GPSService:
|
||||
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
|
||||
@@ -202,12 +228,16 @@ class GPSService:
|
||||
self._connected = True
|
||||
now = time.time()
|
||||
|
||||
# Check for signal loss simulation
|
||||
if signal_lost:
|
||||
if now >= signal_lost_until:
|
||||
# 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
|
||||
@@ -232,6 +262,9 @@ class GPSService:
|
||||
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),
|
||||
@@ -243,6 +276,9 @@ class GPSService:
|
||||
"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:
|
||||
|
||||
@@ -180,9 +180,11 @@ def throttle_flusher():
|
||||
@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),
|
||||
})
|
||||
|
||||
49
pi/backend/utils/at_terminal.py
Normal file
49
pi/backend/utils/at_terminal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Quick AT command terminal - minicom but less hostile.
|
||||
|
||||
Usage:
|
||||
python at_terminal.py [port]
|
||||
|
||||
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import serial
|
||||
import threading
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def reader(ser):
|
||||
"""Background thread: print everything the modem sends."""
|
||||
while True:
|
||||
try:
|
||||
data = ser.read(ser.in_waiting or 1)
|
||||
if data:
|
||||
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Opening {PORT} @ {BAUD} baud")
|
||||
print("Type AT commands. Ctrl+C to quit.\n")
|
||||
|
||||
ser = serial.Serial(PORT, BAUD, timeout=0.1)
|
||||
|
||||
t = threading.Thread(target=reader, args=(ser,), daemon=True)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = input()
|
||||
ser.write((line + "\r\n").encode())
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nBye.")
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
|
||||
// Show all items from the start so the row doesn't jump around
|
||||
_updateStatus('Config', '...');
|
||||
_updateStatus('UART', '...');
|
||||
_updateStatus('GPS', '...');
|
||||
_updateStatus('Navigator', '...');
|
||||
|
||||
// Config must load first (everything else depends on it)
|
||||
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
|
||||
await ConfigService.instance.load();
|
||||
_updateStatus('Config', 'Ready');
|
||||
|
||||
// UART health check and navigator image preload run truly in parallel
|
||||
// UART, GPS, and navigator image preload run truly in parallel
|
||||
_updateStatus('UART', 'Connecting');
|
||||
_updateStatus('GPS', 'Waiting');
|
||||
_updateStatus('Navigator', 'Loading');
|
||||
await Future.wait([
|
||||
_waitForUart(),
|
||||
_waitForGps(),
|
||||
_preloadNavigatorImages(),
|
||||
]);
|
||||
|
||||
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
|
||||
_updateStatus('UART', 'Timeout');
|
||||
}
|
||||
|
||||
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
|
||||
Future<void> _waitForGps() async {
|
||||
final backendUrl = ConfigService.instance.backendUrl;
|
||||
const bailOut = Duration(milliseconds: 7500);
|
||||
const retryDelay = Duration(seconds: 1);
|
||||
final deadline = DateTime.now().add(bailOut);
|
||||
|
||||
_updateStatus('GPS', 'Acquiring');
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$backendUrl/health'))
|
||||
.timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (data['gps_state'] == 'fix') {
|
||||
_updateStatus('GPS', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
|
||||
// Bail out - dashboard will show live GPS state when it arrives
|
||||
_updateStatus('GPS', 'Timeout');
|
||||
}
|
||||
|
||||
/// Preload navigator images into Flutter's image cache
|
||||
///
|
||||
/// Scans for all PNGs in the navigator folder and precaches them.
|
||||
|
||||
@@ -60,6 +60,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
String? _gpsState;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
@@ -109,6 +110,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_gpsSpeed = data.speed;
|
||||
_gpsTrack = data.track;
|
||||
_gpsSatellites = data.satellites;
|
||||
_gpsState = data.gpsState;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,6 +146,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsTrack = cachedGps.track;
|
||||
_gpsSatellites = cachedGps.satellites;
|
||||
_gpsState = cachedGps.gpsState;
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
@@ -200,6 +203,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
gpsState: _gpsState,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
@@ -237,7 +241,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||
GpsCompass(heading: _gpsTrack),
|
||||
GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -40,8 +40,9 @@ class GpsData {
|
||||
final double? track;
|
||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||
final int? satellites;
|
||||
final String? gpsState; // "acquiring", "fix", or "lost"
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites});
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState});
|
||||
|
||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||
return GpsData(
|
||||
@@ -52,6 +53,7 @@ class GpsData {
|
||||
track: (json['track'] as num?)?.toDouble(),
|
||||
mode: (json['mode'] as num?)?.toInt(),
|
||||
satellites: (json['satellites'] as num?)?.toInt(),
|
||||
gpsState: json['gps_state'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
|
||||
|
||||
class GpsCompass extends StatelessWidget {
|
||||
final double? heading;
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
|
||||
const GpsCompass({super.key, this.heading});
|
||||
const GpsCompass({super.key, this.heading, this.gpsState});
|
||||
|
||||
bool get _hasSignal => heading != null;
|
||||
bool get _isAcquiring => gpsState == 'acquiring';
|
||||
|
||||
String get _displayHeading {
|
||||
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal
|
||||
return '${(heading! % 360).round()}'; // No need for the degree symbol
|
||||
if (!_hasSignal) return 'N/A';
|
||||
return '${(heading! % 360).round()}';
|
||||
}
|
||||
|
||||
String get _compassDirection {
|
||||
@@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget {
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A",
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../theme/app_theme.dart';
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
@@ -15,6 +16,7 @@ class SystemBar extends StatelessWidget {
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.gpsState,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
@@ -65,8 +67,10 @@ class SystemBar extends StatelessWidget {
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsSatellites?.toString() ?? 'N/A',
|
||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
||||
value: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix',
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
|
||||
Reference in New Issue
Block a user