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:
Andreas Wrede
2026-07-14 22:03:40 -04:00
co-authored by Claude Fable 5
parent d2a2b19b85
commit 8d5415f326
7 changed files with 261 additions and 1 deletions
+38
View File
@@ -205,6 +205,44 @@ The `homeassistant` LAN name comes from `ap/van-ap-dnsmasq.conf` (`host-record`
support `--reset`; autoconnect reconnects), falling back to a USB unbind/bind of the
Quectel device (vendor `2c7c`) if ModemManager can't reach the modem.
### GPS (gpsd from the cellular modem)
The EC25-AF has a GNSS engine that streams NMEA on its USB interface 01 (`ttyUSB1`,
`gps` port in `mmcli`). Needs its own GPS antenna on the modem's GNSS connector for a fix.
- **One-time modem config** (persists in modem NV; survives reboot/replug/Restart):
GNSS auto-start via `AT+QGPSCFG="autogps",1` + `AT+QGPS=1`. ModemManager holds both
AT ports and swallows replies, so stop it first:
`systemctl stop ModemManager`, send the ATs on `/dev/ttyUSB3`, `systemctl start ModemManager`.
- **Host side** (deployed by deploy.sh, needs `apt install gpsd gpsd-clients`):
`gps/77-modem-gps.rules` udev rule matches the NMEA tty (2c7c:0125 if01), symlinks it
to `/dev/modem-gps`, and hot-adds it to gpsd via `gpsdctl@%k` — the same mechanism as
gpsd's own 60-gpsd.rules, so plug/unplug/renumbering just works. `gps/gpsd.default`
(`/etc/default/gpsd`) keeps `DEVICES` empty (hotplug does it) and runs gpsd with `-n`.
- Verify: `gpspipe -r -n 10` (raw NMEA), `cgps` (fix view), `systemctl status gpsdctl@ttyUSB1`.
- Don't enable MM location APIs (`mmcli --location-enable-gps-*`) at the same time —
MM would open the NMEA port and fight gpsd; `gps-unmanaged` is the only safe one.
### OwnTracks publisher (gpsd -> MQTT)
`gps/van-gps-owntracks` (service `van-gps-owntracks`) streams TPV fixes from local
gpsd and publishes OwnTracks location JSON to `owntracks/rv/gps` on home.wrede.ca
(tid `rv`) — at most every 10 min when parked, immediately after >250 m of movement.
Port of the wayback-era `gps_to_owntracks.py`, adapted to apt-only deps
(`python3-gps`, `python3-paho-mqtt` — the old `gpsdclient` is pip-only) and the
paho 2.x callback API.
- Broker credentials live only in `/etc/van-gps/config.json` (0600, seeded from
`gps/config.json.example` — edit after first deploy). All knobs (broker, topic,
intervals, gpsd host) live there too.
- **client_id must stay `vanq-wan` (or anything unique)**: some legacy client still
holds `vanq` on the broker (old copy on wayback?) and the broker kicks whoever
shares its ID — the symptom is connect/disconnect every few seconds.
- On MQTT disconnect the script exits and systemd restarts it (RestartSec=15);
that *is* the reconnect logic, so a red blip after a WAN failover is normal.
- Debug tracing: `kill -HUP` the process toggles per-fix logging to the journal.
- Verify: `journalctl -u van-gps-owntracks` shows `mqtt connect` and stays quiet;
a restart publishes the current fix immediately (first-message path), so
subscribing to `owntracks/rv/gps` while restarting shows a live message.
- Note: the broker holds an ancient *retained* message on this topic from a 2023
OwnTracks device (tid `RV`, Winegard SSID); our publishes are not retained.
### ZeroTier managed DNS
- `wrede.pvt` resolves over ZeroTier when off the home LAN. Mechanism: `allowDNS=1` (prereq) + `zerotier-systemd-manager` writes `99-ztuga7c2kh.network`, networkd applies it to resolved.
- Verify: `resolvectl status ztuga7c2kh` shows `DNS` scope + the two servers + `wrede.pvt`.
+21 -1
View File
@@ -71,6 +71,25 @@ install -D -m0644 cockpit/cockpit-session-nofile.conf /etc/systemd/system/cockpi
command -v grpcurl >/dev/null 2>&1 \
|| echo " -> grpcurl missing (Starlink card will say so): install linux_arm64 binary from github.com/fullstorydev/grpcurl/releases"
echo "== gps (cellular modem GNSS -> gpsd) =="
if dpkg -s gpsd >/dev/null 2>&1; then
install -D -m0644 gps/77-modem-gps.rules /etc/udev/rules.d/77-modem-gps.rules
install -D -m0644 gps/gpsd.default /etc/default/gpsd
udevadm control --reload
else
echo " -> gpsd not installed (apt install gpsd gpsd-clients) — skipping GPS setup"
fi
# OwnTracks publisher: gpsd fix -> MQTT (broker + creds live only on the
# system, 0600 — same pattern as pushover.json).
install -D -m0755 gps/van-gps-owntracks /usr/local/sbin/van-gps-owntracks
install -D -m0644 gps/van-gps-owntracks.service /etc/systemd/system/van-gps-owntracks.service
if [ ! -f /etc/van-gps/config.json ]; then
install -D -m0600 gps/config.json.example /etc/van-gps/config.json
echo " -> seeded /etc/van-gps/config.json (EDIT IT: add MQTT username + password)"
fi
python3 -c 'import paho.mqtt' 2>/dev/null \
|| echo " -> python3-paho-mqtt missing (apt install python3-paho-mqtt) — van-gps-owntracks won't start"
echo "== thermal monitor =="
install -D -m0755 power/van-thermal /usr/local/sbin/van-thermal
install -D -m0644 power/thermal-config.json /etc/van-thermal/config.json
@@ -102,11 +121,12 @@ systemctl daemon-reexec
# its wait-online would just stall network-online.target. NM-wait-online covers WANs.
systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
systemctl unmask hostapd >/dev/null 2>&1 || true
systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g >/dev/null 2>&1 || true
systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g van-gps-owntracks >/dev/null 2>&1 || true
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
systemctl enable --now bluetooth >/dev/null 2>&1 || true
systemctl start homeassistant || echo " -> homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
systemctl restart van-thermal
systemctl restart van-gps-owntracks
# Pick up unmanaged-devices changes so NM releases/keeps the right interfaces.
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
# restart in dependency order: bridge + members first, then hostapd enslaves the
+5
View File
@@ -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"
+13
View File
@@ -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"
}
+9
View File
@@ -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"
+161
View File
@@ -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)
+14
View File
@@ -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