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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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