diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 87f683d4..535b1b3d 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -46,6 +46,10 @@ module infix-hardware { base iahw:hardware-class; description "This identity is used to a VPD memory on the device."; } + identity wifi { + base iahw:hardware-class; + description "This identity is used to describe a WiFi radio/PHY"; + } deviation "/iehw:hardware/iehw:component/iehw:state/iehw:admin-state" { deviate add { @@ -58,12 +62,6 @@ module infix-hardware { deviation "/iehw:hardware/iehw:component/iehw:state/iehw:standby-state" { deviate not-supported; } - deviation "/iehw:hardware/iehw:component/iehw:parent" { - deviate not-supported; - } - deviation "/iehw:hardware/iehw:component/iehw:parent-rel-pos" { - deviate not-supported; - } deviation "/iehw:hardware/iehw:component/iehw:alias" { deviate not-supported; } diff --git a/src/show/show.py b/src/show/show.py index 1ce61e86..68b16c61 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -203,24 +203,32 @@ def system(args: List[str]) -> None: # Augment with runtime data runtime = {} - # Extract thermal sensors from hardware components - thermal_zones = [] + # Extract CPU temperature and fan speed from hardware components + cpu_temp = None + fan_rpm = None if hardware_data and "ietf-hardware:hardware" in hardware_data: components = hardware_data.get("ietf-hardware:hardware", {}).get("component", []) for component in components: sensor_data = component.get("sensor-data", {}) - if sensor_data and sensor_data.get("value-type") == "celsius": - # Convert from millidegrees to degrees + if not sensor_data: + continue + + name = component.get("name", "") + value_type = sensor_data.get("value-type") + + # Only capture CPU temperature (ignore phy, sfp, etc.) + if value_type == "celsius" and name == "cpu" and cpu_temp is None: temp_millidegrees = sensor_data.get("value", 0) - temp_celsius = temp_millidegrees / 1000.0 + cpu_temp = temp_millidegrees / 1000.0 - thermal_zones.append({ - "type": component.get("name", "unknown"), - "temp": temp_celsius - }) + # Capture fan speed if available + elif value_type == "rpm" and fan_rpm is None: + fan_rpm = sensor_data.get("value", 0) - if thermal_zones: - runtime["thermal"] = thermal_zones + if cpu_temp is not None: + runtime["cpu_temp"] = cpu_temp + if fan_rpm is not None: + runtime["fan_rpm"] = fan_rpm # Extract resource usage from system-state system_state = data.get("ietf-system:system-state", {}) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 7ba241d1..9ffee87f 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -171,15 +171,14 @@ class PadUsbPort: class PadSensor: - name = 20 - type = 15 - value = 15 + name = 30 + value = 20 status = 10 @classmethod def table_width(cls): - """Total width of sensor table""" - return cls.name + cls.type + cls.value + cls.status + """Total width of sensor table (matches show system width)""" + return cls.name + cls.value + cls.status class PadNtpSource: @@ -598,6 +597,8 @@ class Sensor: def __init__(self, data): self.data = data self.name = data.get('name', 'unknown') + self.description = data.get('description') # Human-readable description + self.parent = data.get('parent') # Parent component name sensor_data = data.get('sensor-data', {}) self.value_type = sensor_data.get('value-type', 'unknown') self.value = sensor_data.get('value', 0) @@ -606,26 +607,77 @@ class Sensor: def get_formatted_value(self): """Convert sensor value based on scale and type""" + # Handle temperature sensors if self.value_type == 'celsius': if self.value_scale == 'milli': temp_celsius = self.value / 1000.0 - # Color code like in show system + # Color code based on temperature thresholds if temp_celsius < 60: - return Decore.green(f"{temp_celsius:.1f}°C") + return Decore.green(f"{temp_celsius:.1f} °C") elif temp_celsius < 75: - return Decore.yellow(f"{temp_celsius:.1f}°C") + return Decore.yellow(f"{temp_celsius:.1f} °C") else: - return Decore.red(f"{temp_celsius:.1f}°C") + return Decore.red(f"{temp_celsius:.1f} °C") else: - return f"{self.value}°C" + return f"{self.value} °C" + + # Handle fan speed sensors + elif self.value_type == 'rpm': + return f"{self.value} RPM" + + # Handle voltage sensors + elif self.value_type == 'volts-DC': + if self.value_scale == 'milli': + volts = self.value / 1000.0 + return f"{volts:.2f} VDC" + else: + return f"{self.value} VDC" + + # Handle current sensors + elif self.value_type == 'amperes': + if self.value_scale == 'milli': + amps = self.value / 1000.0 + return f"{amps:.3f} A" + else: + return f"{self.value} A" + + # Handle power sensors + elif self.value_type == 'watts': + if self.value_scale == 'micro': + watts = self.value / 1000000.0 + return f"{watts:.3f} W" + elif self.value_scale == 'milli': + watts = self.value / 1000.0 + return f"{watts:.2f} W" + else: + return f"{self.value} W" + + # For unknown sensor types, show raw value else: - # For other sensor types, just show raw value return f"{self.value} {self.value_type}" - def print(self): + def print(self, indent=0): import re - row = f"{self.name:<{PadSensor.name}}" - row += f"{self.value_type:<{PadSensor.type}}" + # Add indentation for child sensors + indent_str = " " * indent + + # Determine display name: prefer description, fallback to name + if self.description: + # Use description if available (e.g., "WiFi Radio wlan0 (2.4 GHz)") + display_name = self.description + elif indent > 0 and self.parent: + # Child sensor: strip parent prefix from name + # "sfp1-RX_power" -> "RX_power" -> "Rx Power" + display_name = self.name + if display_name.startswith(self.parent + "-"): + display_name = display_name[len(self.parent) + 1:] + # Format: "RX_power" -> "Rx Power" + display_name = display_name.replace('_', ' ').replace('-', ' ').title() + else: + # Standalone sensor without description: use name as-is + display_name = self.name + + row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}" # 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) @@ -1669,15 +1721,46 @@ def show_hardware(json): if sensors: Decore.title("Sensors", width) + + # Print header hdr = (f"{'NAME':<{PadSensor.name}}" - f"{'TYPE':<{PadSensor.type}}" f"{'VALUE':<{PadSensor.value}}" f"{'STATUS':<{PadSensor.status}}") print(Decore.invert(hdr)) + # Build parent-child map + children = {} # parent_name -> [child_components] + standalone = [] # components without parents + for component in sensors: - sensor = Sensor(component) - sensor.print() + parent = component.get("parent") + if parent: + if parent not in children: + children[parent] = [] + children[parent].append(component) + else: + standalone.append(component) + + # Get all parent modules (non-sensor components) + modules = [c for c in components if c.get("class") == "iana-hardware:module"] + + # Display modules with their child sensors (indented) + for module in sorted(modules, key=lambda m: m.get("name", "")): + module_name = module.get("name", "unknown") + print(f"\n{module_name}:") + + if module_name in children: + for child in sorted(children[module_name], key=lambda c: c.get("name", "")): + sensor = Sensor(child) + sensor.print(indent=1) + + # Display standalone sensors (no parent) + if standalone: + if modules: + print() # Add blank line between modules and standalone + for component in sorted(standalone, key=lambda c: c.get("name", "")): + sensor = Sensor(component) + sensor.print() def show_ntp(json): @@ -1777,20 +1860,33 @@ def show_system(json): percent = int((used / total * 100)) if total > 0 else 0 print(f"{'Memory':<20}: {used} / {total} MB ({percent}% used)") - thermal = runtime.get("thermal", []) - if thermal: - for zone in thermal: - zone_type = zone.get("type", "Unknown").replace("-thermal", "") - temp = zone.get("temp", 0) - # Color code temperature: green < 60, yellow 60-75, red > 75 - temp_str = f"{temp:.1f}°C" - if temp < 60: + # Show CPU temperature and fan speed on one line + cpu_temp = runtime.get("cpu_temp") + fan_rpm = runtime.get("fan_rpm") + + if cpu_temp is not None or fan_rpm is not None: + status_line = "" + + # Add CPU temperature with color coding + if cpu_temp is not None: + temp_str = f"{cpu_temp:.1f} °C" + if cpu_temp < 60: temp_colored = Decore.green(temp_str) - elif temp < 75: + elif cpu_temp < 75: temp_colored = Decore.yellow(temp_str) else: temp_colored = Decore.red(temp_str) - print(f"{'Temperature':<20}: {temp_colored} ({zone_type})") + status_line = f"CPU: {temp_colored}" + + # Add fan speed if available + if fan_rpm is not None: + if status_line: + status_line += f", Fan: {fan_rpm} RPM" + else: + status_line = f"Fan: {fan_rpm} RPM" + + if status_line: + print(f"{'Hardware':<20}: {status_line}") disk = runtime.get("disk", []) # Filter out root partition (/) - it's read-only and shows confusing 100% usage diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 202cf5f1..1cbf15a9 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -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() + [], },