diff --git a/usr/lib/linuxmint/mintreport/app.py b/usr/lib/linuxmint/mintreport/app.py index c2c8838..c6cce36 100755 --- a/usr/lib/linuxmint/mintreport/app.py +++ b/usr/lib/linuxmint/mintreport/app.py @@ -25,6 +25,8 @@ from pci import PCIListWidget from usb import USBListWidget from gpu import GPUListWidget +from sensors import SensorsListWidget + setproctitle.setproctitle("mintreport") _ = xapp.util.l10n("mintreport") @@ -68,6 +70,7 @@ def on_command_line(self, app, command_line): group.add_argument("--bios", action="store_const", dest="page", const="bios") group.add_argument("--pci", action="store_const", dest="page", const="pci") group.add_argument("--gpu", action="store_const", dest="page", const="gpu") + group.add_argument("--sensors", action="store_const", dest="page", const="sensors") argv = command_line.get_arguments()[1:] args, _ = parser.parse_known_args(argv) if args.page is None: @@ -405,7 +408,6 @@ def sort_by_date(model, a, b, *args): self.builder.get_object("button_sysinfo_copy").connect("clicked", self.copy_inxi_info) self.builder.get_object("button_sysinfo_upload").connect("clicked", self.upload_inxi_info) - # USB page self.usb_widget = USBListWidget() self.builder.get_object("box_usb_widget").pack_start(self.usb_widget, True, True, 0) @@ -414,6 +416,10 @@ def sort_by_date(model, a, b, *args): self.pci_widget = PCIListWidget() self.builder.get_object("box_pci_widget").pack_start(self.pci_widget, True, True, 0) + # Sensors page + self.sensors_widget = SensorsListWidget() + self.builder.get_object("box_sensors_widget").pack_start(self.sensors_widget, True, True, 0) + # BIOS page self.bios_widget = BIOSListWidget() self.builder.get_object("box_bios_widget").add(self.bios_widget) @@ -427,6 +433,7 @@ def sort_by_date(model, a, b, *args): self.load_pci() self.load_bios() self.load_gpu() + self.load_sensors() def show_page(self, page_name): page_name = f"page_{page_name}" @@ -585,6 +592,10 @@ def load_usb(self): def load_pci(self): self.pci_widget.load() + @xt.run_async + def load_sensors(self): + self.sensors_widget.load() + @xt.run_async def load_bios(self): self.bios_widget.load() diff --git a/usr/lib/linuxmint/mintreport/sensors.py b/usr/lib/linuxmint/mintreport/sensors.py new file mode 100644 index 0000000..a7bb16c --- /dev/null +++ b/usr/lib/linuxmint/mintreport/sensors.py @@ -0,0 +1,269 @@ +import os +import gi +import xapp.util +import xapp.SettingsWidgets as Xs +import re +from enum import IntEnum + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib + +_ = xapp.util.l10n("mintreport") + +SYS_HWMON = "/sys/class/hwmon" + +class SensorType(IntEnum): + TEMP = 0 + FAN = 1 + PWM = 2 + FREQ = 3 + POWER = 4 + VOLTAGE = 5 + CURRENT = 6 + ENERGY = 7 + + +SENSOR_SPECS = { + SensorType.TEMP: { + "pattern":re.compile(r"^(temp\d+)_input$"), + "format":lambda raw: f"{int(raw)/1000:.1f}", + "unit":"°C", + "icon":"xsi-temperature-symbolic" + }, + SensorType.FAN: { + "pattern":re.compile(r"^(fan\d+)_input$"), + "format":lambda raw: raw.strip(), + "unit":_("RPM"), + "icon":"xsi-fan-symbolic" + }, + SensorType.PWM: { + "pattern":re.compile(r"^(pwm\d+)$"), # no _input suffix for pwm type + "format":lambda raw: f"{int(raw)*100/255:.0f}", + "unit":"%", + "icon":"xsi-fan-symbolic" + }, + SensorType.FREQ: { + "pattern":re.compile(r"^(freq\d+)_input$"), + "format":lambda raw: f"{int(raw)/1_000_000_000:.2f}", + "unit":"GHz", + "icon":"xsi-physics-wavelength-symbolic" + }, + SensorType.VOLTAGE: { + "pattern":re.compile(r"^(in\d+)_input$"), + "format":lambda raw: f"{int(raw)/1000:.2f}", + "unit":"V", + "icon":"xsi-physics-volts-symbolic" + }, + SensorType.CURRENT: { + "pattern":re.compile(r"^(curr\d+)_input$"), + "format":lambda raw: f"{int(raw)/1000:.2f}", + "unit":"A", + "icon":"xsi-physics-amps-symbolic" + }, + SensorType.POWER: { + "pattern":re.compile(r"^(power\d+)_input$"), + "format":lambda raw: f"{int(raw)/1_000_000:.1f}", + "unit":"W", + "icon":"xsi-physics-watts-symbolic" + }, + SensorType.ENERGY: { + "pattern":re.compile(r"^(energy\d+)_input$"), + "format":lambda raw: f"{int(raw)/1_000_000:.1f}", + "unit":"J", + "icon":"xsi-power-symbolic" + } +} + +def sensor_spec_from_filename(filename): + for stype, spec in SENSOR_SPECS.items(): + m = spec["pattern"].match(filename) + if m: + base = m.group(1) + return stype, spec, base + return None, None, None + +# Helper funcs to sort sensors in correct numerical order (ex in10 after in9) +def natural_key(label): + # Split around any digit sequence + parts = re.split(r'(\d+)', label) + key = [] + for part in parts: + if part.isdigit(): + key.append(int(part)) + else: + key.append(part.lower()) + return key + +def sort_sensors(sensors): + # Natural sort within each sensor type + sensors.sort(key=lambda s: natural_key(s["label"])) + # Group by sensor type + sensors.sort(key=lambda s: s["type"]) + +class SensorsListWidget(Gtk.ScrolledWindow): + + def __init__(self): + super().__init__() + self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + + self.page = Xs.SettingsPage() + self.page.set_spacing(24) + self.page.set_margin_left(24) + self.page.set_margin_right(24) + self.page.set_margin_top(12) + self.page.set_margin_bottom(12) + self.add(self.page) + + self.sensor_rows = {} # fpath -> (value_label, stype) + self.value_size_group = Gtk.SizeGroup(Gtk.SizeGroupMode.HORIZONTAL) + self.unit_size_group = Gtk.SizeGroup(Gtk.SizeGroupMode.HORIZONTAL) + + self.timeout_id = None + self.refresh_interval = 1 # seconds + + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) + + def load(self): + # do nothing, we do everything in the _on_map() function + pass + + def _on_map(self, *_): + if self.timeout_id is None: + # Refresh existing cards or build them if they don't exist yet + if self.sensor_rows: + self.refresh_values() + else: + self.build_cards() + + self.timeout_id = GLib.timeout_add_seconds( + self.refresh_interval, self.refresh_values + ) + + def _on_unmap(self, *_): + if self.timeout_id is not None: + GLib.source_remove(self.timeout_id) + self.timeout_id = None + + def build_cards(self): + for child in self.page.get_children(): + self.page.remove(child) + self.sensor_rows.clear() + + if not os.path.isdir(SYS_HWMON): + return + + # First pass: collect all chips with their sensors + chips = [] + for hwmon in sorted(os.listdir(SYS_HWMON), key=natural_key): + hwmon_path = os.path.join(SYS_HWMON, hwmon) + device_path = os.path.join(hwmon_path, "device") + + # Determine base path for sensors + base_path = None + if os.path.isdir(device_path): + # Use device/ only if it contains *_input files + # This is required as some modules put sensors files in the device folder (apple-smc for example) + inputs = [f for f in os.listdir(device_path) if f.endswith("_input")] + if inputs: + base_path = device_path + if base_path is None: + base_path = hwmon_path + + # Root name + name_file = os.path.join(base_path, "name") + name = self._read_file(name_file) + name = name.strip() if name else hwmon + + # Process all sensor files in base_path + sensors = [] + for fname in os.listdir(base_path): + stype, spec, base_name = sensor_spec_from_filename(fname) + if spec is None: + continue # that's not a sensor + + fpath = os.path.join(base_path, fname) + raw = self._read_file(fpath) + if raw is None: + continue # unable to read sensor -> skip + + # Label + labelpath = os.path.join(base_path, f"{base_name}_label") + label = self._read_file(labelpath) + label = label.strip() if label else fname.replace("_input", "") + + sensors.append({ + "label": label, + "path": fpath, + "value": spec["format"](raw), + "unit": spec["unit"], + "icon": spec["icon"], + "type": stype, + }) + + if not sensors: + continue + + sort_sensors(sensors) + chips.append((hwmon, name, sensors)) + + # Disambiguate chips that share the same name (e.g. two RAM sticks: spd5118) + name_counts = {} + for _, name, _ in chips: + name_counts[name] = name_counts.get(name, 0) + 1 + + # Second pass: build cards + for hwmon, name, sensors in chips: + if name_counts[name] > 1: + name = f"{name} ({hwmon})" + + section = self.page.add_section(name) + + for s in sensors: + row = Xs.SettingsWidget() + row.set_spacing(8) + + icon = Gtk.Image.new_from_icon_name(s["icon"], Gtk.IconSize.MENU) + icon.set_pixel_size(16) + row.pack_start(icon, False, False, 0) + + name_label = Gtk.Label(label=s["label"]) + name_label.set_xalign(0) + row.pack_start(name_label, True, True, 0) + + value_label = Gtk.Label(label=s["value"]) + value_label.get_style_context().add_class("dim-label") + value_label.set_xalign(1.0) + unit_label = Gtk.Label(label=s["unit"]) + unit_label.get_style_context().add_class("dim-label") + unit_label.set_xalign(0.0) + self.value_size_group.add_widget(value_label) + self.unit_size_group.add_widget(unit_label) + row.pack_end(unit_label, False, False, 0) + row.pack_end(value_label, False, False, 0) + + section.add_row(row) + self.sensor_rows[s["path"]] = (value_label, s["type"]) + + self.page.show_all() + + def refresh_values(self): + for fpath, (value_label, stype) in self.sensor_rows.items(): + raw = self._read_file(fpath) + if raw is None: + continue + + spec = SENSOR_SPECS[stype] + value = spec["format"](raw) + + if value != value_label.get_text(): + value_label.set_text(value) + + return True + + def _read_file(self, path): + try: + with open(path, "r") as f: + return f.read() + except Exception: + return None diff --git a/usr/share/linuxmint/mintreport/mintreport.ui b/usr/share/linuxmint/mintreport/mintreport.ui index cfa03e3..0f55f92 100644 --- a/usr/share/linuxmint/mintreport/mintreport.ui +++ b/usr/share/linuxmint/mintreport/mintreport.ui @@ -306,6 +306,34 @@ 3 + + + True + False + vertical + + + True + False + vertical + + + + + + True + True + 0 + + + + + pense_sensors + Sensors + xsi-temperature-symbolic + 4 + + True @@ -357,7 +385,7 @@ page_bios BIOS xsi-cpu-symbolic - 4 + 5 @@ -590,7 +618,7 @@ page_reports System Reports mintreport-symbolic - 5 + 6 @@ -869,7 +897,7 @@ page_crashes Crash Reports xsi-computer-fail-symbolic - 6 + 7