confd: add support for temperature sensors in ietf-hardware.yang

- Remove class deviation to allow iana-hardware:sensor
 - Populate sensor operational data from /sys/class/thermal
 - Extend 'show hardware'

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2025-10-31 13:26:03 +01:00
parent ee6def793c
commit 8b10cce172
6 changed files with 183 additions and 42 deletions
+98 -8
View File
@@ -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):
+60 -1
View File
@@ -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() +
[],
},
}