gps: modem GNSS -> gpsd -> OwnTracks/MQTT publisher
EC25-AF GNSS wiring (udev hotplug into gpsd) plus van-gps-owntracks, a port of the wayback-era gps_to_owntracks.py: apt-only deps (python3-gps instead of pip-only gpsdclient, paho 2.x callback API), broker secrets moved out of the code into /etc/van-gps/config.json (0600, seeded from a sanitized example), gpsd host now localhost. client_id is vanq-wan — a legacy client still holds vanq on the broker and shared IDs get kicked in a connect/disconnect loop. Exit-on-disconnect + systemd Restart=always is the reconnect logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d2a2b19b85
commit
8d5415f326
@@ -0,0 +1,5 @@
|
||||
# Quectel EC25-AF GNSS NMEA port (USB interface 01): hand it to gpsd on hotplug
|
||||
# via gpsd's own gpsdctl@ mechanism (same pattern as its 60-gpsd.rules) and give
|
||||
# it a stable name. NMEA only streams while the GNSS engine is on — enabled
|
||||
# persistently in modem NV via AT+QGPSCFG="autogps",1 (one-time; see README).
|
||||
ACTION=="add", SUBSYSTEM=="tty", ENV{ID_VENDOR_ID}=="2c7c", ENV{ID_MODEL_ID}=="0125", ENV{ID_USB_INTERFACE_NUM}=="01", SYMLINK+="modem-gps", GROUP="dialout", TAG+="systemd", ENV{SYSTEMD_WANTS}+="gpsdctl@%k.service"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"broker": "home.wrede.ca",
|
||||
"port": 1883,
|
||||
"username": "CHANGE_ME",
|
||||
"password": "CHANGE_ME",
|
||||
"client_id": "vanq-wan",
|
||||
"topic": "owntracks/rv/gps",
|
||||
"tid": "rv",
|
||||
"gpsd_host": "127.0.0.1",
|
||||
"idle_delay_s": 600,
|
||||
"move_trigger_m": 250,
|
||||
"mqtt_version": "3"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Devices gpsd should collect to at boot time.
|
||||
# Empty: the EC25 NMEA port is hot-added via udev (77-modem-gps.rules -> gpsdctl@).
|
||||
DEVICES=""
|
||||
|
||||
# -n: read GNSS data even with no clients connected (keeps the fix warm).
|
||||
GPSD_OPTIONS="-n"
|
||||
|
||||
# Automatically hot add/remove USB GPS devices via gpsdctl (our udev rule uses this).
|
||||
USBAUTO="true"
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/python3 -u
|
||||
# Publish gpsd fixes as OwnTracks location messages over MQTT.
|
||||
# Ported from gps_to_owntracks.py (wayback era): gpsdclient -> python3-gps
|
||||
# (apt-only deps), paho 1.x -> 2.x callback API, hard-coded broker secrets ->
|
||||
# /etc/van-gps/config.json. Exits on MQTT disconnect/DNS failure by design —
|
||||
# systemd Restart=always reconnects with a fresh session.
|
||||
|
||||
import sys
|
||||
import json
|
||||
import signal
|
||||
import os
|
||||
import time
|
||||
import socket
|
||||
|
||||
import gps as gpsd
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.packettypes import PacketTypes
|
||||
|
||||
from datetime import datetime
|
||||
from math import radians, sin, cos, acos
|
||||
|
||||
debug = False # toggle at runtime with SIGHUP
|
||||
config_path = sys.argv[1] if len(sys.argv) > 1 else "/etc/van-gps/config.json"
|
||||
with open(config_path) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
version = cfg.get("mqtt_version", "3") # '3' or '5'
|
||||
mytransport = "tcp" # or 'websockets'
|
||||
broker = cfg["broker"]
|
||||
myport = cfg.get("port", 1883)
|
||||
mq_user = cfg["username"]
|
||||
mq_pw = cfg["password"]
|
||||
mq_id = cfg.get("client_id", "vanq-wan")
|
||||
gpsd_host = cfg.get("gpsd_host", "127.0.0.1")
|
||||
idle_delay = cfg.get("idle_delay_s", 600) # seconds between messages when not moving
|
||||
keep_alive = idle_delay + 60
|
||||
distance_move_trigger = cfg.get("move_trigger_m", 250) # meters of movement that triggers a message
|
||||
|
||||
mytopic = cfg.get("topic", "owntracks/rv/gps")
|
||||
tid = cfg.get("tid", "rv")
|
||||
|
||||
|
||||
def handler(signum, frame):
|
||||
global debug
|
||||
debug = not debug
|
||||
print('debug is now', debug)
|
||||
|
||||
|
||||
def great_circle(llon1, llat1, llon2, llat2):
|
||||
lon1, lat1, lon2, lat2 = map(radians, [llon1, llat1, llon2, llat2])
|
||||
|
||||
a = sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(lon1 - lon2)
|
||||
if a >= 1.0:
|
||||
return 0
|
||||
try:
|
||||
res = int(6371000 * (acos(a)))
|
||||
except ValueError as e:
|
||||
print("gc error: ", e)
|
||||
print(" coords: ", llon1, llat1, llon2, llat2)
|
||||
return 0
|
||||
return res
|
||||
|
||||
|
||||
def on_disconnect(client, userdata, flags, reason_code, properties):
|
||||
print("mqtt disconnect:", reason_code)
|
||||
os._exit(1)
|
||||
|
||||
|
||||
def connect():
|
||||
if version == '5':
|
||||
properties = Properties(PacketTypes.CONNECT)
|
||||
properties.SessionExpiryInterval = 30 * 60 # in seconds
|
||||
|
||||
client.connect(broker,
|
||||
port=myport,
|
||||
clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY,
|
||||
properties=properties,
|
||||
keepalive=keep_alive)
|
||||
elif version == '3':
|
||||
client.connect(broker, port=myport, keepalive=keep_alive)
|
||||
print("mqtt connect")
|
||||
|
||||
|
||||
#
|
||||
# Main
|
||||
#
|
||||
|
||||
print("van-gps-owntracks start", time.asctime(), debug, os.getpid())
|
||||
|
||||
signal.signal(signal.SIGHUP, handler)
|
||||
|
||||
if version == '5':
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=mq_id,
|
||||
transport=mytransport,
|
||||
protocol=mqtt.MQTTv5)
|
||||
if version == '3':
|
||||
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=mq_id,
|
||||
transport=mytransport,
|
||||
protocol=mqtt.MQTTv311,
|
||||
clean_session=True)
|
||||
|
||||
client.username_pw_set(mq_user, mq_pw)
|
||||
client.on_disconnect = on_disconnect
|
||||
|
||||
try:
|
||||
connect()
|
||||
except socket.gaierror as e:
|
||||
print("could not connect mqtt, %s" % e)
|
||||
sys.exit(1)
|
||||
client.loop_start()
|
||||
|
||||
pubproperties = Properties(PacketTypes.PUBLISH)
|
||||
pubproperties.MessageExpiryInterval = keep_alive # in seconds; MQTT5 only
|
||||
|
||||
session = gpsd.gps(host=gpsd_host)
|
||||
session.stream(gpsd.WATCH_ENABLE)
|
||||
lastmsg = {'tst': 0, 'tid': tid, 'lat': 0, 'lon': 0}
|
||||
while session.read() == 0:
|
||||
result = session.data
|
||||
if not result or result.get('class') != 'TPV':
|
||||
continue
|
||||
msg = {'_type': 'location', 'tid': tid, 't': 'p'}
|
||||
|
||||
if result.get('mode', 0) <= 1:
|
||||
continue
|
||||
if not ('lat' in result and 'lon' in result):
|
||||
continue
|
||||
msg['lat'] = result['lat']
|
||||
msg['lon'] = result['lon']
|
||||
if 'alt' in result:
|
||||
msg['alt'] = int(result['alt'])
|
||||
if 'speed' in result:
|
||||
msg['vel'] = int(result['speed']) * 3.6
|
||||
if 'eph' in result:
|
||||
msg['acc'] = int(result['eph'])
|
||||
if 'track' in result:
|
||||
msg['cog'] = int(result['track'])
|
||||
else:
|
||||
continue
|
||||
if 'time' not in result:
|
||||
continue
|
||||
msg['tst'] = int(datetime.fromisoformat(result['time']).timestamp())
|
||||
jmsg = json.dumps(msg)
|
||||
if lastmsg != msg:
|
||||
gc = great_circle(lastmsg['lat'], lastmsg['lon'], msg['lat'], msg['lon'])
|
||||
if debug:
|
||||
print("\rgc is ", gc, ' age is ', msg['tst'] - lastmsg['tst'], " ", end="")
|
||||
if msg['tst'] - lastmsg['tst'] >= idle_delay or gc > distance_move_trigger:
|
||||
if debug:
|
||||
print('* ', gc, jmsg)
|
||||
print('* ', mytopic, pubproperties)
|
||||
rc = client.publish(mytopic, jmsg, 0, properties=pubproperties)
|
||||
if debug:
|
||||
print("* client.publish rc=", rc)
|
||||
lastmsg = msg
|
||||
|
||||
print("gpsd stream ended", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Publish gpsd fixes to OwnTracks via MQTT
|
||||
After=network-online.target gpsd.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/sbin/van-gps-owntracks
|
||||
# The script exits on MQTT disconnect / DNS failure by design; a fresh start
|
||||
# is the reconnect. 15s so a dead WAN doesn't turn this into a hot loop.
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user