From 994e6736ce5a8445d080a89155b8acdb83c5f06e Mon Sep 17 00:00:00 2001 From: Vitaly Grinberg Date: Sat, 28 Feb 2026 10:44:27 +0200 Subject: [PATCH] Auto-detect GNSS serial port by NIC Address the problem of randomly changing GNSS device names after host reboot. Use ts2phc configuration to determine the operation mode and the clock chain leading NIC. In the T-GM case, find the GNSS device by browsing the leading NIC SysFS structure. Add the discovered serial port address to ts2phc.conf. The mechanism only works if `ts2phc.nmea_serialport` setting is omitted from the PTP profile. If the setting is present, it acts as before the change, leaving "Automatic" Vs. "Manual" setting decision to user. Assisted by Cursor AI Signed-off-by: Vitaly Grinberg --- addons/intel/e810.go | 3 + addons/intel/gnss_detect.go | 120 +++++++++++++ addons/intel/gnss_detect_test.go | 284 +++++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 addons/intel/gnss_detect.go create mode 100644 addons/intel/gnss_detect_test.go diff --git a/addons/intel/e810.go b/addons/intel/e810.go index 7b375d75..d80a6d0a 100644 --- a/addons/intel/e810.go +++ b/addons/intel/e810.go @@ -67,6 +67,9 @@ var DpllPins = []*dpll_netlink.PinInfo{} func OnPTPConfigChangeE810(data *interface{}, nodeProfile *ptpv1.PtpProfile) error { glog.Info("calling onPTPConfigChange for e810 plugin") + + autoDetectGNSSSerialPort(nodeProfile) + var e810Opts E810Opts var err error var optsByteArray []byte diff --git a/addons/intel/gnss_detect.go b/addons/intel/gnss_detect.go new file mode 100644 index 00000000..4f9af995 --- /dev/null +++ b/addons/intel/gnss_detect.go @@ -0,0 +1,120 @@ +package intel + +import ( + "fmt" + "strings" + + "github.com/bigkevmcd/go-configparser" + "github.com/golang/glog" + ptpv1 "github.com/k8snetworkplumbingwg/ptp-operator/api/v1" +) + +const ( + nmeaSerialPortKey = "ts2phc.nmea_serialport" + ts2phcMasterKey = "ts2phc.master" + gnssDeviceSysfsTemplate = "/sys/class/net/%s/device/gnss" +) + +var knownNonInterfaceSections = map[string]bool{ + "global": true, + "nmea": true, +} + +// findLeadingInterface parses a ts2phcConf string to determine if auto-detection +// of the GNSS serial port is needed, and if so, returns the leading interface name. +// +// Returns the interface name and true if auto-detection should proceed: +// - [nmea] section must be present (T-GM profile) +// - ts2phc.nmea_serialport must NOT be already set in [global] +// - At least one interface section must exist +// +// The leading interface is the one that does NOT have "ts2phc.master 0". +func findLeadingInterface(ts2phcConf string) (string, bool) { + conf, err := configparser.ParseReaderWithOptions( + strings.NewReader(ts2phcConf), + configparser.Delimiters(" "), + ) + if err != nil { + glog.Warningf("failed to parse ts2phcConf: %v", err) + return "", false + } + + if !conf.HasSection("nmea") { + return "", false + } + + if serialPort, _ := conf.Get("global", nmeaSerialPortKey); serialPort != "" { + glog.Infof("ts2phc.nmea_serialport already set to %q, skipping auto-detection", serialPort) + return "", false + } + + var candidates []string + for _, section := range conf.Sections() { + if knownNonInterfaceSections[section] { + continue + } + val, _ := conf.Get(section, ts2phcMasterKey) + if val != "0" { + candidates = append(candidates, section) + } + } + + if len(candidates) == 0 { + glog.Warning("T-GM profile detected but no leading interface found (all interfaces have ts2phc.master 0)") + return "", false + } + if len(candidates) > 1 { + glog.Warningf("multiple interfaces without ts2phc.master 0: %v; using %s", candidates, candidates[0]) + } + return candidates[0], true +} + +// gnssDeviceFromInterface resolves the GNSS device path for a given network interface +// by reading the sysfs directory /sys/class/net//device/gnss/. +func gnssDeviceFromInterface(iface string) (string, error) { + gnssDir := fmt.Sprintf(gnssDeviceSysfsTemplate, iface) + entries, err := filesystem.ReadDir(gnssDir) + if err != nil { + return "", fmt.Errorf("no GNSS device found for interface %s: %w", iface, err) + } + if len(entries) == 0 { + return "", fmt.Errorf("GNSS sysfs directory %s is empty", gnssDir) + } + if len(entries) > 1 { + glog.Warningf("multiple GNSS devices found for %s, using %s", iface, entries[0].Name()) + } + return fmt.Sprintf("/dev/%s", entries[0].Name()), nil +} + +// autoDetectGNSSSerialPort attempts to auto-detect the GNSS serial port +// from the ts2phcConf and patch it into the node profile if needed. +func autoDetectGNSSSerialPort(nodeProfile *ptpv1.PtpProfile) { + if nodeProfile.Ts2PhcConf == nil { + return + } + leadingIface, found := findLeadingInterface(*nodeProfile.Ts2PhcConf) + if !found { + return + } + gnssPath, err := gnssDeviceFromInterface(leadingIface) + if err != nil { + glog.Warningf("could not auto-detect GNSS device for %s: %v", leadingIface, err) + return + } + glog.Infof("auto-detected GNSS serial port %s from interface %s", gnssPath, leadingIface) + + // Insert into the raw config string to preserve original formatting. + // This mirrors how ExtendGlobalSection in config.go modifies the parsed config. + setting := fmt.Sprintf("%s %s", nmeaSerialPortKey, gnssPath) + lines := strings.Split(*nodeProfile.Ts2PhcConf, "\n") + for i, line := range lines { + if strings.TrimSpace(line) == "[global]" { + result := make([]string, 0, len(lines)+1) + result = append(result, lines[:i+1]...) + result = append(result, setting) + result = append(result, lines[i+1:]...) + *nodeProfile.Ts2PhcConf = strings.Join(result, "\n") + return + } + } +} diff --git a/addons/intel/gnss_detect_test.go b/addons/intel/gnss_detect_test.go new file mode 100644 index 00000000..8a47d68e --- /dev/null +++ b/addons/intel/gnss_detect_test.go @@ -0,0 +1,284 @@ +package intel + +import ( + "errors" + "os" + "strings" + "testing" + + ptpv1 "github.com/k8snetworkplumbingwg/ptp-operator/api/v1" + "github.com/stretchr/testify/assert" +) + +func TestFindLeadingInterface(t *testing.T) { + tests := []struct { + name string + ts2phcConf string + expectedIface string + expectedFound bool + }{ + { + name: "T-GM single interface, no nmea_serialport", + ts2phcConf: `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +verbose 1 +logging_level 7 +ts2phc.pulsewidth 100000000 +[ens7f0] +ts2phc.extts_polarity rising +ts2phc.extts_correction 0`, + expectedIface: "ens7f0", + expectedFound: true, + }, + { + name: "T-GM with nmea_serialport already set", + ts2phcConf: `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +verbose 1 +ts2phc.nmea_serialport /dev/gnss1 +[ens7f0] +ts2phc.extts_polarity rising`, + expectedIface: "", + expectedFound: false, + }, + { + name: "T-BC config without [nmea] section", + ts2phcConf: `[global] +use_syslog 0 +verbose 1 +logging_level 7 +ts2phc.pulsewidth 100000000 +[ens4f0] +ts2phc.extts_polarity rising +ts2phc.master 0 +[ens8f0] +ts2phc.extts_polarity rising +ts2phc.master 0`, + expectedIface: "", + expectedFound: false, + }, + { + name: "multi-interface T-GM: one leading, two slaves", + ts2phcConf: `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +verbose 1 +ts2phc.pulsewidth 100000000 +[ens7f0] +ts2phc.extts_polarity rising +ts2phc.extts_correction 0 +[ens4f0] +ts2phc.extts_polarity rising +ts2phc.master 0 +[ens8f0] +ts2phc.extts_polarity rising +ts2phc.master 0`, + expectedIface: "ens7f0", + expectedFound: true, + }, + { + name: "multi-interface T-GM: all without ts2phc.master 0 warns, returns first", + ts2phcConf: `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +[ens7f0] +ts2phc.extts_polarity rising +[ens8f0] +ts2phc.extts_polarity rising`, + expectedIface: "ens7f0", + expectedFound: true, + }, + { + name: "T-GM with [nmea] but no interface sections", + ts2phcConf: `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +verbose 1`, + expectedIface: "", + expectedFound: false, + }, + { + name: "empty config", + ts2phcConf: "", + expectedIface: "", + expectedFound: false, + }, + { + name: "[nmea] without ts2phc.master still triggers auto-detection", + ts2phcConf: `[nmea] +[global] +use_syslog 0 +[ens7f0] +ts2phc.extts_polarity rising`, + expectedIface: "ens7f0", + expectedFound: true, + }, + { + name: "comments and blank lines are ignored", + ts2phcConf: `# This is a comment +[nmea] +ts2phc.master 1 + +[global] +# serial port not set +use_syslog 0 + +[ens5f0] +ts2phc.extts_polarity rising`, + expectedIface: "ens5f0", + expectedFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iface, found := findLeadingInterface(tt.ts2phcConf) + assert.Equal(t, tt.expectedFound, found, "found mismatch") + assert.Equal(t, tt.expectedIface, iface, "interface mismatch") + }) + } +} + +func TestGnssDeviceFromInterface(t *testing.T) { + tests := []struct { + name string + iface string + setupMock func(*MockFileSystem) + expected string + expectError bool + }{ + { + name: "single GNSS device found", + iface: "ens7f0", + setupMock: func(m *MockFileSystem) { + m.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + []os.DirEntry{MockDirEntry{name: "gnss0"}}, nil) + }, + expected: "/dev/gnss0", + expectError: false, + }, + { + name: "multiple GNSS devices, returns first", + iface: "ens7f0", + setupMock: func(m *MockFileSystem) { + m.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + []os.DirEntry{ + MockDirEntry{name: "gnss0"}, + MockDirEntry{name: "gnss1"}, + }, nil) + }, + expected: "/dev/gnss0", + expectError: false, + }, + { + name: "sysfs directory does not exist", + iface: "ens7f0", + setupMock: func(m *MockFileSystem) { + m.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + nil, errors.New("no such file or directory")) + }, + expected: "", + expectError: true, + }, + { + name: "sysfs directory is empty", + iface: "ens7f0", + setupMock: func(m *MockFileSystem) { + m.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + []os.DirEntry{}, nil) + }, + expected: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockFS, restoreFs := setupMockFS() + defer restoreFs() + tt.setupMock(mockFS) + + result, err := gnssDeviceFromInterface(tt.iface) + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + mockFS.VerifyAllCalls(t) + }) + } +} + +func TestAutoDetectGNSSSerialPort(t *testing.T) { + ts2phcConf := `[nmea] +ts2phc.master 1 +[global] +use_syslog 0 +verbose 1 +[ens7f0] +ts2phc.extts_polarity rising` + + t.Run("patches config when GNSS device found", func(t *testing.T) { + mockFS, restoreFs := setupMockFS() + defer restoreFs() + mockFS.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + []os.DirEntry{MockDirEntry{name: "gnss0"}}, nil) + + conf := ts2phcConf + profile := &ptpv1.PtpProfile{Ts2PhcConf: &conf} + autoDetectGNSSSerialPort(profile) + + assert.Contains(t, *profile.Ts2PhcConf, "ts2phc.nmea_serialport /dev/gnss0") + lines := strings.Split(*profile.Ts2PhcConf, "\n") + for i, line := range lines { + if strings.TrimSpace(line) == "[global]" { + assert.Equal(t, "ts2phc.nmea_serialport /dev/gnss0", lines[i+1]) + break + } + } + mockFS.VerifyAllCalls(t) + }) + + t.Run("skips when nmea_serialport already set", func(t *testing.T) { + confWithPort := `[nmea] +ts2phc.master 1 +[global] +ts2phc.nmea_serialport /dev/gnss1 +[ens7f0] +ts2phc.extts_polarity rising` + conf := confWithPort + profile := &ptpv1.PtpProfile{Ts2PhcConf: &conf} + autoDetectGNSSSerialPort(profile) + + assert.Equal(t, confWithPort, *profile.Ts2PhcConf, "config should be unchanged") + }) + + t.Run("skips when Ts2PhcConf is nil", func(t *testing.T) { + profile := &ptpv1.PtpProfile{Ts2PhcConf: nil} + autoDetectGNSSSerialPort(profile) + assert.Nil(t, profile.Ts2PhcConf) + }) + + t.Run("skips when sysfs lookup fails", func(t *testing.T) { + mockFS, restoreFs := setupMockFS() + defer restoreFs() + mockFS.ExpectReadDir("/sys/class/net/ens7f0/device/gnss", + nil, errors.New("no such file or directory")) + + conf := ts2phcConf + profile := &ptpv1.PtpProfile{Ts2PhcConf: &conf} + autoDetectGNSSSerialPort(profile) + + assert.NotContains(t, *profile.Ts2PhcConf, "ts2phc.nmea_serialport") + mockFS.VerifyAllCalls(t) + }) +}