test: Add GPS test

Only possible to run on virtual Qemu instances right now.
This commit is contained in:
Mattias Walström
2026-03-05 08:21:34 +01:00
parent 054faa6763
commit 565a07a0b2
10 changed files with 438 additions and 2 deletions
+3
View File
@@ -7,3 +7,6 @@
- name: Watchdog reset on system lockup - name: Watchdog reset on system lockup
case: watchdog/test.py case: watchdog/test.py
- name: GPS receiver basic test
case: gps_simple/test.py
+1
View File
@@ -0,0 +1 @@
test.adoc
+29
View File
@@ -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
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""GPS receiver basic test
Verify that two simulated GPS receivers are detected and report valid
fixes via the ietf-hardware operational datastore.
The test injects NMEA sentences through QEMU pipe chardev FIFOs,
which appear as virtio serial ports 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, name="gps0"):
state = gps.get_gps_state(target, name)
try:
lat = float(state["latitude"])
lon = float(state["longitude"])
alt = float(state["altitude"])
except (KeyError, TypeError, ValueError):
test.fail()
if not _near(lat, test_lat, 0.01):
test.fail()
if not _near(lon, test_lon, 0.01):
test.fail()
if not _near(alt, test_alt, 100):
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()
# These are hacks, will only work on virtual devices
pipe0 = f"/tmp/{phys_name}-gps"
pipe1 = f"/tmp/{phys_name}-gps1"
with gps.NMEAGenerator(pipe0, lat=test_lat, lon=test_lon, alt=test_alt), \
gps.NMEAGenerator(pipe1, lat=test_lat, lon=test_lon, alt=test_alt):
with test.step("Configure GPS hardware components"):
target.put_config_dicts({"ietf-hardware": {
"hardware": {
"component": [
{
"name": "gps0",
"class": "infix-hardware:gps",
"infix-hardware:gps-receiver": {}
},
{
"name": "gps1",
"class": "infix-hardware:gps",
"infix-hardware:gps-receiver": {}
}
]
}
}})
with test.step("Verify both GPS receivers are activated"):
until(lambda: gps.is_activated(target, "gps0"), attempts=500)
until(lambda: gps.is_activated(target, "gps1"), attempts=500)
with test.step("Verify both GPS receivers have a fix"):
until(lambda: gps.has_fix(target, "gps0"), attempts=60)
until(lambda: gps.has_fix(target, "gps1"), attempts=60)
with test.step("Verify gps0 position is near the coordinates"):
verify_position(target, "gps0")
with test.step("Verify gps1 position is near the coordinates"):
verify_position(target, "gps1")
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 both GPS receivers are activated"):
until(lambda: gps.is_activated(target, "gps0"), attempts=500)
until(lambda: gps.is_activated(target, "gps1"), attempts=500)
with test.step("Verify both GPS receivers have a fix"):
until(lambda: gps.has_fix(target, "gps0"), attempts=60)
until(lambda: gps.has_fix(target, "gps1"), attempts=60)
with test.step("Verify gps0 position is near the coordinates"):
verify_position(target, "gps0")
with test.step("Verify gps1 position is near the coordinates"):
verify_position(target, "gps1")
test.succeed()
@@ -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> mgmt }",
pos="0,12!",
requires="controller",
];
target [
label="{ <mgmt> mgmt } | target",
pos="10,12!",
requires="infix gps",
];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x1 Pages: 1 -->
<svg width="424pt" height="45pt"
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
<title>1x1</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+210
View File
@@ -0,0 +1,210 @@
"""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 os
import errno
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._fd = -1
self.lat = lat
self.lon = lon
self.alt = alt
self._thread = None
self._stop = threading.Event()
def __enter__(self):
self.start()
return self
def __exit__(self, _, __, ___):
self.close()
def start(self):
self._stop.clear()
self._thread = threading.Thread(target=self._send_loop, daemon=True)
self._thread.start()
print(f"NMEA: started sender thread for {self.pipe_path}")
def close(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
self._thread = None
if self._fd >= 0:
try:
os.close(self._fd)
except OSError:
pass
self._fd = -1
def _send_loop(self):
print(f"NMEA: send_loop entered for {self.pipe_path}")
while not self._stop.is_set():
try:
self._fd = os.open(f"{self.pipe_path}.in",
os.O_WRONLY | os.O_NONBLOCK)
print(f"NMEA: opened {self.pipe_path}.in (fd={self._fd})")
except OSError as e:
if e.errno in (errno.ENXIO, errno.ENOENT):
print(f"NMEA: {self.pipe_path}.in not ready ({e}), retrying ...")
self._stop.wait(0.5)
continue
print(f"NMEA: {self.pipe_path}.in open failed: {e}")
raise
cycles = 0
while not self._stop.is_set():
try:
self._send_cycle()
cycles += 1
if cycles <= 3 or cycles % 10 == 0:
print(f"NMEA: {self.pipe_path} sent cycle {cycles}")
except BlockingIOError:
print(f"NMEA: {self.pipe_path} write blocked (cycle {cycles})")
except OSError as e:
print(f"NMEA: {self.pipe_path} write error: {e}, reconnecting")
break
self._stop.wait(1)
try:
os.close(self._fd)
except OSError:
pass
self._fd = -1
def _send(self, sentence):
data = (str(sentence) + "\r\n").encode()
try:
os.write(self._fd, data)
except BlockingIOError:
pass
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 _get_hardware(target):
data = target.get_data("/ietf-hardware:hardware")
if not data or "hardware" not in data:
return {}
return data["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")
+7
View File
@@ -79,6 +79,13 @@ class Transport(ABC):
"""Check if the device has the given YANG model loaded.""" """Check if the device has the given YANG model loaded."""
return model_name in self.modules 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): def reachable(self):
"""Check if the device reachable on ll6""" """Check if the device reachable on ll6"""
neigh = ll6ping(self.location.interface, flags=["-w1", "-c1", "-L", "-n"]) neigh = ll6ping(self.location.interface, flags=["-w1", "-c1", "-L", "-n"])
+11
View File
@@ -15,6 +15,11 @@ truncate -s 0 $imgdir/{{name}}.mactab
echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab
{{/links}} {{/links}}
{{#qn_gps}}
mkfifo /tmp/{{name}}-gps.in /tmp/{{name}}-gps.out 2>/dev/null
mkfifo /tmp/{{name}}-gps1.in /tmp/{{name}}-gps1.out 2>/dev/null
{{/qn_gps}}
exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
-m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}256M{{/qn_mem}} \ -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}256M{{/qn_mem}} \
{{> ../qeneth/templates/inc/qemu-links}} {{> ../qeneth/templates/inc/qemu-links}}
@@ -24,3 +29,9 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \
$usb_cmd \ $usb_cmd \
{{> ../qeneth/templates/inc/infix-common}} {{> ../qeneth/templates/inc/infix-common}}
{{> ../qeneth/templates/inc/qemu-console}} {{> ../qeneth/templates/inc/qemu-console}}
{{#qn_gps}}
-chardev pipe,id=gps0,path=/tmp/{{name}}-gps \
-device virtserialport,chardev=gps0,name=gps0 \
-chardev pipe,id=gps1,path=/tmp/{{name}}-gps1 \
-device virtserialport,chardev=gps1,name=gps1 \
{{/qn_gps}}
+3 -2
View File
@@ -23,11 +23,12 @@ graph "quad" {
dut1 [ dut1 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut1 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}", label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut1 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",
pos="10,30!", pos="10,30!",
provides="infix watchdog", provides="infix watchdog gps",
expected_boot="primary", expected_boot="primary",
qn_console=9001, qn_console=9001,
qn_mem="384M", qn_mem="384M",
qn_usb="dut1.usb" qn_usb="dut1.usb",
qn_gps="true"
]; ];
dut2 [ dut2 [
label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut2 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}", label="{ <e1> e1 | <e2> e2 | <e3> e3 | <e4> e4 } | dut2 | { <e5> e5 | <e6> e6 | <e7> e7 | <e8> e8}",