diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 0cca741d..d39401b7 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -17,8 +17,8 @@ MODULES=( "ietf-syslog@2024-03-21.yang -e file-action -e file-limit-size -e remote-action" "infix-syslog@2024-07-19.yang" "iana-hardware@2018-03-13.yang" - "ietf-hardware@2018-03-13.yang -e hardware-state" - "infix-hardware@2024-04-25.yang" + "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" + "infix-hardware@2025-10-18.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2024-09-16.yang" "infix-if-type@2025-02-12.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 71ad21f4..87f683d4 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -17,6 +17,10 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2025-10-18 { + description "Enable sensor support, starting with hwmon temperature sensors."; + reference "internal"; + } revision 2024-04-25 { description "Spellcheck leaf: coutry-code -> country-code"; reference "internal"; @@ -34,27 +38,15 @@ module infix-hardware { description "A two-letter country code."; } - identity hardware-class { - description "infix hardware base class"; - } - identity usb { - base hardware-class; + base iahw:hardware-class; description "This identity is used to describe a USB port"; } identity vpd { - base hardware-class; + base iahw:hardware-class; description "This identity is used to a VPD memory on the device."; } - deviation "/iehw:hardware/iehw:component/iehw:class" { - deviate replace { - type identityref { - base hardware-class; - } - } - } - deviation "/iehw:hardware/iehw:component/iehw:state/iehw:admin-state" { deviate add { must ". = 'locked' or . = 'unlocked'" { @@ -66,9 +58,6 @@ module infix-hardware { deviation "/iehw:hardware/iehw:component/iehw:state/iehw:standby-state" { deviate not-supported; } - deviation "/iehw:hardware/iehw:component/iehw:sensor-data" { - deviate not-supported; - } deviation "/iehw:hardware/iehw:component/iehw:parent" { deviate not-supported; } diff --git a/src/confd/yang/confd/infix-hardware@2024-04-25.yang b/src/confd/yang/confd/infix-hardware@2025-10-18.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2024-04-25.yang rename to src/confd/yang/confd/infix-hardware@2025-10-18.yang diff --git a/src/show/show.py b/src/show/show.py index eca68d21..73d4f10f 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -197,24 +197,27 @@ def system(args: List[str]) -> None: print("No system data retrieved.") return + # Get hardware data (including thermal sensors) + hardware_data = run_sysrepocfg("/ietf-hardware:hardware") + # Augment with runtime data runtime = {} - # Get thermal zones + # Extract thermal sensors from hardware components thermal_zones = [] - try: - for zone in os.listdir("/sys/class/thermal"): - if zone.startswith("thermal_zone"): - try: - with open(f"/sys/class/thermal/{zone}/type") as f: - zone_type = f.read().strip() - with open(f"/sys/class/thermal/{zone}/temp") as f: - temp = int(f.read().strip()) / 1000.0 - thermal_zones.append({"type": zone_type, "temp": temp}) - except (FileNotFoundError, ValueError): - pass - except FileNotFoundError: - pass + 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 + temp_millidegrees = sensor_data.get("value", 0) + temp_celsius = temp_millidegrees / 1000.0 + + thermal_zones.append({ + "type": component.get("name", "unknown"), + "temp": temp_celsius + }) if thermal_zones: runtime["thermal"] = thermal_zones diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index f67402ca..f9d3c37a 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -162,6 +162,24 @@ class PadUsbPort: title = 30 name = 20 state = 10 + oper = 10 + + @classmethod + def table_width(cls): + """Total width of USB port table""" + return cls.name + cls.state + cls.oper + + +class PadSensor: + name = 20 + type = 15 + value = 15 + status = 10 + + @classmethod + def table_width(cls): + """Total width of sensor table""" + return cls.name + cls.type + cls.value + cls.status class PadNtpSource: @@ -567,11 +585,54 @@ class USBport: self.data = data self.name = data.get('name', '') self.state = get_json_data('', self.data, 'state', 'admin-state') + self.oper = get_json_data('', self.data, 'state', 'oper-state') def print(self): - #print(self.name) row = f"{self.name:<{PadUsbPort.name}}" row += f"{self.state:<{PadUsbPort.state}}" + row += f"{self.oper:<{PadUsbPort.oper}}" + print(row) + + +class Sensor: + def __init__(self, data): + self.data = data + self.name = data.get('name', 'unknown') + sensor_data = data.get('sensor-data', {}) + self.value_type = sensor_data.get('value-type', 'unknown') + self.value = sensor_data.get('value', 0) + self.value_scale = sensor_data.get('value-scale', 'units') + self.oper_status = sensor_data.get('oper-status', 'unknown') + + def get_formatted_value(self): + """Convert sensor value based on scale and type""" + if self.value_type == 'celsius': + if self.value_scale == 'milli': + temp_celsius = self.value / 1000.0 + # Color code like in show system + if temp_celsius < 60: + return Decore.green(f"{temp_celsius:.1f}°C") + elif temp_celsius < 75: + return Decore.yellow(f"{temp_celsius:.1f}°C") + else: + return Decore.red(f"{temp_celsius:.1f}°C") + else: + return f"{self.value}°C" + else: + # For other sensor types, just show raw value + return f"{self.value} {self.value_type}" + + def print(self): + import re + row = f"{self.name:<{PadSensor.name}}" + row += f"{self.value_type:<{PadSensor.type}}" + # 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) + visible_len = len(re.sub(r'\x1b\[[0-9;]*m', '', value_str)) + padding = PadSensor.value - visible_len + row += value_str + (' ' * padding) + row += f"{self.oper_status:<{PadSensor.status}}" print(row) @@ -1568,17 +1629,46 @@ def show_hardware(json): print("Error, top level \"ietf-hardware:component\" missing") sys.exit(1) - hdr = (f"{'NAME':<{PadUsbPort.name}}" - f"{'STATE':<{PadUsbPort.state}}") - Decore.title("USB PORTS", PadUsbPort.title) # TODO: could be len(hdr) - print(Decore.invert(hdr)) - components = get_json_data({}, json, "ietf-hardware:hardware", "component") - for component in components: - if component.get("class") == "infix-hardware:usb": + # Separate components by type + usb_ports = [c for c in components if c.get("class") == "infix-hardware:usb"] + sensors = [c for c in components if c.get("class") == "iana-hardware:sensor"] + + # Determine overall width (use the wider of the two sections) + width = max(PadUsbPort.table_width(), PadSensor.table_width()) + + # Display full-width inverted heading + print(Decore.invert(f"{'HARDWARE COMPONENTS':<{width}}")) + print() + + # USB Ports section + if usb_ports: + Decore.title("USB Ports", width) + hdr = (f"{'NAME':<{PadUsbPort.name}}" + f"{'STATE':<{PadUsbPort.state}}" + f"{'OPER':<{PadUsbPort.oper}}") + # Pad header to full width + hdr = f"{hdr:<{width}}" + print(Decore.invert(hdr)) + + for component in usb_ports: port = USBport(component) port.print() + print() + + # Sensors section + if sensors: + Decore.title("Sensors", width) + hdr = (f"{'NAME':<{PadSensor.name}}" + f"{'TYPE':<{PadSensor.type}}" + f"{'VALUE':<{PadSensor.value}}" + f"{'STATUS':<{PadSensor.status}}") + print(Decore.invert(hdr)) + + for component in sensors: + sensor = Sensor(component) + sensor.print() def show_ntp(json): diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 94a74f65..5e7f3281 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -1,7 +1,8 @@ import datetime import os +import glob -from .common import insert +from .common import insert, YangDate from .host import HOST @@ -75,6 +76,63 @@ def usb_port_components(systemjson): return ports +def thermal_sensor_components(): + """ + Discover thermal zones and create sensor components. + Returns a list of hardware components with sensor-data. + """ + components = [] + + try: + # Find all thermal zones + thermal_zones = glob.glob("/sys/class/thermal/thermal_zone*") + + for zone_path in thermal_zones: + try: + # Read zone type (e.g., "cpu-thermal", "gpu-thermal") + type_path = os.path.join(zone_path, "type") + if not HOST.exists(type_path): + continue + + zone_type = HOST.read(type_path).strip() + + # Read temperature in millidegrees Celsius + temp_path = os.path.join(zone_path, "temp") + if not HOST.exists(temp_path): + continue + + temp_millidegrees = int(HOST.read(temp_path).strip()) + + # Create component with sensor-data + # Component name: strip "-thermal" suffix for cleaner display + component_name = zone_type.replace("-thermal", "") + + component = { + "name": component_name, + "class": "iana-hardware:sensor", + "sensor-data": { + "value": temp_millidegrees, + "value-type": "celsius", + "value-scale": "milli", + "value-precision": 0, + "value-timestamp": str(YangDate()), + "oper-status": "ok" + } + } + + components.append(component) + + except (FileNotFoundError, ValueError, IOError): + # Skip this thermal zone if we can't read it + continue + + except Exception: + # If we can't access /sys/class/thermal at all, just return empty list + pass + + return components + + def operational(): systemjson = HOST.read_json("/run/system.json") @@ -83,6 +141,7 @@ def operational(): "component": vpd_components(systemjson) + usb_port_components(systemjson) + + thermal_sensor_components() + [], }, }