public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Davide Guerri via pve-devel <pve-devel@lists.proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Davide Guerri <davide.guerri@gmail.com>
Subject: [pve-devel] [PATCH container 1/1] sensors: add hardware sensor module for CPUs, disks, and fans
Date: Tue, 14 Oct 2025 21:36:01 +0200	[thread overview]
Message-ID: <mailman.915.1760476932.390.pve-devel@lists.proxmox.com> (raw)
In-Reply-To: <20251014193601.34285-1-davide.guerri@gmail.com>

[-- Attachment #1: Type: message/rfc822, Size: 19789 bytes --]

From: Davide Guerri <davide.guerri@gmail.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH container 1/1] sensors: add hardware sensor module for CPUs, disks, and fans
Date: Tue, 14 Oct 2025 21:36:01 +0200
Message-ID: <20251014193601.34285-2-davide.guerri@gmail.com>

Add new PVE::SensorInfo.pm module providing functionality to read
temperature, fan, and other sensor data from /sys/class/hwmon.

Includes driver mappings for various hardware (Intel, AMD, ASUS, etc.)
and CPU core topology integration for accurate per-core temperature
reporting.

Signed-off-by: Davide Guerri <davide.guerri@gmail.com>
---
 src/Makefile          |   1 +
 src/PVE/SensorInfo.pm | 385 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 386 insertions(+)
 create mode 100644 src/PVE/SensorInfo.pm

diff --git a/src/Makefile b/src/Makefile
index 0ca817b..388ebbb 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -32,6 +32,7 @@ LIB_SOURCES = \
 	RESTHandler.pm \
 	SafeSyslog.pm \
 	SectionConfig.pm \
+	SensorInfo.pm \
 	SysFSTools.pm \
 	Syscall.pm \
 	Systemd.pm \
