Files
yaris-obd/modules/app_gps.py
T
Mathieu MoranandCopilot be40bbc15f feat: add GPS status endpoint and dashboard indicator
- add /api/gps/status for thread/fix-age visibility
- surface GPS status in Pi health and live GPS cards
- add tests for app_gps status snapshots

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:18:14 -04:00

213 lines
7.0 KiB
Python

from modules.app_events import socketio
from threading import Thread, Lock
import serial
import time
try:
import RPi.GPIO as GPIO
except (ImportError, RuntimeError):
# Not running on a Pi (e.g. local dev machine) - PWRKEY auto power-on is
# simply unavailable there; _pulse_pwrkey() below degrades gracefully.
GPIO = None
# SIM868 GNSS/GSM/GPRS HAT is wired to the Pi's GPIO UART (jumper set to "B: Pi-SIM868"),
# exposed as /dev/serial0 once the Pi's onboard Bluetooth has been disabled/freed.
SERIAL_PORT = "/dev/serial0"
BAUDRATE = 115200
POLL_INTERVAL = 2 # seconds between AT+CGNSINF polls
# This HAT revision has the "P4" GPIO power-on mod wire connected to the SIM868's
# PWRKEY line (confirmed 2026-08-09 by pulsing it and seeing the module respond to
# AT afterward) - physical header pin 7 (BCM GPIO4), same pin Waveshare's own
# GSM_PWRKEY.py reference script uses. This lets us auto power-on the module chip
# on every app start instead of requiring someone to press its PWRKEY button by
# hand after every Pi power cycle.
PWRKEY_PIN = 7 # BOARD numbering
PWRKEY_HOLD_SECONDS = 4 # matches Waveshare's reference script
LocationX = None
LocationY = None
last_fix_at = None
_serial_lock = Lock()
_serial = None
_gps_thread = None
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY = None
def _get_serial():
global _serial
if _serial is None or not _serial.is_open:
_serial = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1)
time.sleep(0.3)
_serial.reset_input_buffer()
return _serial
def _send_at(cmd, wait=0.5):
"""Send an AT command to the SIM868 module and return the raw response."""
ser = _get_serial()
ser.write((cmd + "\r\n").encode())
time.sleep(wait)
return ser.read(1000).decode(errors="replace")
def _module_responds():
"""Check whether the SIM868 module chip is powered on and answering AT commands."""
try:
return "OK" in _send_at("AT", wait=0.3)
except Exception:
return False
def _pulse_pwrkey():
"""Pulse the PWRKEY line low then high, same sequence as Waveshare's own
GSM_PWRKEY.py reference script.
PWRKEY is a toggle, not a simple "power on" switch: pressing it while the
module is off powers it on, but pressing it again while already on powers
it back off. Callers must only invoke this after confirming via
`_module_responds()` that the module is NOT currently responding - never
unconditionally on every app start, or a normal restart while the module
is already up would accidentally shut it back down.
"""
if GPIO is None:
print("RPi.GPIO not available, cannot auto-power-on the SIM868 module - "
"press its PWRKEY button by hand.")
return
GPIO.setmode(GPIO.BOARD)
GPIO.setup(PWRKEY_PIN, GPIO.OUT)
GPIO.output(PWRKEY_PIN, GPIO.LOW)
time.sleep(PWRKEY_HOLD_SECONDS)
GPIO.output(PWRKEY_PIN, GPIO.HIGH)
GPIO.cleanup(PWRKEY_PIN)
def _ensure_module_powered():
"""Power on the SIM868 module chip via its PWRKEY line if it isn't already on.
The HAT's 5V rail can be live (board powered) while the module chip itself
stays fully off until PWRKEY is pulsed - this is what previously required a
manual button press after every Pi power cycle. Returns True once the
module is confirmed responding to AT commands, False if it still isn't
after attempting the power-on pulse (e.g. GPIO unavailable, or this board
doesn't have the P4 mod wire).
"""
if _module_responds():
return True
print("SIM868 module not responding to AT, pulsing PWRKEY to power it on...")
_pulse_pwrkey()
time.sleep(3) # give the module a few seconds to boot after PWRKEY release
for _ in range(5):
if _module_responds():
print("SIM868 module powered on and responding.")
return True
time.sleep(1)
print("SIM868 module still not responding after PWRKEY pulse - check the "
"P4 wiring or press the physical PWRKEY button.")
return False
def _ensure_gnss_powered():
resp = _send_at("AT+CGNSPWR?")
if "+CGNSPWR: 0" in resp:
print("GNSS was off, powering it on...")
_send_at("AT+CGNSPWR=1", wait=1)
def _parse_cgnsinf(resp):
"""Parse a +CGNSINF response line into a dict, or None if there's no fix yet.
Format: +CGNSINF: run,fix,utc,lat,lon,alt,speed,course,fixmode,reserved1,
hdop,pdop,vdop,reserved2,gps_sat_view,gnss_sat_used,gnss_sat_view,cn0,hpa,vpa
"""
for line in resp.splitlines():
line = line.strip()
if line.startswith("+CGNSINF:"):
fields = line.split(":", 1)[1].strip().split(",")
if len(fields) < 8 or fields[1] != "1":
return None
lat, lon = fields[3], fields[4]
if not lat or not lon:
return None
return {
"lat": lat,
"lon": lon,
"alt": fields[5],
"speed": fields[6],
"course": fields[7],
}
return None
def _poll_loop():
global LocationX, LocationY, last_fix_at
try:
with _serial_lock:
if _ensure_module_powered():
_ensure_gnss_powered()
except Exception as e:
print("Failed to initialize GNSS:", str(e))
while True:
try:
with _serial_lock:
resp = _send_at("AT+CGNSINF")
fix = _parse_cgnsinf(resp)
except Exception as e:
print("GPS read error:", str(e))
fix = None
if fix:
LocationX = fix["lat"]
LocationY = fix["lon"]
last_fix_at = time.monotonic()
socketio.emit("gps_location", [LocationX, LocationY])
time.sleep(POLL_INTERVAL)
def get_fix_age():
return None if last_fix_at is None else time.monotonic() - last_fix_at
def get_status():
"""Snapshot of the current GPS/GNSS state for the local dashboard."""
return {
"thread_alive": _gps_thread.is_alive() if _gps_thread is not None else False,
"has_fix": LocationX is not None and LocationY is not None and last_fix_at is not None,
"latitude": LocationX,
"longitude": LocationY,
"fix_age_seconds": get_fix_age(),
}
def start_gps():
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
global _gps_thread
if _gps_thread is None or not _gps_thread.is_alive():
_gps_thread = Thread(target=_poll_loop)
_gps_thread.daemon = True
_gps_thread.start()
print(f"GPS thread started, alive: {_gps_thread.is_alive()}")
else:
print(f"GPS thread already running, alive: {_gps_thread.is_alive()}")
return GPS()
@socketio.on("gps")
def get_gps():
"""Return the last known GPS location and emit it over the 'gps_location' event."""
Location = [LocationX, LocationY]
socketio.emit("gps_location", Location)
print("gps_location emitted:", Location)
return Location