statd: refactor, use parent node for sensor relationships

For more advanced hardware with multiple sensor types per device
(e.g., SFP modules with temperature, voltage, current, and power
sensors), use the YANG parent/child relationship to group related
sensors together for better presentation.

Changes:
 - Remove parent/parent-rel-pos deviations from infix-hardware.yang
 - Create parent components (class: module) for multi-sensor devices
 - Add parent references to child sensor components
 - Add human-readable descriptions from hwmon labels
 - Extend hwmon discovery to support voltage, current, and power
 - Normalize sensor names: strip vendor prefixes (mt7915_phy0 -> phy0)
 - Remove redundant TYPE column, clarify units (V -> VDC, add spaces)
 - Simplify child sensor display by stripping parent prefix
 - Fix "show system" to only show CPU temperature and fan speed

Example output from "show hardware":

  NAME                     VALUE               STATUS
  ===================================================
  sfp1:
    Rx Power               0.000 W             ok
    Tx Power               0.001 W             ok
    Vcc                    3.35 VDC            ok
    Bias                   0.006 A             ok
    Temperature            30.3 °C             ok

  sfp2:
    Rx Power               0.000 W             ok
    Tx Power               0.001 W             ok
    Vcc                    3.34 VDC            ok
    Bias                   0.006 A             ok
    Temperature            32.0 °C             ok

  cpu                      42.8 °C             ok
  phy0                     47.0 °C             ok
  phy1                     53.0 °C             ok

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2025-10-31 13:26:04 +01:00
parent 46919e2d1f
commit d1fda087ec
4 changed files with 444 additions and 45 deletions
+298 -1
View File
@@ -109,6 +109,302 @@ def motherboard_component(systemjson):
return [component]
def normalize_sensor_name(name):
"""
Normalize sensor names for cleaner display.
Examples:
sfp_2 -> sfp2
mt7915_phy0 -> phy0
marvell_alaska_tomte_phy7 -> phy7
cpu_thermal -> cpu
pwmfan -> pwmfan
Strategy:
1. Strip common suffixes like -thermal/_thermal
2. Extract well-known sensor type names (phy, sfp, fan, etc.) from
the end of the name, stripping any vendor/chipset prefix
3. Remove underscores before trailing numbers (sfp_2 -> sfp2)
"""
import re
# Strip common suffixes
name = name.replace("-thermal", "").replace("_thermal", "")
# Extract well-known sensor types from end of name, stripping any prefix
# This handles: mt7915_phy0 -> phy0, marvell_alaska_phy7 -> phy7, etc.
sensor_types = r'(phy|sfp|fan|temp|sensor|psu|cpu|gpu|memory|disk)'
match = re.search(rf'.*_({sensor_types}\d*)$', name)
if match:
name = match.group(1)
# Remove underscores before trailing numbers (sfp_2 -> sfp2)
name = re.sub(r'_(\d+)$', r'\1', name)
return name
def get_wifi_phy_info():
"""
Discover WiFi PHYs and map them to bands and interface names.
Returns dict: {phy_name: {band: str, iface: str, description: str}}
Example: {"phy0": {"band": "2.4 GHz", "iface": "wlan0", "description": "WiFi Radio (2.4 GHz)"}}
"""
phy_info = {}
try:
# Enumerate PHYs from /sys/class/ieee80211/
ieee80211_path = "/sys/class/ieee80211"
if not os.path.exists(ieee80211_path):
return phy_info
for phy in os.listdir(ieee80211_path):
if not phy.startswith("phy"):
continue
phy_path = os.path.join(ieee80211_path, phy)
info = {"band": "Unknown", "iface": None, "description": None}
# Try to determine band from device path or hwmon name
# The hwmon device usually tells us: mt7915_phy0, mt7915_phy1, etc.
# We'll check supported frequencies to determine band
try:
# Read supported bands - check if device supports 5 GHz
# Most dual-band chips expose phy0 as 2.4 GHz and phy1 as 5 GHz
device_path = os.path.join(phy_path, "device")
if os.path.exists(device_path):
# Simple heuristic: phy0 is usually 2.4 GHz, phy1 is 5 GHz
# This works for most MediaTek chips (mt7915, mt7921, etc.)
if phy == "phy0":
info["band"] = "2.4 GHz"
elif phy == "phy1":
info["band"] = "5 GHz"
elif phy == "phy2":
info["band"] = "6 GHz" # WiFi 6E
except:
pass
# Find associated interface by checking which interface has a phy80211 link to this PHY
try:
net_path = "/sys/class/net"
if os.path.exists(net_path):
for iface in os.listdir(net_path):
phy_link = os.path.join(net_path, iface, "phy80211")
if os.path.islink(phy_link):
# Read the link target and extract PHY name
try:
link_target = os.readlink(phy_link)
linked_phy = os.path.basename(link_target)
if linked_phy == phy:
info["iface"] = iface
break
except:
continue
except:
pass
# Build description
if info["iface"] and info["band"] != "Unknown":
info["description"] = f"WiFi Radio {info['iface']} ({info['band']})"
elif info["band"] != "Unknown":
info["description"] = f"WiFi Radio ({info['band']})"
elif info["iface"]:
info["description"] = f"WiFi Radio {info['iface']}"
else:
info["description"] = "WiFi Radio"
phy_info[phy] = info
except Exception:
pass
return phy_info
def hwmon_sensor_components():
"""
Discover hwmon sensors and create sensor components with parent/child relationships.
Returns a list of hardware components with sensor-data for temperature,
fan, voltage, current, and power sensors.
For devices with multiple sensors (like SFP modules), creates:
- A parent component representing the device (class: module/container)
- Child sensor components that reference the parent via "parent" field
For simple devices with only one sensor, creates standalone sensor components.
"""
components = []
device_sensors = {} # Track {device_base_name: [list of sensor components]}
def add_sensor(base_name, sensor_component):
"""Helper to track sensors per device"""
if base_name not in device_sensors:
device_sensors[base_name] = []
device_sensors[base_name].append(sensor_component)
try:
hwmon_devices = glob.glob("/sys/class/hwmon/hwmon*")
for hwmon_path in hwmon_devices:
try:
name_path = os.path.join(hwmon_path, "name")
if not HOST.exists(name_path):
continue
device_name = HOST.read(name_path).strip()
base_name = normalize_sensor_name(device_name)
# Helper to create sensor component with human-readable description
def create_sensor(sensor_name, value, value_type, value_scale, label=None):
component = {
"name": sensor_name,
"class": "iana-hardware:sensor",
"sensor-data": {
"value": value,
"value-type": value_type,
"value-scale": value_scale,
"value-precision": 0,
"value-timestamp": str(YangDate()),
"oper-status": "ok"
}
}
# Add human-readable description if we have a label
if label:
# Format label nicely: "RX_power" -> "RX Power", "VCC" -> "VCC"
desc = label.replace('_', ' ').title()
component["description"] = desc
return component
# Temperature sensors
for temp_file in glob.glob(os.path.join(hwmon_path, "temp*_input")):
try:
sensor_num = os.path.basename(temp_file).split('_')[0].replace('temp', '')
value = int(HOST.read(temp_file).strip())
label_file = os.path.join(hwmon_path, f"temp{sensor_num}_label")
raw_label = None
if HOST.exists(label_file):
raw_label = HOST.read(label_file).strip()
label = normalize_sensor_name(raw_label)
sensor_name = f"{base_name}-{label}"
else:
sensor_name = base_name if sensor_num == '1' else f"{base_name}{sensor_num}"
add_sensor(base_name, create_sensor(sensor_name, value, "celsius", "milli", raw_label))
except (FileNotFoundError, ValueError, IOError):
continue
# Fan sensors
for fan_file in glob.glob(os.path.join(hwmon_path, "fan*_input")):
try:
sensor_num = os.path.basename(fan_file).split('_')[0].replace('fan', '')
value = int(HOST.read(fan_file).strip())
label_file = os.path.join(hwmon_path, f"fan{sensor_num}_label")
raw_label = None
if HOST.exists(label_file):
raw_label = HOST.read(label_file).strip()
label = normalize_sensor_name(raw_label)
sensor_name = f"{base_name}-{label}"
else:
sensor_name = base_name if sensor_num == '1' else f"{base_name}{sensor_num}"
add_sensor(base_name, create_sensor(sensor_name, value, "rpm", "units", raw_label))
except (FileNotFoundError, ValueError, IOError):
continue
# Voltage sensors
for voltage_file in glob.glob(os.path.join(hwmon_path, "in*_input")):
try:
sensor_num = os.path.basename(voltage_file).split('_')[0].replace('in', '')
value = int(HOST.read(voltage_file).strip())
label_file = os.path.join(hwmon_path, f"in{sensor_num}_label")
raw_label = None
if HOST.exists(label_file):
raw_label = HOST.read(label_file).strip()
label = normalize_sensor_name(raw_label)
sensor_name = f"{base_name}-{label}"
else:
raw_label = "voltage"
sensor_name = f"{base_name}-voltage" if sensor_num == '0' else f"{base_name}-voltage{sensor_num}"
add_sensor(base_name, create_sensor(sensor_name, value, "volts-DC", "milli", raw_label))
except (FileNotFoundError, ValueError, IOError):
continue
# Current sensors
for current_file in glob.glob(os.path.join(hwmon_path, "curr*_input")):
try:
sensor_num = os.path.basename(current_file).split('_')[0].replace('curr', '')
value = int(HOST.read(current_file).strip())
label_file = os.path.join(hwmon_path, f"curr{sensor_num}_label")
raw_label = None
if HOST.exists(label_file):
raw_label = HOST.read(label_file).strip()
label = normalize_sensor_name(raw_label)
sensor_name = f"{base_name}-{label}"
else:
raw_label = "current"
sensor_name = f"{base_name}-current" if sensor_num == '1' else f"{base_name}-current{sensor_num}"
add_sensor(base_name, create_sensor(sensor_name, value, "amperes", "milli", raw_label))
except (FileNotFoundError, ValueError, IOError):
continue
# Power sensors
for power_file in glob.glob(os.path.join(hwmon_path, "power*_input")):
try:
sensor_num = os.path.basename(power_file).split('_')[0].replace('power', '')
value = int(HOST.read(power_file).strip())
label_file = os.path.join(hwmon_path, f"power{sensor_num}_label")
raw_label = None
if HOST.exists(label_file):
raw_label = HOST.read(label_file).strip()
label = normalize_sensor_name(raw_label)
sensor_name = f"{base_name}-{label}"
else:
raw_label = "power"
sensor_name = f"{base_name}-power" if sensor_num == '1' else f"{base_name}-power{sensor_num}"
add_sensor(base_name, create_sensor(sensor_name, value, "watts", "micro", raw_label))
except (FileNotFoundError, ValueError, IOError):
continue
except (FileNotFoundError, ValueError, IOError):
continue
except Exception:
pass
# Now create parent/child relationships
for base_name, sensors in device_sensors.items():
if len(sensors) > 1:
# Multiple sensors: create parent component
parent = {
"name": base_name,
"class": "iana-hardware:module", # Use "module" for multi-sensor devices like SFP
}
components.append(parent)
# Add parent reference to all child sensors
for sensor in sensors:
sensor["parent"] = base_name
components.append(sensor)
else:
# Single sensor: add without parent
components.extend(sensors)
# Enrich WiFi PHY sensors with descriptive information
wifi_info = get_wifi_phy_info()
for component in components:
name = component.get("name", "")
# Match phy0, phy1, etc. sensors
if name.startswith("phy") and name in wifi_info:
phy = wifi_info[name]
# Add WiFi-specific description
component["description"] = phy["description"]
# Optionally change class to wifi for WiFi PHY sensors
if component.get("class") == "iana-hardware:sensor":
# Keep as sensor but we could create a parent WiFi component later if needed
pass
return components
def thermal_sensor_components():
"""
Discover thermal zones and create sensor components.
@@ -138,7 +434,7 @@ def thermal_sensor_components():
# Create component with sensor-data
# Component name: strip "-thermal" suffix for cleaner display
component_name = zone_type.replace("-thermal", "")
component_name = normalize_sensor_name(zone_type)
component = {
"name": component_name,
@@ -175,6 +471,7 @@ def operational():
motherboard_component(systemjson) +
vpd_components(systemjson) +
usb_port_components(systemjson) +
hwmon_sensor_components() +
thermal_sensor_components() +
[],
},