diff --git a/src/PVE/SensorInfo.pm b/src/PVE/SensorInfo.pm
new file mode 100644
index 0000000..3938a4e
--- /dev/null
+++ b/src/PVE/SensorInfo.pm
@@ -0,0 +1,385 @@
+package PVE::SensorInfo;
+
+use v5.36;
+
+use PVE::Tools qw(file_read_firstline dir_glob_foreach);
+
+my $hwmon_base = "/sys/class/hwmon";
+my $cpu_base = "/sys/devices/system/cpu";
+
+# CPU sensor driver names
+my %cpu_drivers = (
+    'coretemp' => 'Intel',
+    'k10temp' => 'AMD',
+    'k8temp' => 'AMD K8',
+    'zenpower' => 'AMD Ryzen',
+    'fam15h_power' => 'AMD',
+    'atk0110' => 'ASUS',
+    'w83627ehf' => 'Winbond',
+    'nct6775' => 'Nuvoton',
+    'it87' => 'ITE',
+    'hp_wmi' => 'HPE',
+    'asus_wmi' => 'ASUS WMI',
+);
+
+# Disk sensor driver names
+my %disk_drivers = (
+    'drivetemp' => 'SATA/SAS',
+    'nvme' => 'NVMe',
+);
+
+# Fan sensor driver names (motherboard and chassis fans)
+my %fan_drivers = (
+    'nct6775' => 'Nuvoton',
+    'it87' => 'ITE',
+    'w83627ehf' => 'Winbond',
+    'asus_wmi_sensors' => 'ASUS WMI',
+    'atk0110' => 'ASUS',
+    'dell_smm' => 'Dell',
+    'gigabyte_waterforce' => 'Gigabyte',
+    'corsairpsu' => 'Corsair PSU',
+    'nzxt_smart2' => 'NZXT',
+    'drivetemp' => 'Drive',
+);
+
+my $classify_temperature_sensor = sub {
+    my ($hwmon, $name) = @_;
+
+    return 'cpu' if exists $cpu_drivers{$name};
+    return 'disk' if exists $disk_drivers{$name};
+
+    return 'other';
+};
+
+my $build_core_mapping = sub {
+    my $core_map = {};
+    my $seen_cores = {};
+    my $logical_num = 0;
+
+    dir_glob_foreach(
+        $cpu_base,
+        'cpu\d+',
+        sub {
+            my ($cpu) = @_;
+
+            my $cpu_path = "$cpu_base/$cpu";
+            my $core_id_file = "$cpu_path/topology/core_id";
+            my $package_id_file = "$cpu_path/topology/physical_package_id";
+
+            return if !-r $core_id_file;
+
+            my $physical_core = file_read_firstline($core_id_file);
+            my $package = file_read_firstline($package_id_file);
+            $package = 0 if !defined($package);
+
+            return if !defined($physical_core);
+
+            my $key = "${package}:${physical_core}";
+
+            if (!exists $seen_cores->{$key}) {
+                my $info = {
+                    logical_num => $logical_num,
+                    physical_id => $physical_core,
+                    package => $package,
+                };
+
+                $core_map->{$key} = $info;
+                $seen_cores->{$key} = 1;
+                $logical_num++;
+            }
+        },
+    );
+
+    return $core_map;
+};
+
+my $get_disk_device_from_hwmon = sub {
+    my ($hwmon_path, $sensor_name) = @_;
+
+    # For NVMe devices: /sys/class/hwmon/hwmonX/device -> ../../nvmeY
+    # The nvme controller directory contains nvmeYn1 subdirectory
+    # (namespace)
+    if ($sensor_name eq 'nvme') {
+        if (-l "$hwmon_path/device") {
+            my $device_link = readlink("$hwmon_path/device");
+            if (defined($device_link) && $device_link =~ m/(nvme\d+)/) {
+                my $nvme_ctrl = $1;
+                # Look for the first namespace (typically nvmeXn1)
+                my $device_path = "$hwmon_path/device";
+                if (opendir(my $dh, $device_path)) {
+                    my @namespaces = ();
+                    while (my $entry = readdir($dh)) {
+                        if ($entry =~ m/^(nvme\d+n\d+)$/) {
+                            push @namespaces, $entry;
+                        }
+                    }
+                    closedir($dh);
+                    if (@namespaces) {
+                        # Return the first namespace, sorted (usually nvmeXn1)
+                        return (sort @namespaces)[0];
+                    }
+                }
+            }
+        }
+    }
+
+    # For drivetemp devices: name format is "drivetemp"
+    # Device path: /sys/class/hwmon/hwmonX/device -> ../../X:Y:Z:W/
+    # (SCSI host:channel:id:lun)
+    if ($sensor_name eq 'drivetemp') {
+        if (-l "$hwmon_path/device") {
+            my $device_link = readlink("$hwmon_path/device");
+            if (defined($device_link)) {
+                # Extract SCSI address (e.g., "../../6:0:0:0/")
+                if ($device_link =~ m/(\d+):(\d+):(\d+):(\d+)/) {
+                    my ($host, $channel, $id, $lun) = ($1, $2, $3, $4);
+
+                    my $scsi_device_path =
+                        "/sys/class/scsi_disk/" . "${host}:${channel}:${id}:${lun}/device";
+
+                    # Look for the block device
+                    my $scsi_block_path = "${scsi_device_path}/block";
+                    if (-d $scsi_block_path) {
+                        # Find the block device name (e.g., "sda")
+                        opendir(my $dh, $scsi_block_path) or return undef;
+                        while (my $entry = readdir($dh)) {
+                            next if $entry =~ /^\./;
+                            closedir($dh);
+                            return $entry;
+                        }
+                        closedir($dh);
+                    }
+                }
+            }
+        }
+    }
+
+    return undef;
+};
+
+# returns hashref of current TEMPERATURE readings for given sensor type
+# type can be 'cpu', 'disk', 'other', or 'all' (default)
+#
+# return format:
+# {
+#     'coretemp/Core 0' => {
+#         temperature => 45.0,      # in degrees Celsius
+#         unit => 'celsius',
+#         type => 'cpu',
+#         driver => 'Intel',
+#         max => 100.0,            # optional
+#         critical => 100.0,       # optional
+#         logical_core => 0,       # optional, for CPU cores
+#         physical_core => 0,      # optional, for CPU cores
+#         package => 0,            # optional, for CPU cores
+#     },
+#     'hwmon4/Sensor 1' => {
+#         temperature => 35.0,      # in degrees Celsius
+#         unit => 'celsius',
+#         type => 'disk',
+#         driver => 'SATA/SAS',
+#         device => 'sda',         # block device name (for disks)
+#     },
+#     'hwmon1/Composite' => {
+#         temperature => 42.0,      # in degrees Celsius
+#         unit => 'celsius',
+#         type => 'disk',
+#         driver => 'NVMe',
+#         device => 'nvme0n1',     # block device name (for disks)
+#     },
+#     'hwmon2/Composite' => {
+#         temperature => 43.0,      # in degrees Celsius
+#         unit => 'celsius',
+#         type => 'disk',
+#         driver => 'NVMe',
+#         device => 'nvme1n1',     # block device name (for disks)
+#     },
+#     ...
+# }
+#
+sub read_temperatures {
+    my ($type) = @_;
+
+    $type = 'all' if !defined($type);
+    my @types = ($type eq 'all') ? qw(cpu disk other) : ($type);
+
+    my $results = {};
+    return $results if !-d $hwmon_base;
+
+    # Build core mapping once for all sensors
+    my $core_mapping = $build_core_mapping->();
+
+    dir_glob_foreach(
+        $hwmon_base,
+        'hwmon\d+',
+        sub {
+            my ($hwmon) = @_;
+
+            my $hwmon_path = "$hwmon_base/$hwmon";
+            my $sensor_name = file_read_firstline("$hwmon_path/name");
+            return if !defined($sensor_name);
+
+            # Determine sensor type and skip if not requested
+            my $sensor_type = $classify_temperature_sensor->($hwmon_path, $sensor_name);
+            return if !grep { $_ eq $sensor_type } @types;
+
+            my $driver = $cpu_drivers{$sensor_name} || $disk_drivers{$sensor_name} || 'Unknown';
+            my $is_cpu_sensor = exists $cpu_drivers{$sensor_name};
+            my $is_disk_sensor = exists $disk_drivers{$sensor_name};
+
+            # Get disk device information for disk sensors
+            my $disk_device;
+            if ($is_disk_sensor) {
+                $disk_device = $get_disk_device_from_hwmon->($hwmon_path, $sensor_name);
+            }
+
+            # Read all temperature sensors for this hwmon device
+            dir_glob_foreach(
+                $hwmon_path,
+                'temp\d+_input',
+                sub {
+                    my ($temp_input) = @_;
+                    my ($num) = $temp_input =~ /temp(\d+)_input$/;
+                    return if !defined($num);
+
+                    # Read current temperature
+                    my $raw_temp = file_read_firstline("$hwmon_path/$temp_input");
+                    return if !defined($raw_temp);
+
+                    my $temp_c = $raw_temp / 1000.0;
+
+                    # Get label
+                    my $label = file_read_firstline("$hwmon_path/temp${num}_label");
+                    $label = "Sensor $num" if !defined($label);
+
+                    # Create unique key: use hwmon ID for disk sensors, sensor name for others
+                    my $key = $is_disk_sensor ? "$hwmon/$label" : "$sensor_name/$label";
+
+                    $results->{$key} = {
+                        temperature => $temp_c,
+                        unit => 'celsius',
+                        type => $sensor_type,
+                        driver => $driver,
+                    };
+
+                    # Add CPU topology information
+                    if ($is_cpu_sensor && $label =~ /^Core (\d+)$/) {
+                        my $physical_id = $1;
+                        foreach my $map_key (keys %$core_mapping) {
+                            my $map = $core_mapping->{$map_key};
+                            if ($map->{physical_id} eq $physical_id) {
+                                $results->{$key}->{logical_core} = $map->{logical_num};
+                                $results->{$key}->{physical_core} = $physical_id;
+                                $results->{$key}->{package} = $map->{package};
+                                last;
+                            }
+                        }
+                    }
+
+                    # Add disk device information
+                    if ($is_disk_sensor && defined($disk_device)) {
+                        $results->{$key}->{device} = $disk_device;
+                    }
+
+                    # Add temperature limits
+                    my $max = file_read_firstline("$hwmon_path/temp${num}_max");
+                    $results->{$key}->{max} = $max / 1000.0 if defined($max);
+
+                    my $crit = file_read_firstline("$hwmon_path/temp${num}_crit");
+                    $results->{$key}->{critical} = $crit / 1000.0 if defined($crit);
+                },
+            );
+        },
+    );
+
+    return $results;
+}
+
+# returns hashref of current FAN SPEED readings
+#
+# return format:
+# {
+#     'nct6775/CPU Fan' => {
+#         speed => 1500,       # in RPM
+#         unit => 'rpm',
+#         driver => 'Nuvoton',
+#         min => 0,           # optional
+#         max => 2000,        # optional
+#         target => 1500,     # optional
+#         alarm => 0,         # optional, 0 or 1
+#     },
+#     ...
+# }
+#
+sub read_fan_speeds {
+    my $results = {};
+    return $results if !-d $hwmon_base;
+
+    dir_glob_foreach(
+        $hwmon_base,
+        'hwmon\d+',
+        sub {
+            my ($hwmon) = @_;
+
+            my $hwmon_path = "$hwmon_base/$hwmon";
+            my $sensor_name = file_read_firstline("$hwmon_path/name");
+            return if !defined($sensor_name);
+
+            my $driver = $fan_drivers{$sensor_name} || 'Unknown';
+
+            # Read all fan sensors for this hwmon device
+            dir_glob_foreach(
+                $hwmon_path,
+                'fan\d+_input',
+                sub {
+                    my ($fan_input) = @_;
+                    my ($num) = $fan_input =~ /fan(\d+)_input$/;
+                    return if !defined($num);
+
+                    # Read current fan speed
+                    my $raw_speed = file_read_firstline("$hwmon_path/$fan_input");
+                    return if !defined($raw_speed);
+
+                    # Get label
+                    my $label = file_read_firstline("$hwmon_path/fan${num}_label");
+                    $label = "Fan $num" if !defined($label);
+
+                    my $key = "$sensor_name/$label";
+
+                    $results->{$key} = {
+                        speed => int($raw_speed),
+                        unit => 'rpm',
+                        driver => $driver,
+                    };
+
+                    # Add optional fan metadata
+                    my $min = file_read_firstline("$hwmon_path/fan${num}_min");
+                    $results->{$key}->{min} = int($min) if defined($min);
+
+                    my $max = file_read_firstline("$hwmon_path/fan${num}_max");
+                    $results->{$key}->{max} = int($max) if defined($max);
+
+                    my $target = file_read_firstline("$hwmon_path/fan${num}_target");
+                    $results->{$key}->{target} = int($target) if defined($target);
+
+                    my $alarm = file_read_firstline("$hwmon_path/fan${num}_alarm");
+                    $results->{$key}->{alarm} = int($alarm) if defined($alarm);
+                },
+            );
+        },
+    );
+
+    return $results;
+}
+
+# convenience function: read CPU temperatures only
+sub read_cpu_temps {
+    return read_temperatures('cpu');
+}
+
+# convenience function: read disk temperatures only
+sub read_disk_temps {
+    return read_temperatures('disk');
+}
+
+1;
-- 
2.50.1 (Apple Git-155)



[-- Attachment #2: Type: text/plain, Size: 160 bytes --]

_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

           reply	other threads:[~2025-10-14 21:22 UTC|newest]

Thread overview: expand[flat|nested]  mbox.gz  Atom feed
 [parent not found: <20251014193601.34285-1-davide.guerri@gmail.com>]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=mailman.915.1760476932.390.pve-devel@lists.proxmox.com \
    --to=pve-devel@lists.proxmox.com \
    --cc=davide.guerri@gmail.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal