diff --git a/collector/ethtool_linux.go b/collector/ethtool_linux.go index e4d86bcd8c..3cdb633b15 100644 --- a/collector/ethtool_linux.go +++ b/collector/ethtool_linux.go @@ -26,6 +26,7 @@ import ( "os" "regexp" "sort" + "strconv" "strings" "sync" "syscall" @@ -49,6 +50,7 @@ type Ethtool interface { DriverInfo(string) (ethtool.DrvInfo, error) Stats(string) (map[string]uint64, error) LinkInfo(string) (ethtool.EthtoolCmd, error) + ModuleEeprom(string) ([]byte, error) } type ethtoolLibrary struct { @@ -69,15 +71,24 @@ func (e *ethtoolLibrary) LinkInfo(intf string) (ethtool.EthtoolCmd, error) { return ethtoolCmd, err } +func (e *ethtoolLibrary) ModuleEeprom(intf string) ([]byte, error) { + return e.ethtool.ModuleEeprom(intf) +} + type ethtoolCollector struct { - fs sysfs.FS - entries map[string]*prometheus.Desc - entriesMutex sync.Mutex - ethtool Ethtool - deviceFilter deviceFilter - infoDesc *prometheus.Desc - metricsPattern *regexp.Regexp - logger *slog.Logger + fs sysfs.FS + entries map[string]*prometheus.Desc + entriesMutex sync.Mutex + ethtool Ethtool + deviceFilter deviceFilter + infoDesc *prometheus.Desc + moduleTemperatureDesc *prometheus.Desc + moduleVoltageDesc *prometheus.Desc + moduleTxBiasDesc *prometheus.Desc + moduleTxPowerDesc *prometheus.Desc + moduleRxPowerDesc *prometheus.Desc + metricsPattern *regexp.Regexp + logger *slog.Logger } // makeEthtoolCollector is the internal constructor for EthtoolCollector. @@ -111,6 +122,31 @@ func makeEthtoolCollector(logger *slog.Logger) (*ethtoolCollector, error) { deviceFilter: newDeviceFilter(*ethtoolDeviceExclude, *ethtoolDeviceInclude), metricsPattern: regexp.MustCompile(*ethtoolIncludedMetrics), logger: logger, + moduleTemperatureDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ethtool", "module_temperature_celsius"), + "Module temperature in degrees Celsius", + []string{"device"}, nil, + ), + moduleVoltageDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ethtool", "module_voltage_volts"), + "Module supply voltage in volts", + []string{"device"}, nil, + ), + moduleTxBiasDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ethtool", "module_tx_bias_milliamperes"), + "Module TX laser bias current in milliamperes", + []string{"device", "lane"}, nil, + ), + moduleTxPowerDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ethtool", "module_tx_power_milliwatts"), + "Module TX optical power in milliwatts", + []string{"device", "lane"}, nil, + ), + moduleRxPowerDesc: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "ethtool", "module_rx_power_milliwatts"), + "Module RX optical power in milliwatts", + []string{"device", "lane"}, nil, + ), entries: map[string]*prometheus.Desc{ "rx_bytes": prometheus.NewDesc( prometheus.BuildFQName(namespace, "ethtool", "received_bytes_total"), @@ -445,6 +481,27 @@ func (c *ethtoolCollector) Update(ch chan<- prometheus.Metric) error { } } + eepromData, err := c.ethtool.ModuleEeprom(device) + if err == nil { + modMetrics, parseErr := parseModuleEeprom(eepromData) + if parseErr == nil { + ch <- prometheus.MustNewConstMetric(c.moduleTemperatureDesc, prometheus.GaugeValue, modMetrics.temperature, device) + ch <- prometheus.MustNewConstMetric(c.moduleVoltageDesc, prometheus.GaugeValue, modMetrics.voltage, device) + for i, lane := range modMetrics.lanes { + laneStr := strconv.Itoa(i + 1) + ch <- prometheus.MustNewConstMetric(c.moduleTxBiasDesc, prometheus.GaugeValue, lane.txBias, device, laneStr) + ch <- prometheus.MustNewConstMetric(c.moduleTxPowerDesc, prometheus.GaugeValue, lane.txPower, device, laneStr) + ch <- prometheus.MustNewConstMetric(c.moduleRxPowerDesc, prometheus.GaugeValue, lane.rxPower, device, laneStr) + } + } else { + c.logger.Debug("ethtool module EEPROM parse error", "err", parseErr, "device", device) + } + } else if err != unix.EOPNOTSUPP { + c.logger.Error("ethtool module EEPROM error", "err", err, "device", device) + } else { + c.logger.Debug("ethtool module EEPROM error", "err", err, "device", device) + } + if len(stats) == 0 { // No stats returned; device does not support ethtool stats. continue diff --git a/collector/ethtool_linux_test.go b/collector/ethtool_linux_test.go index 84cca88897..e329849b8a 100644 --- a/collector/ethtool_linux_test.go +++ b/collector/ethtool_linux_test.go @@ -17,6 +17,7 @@ package collector import ( "bufio" + "errors" "fmt" "io" "log/slog" @@ -257,6 +258,14 @@ func (e *EthtoolFixture) LinkInfo(intf string) (ethtool.EthtoolCmd, error) { return res, err } +func (e *EthtoolFixture) ModuleEeprom(intf string) ([]byte, error) { + data, err := os.ReadFile(filepath.Join(e.fixturePath, intf, "module_eeprom")) + if errors.Is(err, os.ErrNotExist) { + return nil, unix.EOPNOTSUPP + } + return data, err +} + func NewEthtoolTestCollector(logger *slog.Logger) (Collector, error) { collector, err := makeEthtoolCollector(logger) if err != nil { @@ -288,7 +297,22 @@ func TestBuildEthtoolFQName(t *testing.T) { } func TestEthToolCollector(t *testing.T) { - testcase := `# HELP node_ethtool_align_errors Network interface align_errors + testcase := `# HELP node_ethtool_module_rx_power_milliwatts Module RX optical power in milliwatts +# TYPE node_ethtool_module_rx_power_milliwatts gauge +node_ethtool_module_rx_power_milliwatts{device="eth0",lane="1"} 0.5 +# HELP node_ethtool_module_temperature_celsius Module temperature in degrees Celsius +# TYPE node_ethtool_module_temperature_celsius gauge +node_ethtool_module_temperature_celsius{device="eth0"} 25 +# HELP node_ethtool_module_tx_bias_milliamperes Module TX laser bias current in milliamperes +# TYPE node_ethtool_module_tx_bias_milliamperes gauge +node_ethtool_module_tx_bias_milliamperes{device="eth0",lane="1"} 20 +# HELP node_ethtool_module_tx_power_milliwatts Module TX optical power in milliwatts +# TYPE node_ethtool_module_tx_power_milliwatts gauge +node_ethtool_module_tx_power_milliwatts{device="eth0",lane="1"} 1 +# HELP node_ethtool_module_voltage_volts Module supply voltage in volts +# TYPE node_ethtool_module_voltage_volts gauge +node_ethtool_module_voltage_volts{device="eth0"} 3.2976 +# HELP node_ethtool_align_errors Network interface align_errors # TYPE node_ethtool_align_errors untyped node_ethtool_align_errors{device="eth0"} 0 # HELP node_ethtool_info A metric with a constant '1' value labeled by bus_info, device, driver, expansion_rom_version, firmware_version, version. diff --git a/collector/ethtool_sfp_linux.go b/collector/ethtool_sfp_linux.go new file mode 100644 index 0000000000..7118412bec --- /dev/null +++ b/collector/ethtool_sfp_linux.go @@ -0,0 +1,215 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !noethtool + +// SFP/QSFP module EEPROM parsing for Digital Optical Monitoring (DOM) / +// Digital Diagnostic Monitoring (DDM) data. +// +// Standards: +// - SFF-8472: SFP/SFP+ DDM (A0 + A2 EEPROM pages, 512 bytes total) +// - SFF-8636: QSFP/QSFP28 DOM (page 0, 256 bytes) + +package collector + +import ( + "encoding/binary" + "fmt" +) + +// SFP/QSFP module identifier values (EEPROM byte 0). +const ( + sfpIdentifierSFP = 0x03 // SFP/SFP+/SFP28 (SFF-8472) + sfpIdentifierSFPAlt = 0x0B // SFP+ alternative identifier + sfpIdentifierQSFP = 0x0C // QSFP (SFF-8436) + sfpIdentifierQSFPP = 0x0D // QSFP+ (SFF-8436) + sfpIdentifierQSFP28 = 0x11 // QSFP28 (SFF-8636) +) + +// sfpLaneMetrics holds per-lane optical monitoring values. +type sfpLaneMetrics struct { + txBias float64 // TX laser bias current in amperes + txPower float64 // TX optical power in watts + rxPower float64 // RX optical power in watts +} + +// sfpMetrics holds parsed DOM/DDM values from a transceiver module. +type sfpMetrics struct { + temperature float64 // Module temperature in degrees Celsius + voltage float64 // Module supply voltage in volts + lanes []sfpLaneMetrics // Per-lane metrics (1 lane for SFP, 4 for QSFP) +} + +// parseModuleEeprom parses raw EEPROM bytes returned by ethtool GMODULEEEPROM +// and extracts DOM/DDM values. +// +// Returns an error if the data is too short, the identifier is unrecognised, or DDM is not available. +func parseModuleEeprom(data []byte) (sfpMetrics, error) { + if len(data) < 1 { + return sfpMetrics{}, fmt.Errorf("module EEPROM data too short (%d bytes)", len(data)) + } + + switch data[0] { + case sfpIdentifierSFP, sfpIdentifierSFPAlt: + return parseSFF8472(data) + case sfpIdentifierQSFP, sfpIdentifierQSFPP, sfpIdentifierQSFP28: + return parseSFF8636(data) + default: + return sfpMetrics{}, fmt.Errorf("unsupported module identifier 0x%02x", data[0]) + } +} + +// parseSFF8472 parses SFP/SFP+ DDM data per SFF-8472. +func parseSFF8472(data []byte) (sfpMetrics, error) { + const ( + a0DiagnosticType = 92 // A0 page: diagnostic monitoring type byte + ddmSupportBit = 0x40 // bit 6: DDM implemented + + // Offsets within the full 512-byte dump (A2 page starts at 256). + a2PageOffset = 256 + valuesOffset = a2PageOffset + 96 + + tempOffset = valuesOffset + voltageOffset = tempOffset + 2 + txBiasOffset = voltageOffset + 2 + txPowerOffset = txBiasOffset + 2 + rxPowerOffset = txPowerOffset + 2 + minLen = rxPowerOffset + 2 + ) + + if len(data) < a0DiagnosticType+1 { + return sfpMetrics{}, fmt.Errorf("SFF-8472 EEPROM too short for diagnostic type byte (%d bytes)", len(data)) + } + if data[a0DiagnosticType]&ddmSupportBit == 0 { + return sfpMetrics{}, fmt.Errorf("SFP module does not support DDM (diagnostic type byte: 0x%02x)", data[a0DiagnosticType]) + } + if len(data) < minLen { + return sfpMetrics{}, fmt.Errorf("SFF-8472 EEPROM too short for DDM values (%d bytes, need %d)", len(data), minLen) + } + + temp := parseSFPTemperature(data[tempOffset:]) + voltage := parseSFPVoltage(data[voltageOffset:]) + + txBias := parseSFPBias(data[txBiasOffset:]) + txPower := parseSFPPower(data[txPowerOffset:]) + rxPower := parseSFPPower(data[rxPowerOffset:]) + + return sfpMetrics{ + temperature: temp, + voltage: voltage, + lanes: []sfpLaneMetrics{ + {txBias: txBias, txPower: txPower, rxPower: rxPower}, + }, + }, nil +} + +// parseSFF8636 parses QSFP/QSFP28 DOM data per SFF-8636. +func parseSFF8636(data []byte) (sfpMetrics, error) { + // All real-time values are on Page 00h. + const ( + // Table 6-8 Free Side Monitoring Values + tempOffset = 22 // Temperature MSB + voltageOffset = 26 // Supply voltage MSB + + // Table 6-9 Channel Monitoring Values. + numLanes = 4 + rxPowerOffset = 34 // RX power ch1 MSB + txBiasOffset = rxPowerOffset + numLanes*2 // TX bias ch1 MSB + txPowerOffset = txBiasOffset + numLanes*2 // TX power ch1 MSB + + minLen = txPowerOffset + numLanes*2 + ) + + if len(data) < minLen { + return sfpMetrics{}, fmt.Errorf("SFF-8636 EEPROM too short (%d bytes, need %d)", len(data), minLen) + } + + temp := parseSFPTemperature(data[tempOffset:]) + voltage := parseSFPVoltage(data[voltageOffset:]) + + lanes := make([]sfpLaneMetrics, numLanes) + for i := range numLanes { + lanes[i] = sfpLaneMetrics{ + rxPower: parseSFPPower(data[rxPowerOffset+i*2:]), + txBias: parseSFPBias(data[txBiasOffset+i*2:]), + txPower: parseSFPPower(data[txPowerOffset+i*2:]), + } + } + + return sfpMetrics{ + temperature: temp, + voltage: voltage, + lanes: lanes, + }, nil +} + +func parseSFPTemperature(b []byte) float64 { + // SFF-8472 + // + // Table 9-1 Bit Weights (°C) for Temperature Reporting Registers + // + // +----------------------------------+----------------------------------+-------+-------+ + // | Most Significant Byte (byte 96) | Least Significant Byte (byte 97) | | | + // +------+----+----+----+---+---+---+---+---+---+----+-----+-----+------+-------+-------+ + // | D7 | D6 | D5 | D4 | D3| D2| D1| D0| D7| D6| D5 | D4 | D3 | D2 | D1 | D0 | + // +------+----+----+----+---+---+---+---+---+---+----+-----+-----+------+-------+-------+ + // | Sign | 64 | 32 | 16 | 8 | 4 | 2 | 1 |1/2|1/4|1/8 |1/16 |1/32 | 1/64 | 1/128 | 1/256 | + // +------+----+----+----+---+---+---+---+---+---+----+-----+-----+------+-------+-------+ + // + rawVal := int16(binary.BigEndian.Uint16(b)) + return float64(rawVal) / 256.0 +} + +func parseSFPVoltage(b []byte) float64 { + // SFF-8472 + // + // 9.2 Internal Calibration + // + // ... + // 2) Internally measured transceiver supply voltage. Represented as a 16-bit unsigned integer with the voltage + // defined as the full 16-bit value (0-65535) with LSB equal to 100 microvolts, yielding a total range of 0 V to +6.55 V. + rawVal := binary.BigEndian.Uint16(b) + mV := float64(rawVal) / 10 + V := mV / 1000 + return V +} + +func parseSFPBias(b []byte) float64 { + // SFF-8472 + // + // 9.2 Internal Calibration + // + // ... + // 3) Measured TX bias current in mA. Represented as a 16-bit unsigned integer with the current defined as the full + // 16-bit value (0-65535) with LSB equal to 2 microamps, yielding a total range of 0 to 131 mA. + rawVal := binary.BigEndian.Uint16(b) + mA := float64(rawVal) / 500 + return mA +} + +func parseSFPPower(b []byte) float64 { + // SFF-8472 + // + // 9.2 Internal Calibration + // + // ... + // 4) Measured TX output power in mW. Represented as a 16-bit unsigned integer with the power defined as the + // full 16-bit value (0-65535) with LSB equal to 0.1 microwatts, yielding a total range of 0 to 6.5535 mW (-40 to +8.2 dBm). + // ... + // 5) Measured RX received optical power in mW. Value can represent either average received power or OMA + // depending upon how bit 3 of byte 92 (A0h) is set. Represented as a 16-bit unsigned integer with the power + // defined as the full 16-bit value (0-65535) with LSB equal to 0.1 microwatts, yielding a total range of 0 to 6.5535 mW (-40 to +8.2 dBm). + rawVal := binary.BigEndian.Uint16(b) + mW := float64(rawVal) / 10000 + return mW +} diff --git a/collector/ethtool_sfp_linux_test.go b/collector/ethtool_sfp_linux_test.go new file mode 100644 index 0000000000..1333f157c6 --- /dev/null +++ b/collector/ethtool_sfp_linux_test.go @@ -0,0 +1,427 @@ +// Copyright 2021 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !noethtool + +package collector + +import ( + "encoding/binary" + "fmt" + "math" + "testing" +) + +func almostEqual(a, b float64) bool { + if a == b { + return true + } + // Relative tolerance of 1e-9 covers float64 rounding from integer * small-constant arithmetic. + return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) < 0.1 +} + +func makeSFF8472(temp int16, voltage, txBias, txPower, rxPower uint16, ddmByte byte) []byte { + data := make([]byte, 512) + data[0] = sfpIdentifierSFP + data[92] = ddmByte + binary.BigEndian.PutUint16(data[352:], uint16(temp)) + binary.BigEndian.PutUint16(data[354:], voltage) + binary.BigEndian.PutUint16(data[356:], txBias) + binary.BigEndian.PutUint16(data[358:], txPower) + binary.BigEndian.PutUint16(data[360:], rxPower) + return data +} + +func makeSFF8636(temp int16, voltage uint16, rxPower, txBias, txPower [4]uint16) []byte { + data := make([]byte, 58) + data[0] = sfpIdentifierQSFP28 + binary.BigEndian.PutUint16(data[22:], uint16(temp)) + binary.BigEndian.PutUint16(data[26:], voltage) + for i := range 4 { + binary.BigEndian.PutUint16(data[34+i*2:], rxPower[i]) + binary.BigEndian.PutUint16(data[42+i*2:], txBias[i]) + binary.BigEndian.PutUint16(data[50+i*2:], txPower[i]) + } + return data +} + +var sff8472Cases = []struct { + name string + data []byte + wantErr bool + wantTemp float64 + wantVoltage float64 + wantTxBias float64 + wantTxPower float64 + wantRxPower float64 +}{ + { + name: "typical values", + data: makeSFF8472(25*256, 33000, 10000, 10000, 5000, 0x40), + wantTemp: 25.0, + wantVoltage: 33000 * 100e-6, + wantTxBias: 20.0, + wantTxPower: 1.0, + wantRxPower: 0.5, + }, + { + name: "negative temperature", + data: makeSFF8472(-10*256, 33000, 5000, 8000, 4000, 0x40), + wantTemp: -10.0, + wantVoltage: 33000 * 100e-6, + wantTxBias: 10.0, + wantTxPower: 0.8, + wantRxPower: 0.4, + }, + { + name: "fractional temperature", + data: makeSFF8472(int16(25*256+128), 33000, 0, 0, 0, 0x40), + wantTemp: 25.5, + wantVoltage: 33000 * 100e-6, + }, + { + name: "DDM not supported", + data: makeSFF8472(0, 0, 0, 0, 0, 0x00), + wantErr: true, + }, + { + name: "too short for diagnostic type byte", + data: make([]byte, 50), + wantErr: true, + }, + { + name: "too short for DDM values", + data: func() []byte { + d := make([]byte, 200) + d[0] = sfpIdentifierSFP + d[92] = 0x40 + return d + }(), + wantErr: true, + }, + { + name: "empty", + data: []byte{}, + wantErr: true, + }, + { + name: "zero values", + data: makeSFF8472(0, 0, 0, 0, 0, 0x40), + }, + { + name: "alt SFP identifier (0x0B)", + data: func() []byte { + d := makeSFF8472(25*256, 33000, 10000, 10000, 5000, 0x40) + d[0] = sfpIdentifierSFPAlt + return d + }(), + wantTemp: 25.0, + wantVoltage: 33000 * 100e-6, + wantTxBias: 20.0, + wantTxPower: 1.0, + wantRxPower: 0.5, + }, +} + +var sff8636Cases = []struct { + name string + data []byte + wantErr bool + wantTemp float64 + wantVoltage float64 + wantLanes [4]sfpLaneMetrics +}{ + { + name: "typical values", + data: makeSFF8636( + 25*256, 33000, + [4]uint16{5000, 4800, 4900, 5100}, + [4]uint16{10000, 9800, 10200, 10100}, + [4]uint16{9000, 8800, 9100, 9200}, + ), + wantTemp: 25.0, + wantVoltage: 33000 * 100e-6, + wantLanes: [4]sfpLaneMetrics{ + {txBias: 20.0, txPower: 0.9, rxPower: 0.5}, + {txBias: 19.6, txPower: 0.88, rxPower: 0.48}, + {txBias: 20.4, txPower: 0.91, rxPower: 0.49}, + {txBias: 20.2, txPower: 0.92, rxPower: 0.51}, + }, + }, + { + name: "negative temperature", + data: makeSFF8636( + -5*256, 33000, + [4]uint16{}, [4]uint16{}, [4]uint16{}, + ), + wantTemp: -5.0, + wantVoltage: 33000 * 100e-6, + }, + { + name: "too short", + data: make([]byte, 10), + wantErr: true, + }, + { + name: "empty", + data: []byte{}, + wantErr: true, + }, + { + name: "zero values", + data: makeSFF8636(0, 0, [4]uint16{}, [4]uint16{}, [4]uint16{}), + }, +} + +// moduleEepromCases are the table-driven test cases for parseModuleEeprom, also +// used as fuzzing seeds. +var moduleEepromCases = []struct { + name string + data []byte + wantErr bool + wantLanes int +}{ + { + name: "SFP (0x03)", + data: makeSFF8472(25*256, 33000, 10000, 10000, 5000, 0x40), + wantLanes: 1, + }, + { + name: "SFP alt (0x0B)", + data: func() []byte { + d := makeSFF8472(25*256, 33000, 10000, 10000, 5000, 0x40) + d[0] = sfpIdentifierSFPAlt + return d + }(), + wantLanes: 1, + }, + { + name: "QSFP (0x0C)", + data: func() []byte { + d := makeSFF8636(25*256, 33000, [4]uint16{}, [4]uint16{}, [4]uint16{}) + d[0] = sfpIdentifierQSFP + return d + }(), + wantLanes: 4, + }, + { + name: "QSFP+ (0x0D)", + data: func() []byte { + d := makeSFF8636(25*256, 33000, [4]uint16{}, [4]uint16{}, [4]uint16{}) + d[0] = sfpIdentifierQSFPP + return d + }(), + wantLanes: 4, + }, + { + name: "QSFP28 (0x11)", + data: makeSFF8636(25*256, 33000, [4]uint16{}, [4]uint16{}, [4]uint16{}), + wantLanes: 4, + }, + { + name: "unknown identifier", + data: []byte{0x01}, + wantErr: true, + }, + { + name: "empty", + data: []byte{}, + wantErr: true, + }, +} + +func TestParseSFF8472(t *testing.T) { + for _, tc := range sff8472Cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseSFF8472(tc.data) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !almostEqual(got.temperature, tc.wantTemp) { + t.Errorf("temperature: got %v, want %v", got.temperature, tc.wantTemp) + } + if !almostEqual(got.voltage, tc.wantVoltage) { + t.Errorf("voltage: got %v, want %v", got.voltage, tc.wantVoltage) + } + if len(got.lanes) != 1 { + t.Fatalf("expected 1 lane, got %d", len(got.lanes)) + } + if !almostEqual(got.lanes[0].txBias, tc.wantTxBias) { + t.Errorf("txBias: got %v, want %v", got.lanes[0].txBias, tc.wantTxBias) + } + if !almostEqual(got.lanes[0].txPower, tc.wantTxPower) { + t.Errorf("txPower: got %v, want %v", got.lanes[0].txPower, tc.wantTxPower) + } + if !almostEqual(got.lanes[0].rxPower, tc.wantRxPower) { + t.Errorf("rxPower: got %v, want %v", got.lanes[0].rxPower, tc.wantRxPower) + } + }) + } +} + +func TestParseSFF8636(t *testing.T) { + for _, tc := range sff8636Cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseSFF8636(tc.data) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !almostEqual(got.temperature, tc.wantTemp) { + t.Errorf("temperature: got %v, want %v", got.temperature, tc.wantTemp) + } + if !almostEqual(got.voltage, tc.wantVoltage) { + t.Errorf("voltage: got %v, want %v", got.voltage, tc.wantVoltage) + } + if len(got.lanes) != 4 { + t.Fatalf("expected 4 lanes, got %d", len(got.lanes)) + } + for i, want := range tc.wantLanes { + l := got.lanes[i] + if !almostEqual(l.txBias, want.txBias) { + t.Errorf("lane %d txBias: got %v, want %v", i, l.txBias, want.txBias) + } + if !almostEqual(l.txPower, want.txPower) { + t.Errorf("lane %d txPower: got %v, want %v", i, l.txPower, want.txPower) + } + if !almostEqual(l.rxPower, want.rxPower) { + t.Errorf("lane %d rxPower: got %v, want %v", i, l.rxPower, want.rxPower) + } + } + }) + } +} + +func TestParseModuleEeprom(t *testing.T) { + for _, tc := range moduleEepromCases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseModuleEeprom(tc.data) + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got.lanes) != tc.wantLanes { + t.Errorf("lanes: got %d, want %d", len(got.lanes), tc.wantLanes) + } + }) + } +} + +func FuzzParseSFF8472(f *testing.F) { + for _, tc := range sff8472Cases { + f.Add(tc.data) + } + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = parseSFF8472(data) //nolint:errcheck + }) +} + +func FuzzParseSFF8636(f *testing.F) { + for _, tc := range sff8636Cases { + f.Add(tc.data) + } + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = parseSFF8636(data) //nolint:errcheck + }) +} + +func FuzzParseModuleEeprom(f *testing.F) { + for _, tc := range moduleEepromCases { + f.Add(tc.data) + } + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = parseModuleEeprom(data) //nolint:errcheck + }) +} + +func Test_parseSFPTemperature(t *testing.T) { + // Values from Table 9-4 TEC Current Format. + tests := []struct { + b [2]byte + want float64 // celsius + }{ + {[2]byte{0x7D, 0}, 125.0}, + {[2]byte{0x19, 0}, 25.0}, + {[2]byte{0x00, 0xFF}, 1}, + {[2]byte{0x00, 0x01}, 0.004}, + {[2]byte{}, 0}, + {[2]byte{0xFF, 0xFF}, -0.004}, + {[2]byte{0xE7, 0x00}, -25.0}, + {[2]byte{0xD8, 0x00}, -40.0}, + } + for i, tt := range tests { + tt := tt + t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) { + got := parseSFPTemperature(tt.b[:]) + if !almostEqual(tt.want, got) { + t.Fatalf("Expected ~ %v, got %v", tt.want, got) + } + }) + } +} + +func Test_parseSFPVoltage(t *testing.T) { + tests := []struct { + b [2]byte + want float64 // volts + }{ + {[2]byte{0xFF, 0xFF}, 6.55}, + {[2]byte{0x00, 0x01}, 0.0001}, + {[2]byte{}, 0}, + } + for i, tt := range tests { + tt := tt + t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) { + got := parseSFPVoltage(tt.b[:]) + if !almostEqual(tt.want, got) { + t.Fatalf("Expected ~ %v, got %v", tt.want, got) + } + }) + } +} + +func Test_parseSFPPower(t *testing.T) { + tests := []struct { + b [2]byte + want float64 // milliwatts + }{ + {[2]byte{0xFF, 0xFF}, 6.55}, + {[2]byte{0x00, 0x01}, 0.0001}, + {[2]byte{}, 0}, + } + for i, tt := range tests { + tt := tt + t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) { + got := parseSFPPower(tt.b[:]) + if !almostEqual(tt.want, got) { + t.Fatalf("Expected ~ %v, got %v", tt.want, got) + } + }) + } +} diff --git a/collector/fixtures/ethtool/eth0/module_eeprom b/collector/fixtures/ethtool/eth0/module_eeprom new file mode 100644 index 0000000000..b7079db503 Binary files /dev/null and b/collector/fixtures/ethtool/eth0/module_eeprom differ