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. Physical switches/connectors need debouncing. Current implementation requires 15 consecutive identical readings (~750ms at 20Hz) before accepting a state change. Tune `required_consecutive` in `gpio_service.py` as needed.
## Utilities
Standalone tools live in `pi/utils/` (not part of the backend service):
| Tool | Description |
|------|-------------|
| `at_terminal.py` | Interactive AT command terminal for SIM7600 (pyserial). Default port: `/dev/ttyUSB2` |
```bash
python pi/utils/at_terminal.py # default /dev/ttyUSB2
python pi/utils/at_terminal.py /dev/ttyUSB3 # specify port
```
## Deploy ## Deploy
TODO: Add to `scripts/deploy.py` as second target + systemd service. TODO: Add to `scripts/deploy.py` as second target + systemd service.

View File

@@ -40,6 +40,9 @@ class GPSService:
# Callback for push-based updates # Callback for push-based updates
self._on_data_callback = None 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 # Periodic status logging
self._last_status_log = 0.0 self._last_status_log = 0.0
self._fix_count = 0 self._fix_count = 0
@@ -57,6 +60,17 @@ class GPSService:
with self._lock: with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"} return self._latest.copy() if self._latest else {"error": "no data"}
def _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]]: def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered GPS history.""" """Get buffered GPS history."""
with self._lock: with self._lock:
@@ -122,7 +136,8 @@ class GPSService:
self._last_status_log = time.time() self._last_status_log = time.time()
self._fix_count = 0 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"]): for result in client.dict_stream(filter=["TPV"]):
if not self._running: if not self._running:
@@ -140,16 +155,23 @@ class GPSService:
"satellites": result.get("satellites"), # from SKY messages "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 # 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): if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
# No real data yet, check timeout # No real data yet, check timeout
if time.time() > first_fix_timeout: if time.time() > fix_timeout:
print("[GPS] No GPS fix after 5s, will retry connection") 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") raise ConnectionError("No GPS fix within timeout")
continue # Skip empty fixes continue # Skip empty fixes
# Got real data, disable timeout # Got real data — mark first fix, reset timeout to shorter window
first_fix_timeout = float('inf') 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: with self._lock:
self._latest = fix self._latest = fix
@@ -178,8 +200,9 @@ class GPSService:
"""Generate realistic mock GPS data for development/testing. """Generate realistic mock GPS data for development/testing.
Simulates: Simulates:
- Initial acquisition delay (~3s before first fix)
- Normal 3D fix with satellites - 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 - Wandering position near Tokyo
""" """
self._last_status_log = time.time() self._last_status_log = time.time()
@@ -189,6 +212,9 @@ class GPSService:
signal_lost = False signal_lost = False
signal_lost_until = 0.0 signal_lost_until = 0.0
# Simulate cold start acquisition (~3s)
acquiring_until = time.time() + 3.0
# Base position (Tokyo area) # Base position (Tokyo area)
base_lat = 35.6762 base_lat = 35.6762
base_lon = 139.6503 base_lon = 139.6503
@@ -202,12 +228,16 @@ class GPSService:
self._connected = True self._connected = True
now = time.time() now = time.time()
# Check for signal loss simulation # Simulate initial acquisition period
if signal_lost: if now < acquiring_until:
if now >= signal_lost_until: signal_lost = True # No fix yet
elif signal_lost and now >= signal_lost_until:
signal_lost = False signal_lost = False
if self._has_ever_fixed:
print("[GPS] Signal recovered (stub)") print("[GPS] Signal recovered (stub)")
else: else:
print("[GPS] First fix acquired (stub)")
elif not signal_lost:
# ~30% chance per second to lose signal # ~30% chance per second to lose signal
if random.random() < 0.3: if random.random() < 0.3:
signal_lost = True signal_lost = True
@@ -232,6 +262,9 @@ class GPSService:
heading = (heading + random.uniform(1, 3)) % 360 heading = (heading + random.uniform(1, 3)) % 360
speed = max(0, min(30, speed + random.uniform(-2, 2))) speed = max(0, min(30, speed + random.uniform(-2, 2)))
if not self._has_ever_fixed:
self._has_ever_fixed = True
fix = { fix = {
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": base_lat + random.uniform(-0.001, 0.001), "lat": base_lat + random.uniform(-0.001, 0.001),
@@ -243,6 +276,9 @@ class GPSService:
"satellites": random.randint(6, 12), "satellites": random.randint(6, 12),
} }
# Attach state — same logic as real GPS path
fix["gps_state"] = self._gps_state(fix)
with self._lock: with self._lock:
self._latest = fix self._latest = fix
if fix.get("lat") is not None: if fix.get("lat") is not None:

View File

@@ -180,9 +180,11 @@ def throttle_flusher():
@app.route("/health") @app.route("/health")
def health(): def health():
"""Health check endpoint.""" """Health check endpoint."""
gps_latest = gps.get_latest()
return jsonify({ return jsonify({
"status": "ok", "status": "ok",
"gps_connected": gps.connected, "gps_connected": gps.connected,
"gps_state": gps_latest.get("gps_state", "acquiring"),
"arduino_connected": arduino.connected, "arduino_connected": arduino.connected,
"ws_clients": len(connected_clients), "ws_clients": len(connected_clients),
}) })

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()

View File

@@ -41,6 +41,7 @@ class _AppRootState extends State<AppRoot> {
// Show all items from the start so the row doesn't jump around // Show all items from the start so the row doesn't jump around
_updateStatus('Config', '...'); _updateStatus('Config', '...');
_updateStatus('UART', '...'); _updateStatus('UART', '...');
_updateStatus('GPS', '...');
_updateStatus('Navigator', '...'); _updateStatus('Navigator', '...');
// Config must load first (everything else depends on it) // Config must load first (everything else depends on it)
@@ -48,11 +49,13 @@ class _AppRootState extends State<AppRoot> {
await ConfigService.instance.load(); await ConfigService.instance.load();
_updateStatus('Config', 'Ready'); _updateStatus('Config', 'Ready');
// UART health check and navigator image preload run truly in parallel // UART, GPS, and navigator image preload run truly in parallel
_updateStatus('UART', 'Connecting'); _updateStatus('UART', 'Connecting');
_updateStatus('GPS', 'Waiting');
_updateStatus('Navigator', 'Loading'); _updateStatus('Navigator', 'Loading');
await Future.wait([ await Future.wait([
_waitForUart(), _waitForUart(),
_waitForGps(),
_preloadNavigatorImages(), _preloadNavigatorImages(),
]); ]);
@@ -100,6 +103,39 @@ class _AppRootState extends State<AppRoot> {
_updateStatus('UART', 'Timeout'); _updateStatus('UART', 'Timeout');
} }
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
Future<void> _waitForGps() async {
final backendUrl = ConfigService.instance.backendUrl;
const bailOut = Duration(milliseconds: 7500);
const retryDelay = Duration(seconds: 1);
final deadline = DateTime.now().add(bailOut);
_updateStatus('GPS', 'Acquiring');
while (DateTime.now().isBefore(deadline)) {
try {
final response = await http
.get(Uri.parse('$backendUrl/health'))
.timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['gps_state'] == 'fix') {
_updateStatus('GPS', 'Ready');
return;
}
}
} catch (e) {
// Backend not reachable yet - keep trying
}
await Future.delayed(retryDelay);
}
// Bail out - dashboard will show live GPS state when it arrives
_updateStatus('GPS', 'Timeout');
}
/// Preload navigator images into Flutter's image cache /// Preload navigator images into Flutter's image cache
/// ///
/// Scans for all PNGs in the navigator folder and precaches them. /// Scans for all PNGs in the navigator folder and precaches them.

View File

@@ -60,6 +60,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
// Placeholder values for system bar // Placeholder values for system bar
int? _gpsSatellites; int? _gpsSatellites;
String? _gpsState;
int? _lteSignal; int? _lteSignal;
// WebSocket connection state // WebSocket connection state
@@ -109,6 +110,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
_gpsSpeed = data.speed; _gpsSpeed = data.speed;
_gpsTrack = data.track; _gpsTrack = data.track;
_gpsSatellites = data.satellites; _gpsSatellites = data.satellites;
_gpsState = data.gpsState;
}); });
}); });
@@ -144,6 +146,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
_gpsSpeed = cachedGps.speed; _gpsSpeed = cachedGps.speed;
_gpsTrack = cachedGps.track; _gpsTrack = cachedGps.track;
_gpsSatellites = cachedGps.satellites; _gpsSatellites = cachedGps.satellites;
_gpsState = cachedGps.gpsState;
} }
_wsState = WebSocketService.instance.connectionState; _wsState = WebSocketService.instance.connectionState;
@@ -200,6 +203,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
// System status bar // System status bar
SystemBar( SystemBar(
gpsSatellites: _gpsSatellites, gpsSatellites: _gpsSatellites,
gpsState: _gpsState,
lteSignal: _lteSignal, lteSignal: _lteSignal,
piTemp: _piTemp, piTemp: _piTemp,
voltage: _voltage, voltage: _voltage,
@@ -237,7 +241,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
GpsCompass(heading: _gpsTrack), GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
StatBox(value: _formatGear(_gear), label: 'GEAR'), StatBox(value: _formatGear(_gear), label: 'GEAR'),
], ],
), ),

View File

@@ -40,8 +40,9 @@ class GpsData {
final double? track; final double? track;
final int? mode; // 0=no fix, 2=2D, 3=3D final int? mode; // 0=no fix, 2=2D, 3=3D
final int? satellites; final int? satellites;
final String? gpsState; // "acquiring", "fix", or "lost"
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites}); GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState});
factory GpsData.fromJson(Map<String, dynamic> json) { factory GpsData.fromJson(Map<String, dynamic> json) {
return GpsData( return GpsData(
@@ -52,6 +53,7 @@ class GpsData {
track: (json['track'] as num?)?.toDouble(), track: (json['track'] as num?)?.toDouble(),
mode: (json['mode'] as num?)?.toInt(), mode: (json['mode'] as num?)?.toInt(),
satellites: (json['satellites'] as num?)?.toInt(), satellites: (json['satellites'] as num?)?.toInt(),
gpsState: json['gps_state'] as String?,
); );
} }
} }

View File

@@ -4,14 +4,16 @@ import '../theme/app_theme.dart';
class GpsCompass extends StatelessWidget { class GpsCompass extends StatelessWidget {
final double? heading; final double? heading;
final String? gpsState; // "acquiring", "fix", "lost"
const GpsCompass({super.key, this.heading}); const GpsCompass({super.key, this.heading, this.gpsState});
bool get _hasSignal => heading != null; bool get _hasSignal => heading != null;
bool get _isAcquiring => gpsState == 'acquiring';
String get _displayHeading { String get _displayHeading {
if (!_hasSignal) return 'N/A'; // Just make it clear; redundant anyways, this only gets called when _hasSignal if (!_hasSignal) return 'N/A';
return '${(heading! % 360).round()}'; // No need for the degree symbol return '${(heading! % 360).round()}';
} }
String get _compassDirection { String get _compassDirection {
@@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget {
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: Text( child: Text(
_hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A", _hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
style: TextStyle( style: TextStyle(
fontSize: 80, fontSize: 80,
color: theme.subdued, color: theme.subdued,

View File

@@ -7,6 +7,7 @@ import '../theme/app_theme.dart';
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. /// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
class SystemBar extends StatelessWidget { class SystemBar extends StatelessWidget {
final int? gpsSatellites; // null = disconnected final int? gpsSatellites; // null = disconnected
final String? gpsState; // "acquiring", "fix", "lost"
final int? lteSignal; // null = disconnected, 0-4 bars final int? lteSignal; // null = disconnected, 0-4 bars
final double? piTemp; // null = unavailable final double? piTemp; // null = unavailable
final double? voltage; // null = Arduino disconnected final double? voltage; // null = Arduino disconnected
@@ -15,6 +16,7 @@ class SystemBar extends StatelessWidget {
const SystemBar({ const SystemBar({
super.key, super.key,
this.gpsSatellites, this.gpsSatellites,
this.gpsState,
this.lteSignal, this.lteSignal,
this.piTemp, this.piTemp,
this.voltage, this.voltage,
@@ -65,8 +67,10 @@ class SystemBar extends StatelessWidget {
), ),
_Indicator( _Indicator(
label: 'GPS', label: 'GPS',
value: gpsSatellites?.toString() ?? 'N/A', value: gpsState == 'acquiring' ? 'ACQ'
isAbnormal: gpsSatellites == null || gpsSatellites == 0, : gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
: '0', // lost or unknown
isAbnormal: gpsState != 'fix',
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
labelSize: labelSize, labelSize: labelSize,
valueSize: valueSize, valueSize: valueSize,