From 681251f899100c6036808efa556e268d60a237b8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 8 Feb 2026 10:01:39 +0100 Subject: [PATCH] statd: support gps reference clocks in 'show ' This patch adds a background gps monitor to statd because the gpspipe program, normally used to interface with gpsd, slows down access to the operational datastore with *seconds*. This background monitor is not load bearing for how chrony accesses the gps + nmea information from gpsd, this is handled separately in SHM. Signed-off-by: Joachim Wiberg --- src/bin/show/__init__.py | 8 + src/confd/src/hardware.c | 5 + src/statd/Makefile.am | 2 +- src/statd/gpsd.c | 415 ++++++++++++++++++++++ src/statd/gpsd.h | 32 ++ src/statd/python/cli_pretty/cli_pretty.py | 81 ++++- src/statd/python/yanger/ietf_hardware.py | 80 +++++ src/statd/python/yanger/ietf_ntp.py | 42 ++- src/statd/python/yanger/ietf_system.py | 26 ++ src/statd/python/yanger/mock.leases | 3 + src/statd/statd.c | 21 +- 11 files changed, 710 insertions(+), 5 deletions(-) create mode 100644 src/statd/gpsd.c create mode 100644 src/statd/gpsd.h create mode 100644 src/statd/python/yanger/mock.leases diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index f86a164a..85846731 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -106,6 +106,7 @@ def ntp(args: List[str]) -> None: # Fetch both client and server operational data client_data = get_json("/system-state/ntp") server_data = get_json("/ietf-ntp:ntp") + hw_data = get_json("/ietf-hardware:hardware") # Merge into single data structure data = {} @@ -113,6 +114,8 @@ def ntp(args: List[str]) -> None: data.update(client_data) if server_data: data.update(server_data) + if hw_data: + data.update(hw_data) if RAW_OUTPUT: if not data: @@ -147,6 +150,11 @@ def ntp_source(args: List[str]) -> None: print("No ntp server data retrieved.") return + # Also fetch hardware data for GPS receiver info + hw_data = get_json("/ietf-hardware:hardware") + if hw_data: + data.update(hw_data) + if RAW_OUTPUT: print(json.dumps(data, indent=2)) return diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index e7c30450..5857ceb1 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -707,6 +707,11 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l free(wifi_iface_list); wifi_iface_list = NULL; wifi_iface_count = 0; + } else if (!strcmp(class, "infix-hardware:gps")) { + if (event != SR_EV_DONE) + continue; + + systemf("initctl -nbq touch statd"); } } diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am index 9dd4fa4d..160495b2 100644 --- a/src/statd/Makefile.am +++ b/src/statd/Makefile.am @@ -2,7 +2,7 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 sbin_PROGRAMS = statd -statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h +statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h gpsd.c gpsd.h statd_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE statd_CFLAGS = -W -Wall -Wextra statd_CFLAGS += $(jansson_CFLAGS) $(libyang_CFLAGS) $(sysrepo_CFLAGS) diff --git a/src/statd/gpsd.c b/src/statd/gpsd.c new file mode 100644 index 00000000..770b4dbe --- /dev/null +++ b/src/statd/gpsd.c @@ -0,0 +1,415 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +/* + * Background GPS monitor for statd. + * + * Maintains a persistent connection to gpsd (localhost:2947) and caches + * GPS device status to /run/gps-status.json. The yanger ietf_hardware + * module reads this cache instead of spawning gpspipe, avoiding blocking + * the operational datastore. + * + * Activated on SIGHUP when sysrepo running config contains a hardware + * component with class infix-hardware:gps, reconnects automatically + * if gpsd restarts. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "gpsd.h" + +static int gps_device_present(void) +{ + struct stat st; + int i; + + for (i = 0; i < 4; i++) { + char path[32]; + + snprintf(path, sizeof(path), "/dev/gps%d", i); + if (stat(path, &st) == 0) + return 1; + } + + return 0; +} + +static void cache_write(struct gpsd_ctx *ctx) +{ + char tmp[] = GPSD_CACHE_FILE ".XXXXXX"; + int fd; + + if (!ctx->cache) + return; + + fd = mkstemp(tmp); + if (fd < 0) { + ERROR("gpsd: failed to create temp file: %s", strerror(errno)); + return; + } + + if (json_dumpfd(ctx->cache, fd, JSON_INDENT(2)) < 0) { + ERROR("gpsd: failed to write cache"); + close(fd); + unlink(tmp); + return; + } + + close(fd); + if (rename(tmp, GPSD_CACHE_FILE) < 0) { + ERROR("gpsd: failed to rename cache: %s", strerror(errno)); + unlink(tmp); + } +} + +static void handle_devices(struct gpsd_ctx *ctx, json_t *msg) +{ + json_t *devices = json_object_get(msg, "devices"); + size_t i; + + if (!json_is_array(devices)) + return; + + for (i = 0; i < json_array_size(devices); i++) { + json_t *dev = json_array_get(devices, i); + const char *path = json_string_value(json_object_get(dev, "path")); + json_t *entry, *driver, *activated; + + if (!path) + continue; + + entry = json_object_get(ctx->cache, path); + if (!entry) { + entry = json_object(); + json_object_set_new(ctx->cache, path, entry); + } + + driver = json_object_get(dev, "driver"); + if (json_is_string(driver)) + json_object_set(entry, "driver", driver); + + /* activated is a timestamp string when active, absent when not */ + activated = json_object_get(dev, "activated"); + if (activated && json_is_string(activated) && + strlen(json_string_value(activated)) > 0) + json_object_set_new(entry, "activated", json_true()); + else + json_object_set_new(entry, "activated", json_false()); + } +} + +static void handle_tpv(struct gpsd_ctx *ctx, json_t *msg) +{ + const char *path = json_string_value(json_object_get(msg, "device")); + json_t *entry, *val; + + if (!path) + return; + + entry = json_object_get(ctx->cache, path); + if (!entry) { + entry = json_object(); + json_object_set_new(ctx->cache, path, entry); + } + + /* Fix mode: 0=unknown, 1=none, 2=2D, 3=3D */ + val = json_object_get(msg, "mode"); + if (json_is_integer(val)) + json_object_set(entry, "mode", val); + + val = json_object_get(msg, "lat"); + if (json_is_number(val)) + json_object_set(entry, "lat", val); + + val = json_object_get(msg, "lon"); + if (json_is_number(val)) + json_object_set(entry, "lon", val); + + val = json_object_get(msg, "altHAE"); + if (json_is_number(val)) + json_object_set(entry, "altHAE", val); +} + +static void handle_sky(struct gpsd_ctx *ctx, json_t *msg) +{ + const char *path = json_string_value(json_object_get(msg, "device")); + json_t *entry, *sats; + size_t i, visible, used; + + if (!path) + return; + + entry = json_object_get(ctx->cache, path); + if (!entry) { + entry = json_object(); + json_object_set_new(ctx->cache, path, entry); + } + + sats = json_object_get(msg, "satellites"); + if (!json_is_array(sats)) + return; + + visible = json_array_size(sats); + used = 0; + for (i = 0; i < visible; i++) { + json_t *sat = json_array_get(sats, i); + + if (json_is_true(json_object_get(sat, "used"))) + used++; + } + + json_object_set_new(entry, "satellites_visible", json_integer(visible)); + json_object_set_new(entry, "satellites_used", json_integer(used)); +} + +static void process_line(struct gpsd_ctx *ctx, const char *line) +{ + json_error_t err; + json_t *msg; + const char *cls; + + msg = json_loads(line, 0, &err); + if (!msg) + return; + + cls = json_string_value(json_object_get(msg, "class")); + if (!cls) + goto out; + + if (strcmp(cls, "DEVICES") == 0) + handle_devices(ctx, msg); + else if (strcmp(cls, "TPV") == 0) + handle_tpv(ctx, msg); + else if (strcmp(cls, "SKY") == 0) + handle_sky(ctx, msg); + + cache_write(ctx); +out: + json_decref(msg); +} + +static void gpsd_disconnect(struct gpsd_ctx *ctx) +{ + if (!ctx->connected) + return; + + ev_io_stop(ctx->loop, &ctx->sock_watcher); + close(ctx->sock_fd); + ctx->sock_fd = -1; + ctx->connected = 0; + ctx->buf_used = 0; + DEBUG("gpsd: disconnected"); +} + +static void sock_read_cb(struct ev_loop *, ev_io *w, int) +{ + struct gpsd_ctx *ctx = (struct gpsd_ctx *)w->data; + char *start, *nl; + ssize_t n; + + n = read(ctx->sock_fd, ctx->buf + ctx->buf_used, + sizeof(ctx->buf) - ctx->buf_used - 1); + if (n <= 0) { + if (n < 0 && (errno == EAGAIN || errno == EINTR)) + return; + DEBUG("gpsd: connection lost (%s)", n == 0 ? "EOF" : strerror(errno)); + gpsd_disconnect(ctx); + return; + } + + ctx->buf_used += n; + ctx->buf[ctx->buf_used] = '\0'; + + /* Process complete lines (gpsd sends one JSON object per line) */ + start = ctx->buf; + while ((nl = strchr(start, '\n')) != NULL) { + *nl = '\0'; + if (nl > start) + process_line(ctx, start); + start = nl + 1; + } + + /* Shift remaining partial line to beginning of buffer */ + if (start != ctx->buf) { + ctx->buf_used -= (start - ctx->buf); + memmove(ctx->buf, start, ctx->buf_used); + } + + /* Buffer overflow protection */ + if (ctx->buf_used >= sizeof(ctx->buf) - 1) { + ERROR("gpsd: read buffer overflow, resetting"); + ctx->buf_used = 0; + } +} + +static int gpsd_connect(struct gpsd_ctx *ctx) +{ + static const char watch_cmd[] = "?WATCH={\"enable\":true,\"json\":true};\n"; + struct sockaddr_in addr; + int fd, flags; + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + DEBUG("gpsd: socket(): %s", strerror(errno)); + return -1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(GPSD_PORT); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + DEBUG("gpsd: connect(): %s", strerror(errno)); + close(fd); + return -1; + } + + /* Enable JSON watch mode (socket still blocking, localhost = instant) */ + if (write(fd, watch_cmd, strlen(watch_cmd)) < 0) { + ERROR("gpsd: failed to send WATCH command: %s", strerror(errno)); + close(fd); + return -1; + } + + /* Switch to non-blocking for ev_io */ + flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + + /* Clear stale cache data from previous connection */ + json_object_clear(ctx->cache); + + ctx->sock_fd = fd; + ctx->connected = 1; + ctx->buf_used = 0; + + ev_io_init(&ctx->sock_watcher, sock_read_cb, fd, EV_READ); + ctx->sock_watcher.data = ctx; + ev_io_start(ctx->loop, &ctx->sock_watcher); + + INFO("gpsd: connected"); + return 0; +} + +static void check_timer_cb(struct ev_loop *, ev_timer *w, int) +{ + struct gpsd_ctx *ctx = (struct gpsd_ctx *)w->data; + + if (ctx->connected) + return; + + if (!gps_device_present()) { + unlink(GPSD_CACHE_FILE); + return; + } + + gpsd_connect(ctx); +} + +/* + * Check sysrepo running config for hardware components with class + * infix-hardware:gps. Returns 1 if at least one is found. + */ +static int has_gps_component(sr_conn_ctx_t *conn) +{ + sr_session_ctx_t *ses; + sr_val_t *vals = NULL; + size_t cnt = 0; + int found; + + if (sr_session_start(conn, SR_DS_RUNNING, &ses)) + return 0; + + sr_get_items(ses, + "/ietf-hardware:hardware/component[class='infix-hardware:gps']/name", + 0, 0, &vals, &cnt); + + found = cnt > 0; + sr_free_values(vals, cnt); + sr_session_stop(ses); + + return found; +} + +static void gpsd_activate(struct gpsd_ctx *ctx) +{ + if (ctx->active) + return; + + ctx->active = 1; + ev_timer_start(ctx->loop, &ctx->check_timer); + + if (gps_device_present()) + gpsd_connect(ctx); + + INFO("gpsd: GPS monitoring activated"); +} + +static void gpsd_deactivate(struct gpsd_ctx *ctx) +{ + if (!ctx->active) + return; + + ctx->active = 0; + gpsd_disconnect(ctx); + ev_timer_stop(ctx->loop, &ctx->check_timer); + json_object_clear(ctx->cache); + unlink(GPSD_CACHE_FILE); + + INFO("gpsd: GPS monitoring deactivated"); +} + +void gpsd_reload(struct gpsd_ctx *ctx) +{ + if (has_gps_component(ctx->sr_conn)) + gpsd_activate(ctx); + else + gpsd_deactivate(ctx); +} + +int gpsd_init(struct gpsd_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *conn) +{ + memset(ctx, 0, sizeof(*ctx)); + ctx->loop = loop; + ctx->sock_fd = -1; + ctx->sr_conn = conn; + + ctx->cache = json_object(); + if (!ctx->cache) { + ERROR("gpsd: failed to create cache object"); + return -1; + } + + ev_timer_init(&ctx->check_timer, check_timer_cb, + GPSD_CHECK_INTERVAL, GPSD_CHECK_INTERVAL); + ctx->check_timer.data = ctx; + /* Timer not started here -- gpsd_reload() activates when needed */ + + INFO("gpsd: GPS monitor initialized"); + return 0; +} + +void gpsd_exit(struct gpsd_ctx *ctx) +{ + gpsd_deactivate(ctx); + + if (ctx->cache) { + json_decref(ctx->cache); + ctx->cache = NULL; + } + + INFO("gpsd: GPS monitor stopped"); +} diff --git a/src/statd/gpsd.h b/src/statd/gpsd.h new file mode 100644 index 00000000..94fdc71b --- /dev/null +++ b/src/statd/gpsd.h @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#ifndef STATD_GPSD_H_ +#define STATD_GPSD_H_ + +#include +#include +#include + +#define GPSD_CACHE_FILE "/run/gps-status.json" +#define GPSD_PORT 2947 +#define GPSD_CHECK_INTERVAL 10.0 /* Seconds between device/connection checks */ +#define GPSD_READ_BUFSZ 4096 + +struct gpsd_ctx { + struct ev_loop *loop; + ev_timer check_timer; /* Periodic check for GPS devices / reconnect */ + ev_io sock_watcher; /* Read watcher on gpsd socket */ + int sock_fd; /* TCP socket to gpsd */ + char buf[GPSD_READ_BUFSZ]; /* Line accumulation buffer */ + size_t buf_used; + json_t *cache; /* Accumulated GPS data, keyed by device path */ + int connected; + sr_conn_ctx_t *sr_conn; /* Sysrepo connection for config queries */ + int active; /* GPS monitoring enabled (config has gps component) */ +}; + +int gpsd_init(struct gpsd_ctx *ctx, struct ev_loop *loop, sr_conn_ctx_t *conn); +void gpsd_reload(struct gpsd_ctx *ctx); +void gpsd_exit(struct gpsd_ctx *ctx); + +#endif diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 488655d7..54bc5e05 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2132,6 +2132,7 @@ def show_hardware(json): usb_ports = [c for c in components if c.get("class") == "infix-hardware:usb"] sensors = [c for c in components if c.get("class") == "iana-hardware:sensor"] wifi_radios = [c for c in components if c.get("class") == "infix-hardware:wifi"] + gps_receivers = [c for c in components if c.get("class") == "infix-hardware:gps"] width = max(PadSensor.table_width(), 62) @@ -2203,6 +2204,44 @@ def show_hardware(json): radios_table.row(phy, manufacturer, bands_str, standard_str, max_ap) radios_table.print() + if gps_receivers: + Decore.title("GPS/GNSS Receivers", width) + + for component in gps_receivers: + gps = component.get("infix-hardware:gps-receiver", {}) + name = component.get("name", "unknown") + device = gps.get("device", "N/A") + driver = gps.get("driver", "Unknown") + fix = gps.get("fix-mode", "none") + activated = gps.get("activated", False) + + print(f"{'Name':<20}: {name}") + print(f"{'Device':<20}: {device}") + print(f"{'Driver':<20}: {driver}") + print(f"{'Status':<20}: {'Active' if activated else 'Inactive'}") + print(f"{'Fix':<20}: {fix.upper()}") + + sat_vis = gps.get("satellites-visible") + sat_used = gps.get("satellites-used") + if sat_vis is not None: + print(f"{'Satellites':<20}: {sat_used}/{sat_vis} (used/visible)") + + lat = gps.get("latitude") + lon = gps.get("longitude") + alt = gps.get("altitude") + if lat is not None and lon is not None: + lat_f = float(lat) + lon_f = float(lon) + lat_dir = "N" if lat_f >= 0 else "S" + lon_dir = "E" if lon_f >= 0 else "W" + pos = f"{abs(lat_f):.6f}{lat_dir} {abs(lon_f):.6f}{lon_dir}" + if alt is not None: + pos += f" {alt}m" + print(f"{'Position':<20}: {pos}") + + pps = gps.get("pps-available", False) + print(f"{'PPS':<20}: {'Available' if pps else 'Not available'}") + if usb_ports: Decore.title("USB Ports", width) @@ -2509,9 +2548,16 @@ def show_ntp(json, address=None): show_ntp_source_detail_single(matching[0], False) return + # Check for GPS/GNSS hardware reference clocks + hw_components = json.get("ietf-hardware:hardware", {}).get("component", []) + gps_sources = [c for c in hw_components if c.get("class") == "infix-hardware:gps"] + if is_server: if sources: print(f"{'Mode':<20}: Relay (no local reference clock)") + elif gps_sources: + gps_names = ", ".join(c.get("name", "?") for c in gps_sources) + print(f"{'Mode':<20}: Server (GPS reference clock: {gps_names})") else: print(f"{'Mode':<20}: Server (local reference clock)") print(f"{'Port':<20}: {port}") @@ -2808,10 +2854,43 @@ def show_ntp_source(json, address=None): return associations = ntp_data.get("associations", {}).get("association", []) - if not associations: + + # Check for GPS/GNSS reference clock sources from hardware data + hw_components = json.get("ietf-hardware:hardware", {}).get("component", []) + gps_sources = [c for c in hw_components if c.get("class") == "infix-hardware:gps"] + + # Show GPS reference clocks + if gps_sources: + clock_state = ntp_data.get("clock-state", {}).get("system-status", {}) + clock_refid = clock_state.get("clock-refid", "").strip() + + for gps in gps_sources: + gps_data = gps.get("infix-hardware:gps-receiver", {}) + name = gps.get("name", "unknown") + driver = gps_data.get("driver", "Unknown") + fix = gps_data.get("fix-mode", "none") + activated = gps_data.get("activated", False) + sat_used = gps_data.get("satellites-used", 0) + sat_vis = gps_data.get("satellites-visible", 0) + + # Determine if this GPS is the current sync source + is_synced = clock_refid in ("GPS", "PPS", "GLO", "GAL", "BDS", "GNSS") + + state = "selected" if is_synced else ("active" if activated else "inactive") + print(f"{'Reference Clock':<20}: {name} ({driver})") + print(f"{'Status':<20}: {state}") + print(f"{'Fix Mode':<20}: {fix.upper()}") + if sat_vis: + print(f"{'Satellites':<20}: {sat_used}/{sat_vis} (used/visible)") + print() + + if not associations and not gps_sources: print("No NTP associations found.") return + if not associations: + return + # If address specified, show detailed view for that association if address: matching = [a for a in associations if a.get('address') == address] diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 4ee32970..b68493c4 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -667,6 +667,85 @@ def wifi_radio_components(): return components +def gps_receiver_components(): + """Discover GPS/GNSS receivers and populate operational state. + + GPS devices are discovered via /dev/gps* symlinks (created by udev rules). + Status is read from /run/gps-status.json, a cache maintained by statd's + background GPS monitor (gpsd.c) which streams data from gpsd without + blocking the operational datastore. + """ + components = [] + + # Discover GPS devices via /dev/gps* symlinks (created by udev rules) + gps_devices = {} + for i in range(4): + dev_path = f"/dev/gps{i}" + if not HOST.exists(dev_path): + continue + # Resolve symlink to actual device (for matching gpsd cache keys) + actual = HOST.run(("readlink", "-f", dev_path), default="").strip() + gps_devices[actual] = { + "name": f"gps{i}", + "symlink": dev_path, + } + + if not gps_devices: + return components + + # Read cached GPS status from statd background monitor + cache = HOST.read_json("/run/gps-status.json", {}) + + # Build hardware components for each discovered GPS device + for actual_path, dev in gps_devices.items(): + name = dev["name"] + component = { + "name": name, + "class": "infix-hardware:gps", + "description": "GPS/GNSS Receiver" + } + + gps_data = {} + gps_data["device"] = dev["symlink"] + + # Look up cached status by actual device path + info = cache.get(actual_path, {}) + + if info.get("driver"): + gps_data["driver"] = info["driver"] + gps_data["activated"] = bool(info.get("activated")) + + mode = info.get("mode", 0) + if mode == 2: + gps_data["fix-mode"] = "2d" + elif mode == 3: + gps_data["fix-mode"] = "3d" + else: + gps_data["fix-mode"] = "none" + + if "lat" in info: + gps_data["latitude"] = f"{float(info['lat']):.6f}" + if "lon" in info: + gps_data["longitude"] = f"{float(info['lon']):.6f}" + if "altHAE" in info: + gps_data["altitude"] = f"{float(info['altHAE']):.1f}" + + if "satellites_visible" in info: + gps_data["satellites-visible"] = int(info["satellites_visible"]) + gps_data["satellites-used"] = int(info.get("satellites_used", 0)) + + # Check for PPS device availability + pps_path = f"/dev/pps{name.replace('gps', '')}" + gps_data["pps-available"] = HOST.exists(pps_path) + + if gps_data: + component["infix-hardware:gps-receiver"] = gps_data + + components.append(component) + + return components + + def operational(): systemjson = HOST.read_json("/run/system.json", {}) @@ -679,6 +758,7 @@ def operational(): hwmon_sensor_components() + thermal_sensor_components() + wifi_radio_components() + + gps_receiver_components() + [], }, } diff --git a/src/statd/python/yanger/ietf_ntp.py b/src/statd/python/yanger/ietf_ntp.py index cdfade9e..80c80e2e 100644 --- a/src/statd/python/yanger/ietf_ntp.py +++ b/src/statd/python/yanger/ietf_ntp.py @@ -5,7 +5,39 @@ from .host import HOST def add_ntp_associations(out): - """Add NTP association information from chronyc sources and sourcestats""" + """Add NTP association information from chronyc sources and sourcestats. + + The chronyc -c sources output is CSV with the following fields: + [0] Mode indicator: + ^ server (we're a client to this source) + = peer (symmetric mode) + # local reference clock (GPS, PPS, etc.) - skipped, no IP address + [1] State indicator: + * selected (current sync source) + + candidate + - outlier + ? unusable + x falseticker + ~ unstable + [2] Address (IP address or refclock name like "GPS") + [3] Stratum + [4] Poll interval (log2 seconds) + [5] Reach (octal reachability register) + [6] LastRx (seconds since last response) + [7] Last offset (seconds) + [8] Offset at last update (seconds) + [9] Error estimate (seconds) + + The chronyc -c sourcestats output is CSV with: + [0] Address + [1] NP (number of sample points) + [2] NR (number of runs) + [3] Span (seconds) + [4] Frequency (ppm) + [5] Freq Skew (ppm) + [6] Offset (seconds) + [7] Std Dev (seconds) + """ try: # Get basic source information sources_data = HOST.run_multiline(["chronyc", "-c", "sources"], []) @@ -44,6 +76,10 @@ def add_ntp_associations(out): continue mode_indicator = parts[0] + # Skip reference clocks (mode "#") as they have names like "GPS" instead of IP addresses + if mode_indicator == "#": + continue + state_indicator = parts[1] address = parts[2] stratum = int(parts[3]) @@ -156,7 +192,9 @@ def add_ntp_clock_state(out): refid_name = parts[1] if refid_name: - system_status["clock-refid"] = refid_name + # NTP refids are always 4 bytes; chronyc strips trailing padding. + # YANG typedef 'refid' requires exactly length 4 for strings. + system_status["clock-refid"] = refid_name.ljust(4)[:4] elif refid_ip and len(refid_ip) == 8: try: a = int(refid_ip[0:2], 16) diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index eeca4b71..60c7c6c0 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -36,6 +36,29 @@ def get_boot_order(): return order def add_ntp(out): + """Add NTP source information from chronyc sources. + + The chronyc -c sources output is CSV with the following fields: + [0] Mode indicator: + ^ server (we're a client to this source) + = peer (symmetric mode) + # local reference clock (GPS, PPS, etc.) - skipped, no IP address + [1] State indicator: + * selected (current sync source) + + candidate + - outlier + ? unusable + x falseticker + ~ unstable + [2] Address (IP address or refclock name like "GPS") + [3] Stratum + [4] Poll interval (log2 seconds) + [5] Reach (octal reachability register) + [6] LastRx (seconds since last response) + [7] Last offset (seconds) + [8] Offset at last update (seconds) + [9] Error estimate (seconds) + """ data = HOST.run_multiline(["chronyc", "-c", "sources"], []) source = [] state_mode_map = { @@ -54,6 +77,9 @@ def add_ntp(out): for line in data: src = {} line = line.split(',') + # Skip reference clocks (mode "#") as they have names like "GPS" instead of IP addresses + if line[0] == "#": + continue src["address"] = line[2] src["mode"] = state_mode_map[line[0]] src["state"] = source_state_map[line[1]] diff --git a/src/statd/python/yanger/mock.leases b/src/statd/python/yanger/mock.leases new file mode 100644 index 00000000..b27e301e --- /dev/null +++ b/src/statd/python/yanger/mock.leases @@ -0,0 +1,3 @@ +1692853200 00:1a:2b:3c:4d:5e 192.168.1.100 hostname01 01:00:1a:2b:3c:4d:5e +1692853800 00:1a:2b:3c:4d:5f 192.168.1.101 hostname02 * +1692854400 00:1a:2b:3c:4d:60 192.168.1.102 hostname03 01:00:1a:2b:3c:4d:60 diff --git a/src/statd/statd.c b/src/statd/statd.c index b07ce04c..139c0943 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -30,6 +30,7 @@ #include "shared.h" #include "journal.h" +#include "gpsd.h" /* New kernel feature, not in sys/mman.h yet */ #ifndef MFD_NOEXEC_SEAL @@ -69,6 +70,7 @@ struct statd { sr_conn_ctx_t *sr_conn; /* Connection (owns YANG context) */ struct ev_loop *ev_loop; struct journal_ctx journal; /* Journal thread context */ + struct gpsd_ctx gpsd; /* GPS monitor context */ }; static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent, @@ -350,6 +352,14 @@ static void sigusr1_cb(struct ev_loop *, struct ev_signal *, int) debug ^= 1; } +static void sighup_cb(struct ev_loop *, struct ev_signal *w, int) +{ + struct statd *statd = (struct statd *)w->data; + + INFO("SIGHUP received, reloading GPS config"); + gpsd_reload(&statd->gpsd); +} + static void sr_event_cb(struct ev_loop *, struct ev_io *w, int) { struct sub *sub = (struct sub *)w->data; @@ -452,7 +462,7 @@ static int subscribe_to_all(struct statd *statd) int main(int argc, char *argv[]) { - struct ev_signal sigint_watcher, sigusr1_watcher; + struct ev_signal sigint_watcher, sigusr1_watcher, sighup_watcher; int log_opts = LOG_PID | LOG_NDELAY; struct statd statd = {}; const char *env; @@ -513,6 +523,10 @@ int main(int argc, char *argv[]) sigusr1_watcher.data = &statd; ev_signal_start(statd.ev_loop, &sigusr1_watcher); + ev_signal_init(&sighup_watcher, sighup_cb, SIGHUP); + sighup_watcher.data = &statd; + ev_signal_start(statd.ev_loop, &sighup_watcher); + err = journal_start(&statd.journal, statd.sr_query_ses); if (err) { sr_session_stop(statd.sr_query_ses); @@ -521,6 +535,10 @@ int main(int argc, char *argv[]) return EXIT_FAILURE; } + if (gpsd_init(&statd.gpsd, statd.ev_loop, statd.sr_conn)) + INFO("GPS monitoring not available"); + gpsd_reload(&statd.gpsd); + /* Signal readiness to Finit */ pidfile(NULL); @@ -530,6 +548,7 @@ int main(int argc, char *argv[]) /* We should never get here during normal operation */ INFO("Status daemon shutting down"); + gpsd_exit(&statd.gpsd); journal_stop(&statd.journal); unsub_to_all(&statd);