Files
RPi-2W-Stratum-1-NTP-Server/telegraf/gpsdMeasurement.py
2026-04-01 19:13:37 +02:00

334 lines
10 KiB
Python

#!/usr/bin/env python3
import json
import math
import signal
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional
# offset_ns is computed as:
# (clock_sec * 1e9 + clock_nsec) - (real_sec * 1e9 + real_nsec)
GPSPIPE_CMD = ["gpspipe", "-w", "--json"]
HOST_TAG = "ntprana"
running = True
def handle_signal(signum, frame):
global running
running = False
signal.signal(signal.SIGINT, handle_signal)
signal.signal(signal.SIGTERM, handle_signal)
def esc_tag(value: Any) -> str:
s = str(value)
return s.replace("\\", "\\\\").replace(" ", r"\ ").replace(",", r"\,").replace("=", r"\=")
def esc_field_str(value: Any) -> str:
s = str(value)
s = s.replace("\\", "\\\\").replace('"', r"\"")
return f'"{s}"'
def is_num(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def bool_to_field(value: bool) -> str:
return "true" if value else "false"
def build_line(measurement: str, tags: Dict[str, Any], fields: Dict[str, Any], ts_ns: Optional[int] = None) -> Optional[str]:
clean_fields = []
for key, value in fields.items():
if value is None:
continue
if isinstance(value, bool):
clean_fields.append(f"{key}={bool_to_field(value)}")
elif isinstance(value, int) and not isinstance(value, bool):
clean_fields.append(f"{key}={value}i")
elif is_num(value):
clean_fields.append(f"{key}={value}")
else:
clean_fields.append(f"{key}={esc_field_str(value)}")
if not clean_fields:
return None
tag_str = ",".join(f"{esc_tag(k)}={esc_tag(v)}" for k, v in tags.items() if v is not None)
line = measurement
if tag_str:
line += "," + tag_str
line += " " + ",".join(clean_fields)
if ts_ns is not None:
line += f" {ts_ns}"
return line
def gps_constellation_name(gnssid: Optional[int]) -> str:
mapping = {
0: "gps",
1: "sbas",
2: "galileo",
3: "beidou",
4: "imes",
5: "qzss",
6: "glonass",
}
return mapping.get(gnssid, "unknown")
def summarize_satellites(satellites: List[Dict[str, Any]]) -> Dict[str, Any]:
out: Dict[str, Any] = {
"gps_visible": 0,
"gps_used": 0,
"sbas_visible": 0,
"sbas_used": 0,
"galileo_visible": 0,
"galileo_used": 0,
"beidou_visible": 0,
"beidou_used": 0,
"qzss_visible": 0,
"qzss_used": 0,
"glonass_visible": 0,
"glonass_used": 0,
"unknown_visible": 0,
"unknown_used": 0,
"used_signal_sum": 0.0,
"used_signal_count": 0,
"visible_signal_sum": 0.0,
"visible_signal_count": 0,
"max_signal": 0.0,
}
for sat in satellites:
constellation = gps_constellation_name(sat.get("gnssid"))
ss = sat.get("ss", 0.0)
used = bool(sat.get("used", False))
vis_key = f"{constellation}_visible"
used_key = f"{constellation}_used"
if vis_key not in out:
vis_key = "unknown_visible"
used_key = "unknown_used"
out[vis_key] += 1
if used:
out[used_key] += 1
if is_num(ss):
out["visible_signal_sum"] += float(ss)
out["visible_signal_count"] += 1
out["max_signal"] = max(out["max_signal"], float(ss))
if used:
out["used_signal_sum"] += float(ss)
out["used_signal_count"] += 1
out["avg_signal_visible"] = (
out["visible_signal_sum"] / out["visible_signal_count"] if out["visible_signal_count"] else None
)
out["avg_signal_used"] = (
out["used_signal_sum"] / out["used_signal_count"] if out["used_signal_count"] else None
)
# cleanup helper sums/counters from final output
del out["visible_signal_sum"]
del out["visible_signal_count"]
del out["used_signal_sum"]
del out["used_signal_count"]
return out
def pps_offset_ns(pps: Dict[str, Any]) -> Optional[int]:
real_sec = pps.get("real_sec")
real_nsec = pps.get("real_nsec")
clock_sec = pps.get("clock_sec")
clock_nsec = pps.get("clock_nsec")
if not all(isinstance(v, int) for v in [real_sec, real_nsec, clock_sec, clock_nsec]):
return None
real_total = real_sec * 1_000_000_000 + real_nsec
clock_total = clock_sec * 1_000_000_000 + clock_nsec
return clock_total - real_total
def main():
global running
proc = subprocess.Popen(
GPSPIPE_CMD,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
latest_tpv: Optional[Dict[str, Any]] = None
latest_sky: Optional[Dict[str, Any]] = None
latest_pps: Optional[Dict[str, Any]] = None
last_emit_sec: Optional[int] = None
# NGL at some point I just put cloudee to do it bc I've wrote simmilar code before many times
try:
assert proc.stdout is not None
while running:
line = proc.stdout.readline()
if not line:
if proc.poll() is not None:
break
time.sleep(0.05)
continue
line = line.strip()
if not line or not line.startswith("{"):
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
cls = msg.get("class")
if cls == "TPV":
latest_tpv = msg
elif cls == "SKY":
if latest_sky is None:
latest_sky = msg
else:
merged = latest_sky.copy()
merged.update(msg)
# Keep the richer satellites list if the new SKY message doesn't include it
if "satellites" not in msg and "satellites" in latest_sky:
merged["satellites"] = latest_sky["satellites"]
# Keep nSat/uSat if the new SKY message doesn't include them
if "nSat" not in msg and "nSat" in latest_sky:
merged["nSat"] = latest_sky["nSat"]
if "uSat" not in msg and "uSat" in latest_sky:
merged["uSat"] = latest_sky["uSat"]
latest_sky = merged
elif cls == "PPS":
latest_pps = msg
else:
continue
now_sec = int(time.time())
if last_emit_sec == now_sec:
continue
lines_out = []
ts_ns = time.time_ns()
common_tags = {
"host": HOST_TAG,
"device": (latest_tpv or latest_sky or latest_pps or {}).get("device", "unknown"),
}
if latest_tpv:
fix_fields = {
"mode": latest_tpv.get("mode"),
"lat": latest_tpv.get("lat"),
"lon": latest_tpv.get("lon"),
"alt": latest_tpv.get("alt"),
"alt_msl": latest_tpv.get("altMSL"),
"alt_hae": latest_tpv.get("altHAE"),
"speed": latest_tpv.get("speed"),
"climb": latest_tpv.get("climb"),
"track": latest_tpv.get("track"),
"magtrack": latest_tpv.get("magtrack"),
"magvar": latest_tpv.get("magvar"),
"ept": latest_tpv.get("ept"),
"epx": latest_tpv.get("epx"),
"epy": latest_tpv.get("epy"),
"epv": latest_tpv.get("epv"),
"eph": latest_tpv.get("eph"),
"sep": latest_tpv.get("sep"),
"ecefpAcc": latest_tpv.get("ecefpAcc"),
"ecefvAcc": latest_tpv.get("ecefvAcc"),
"leapseconds": latest_tpv.get("leapseconds"),
"fix_valid": latest_tpv.get("mode", 0) >= 2,
"fix_3d": latest_tpv.get("mode", 0) == 3,
}
line_fix = build_line("gps_fix", common_tags, fix_fields, ts_ns)
if line_fix:
lines_out.append(line_fix)
if latest_sky:
satellites = latest_sky.get("satellites", [])
sat_summary = summarize_satellites(satellites) if isinstance(satellites, list) else {}
n_sat = latest_sky.get("nSat")
u_sat = latest_sky.get("uSat")
sat_used_ratio = None
if isinstance(n_sat, int) and n_sat > 0 and isinstance(u_sat, int):
sat_used_ratio = u_sat / n_sat
sky_fields = {
"nSat": n_sat,
"uSat": u_sat,
"sat_used_ratio": sat_used_ratio,
"gdop": latest_sky.get("gdop"),
"hdop": latest_sky.get("hdop"),
"pdop": latest_sky.get("pdop"),
"tdop": latest_sky.get("tdop"),
"vdop": latest_sky.get("vdop"),
"xdop": latest_sky.get("xdop"),
"ydop": latest_sky.get("ydop"),
**sat_summary,
}
line_sky = build_line("gps_sky", common_tags, sky_fields, ts_ns)
if line_sky:
lines_out.append(line_sky)
if latest_pps:
offset_ns = pps_offset_ns(latest_pps)
pps_fields = {
"offset_ns": offset_ns,
"precision_exp": latest_pps.get("precision"),
"present": True,
}
line_pps = build_line(
"gps_pps",
{
"host": HOST_TAG,
"device": latest_pps.get("device", "/dev/pps0"),
},
pps_fields,
ts_ns,
)
if line_pps:
lines_out.append(line_pps)
if lines_out:
sys.stdout.write("\n".join(lines_out) + "\n")
sys.stdout.flush()
last_emit_sec = now_sec
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
if __name__ == "__main__":
main()