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) + }) +}