From 02290576123bf4316d3f1cff5e398892a2a47748 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 18 Jun 2026 20:02:49 +0200 Subject: [PATCH] statd: fix temperature sensor display on Marvell switches The per-port PHY temperature sensors on Marvell DSA switches show up in 'show hardware' with the name the kernel derives from the full device tree path, e.g. cp0busbusf2000000mdio12a200switch2mdio01, which honestly is completely unreadable and also overruns the value column. Name each sensor after the front-panel port it serves (e1, e2, ...) by matching the PHY's device-tree phandle against each interface's phy-handle. Also: - show system: report a representative SoC temperature on CN913x by matching the ap-* and cp-* thermal zones, hottest wins. - cli-pretty: truncate over-long sensor names so they can never spill into the value column again. Signed-off-by: Joachim Wiberg --- src/bin/show/__init__.py | 23 ++++++++++--- src/statd/python/cli_pretty/cli_pretty.py | 21 +++++++++--- src/statd/python/yanger/ietf_hardware.py | 41 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index 14785d9c..0b3471bd 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -544,6 +544,13 @@ def mdns(args: List[str]) -> None: cli_pretty(data, "show-mdns") +# Sensor names that represent the SoC/CPU temperature (not per-port PHYs). +# Matches "cpu"/"soc"/"core", and Marvell CN913x "ap-*" / "cp-*" zones. +# Note the hyphen after "cp" so mangled PHY names like "cp0busbus…" never +# match. +SOC_TEMP_RE = re.compile(r'^(cpu|soc|core|ap-|cp\d+-)') + + def system(args: List[str]) -> None: # Get system state from sysrepo data = get_json("/ietf-system:system-state") @@ -562,6 +569,7 @@ def system(args: List[str]) -> None: fan_rpm = None if hardware_data and "ietf-hardware:hardware" in hardware_data: components = hardware_data.get("ietf-hardware:hardware", {}).get("component", []) + soc_temps = [] for component in components: sensor_data = component.get("sensor-data", {}) if not sensor_data: @@ -570,16 +578,21 @@ def system(args: List[str]) -> None: name = component.get("name", "") value_type = sensor_data.get("value-type") - # Only capture CPU/SoC temperature (ignore phy, sfp, etc.) - # Different platforms use different names: cpu, soc, core, etc. - if value_type == "celsius" and name in ("cpu", "soc", "core") and cpu_temp is None: - temp_millidegrees = sensor_data.get("value", 0) - cpu_temp = temp_millidegrees / 1000.0 + # Capture SoC/CPU temperature, ignoring per-port phy, sfp, etc. + # Platforms name the zone differently: a plain "cpu"/"soc"/"core", + # or, on Marvell CN913x, an "ap-*" (application processor) or + # "cp-*" (communication processor) cluster. Collect them all + # and report the hottest as the representative SoC temperature. + if value_type == "celsius" and SOC_TEMP_RE.match(name): + soc_temps.append(sensor_data.get("value", 0) / 1000.0) # Capture fan speed if available elif value_type == "rpm" and fan_rpm is None: fan_rpm = sensor_data.get("value", 0) + if soc_temps: + cpu_temp = max(soc_temps) + if cpu_temp is not None: runtime["cpu_temp"] = cpu_temp if fan_rpm is not None: diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index a686e8db..b4c5b34e 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -916,7 +916,12 @@ class Sensor: # Standalone sensor without description: use name as-is display_name = self.name - row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}" + # Truncate over-long names so they never spill into the VALUE column + # (e.g. unmapped switch-PHY hwmon names derived from the DT path). + field = PadSensor.name - len(indent_str) + if len(display_name) >= field: + display_name = display_name[:field - 2] + "…" + row = f"{indent_str}{display_name:<{field}}" # For colored value, pad manually to account for ANSI codes value_str = self.get_formatted_value() # Count visible characters (strip ANSI codes for length calculation) @@ -2245,6 +2250,13 @@ def show_services(json): service_table.print() +def sensor_sort_key(component): + """Natural sort key for sensor names: digit runs compare numerically so + e2 sorts before e10, while keeping ap-cpu/cp0-ic/sfp groups together.""" + name = component.get("name", "") + return [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', name)] + + def show_hardware(json): if not json.get("ietf-hardware:hardware"): print("Error, top level \"ietf-hardware:component\" missing") @@ -2418,15 +2430,16 @@ def show_hardware(json): print(f"\n{module_name}:") if module_name in children: - for child in sorted(children[module_name], key=lambda c: c.get("name", "")): + for child in sorted(children[module_name], key=sensor_sort_key): sensor = Sensor(child) sensor.print(indent=1) - # Display standalone sensors (no parent) + # Display standalone sensors (no parent), naturally sorted so port + # temperatures read e1, e2, ... e28 rather than e1, e10, e11, ... if standalone: if modules: print() # Add blank line between modules and standalone - for component in sorted(standalone, key=lambda c: c.get("name", "")): + for component in sorted(standalone, key=sensor_sort_key): sensor = Sensor(component) sensor.print() diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 62e32cd7..934b2f6d 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -149,6 +149,36 @@ def normalize_sensor_name(name): return name +def _dt_phandle(path): + """Read a device-tree phandle cell as a normalized hex string. + + phandle/phy-handle properties are 4-byte big-endian cells. Read them via + od(1) so the binary content survives the text-based HOST transport (works + both locally and over the ssh-style remote transport). + """ + out = HOST.run(("od", "-An", "-tx1", path), default="") + return "".join(out.split()) if out else None + + +def phy_handle_to_ifname(): + """Map a PHY's device-tree phandle to the interface it drives. + + DSA user ports carry a "phy-handle" pointing at the PHY that serves them. + The reverse map lets us name a switch PHY's hwmon temperature sensor after + the front-panel port (e.g. e1) instead of the unreadable name the kernel + derives from the full device-tree path (cp0busbusf2000000mdio...). + """ + mapping = {} + for ifname in HOST.run(("ls", "/sys/class/net"), default="").split(): + handle_path = os.path.join("/sys/class/net", ifname, "of_node", "phy-handle") + if not HOST.exists(handle_path): + continue + handle = _dt_phandle(handle_path) + if handle: + mapping[handle] = ifname + return mapping + + def get_wifi_phy_info(): """ Discover WiFi PHYs using iw list command. @@ -218,6 +248,7 @@ def hwmon_sensor_components(): """ components = [] device_sensors = {} # Track {device_base_name: [list of sensor components]} + phy_ifname = phy_handle_to_ifname() def add_sensor(base_name, sensor_component): """Helper to track sensors per device""" @@ -244,6 +275,16 @@ def hwmon_sensor_components(): base_name = normalize_sensor_name(device_name) + # Switch PHYs get an hwmon name derived from their full + # device-tree path (e.g. cp0busbusf2000000mdio12a200switch2mdio01). + # If this PHY drives a known port, name the sensor after that + # port (e1, e2, ...) instead. + phandle_path = os.path.join(hwmon_path, "device", "of_node", "phandle") + if HOST.exists(phandle_path): + ifname = phy_ifname.get(_dt_phandle(phandle_path)) + if ifname: + base_name = ifname + # Helper to create sensor component with human-readable description def create_sensor(sensor_name, value, value_type, value_scale, label=None): component = {