diff --git a/README.md b/README.md index 826f39d..dd5e541 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/deploy.sh b/deploy.sh index a9eb104..2a54061 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/gps/77-modem-gps.rules b/gps/77-modem-gps.rules new file mode 100644 index 0000000..8caee08 --- /dev/null +++ b/gps/77-modem-gps.rules @@ -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" diff --git a/gps/config.json.example b/gps/config.json.example new file mode 100644 index 0000000..e5b9fee --- /dev/null +++ b/gps/config.json.example @@ -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" +} diff --git a/gps/gpsd.default b/gps/gpsd.default new file mode 100644 index 0000000..9a0a33a --- /dev/null +++ b/gps/gpsd.default @@ -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" diff --git a/gps/van-gps-owntracks b/gps/van-gps-owntracks new file mode 100644 index 0000000..7fe8070 --- /dev/null +++ b/gps/van-gps-owntracks @@ -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) diff --git a/gps/van-gps-owntracks.service b/gps/van-gps-owntracks.service new file mode 100644 index 0000000..6ee5259 --- /dev/null +++ b/gps/van-gps-owntracks.service @@ -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