Skip to content
13 changes: 12 additions & 1 deletion usr/lib/linuxmint/mintreport/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -409,7 +412,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)
Expand All @@ -418,6 +420,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)
Expand All @@ -431,6 +437,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}"
Expand Down Expand Up @@ -587,6 +594,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()
Expand Down
288 changes: 288 additions & 0 deletions usr/lib/linuxmint/mintreport/sensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import os
import gi
import xapp.util
import re
from enum import IntEnum

gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango

_ = xapp.util.l10n("mintreport")

SYS_HWMON = "/sys/class/hwmon"

COL_NAME, COL_VALUE, COL_UNIT, COL_SENSITIVE, COL_ICON_NAME = range(5)

class SensorType(IntEnum):
TEMP = 0
FAN = 1
PWM = 2
FREQ = 3
POWER = 4
VOLTAGE = 5
CURRENT = 6
ENERGY = 7


SENSOR_SPECS = {
SensorType.TEMP: {
"prefix":"temp",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1000:.1f}",
"unit":"°C",
"icon":"xsi-temperature-symbolic"
},
SensorType.FAN: {
"prefix":"fan",
"suffix":"_input",
"format":lambda raw: raw.strip(),
"unit":_("RPM"),
"icon":"xsi-cog-symbolic"
},
SensorType.PWM: {
"prefix":"pwm",
"suffix":"", # no _input suffix for pwm
"format":lambda raw: f"{int(raw)*100/255:.0f}",
"unit":"%",
"icon":"xsi-cog-symbolic"
},
SensorType.FREQ: {
"prefix":"freq",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1_000_000_000:.3f}",
"unit":"GHz",
"icon":"xsi-cog-symbolic"
},
SensorType.VOLTAGE: {
"prefix":"in",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1000:.3f}",
"unit":"V",
"icon":"xsi-cog-symbolic"
},
SensorType.CURRENT: {
"prefix":"curr",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1000:.3f}",
"unit":"A",
"icon":"xsi-cog-symbolic"
},
SensorType.POWER: {
"prefix":"power",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1_000_000:.1f}",
"unit":"W",
"icon":"xsi-cog-symbolic"
},
SensorType.ENERGY: {
"prefix":"energy",
"suffix":"_input",
"format":lambda raw: f"{int(raw)/1_000_000:.3f}",
"unit":"J",
"icon":"xsi-cog-symbolic"
}
}

def sensor_spec_from_filename(filename):
for stype, spec in SENSOR_SPECS.items():
prefix = spec["prefix"]
suffix = spec["suffix"]
if filename.startswith(prefix) and filename.endswith(suffix):
return stype, spec
return 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.treestore = Gtk.TreeStore(str, str, str, bool, str)

self.treeview = Gtk.TreeView(model=self.treestore)
self.treeview.set_enable_tree_lines(True)
self.treeview.set_property("expand", True)
self.treeview.set_headers_clickable(True)

# --- Columns ---
# Name column with device icon
icon_renderer = Gtk.CellRendererPixbuf()
icon_renderer.set_property("xpad", 2)
icon_renderer.set_property("ypad", 2)
text_renderer = Gtk.CellRendererText()
text_renderer.set_property("ypad", 6)
column = Gtk.TreeViewColumn(_("Name"))
column.pack_start(icon_renderer, False)
column.pack_start(text_renderer, True)
column.add_attribute(icon_renderer, "icon-name", COL_ICON_NAME)
column.add_attribute(text_renderer, "text", COL_NAME)
column.add_attribute(text_renderer, "sensitive", COL_SENSITIVE)
text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
self.treeview.append_column(column)
column.set_expand(True)
column.set_resizable(True)

# Value column
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(_("Value"), renderer, text=COL_VALUE)
column.set_expand(False)
self.treeview.append_column(column)

# Unit column
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(_("Unit"), renderer, text=COL_UNIT)
column.set_expand(False)
self.treeview.append_column(column)

self.add(self.treeview)
self.set_shadow_type(Gtk.ShadowType.IN)

self.sensor_rows = {}

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, *arg):
if self.timeout_id is None:
# Refresh existing tree or build it is does not exist yet
if self.sensor_rows:
self.refresh_values()
else:
self.build_tree()

self.timeout_id = GLib.timeout_add_seconds(
self.refresh_interval, self.refresh_values
)

def _on_unmap(self, *arg):
if self.timeout_id is not None:
GLib.source_remove(self.timeout_id)
self.timeout_id = None

def build_tree(self):
self.treestore.clear()
self.sensor_rows.clear()

if not os.path.isdir(SYS_HWMON):
return

# sort hwmon folders by natural order, as the listdir order is random
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

parent = self.treestore.append(
None, [name, "", "", True, "xsi-cpu-symbolic"]
)

device_without_sensors = True

# Process all *_input files in base_path
sensors = []
for fname in os.listdir(base_path):
stype, spec = 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
labelname = fname.replace(spec["suffix"], "_label")
labelpath = os.path.join(base_path, labelname)
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,
})

sort_sensors(sensors)

# Add sorted sensors to treestore
for s in sensors:
itr = self.treestore.append(
parent,
[s["label"], s["value"], s["unit"], True, s["icon"]],
)

# Store TreeStore itr and sensor type by path for refresh
self.sensor_rows[s["path"]] = (itr, s["type"])
device_without_sensors = False

if device_without_sensors:
self.treestore.set_value(parent, COL_SENSITIVE, False)

self.treeview.expand_all()

def refresh_values(self):
self.treestore.freeze_notify()

for fpath, (itr, 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 != self.treestore.get_value(itr, COL_VALUE):
self.treestore.set_value(itr, COL_VALUE, value)

self.treestore.thaw_notify()
return True

def _read_file(self, path):
try:
with open(path, "r") as f:
return f.read()
except Exception:
return None
Loading