diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 538961f4b..85d34a1b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -133,10 +133,6 @@ jobs: podman rm -af || true pkill -9 -f rootlessport || true - - name: Unit Test ${{ env.TARGET }} - run: | - make test-unit - - name: Prepare parallel build id: parallel run: | diff --git a/.github/workflows/trigger.yml b/.github/workflows/trigger.yml index 4e68ae69c..223c1a8cb 100644 --- a/.github/workflows/trigger.yml +++ b/.github/workflows/trigger.yml @@ -50,23 +50,29 @@ jobs: echo "aarch64_target=aarch64" >> $GITHUB_OUTPUT echo "arm_target=arm" >> $GITHUB_OUTPUT fi + unit-test: + needs: check-trigger + uses: ./.github/workflows/unit-test.yml + with: + name: "infix" + target: ${{ needs.check-trigger.outputs.x86_64_target }} build-x86_64: - needs: check-trigger + needs: [check-trigger, unit-test] uses: ./.github/workflows/build.yml with: name: "infix" target: ${{ needs.check-trigger.outputs.x86_64_target }} build-aarch64: - needs: check-trigger + needs: [check-trigger, unit-test] uses: ./.github/workflows/build.yml with: name: "infix" target: ${{ needs.check-trigger.outputs.aarch64_target }} build-arm: - needs: check-trigger + needs: [check-trigger, unit-test] uses: ./.github/workflows/build.yml with: name: "infix" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 000000000..21f1b3585 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,116 @@ +name: unit-test + +on: + workflow_dispatch: + inputs: + target: + description: "Build target (e.g. aarch64 or aarch64_minimal)" + default: "x86_64" + type: string + parallel: + description: 'Massive parallel build of each image' + default: true + type: boolean + name: + description: "Name (for spin overrides)" + default: "infix" + type: string + infix_repo: + description: 'Repo to checkout (for spin overrides)' + default: kernelkit/infix + type: string + infix_branch: + description: 'Branch/tag/commit to checkout (for spin overrides)' + default: '' + type: string + + workflow_call: + inputs: + target: + required: true + type: string + name: + required: true + type: string + infix_repo: + required: false + type: string + default: kernelkit/infix + infix_branch: + required: false + type: string + default: '' + description: 'Branch/tag/commit to checkout (for spin overrides). Defaults to github.ref if not specified' + parallel: + required: false + type: boolean + default: true + pre_build_script: + required: false + type: string + default: '' + description: 'Optional script to run after checkout (for spin customization)' + secrets: + CHECKOUT_TOKEN: + required: false + description: 'Token for cross-repository access' + +env: + NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.name || inputs.name }} + TARGET: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.target || inputs.target }} + INFIX_REPO: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.infix_repo || inputs.infix_repo }} + INFIX_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.infix_branch || inputs.infix_branch }} + +jobs: + unit-tests: + name: Build ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.name || inputs.name }} ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.target || inputs.target }} + runs-on: [ self-hosted, latest ] + env: + PARALLEL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.parallel == 'true' || github.event_name != 'workflow_dispatch' && inputs.parallel == true }} + strategy: + fail-fast: false + steps: + - name: Cleanup Build Folder + run: | + ls -la ./ + rm -rf ./* || true + rm -rf ./.??* || true + ls -la ./ + + - name: Checkout infix repo + uses: actions/checkout@v4 + with: + repository: ${{ env.INFIX_REPO }} + ref: ${{ env.INFIX_BRANCH != '' && env.INFIX_BRANCH || github.ref }} + clean: true + fetch-depth: 0 + submodules: recursive + token: ${{ secrets.CHECKOUT_TOKEN || github.token }} + + - uses: ./.github/actions/podman-cleanup + + - name: Run pre-build script + if: ${{ inputs.pre_build_script != '' }} + run: | + cat > ./pre-build.sh << 'EOF' + ${{ inputs.pre_build_script }} + EOF + chmod +x ./pre-build.sh + bash ./pre-build.sh + + - uses: ./.github/actions/cache-restore + with: + target: ${{ env.TARGET }} + + - name: Configure ${{ env.TARGET }} + run: | + make ${{ env.TARGET }}_defconfig + + - name: Cleanup stale containers and ports + run: | + podman rm -af || true + pkill -9 -f rootlessport || true + + - name: Unit Test ${{ env.TARGET }} + run: | + make test-unit diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 7cfb2347f..e01756ca6 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -30,6 +30,11 @@ CONFD_CONF_OPTS += --enable-wifi else CONFD_CONF_OPTS += --disable-wifi endif +ifeq ($(BR2_PACKAGE_FEATURE_GPS),y) +CONFD_CONF_OPTS += --enable-gps +else +CONFD_CONF_OPTS += --disable-gps +endif define CONFD_INSTALL_EXTRA for fn in confd.conf resolvconf.conf; do \ cp $(CONFD_PKGDIR)/$$fn $(FINIT_D)/available/; \ @@ -80,6 +85,12 @@ define CONFD_INSTALL_YANG_MODULES_WIFI $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/wifi.inc endef endif +ifeq ($(BR2_PACKAGE_FEATURE_GPS),y) +define CONFD_INSTALL_YANG_MODULES_GPS + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/gps.inc +endef +endif # PER_PACKAGE_DIR # Since the last package in the dependency chain that runs sysrepoctl is confd, we need to @@ -109,6 +120,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_EXTRA CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP diff --git a/package/feature-gps/25-gpsd.rules b/package/feature-gps/25-gpsd.rules index 906936b46..a95c535e8 100644 --- a/package/feature-gps/25-gpsd.rules +++ b/package/feature-gps/25-gpsd.rules @@ -7,59 +7,60 @@ SUBSYSTEM!="tty", GOTO="gpsd_rules_end" # Prolific Technology, Inc. PL2303 Serial Port [linux module: pl2303] # !!! rule disabled in Debian as it matches too many other devices -# ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +# ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", SYMLINK+="gps%n" # ATEN International Co., Ltd UC-232A Serial Port [linux module: pl2303] -ATTRS{idVendor}=="0557", ATTRS{idProduct}=="2008", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="0557", ATTRS{idProduct}=="2008", SYMLINK+="gps%n" # PS-360 OEM (GPS sold with MS Street and Trips 2005) [linux module: pl2303] -ATTRS{idVendor}=="067b", ATTRS{idProduct}=="aaa0", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="067b", ATTRS{idProduct}=="aaa0", SYMLINK+="gps%n" # FTDI 8U232AM / FT232 [linux module: ftdi_sio] # !!! rule disabled in Debian as it matches too many other devices -# ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +# ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="gps%n" # Cypress M8/CY7C64013 (Delorme uses these) [linux module: cypress_m8] -ATTRS{idVendor}=="1163", ATTRS{idProduct}=="0100", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1163", ATTRS{idProduct}=="0100", SYMLINK+="gps%n" # Cypress M8/CY7C64013 (DeLorme LT-40) -ATTRS{idVendor}=="1163", ATTRS{idProduct}=="0200", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1163", ATTRS{idProduct}=="0200", SYMLINK+="gps%n" # Garmin International GPSmap, various models (tested with Garmin GPS 18 USB) [linux module: garmin_gps] -ATTRS{idVendor}=="091e", ATTRS{idProduct}=="0003", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="091e", ATTRS{idProduct}=="0003", SYMLINK+="gps%n" # Cygnal Integrated Products, Inc. CP210x Composite Device (Used by Holux m241 and Wintec grays2 wbt-201) [linux module: cp210x] # !!! rule disabled in Debian as it matches too many other devices -#ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +#ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="gps%n" # Cygnal Integrated Products, Inc. [linux module: cp210x] # !!! rule disabled in Debian as it matches too many other devices -#ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea71", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +#ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea71", SYMLINK+="gps%n" # u-blox AG, u-blox 5 (tested with Navilock NL-402U) [linux module: cdc_acm] -ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a5", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a5", SYMLINK+="gps%n" # u-blox AG, u-blox 6 (tested with GNSS Evaluation Kit TCXO) [linux module: cdc_acm] -ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a6", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a6", SYMLINK+="gps%n" # u-blox AG, u-blox 7 [linux module: cdc_acm] -ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a7", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a7", SYMLINK+="gps%n" # u-blox AG, u-blox 8 (tested with GNSS Evaluation Kit EKV-M8N) [linux module: cdc_acm] -ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a8", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a8", SYMLINK+="gps%n" # u-blox AG, u-blox 9 (tested with GNSS Evaluation Kit C099-F9P) [linux module: cdc_acm] -ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a9", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="1546", ATTRS{idProduct}=="01a9", SYMLINK+="gps%n" # MediaTek (tested with HOLUX M-1200E) [linux module: cdc_acm] -ATTRS{idVendor}=="0e8d", ATTRS{idProduct}=="3329", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{idVendor}=="0e8d", ATTRS{idProduct}=="3329", SYMLINK+="gps%n" # Telit wireless solutions (tested with HE910G) [linux module: cdc_acm] -ATTRS{interface}=="Telit Wireless Module Port", ATTRS{bInterfaceNumber}=="06", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" +ATTRS{interface}=="Telit Wireless Module Port", ATTRS{bInterfaceNumber}=="06", SYMLINK+="gps%n" -# u-blox AG, u-blox 8 (tested with u-blox8 GNSS Mouse Receiver / GR-801) [linux module: cdc_acm] +# u-blox AG, u-blox 8 (tested with u-blox8 GNSS Mouse Receiver / GR-801) [linux module: cdc_acm] # !!! rule disabled in Debian as it matches too many other devices -#ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", SYMLINK+="gps%n", RUN+="/usr/lib/udev/gpsd.hotplug" - -ACTION=="remove", RUN+="/usr/lib/udev/gpsd.hotplug" +#ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", SYMLINK+="gps%n" LABEL="gpsd_rules_end" + +# Virtio serial GPS port (QEMU emulated GPS for testing) +SUBSYSTEM=="virtio-ports", ATTR{name}=="gps0", SYMLINK+="gps0" diff --git a/src/confd/configure.ac b/src/confd/configure.ac index 6266615be..f8071c2c3 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -46,6 +46,10 @@ AC_ARG_ENABLE(wifi, AS_HELP_STRING([--enable-wifi], [Enable support for Wi-Fi]),,[ enable_wifi=no]) +AC_ARG_ENABLE(gps, + AS_HELP_STRING([--enable-gps], [Enable support for GPS receivers]),,[ + enable_gps=no]) + AC_ARG_WITH(login-shell, AS_HELP_STRING([--with-login-shell=shell], [Login shell for new users, default: /bin/false]), [login_shell=$withval], [login_shell=yes]) @@ -60,6 +64,9 @@ AS_IF([test "x$enable_containers" = "xyes"], [ AS_IF([test "x$enable_wifi" = "xyes"], [ AC_DEFINE(HAVE_WIFI, 1, [Built with Wi-Fi support])]) +AS_IF([test "x$enable_gps" = "xyes"], [ + AC_DEFINE(HAVE_GPS, 1, [Built with GPS receiver support])]) + AS_IF([test "x$with_login_shell" != "xno"], [ AS_IF([test "x$login_shell" = "xyes"], [login_shell=/bin/false]) AC_DEFINE_UNQUOTED(LOGIN_SHELL, "$login_shell", [Default: /bin/false])],[ @@ -131,6 +138,7 @@ cat < 0) { + FILE *fp; + + fp = fopen(GPSD_CONF_NEXT, "w"); + if (!fp) { + ERROR("Could not open " GPSD_CONF_NEXT); + return SR_ERR_INTERNAL; + } + int i; + + fprintf(fp, "# Generated by confd, do not edit.\n"); + fprintf(fp, "service"); + fprintf(fp, " "); + fprintf(fp, " pid:!/run/gpsd.pid"); + fprintf(fp, " env:-/etc/default/gpsd \\\n"); + fprintf(fp, "\t[2345] gpsd -n"); + for (i = 0; i < num_gps && i < GPSD_MAX_DEVICES; i++) + fprintf(fp, " /dev/%s", gps_names[i]); + fprintf(fp, " -P /run/gpsd.pid -- GPS Daemon\n"); + fclose(fp); + } + break; + + case SR_EV_DONE: + if (fexist(GPSD_CONF_NEXT)) { + unlink(GPSD_CONF); + rename(GPSD_CONF_NEXT, GPSD_CONF); + systemf("initctl -nbq enable gpsd"); + } else { + unlink(GPSD_CONF); + systemf("initctl -nbq disable gpsd"); + } + break; + } + } err: return rc; diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index b28ada610..6da99e9b9 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -45,6 +45,13 @@ module infix-hardware { description "Initial"; reference "internal"; } + feature wifi { + description "WiFi support is an optional build-time feature in Infix."; + } + + feature gps { + description "GPS support is an optional build-time feature in Infix."; + } typedef country-code { type string { @@ -104,11 +111,13 @@ module infix-hardware { description "This identity is used to a VPD memory on the device."; } identity wifi { + if-feature wifi; base iahw:hardware-class; description "This identity is used to describe a WiFi radio/PHY"; } identity gps { + if-feature gps; base iahw:hardware-class; description "GPS/GNSS receiver for time synchronization"; } @@ -211,6 +220,7 @@ module infix-hardware { */ container wifi-radio { + if-feature wifi; when "derived-from-or-self(../iehw:class, 'ih:wifi')"; presence "WiFi radio configuration"; description @@ -534,6 +544,7 @@ module infix-hardware { */ container gps-receiver { + if-feature gps; when "derived-from-or-self(../iehw:class, 'ih:gps')"; presence "GPS receiver configuration"; description diff --git a/src/confd/yang/gps.inc b/src/confd/yang/gps.inc new file mode 100644 index 000000000..f009b15e4 --- /dev/null +++ b/src/confd/yang/gps.inc @@ -0,0 +1,3 @@ +MODULES=( + "infix-hardware -e gps" +) diff --git a/src/confd/yang/wifi.inc b/src/confd/yang/wifi.inc index 280851db7..21d9a93c3 100644 --- a/src/confd/yang/wifi.inc +++ b/src/confd/yang/wifi.inc @@ -1,4 +1,5 @@ MODULES=( "infix-interfaces -e wifi" + "infix-hardware -e wifi" "infix-if-type -e wifi" ) diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am index 160495b20..9dd4fa4d7 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 gpsd.c gpsd.h +statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.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 deleted file mode 100644 index 770b4dbed..000000000 --- a/src/statd/gpsd.c +++ /dev/null @@ -1,415 +0,0 @@ -/* 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 deleted file mode 100644 index 94fdc71be..000000000 --- a/src/statd/gpsd.h +++ /dev/null @@ -1,32 +0,0 @@ -/* 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/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b68493c4e..f1fda8283 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -667,13 +667,53 @@ def wifi_radio_components(): return components +def _gpsd_poll(): + """Query gpsd for current state via ?POLL command. + + Returns a dict keyed by device path with tpv/sky data merged, + or empty dict on failure. + """ + import json + import socket + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.5) + sock.connect(("127.0.0.1", 2947)) + + # Read VERSION banner + sock.recv(4096) + + # Send POLL request + sock.sendall(b'?WATCH={"enable":true,"json":true};\n?POLL;\n') + + # Read until we get the POLL response + buf = b"" + for _ in range(5): + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + for line in buf.decode(errors="replace").splitlines(): + try: + msg = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + if msg.get("class") == "POLL": + sock.close() + return msg + sock.close() + except (OSError, socket.error): + pass + + return {} + + 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. + GPS devices are discovered via /dev/gps* symlinks (created by udev + rules). Status is queried live from gpsd via the ?POLL command. """ components = [] @@ -683,7 +723,6 @@ def gps_receiver_components(): 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}", @@ -693,12 +732,24 @@ def gps_receiver_components(): if not gps_devices: return components - # Read cached GPS status from statd background monitor - cache = HOST.read_json("/run/gps-status.json", {}) + # Query gpsd directly for current state + poll = _gpsd_poll() + active = poll.get("active", 0) + + # Index TPV and SKY responses by device path + tpv_by_dev = {} + for tpv in poll.get("tpv", []): + dev = tpv.get("device", "") + tpv_by_dev[dev] = tpv + + sky_by_dev = {} + for sky in poll.get("sky", []): + dev = sky.get("device", "") + sky_by_dev[dev] = sky - # Build hardware components for each discovered GPS device for actual_path, dev in gps_devices.items(): name = dev["name"] + symlink = dev["symlink"] component = { "name": name, "class": "infix-hardware:gps", @@ -706,16 +757,22 @@ def gps_receiver_components(): } gps_data = {} - gps_data["device"] = dev["symlink"] + gps_data["device"] = symlink + + # Match by resolved path or symlink (fallback to single report) + tpv = tpv_by_dev.get(actual_path, tpv_by_dev.get(symlink, {})) + sky = sky_by_dev.get(actual_path, sky_by_dev.get(symlink, {})) + if not tpv and len(tpv_by_dev) == 1: + tpv = next(iter(tpv_by_dev.values())) + if not sky and len(sky_by_dev) == 1: + sky = next(iter(sky_by_dev.values())) - # Look up cached status by actual device path - info = cache.get(actual_path, {}) + gps_data["activated"] = active > 0 and bool(tpv) - if info.get("driver"): - gps_data["driver"] = info["driver"] - gps_data["activated"] = bool(info.get("activated")) + if tpv.get("driver"): + gps_data["driver"] = tpv["driver"] - mode = info.get("mode", 0) + mode = tpv.get("mode", 0) if mode == 2: gps_data["fix-mode"] = "2d" elif mode == 3: @@ -723,16 +780,39 @@ def gps_receiver_components(): 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)) + if "lat" in tpv: + gps_data["latitude"] = f"{float(tpv['lat']):.6f}" + if "lon" in tpv: + gps_data["longitude"] = f"{float(tpv['lon']):.6f}" + if "altHAE" in tpv: + gps_data["altitude"] = f"{float(tpv['altHAE']):.1f}" + + sat_vis = 0 + sat_used = 0 + + sats = sky.get("satellites", []) + if isinstance(sats, list): + sat_vis = len(sats) + sat_used = sum(1 for s in sats if s.get("used")) + + # Fallback for gpsd variants that report aggregated counters only + # (common in POLL output for some setups). + if not sat_vis: + sat_vis = int(sky.get("nSat", sky.get("satellites_visible", 0)) or 0) + if not sat_used: + sat_used = int(sky.get("uSat", sky.get("satellites_used", 0)) or 0) + + # Last fallback to TPV keys when SKY is absent. + if not sat_vis: + sat_vis = int(tpv.get("nSat", tpv.get("satellites_visible", 0)) or 0) + if not sat_used: + sat_used = int(tpv.get("uSat", tpv.get("satellites_used", 0)) or 0) + + if sat_used > sat_vis: + sat_vis = sat_used + + gps_data["satellites-visible"] = sat_vis + gps_data["satellites-used"] = sat_used # Check for PPS device availability pps_path = f"/dev/pps{name.replace('gps', '')}" diff --git a/src/statd/statd.c b/src/statd/statd.c index 139c09435..13214165e 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -30,7 +30,6 @@ #include "shared.h" #include "journal.h" -#include "gpsd.h" /* New kernel feature, not in sys/mman.h yet */ #ifndef MFD_NOEXEC_SEAL @@ -70,7 +69,6 @@ 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, @@ -352,13 +350,6 @@ 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) { @@ -462,7 +453,7 @@ static int subscribe_to_all(struct statd *statd) int main(int argc, char *argv[]) { - struct ev_signal sigint_watcher, sigusr1_watcher, sighup_watcher; + struct ev_signal sigint_watcher, sigusr1_watcher; int log_opts = LOG_PID | LOG_NDELAY; struct statd statd = {}; const char *env; @@ -523,10 +514,6 @@ 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); @@ -535,10 +522,6 @@ 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); @@ -548,7 +531,6 @@ 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); diff --git a/test/.env b/test/.env index 6ea67b8bc..b804a029f 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.7 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.9 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/case/hardware/all.yaml b/test/case/hardware/all.yaml index 1ca32dd3a..4eaf24f8f 100644 --- a/test/case/hardware/all.yaml +++ b/test/case/hardware/all.yaml @@ -7,3 +7,6 @@ - name: Watchdog reset on system lockup case: watchdog/test.py + +- name: GPS receiver basic test + case: gps_simple/test.py diff --git a/test/case/hardware/gps_simple/Readme.adoc b/test/case/hardware/gps_simple/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/hardware/gps_simple/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/hardware/gps_simple/test.adoc b/test/case/hardware/gps_simple/test.adoc new file mode 100644 index 000000000..b8fc64dbf --- /dev/null +++ b/test/case/hardware/gps_simple/test.adoc @@ -0,0 +1,29 @@ +=== GPS receiver basic test + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/hardware/gps_simple] + +==== Description + +Verify that a simulated GPS receiver is detected and reports a valid +fix via the ietf-hardware operational datastore. + +The test injects NMEA sentences through a QEMU pipe chardev FIFO, +which appears as a virtio serial port inside the guest. + +==== Topology + +image::topology.svg[GPS receiver basic test topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure GPS hardware component +. Verify GPS is activated +. Verify GPS has a fix +. Verify the position is near the coordinates you test with +. Save the configuration to startup configuration and reboot +. Verify GPS is activated +. Verify GPS has a fix +. Verify the position is near the coordinates you test with + + diff --git a/test/case/hardware/gps_simple/test.py b/test/case/hardware/gps_simple/test.py new file mode 100755 index 000000000..2203b9e8f --- /dev/null +++ b/test/case/hardware/gps_simple/test.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""GPS receiver basic test + +Verify that a simulated GPS receiver is detected and reports a valid +fix via the ietf-hardware operational datastore. + +The test injects NMEA sentences through a QEMU pipe chardev FIFO, +which appears as a virtio serial port inside the guest. +""" +import infamy +import infamy.gps as gps +from infamy.util import until, wait_boot + +# Fun facts: The top of mount everest +test_lat = 27.9881 +test_lon = 86.9250 +test_alt = 8848.86 + + +def _near(a, b, tol): + return abs(a - b) <= tol + +def verify_position(target): + state = gps.get_gps_state(target) + + try: + lat = float(state["latitude"]) + lon = float(state["longitude"]) + alt = float(state["altitude"]) + except (KeyError, TypeError, ValueError): + test.fail() + + if not _near(lat, lat, 1e-4): + test.fail() + if not _near(lon, lon, 1e-4): + test.fail() + if not _near(alt, alt, 0.2): + test.fail() + + try: + sat_used = int(state["satellites-used"]) + except (KeyError, TypeError, ValueError): + test.fail() + + if sat_used != 8: + test.fail() + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + + phys_name = env.ltop.mapping["target"][None] + + + if not target.has_feature("infix-hardware", "gps"): + test.skip() + + # This is a hack, will only work on virtual devices + pipe_path = f"/tmp/{phys_name}-gps" + + with gps.NMEAGenerator( + pipe_path, + lat=test_lat, + lon=test_lon, + alt=test_alt): + + + with test.step("Configure GPS hardware component"): + target.put_config_dicts({"ietf-hardware": { + "hardware": { + "component": [{ + "name": "gps0", + "class": "infix-hardware:gps", + "infix-hardware:gps-receiver": {} + }] + } + }}) + + with test.step("Verify GPS is activated"): + until(lambda: gps.is_activated(target), attempts=500) + + with test.step("Verify GPS has a fix"): + until(lambda: gps.has_fix(target), attempts=60) + + with test.step("Verify the position is near the coordinates you test with"): + verify_position(target) + + with test.step("Save the configuration to startup configuration and reboot"): + target.startup_override() + target.copy("running", "startup") + target.reboot() + if not wait_boot(target, env): + test.fail() + target = env.attach("target", "mgmt", test_reset=False) + + with test.step("Verify GPS is activated"): + until(lambda: gps.is_activated(target), attempts=500) + + with test.step("Verify GPS has a fix"): + until(lambda: gps.has_fix(target), attempts=60) + with test.step("Verify the position is near the coordinates you test with"): + verify_position(target) + test.succeed() diff --git a/test/case/hardware/gps_simple/topology.dot b/test/case/hardware/gps_simple/topology.dot new file mode 100644 index 000000000..e21cd6b31 --- /dev/null +++ b/test/case/hardware/gps_simple/topology.dot @@ -0,0 +1,22 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + requires="infix gps", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] +} diff --git a/test/case/hardware/gps_simple/topology.svg b/test/case/hardware/gps_simple/topology.svg new file mode 100644 index 000000000..6fc6f47a8 --- /dev/null +++ b/test/case/hardware/gps_simple/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + diff --git a/test/case/hardware/usb/test.py b/test/case/hardware/usb/test.py index ff88e3a84..cd9d4312f 100755 --- a/test/case/hardware/usb/test.py +++ b/test/case/hardware/usb/test.py @@ -16,7 +16,6 @@ import copy import infamy.usb as usb import time -import infamy.netconf as netconf from infamy.util import until, wait_boot with infamy.Test() as test: diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 7991c1d6d..7678ec10a 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -57,7 +57,7 @@ ADD docker/yang /root/yang RUN ~/init-venv.sh ~/pip-requirements.txt # Download container images for upgrade testing -COPY case/infix_containers/upgrade/download.sh /tmp/download.sh +COPY case/containers/upgrade/download.sh /tmp/download.sh RUN /tmp/download.sh /srv && rm /tmp/download.sh COPY docker/entrypoint.sh /entrypoint.sh diff --git a/test/docker/pip-requirements.txt b/test/docker/pip-requirements.txt index 1a8df2d40..83793011d 100644 --- a/test/docker/pip-requirements.txt +++ b/test/docker/pip-requirements.txt @@ -6,6 +6,7 @@ pydot==1.4.2 pyyaml==6.0.1 passlib==1.7.4 requests~=2.32.4 +pynmea2==1.19.0 # GHSA-cq46-m9x9-j8w2: scapy <=2.6.1 has pickle deserialization vuln in session # loading (-s flag). Low risk: test framework only uses packet crafting (Ether, # sendp, LLDP), not session loading. Update to 2.7.0+ when available on PyPI. diff --git a/test/infamy/gps.py b/test/infamy/gps.py new file mode 100644 index 000000000..f9a2e8a56 --- /dev/null +++ b/test/infamy/gps.py @@ -0,0 +1,185 @@ +"""GPS/NMEA test helpers + +Provides an NMEAGenerator class that writes NMEA sentences to a QEMU +pipe chardev FIFO, simulating a GPS receiver. Also provides helpers +for querying GPS operational state via YANG. +""" + +import threading +import time +import pynmea2 + + +class NMEAGenerator: + """Write NMEA sentences to a QEMU pipe chardev FIFO. + + Sends a full cycle of NMEA sentences (like a real u-blox receiver) + continuously at 1 Hz in a background thread. + + The pipe_path should be the base path (without .in/.out suffix), + matching the QEMU ``-chardev pipe,path=...`` argument. + + Usage:: + + with NMEAGenerator("/tmp/node-gps") as nmea: + # NMEA data is being sent in background + time.sleep(10) + """ + + def __init__(self, pipe_path, lat=48.1173, lon=11.5167, alt=545.4): + self.pipe_path = pipe_path + self._fifo = None + self.lat = lat + self.lon = lon + self.alt = alt + self._thread = None + self._stop = threading.Event() + + def __enter__(self): + self.start() + + def __exit__(self, _, __, ___): + self.close() + + def start(self): + self._fifo = open(f"{self.pipe_path}.in", "w") + self._stop.clear() + self._thread = threading.Thread(target=self._send_loop, daemon=True) + self._thread.start() + + + def close(self): + self._stop.set() + if self._thread: + self._thread.join(timeout=5) + self._thread = None + if self._fifo: + self._fifo.close() + self._fifo = None + + def _send_loop(self): + while not self._stop.is_set(): + try: + self._send_cycle() + except OSError: + break + self._stop.wait(1) + + def _send(self, sentence): + self._fifo.write(str(sentence) + "\r\n") + self._fifo.flush() + + def _send_cycle(self): + """Send a full NMEA cycle matching real u-blox GPS output.""" + now = time.gmtime() + utc = time.strftime("%H%M%S.00", now) + date = time.strftime("%d%m%y", now) + + lat_deg = int(abs(self.lat)) + lat_min = (abs(self.lat) - lat_deg) * 60 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + lat_ns = "N" if self.lat >= 0 else "S" + + lon_deg = int(abs(self.lon)) + lon_min = (abs(self.lon) - lon_deg) * 60 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + lon_ew = "E" if self.lon >= 0 else "W" + + # RMC - Recommended Minimum + self._send(pynmea2.RMC("GP", "RMC", ( + utc, "A", + lat_str, lat_ns, + lon_str, lon_ew, + "0.0", "0.0", + date, + "0.0", "E", + "A", + ))) + + # VTG - Track Made Good and Ground Speed + self._send(pynmea2.VTG("GP", "VTG", ( + "0.0", "T", + "", "M", + "0.0", "N", + "0.0", "K", + "A", + ))) + + # GGA - Fix Data + self._send(pynmea2.GGA("GP", "GGA", ( + utc, + lat_str, lat_ns, + lon_str, lon_ew, + "1", "08", "0.9", + f"{self.alt:.1f}", "M", + "47.0", "M", + "", "", + ))) + + # GSA - DOP and Active Satellites (3D fix, 8 sats) + self._send(pynmea2.GSA("GP", "GSA", ( + "A", "3", + "01", "02", "03", "04", "05", "06", "07", "08", + "", "", "", "", + "1.5", "0.9", "1.2", + ))) + + # GSV - Satellites in View (4 sats per message, 2 messages) + self._send(pynmea2.GSV("GP", "GSV", ( + "2", "1", "08", + "01", "45", "045", "40", + "02", "30", "090", "38", + "03", "60", "135", "42", + "04", "15", "180", "35", + ))) + self._send(pynmea2.GSV("GP", "GSV", ( + "2", "2", "08", + "05", "50", "225", "41", + "06", "25", "270", "36", + "07", "70", "315", "44", + "08", "20", "000", "33", + ))) + + # GLL - Geographic Position + self._send(pynmea2.GLL("GP", "GLL", ( + lat_str, lat_ns, + lon_str, lon_ew, + utc, + "A", + "A", + ))) + + def __enter__(self): + self.start() + return self + + def __exit__(self, *args): + self.close() + + +def _get_hardware(target): + return target.get_data("/ietf-hardware:hardware")["hardware"] + + +def get_gps_state(target, name="gps0"): + """Get GPS receiver operational state for a named component.""" + hardware = _get_hardware(target) + for component in hardware.get("component", []): + if component.get("name") == name: + return component.get("infix-hardware:gps-receiver", + component.get("gps-receiver")) + return None + + +def is_activated(target, name="gps0"): + """Check if gpsd has activated the GPS device.""" + state = get_gps_state(target, name) + return state.get("activated", False) if state else False + + +def has_fix(target, name="gps0"): + """Check if GPS reports a fix (2D or 3D).""" + state = get_gps_state(target, name) + if not state: + return False + return state.get("fix-mode") in ("2d", "3d") diff --git a/test/infamy/transport.py b/test/infamy/transport.py index 0d0db27c5..e660e3e47 100644 --- a/test/infamy/transport.py +++ b/test/infamy/transport.py @@ -79,6 +79,13 @@ def has_model(self, model_name): """Check if the device has the given YANG model loaded.""" return model_name in self.modules + def has_feature(self, model_name, feature_name): + """Check if a specific feature is enabled on the device for a given YANG model.""" + if model_name not in self.modules: + return False + features = self.modules[model_name].get("feature", []) + return feature_name in features + def reachable(self): """Check if the device reachable on ll6""" neigh = ll6ping(self.location.interface, flags=["-w1", "-c1", "-L", "-n"]) diff --git a/test/templates/infix-bios-x86_64.mustache b/test/templates/infix-bios-x86_64.mustache index 09a87e0c8..fb8e1e579 100644 --- a/test/templates/infix-bios-x86_64.mustache +++ b/test/templates/infix-bios-x86_64.mustache @@ -15,6 +15,10 @@ truncate -s 0 $imgdir/{{name}}.mactab echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab {{/links}} +{{#qn_gps}} +mkfifo /tmp/{{name}}-gps.in /tmp/{{name}}-gps.out 2>/dev/null +{{/qn_gps}} + exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}256M{{/qn_mem}} \ {{> ../qeneth/templates/inc/qemu-links}} @@ -24,3 +28,7 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ $usb_cmd \ {{> ../qeneth/templates/inc/infix-common}} {{> ../qeneth/templates/inc/qemu-console}} +{{#qn_gps}} + -chardev pipe,id=gps0,path=/tmp/{{name}}-gps \ + -device virtserialport,chardev=gps0,name=gps0 \ +{{/qn_gps}} diff --git a/test/virt/quad/topology.dot.in b/test/virt/quad/topology.dot.in index 609cdced2..a0c363faa 100644 --- a/test/virt/quad/topology.dot.in +++ b/test/virt/quad/topology.dot.in @@ -23,11 +23,12 @@ graph "quad" { dut1 [ label="{ e1 | e2 | e3 | e4 } | dut1 | { e5 | e6 | e7 | e8}", pos="10,30!", - provides="infix watchdog", + provides="infix watchdog gps", expected_boot="primary", qn_console=9001, qn_mem="384M", - qn_usb="dut1.usb" + qn_usb="dut1.usb", + qn_gps="true" ]; dut2 [ label="{ e1 | e2 | e3 | e4 } | dut2 | { e5 | e6 | e7 | e8}",