diff --git a/CPUTemperature.py b/CPUTemperature.py new file mode 100644 index 0000000..2093eba --- /dev/null +++ b/CPUTemperature.py @@ -0,0 +1,108 @@ +from GtkHelper.ComboRow import SimpleComboRowItem + +import psutil + +# The units the temperature can be shown in, the sensors always report celsius +UNITS = ["C", "F"] + +def get_unit_items() -> list[SimpleComboRowItem]: + return [SimpleComboRowItem("C", "°C"), SimpleComboRowItem("F", "°F")] + +def celcius_to_fahrenheit(celsius: float) -> float: + return celsius * 1.8 + 32 + +# The sensor to read per chip, in order of preference. These are the package and control +# sensors that monitoring tools like btop show. The per core and per die sensors next to them +# (Core 0, Tccd1, ...) follow every boost of a single core, which swings by tens of degrees +# between two reads and is far too sporadic to put on a key. +PREFERRED_SENSORS = { + "coretemp": ["Package id 0"], # intel + "k10temp": ["Tctl", "Tdie"], # amd +} + +# Picks the sensor above instead of a specific one +AUTO = "auto" + +# Chips that only report cpu temperatures, so every sensor on them is one. Covers intel +# (coretemp), amd (k10temp, k8temp, the third party zenpower), arm boards (cpu_thermal) and the +# acpi thermal zone that laptops and virtual machines fall back to. +CPU_CHIPS = ["coretemp", "k10temp", "k8temp", "zenpower", "cpu_thermal", "acpitz"] + +# Chips that mix cpu sensors with board ones only name them, like the "CPU" sensor of the +# embedded controller on asus boards or the "CPUTIN" one of super i/o chips +CPU_NAME = "cpu" + +def is_cpu_sensor(chip: str, sensor) -> bool: + if chip in CPU_CHIPS or CPU_NAME in chip.lower(): + return True + + return CPU_NAME in (sensor.label or "").lower() + +def get_sensor_item(chip: str, index: int, sensor) -> SimpleComboRowItem: + label = sensor.label or f"Sensor {index}" + return SimpleComboRowItem(f"{chip}:{index}", f"{chip}: {label}") + +def get_sensor_items() -> list[SimpleComboRowItem]: + """The cpu sensors of this machine, to let the user pick one themselves.""" + + temperatures = psutil.sensors_temperatures() + + items = [SimpleComboRowItem(AUTO, "Auto")] + for chip, sensors in temperatures.items(): + for index, sensor in enumerate(sensors): + if is_cpu_sensor(chip, sensor): + items.append(get_sensor_item(chip, index, sensor)) + + # Nothing on this machine is recognizable as a cpu sensor, offering all of them beats + # offering none of them + if len(items) == 1: + for chip, sensors in temperatures.items(): + for index, sensor in enumerate(sensors): + items.append(get_sensor_item(chip, index, sensor)) + + return items + +def get_auto_temp(temperatures: dict) -> float | None: + for chip, labels in PREFERRED_SENSORS.items(): + sensors = temperatures.get(chip) + if not sensors: + continue + + for label in labels: + for sensor in sensors: + if sensor.label == label: + return sensor.current + + # Unknown sensor layout, the package sensor comes first on every chip we know of + return sensors[0].current + + # Neither an intel nor an amd chip, take the first sensor that reads the cpu at all + for chip, sensors in temperatures.items(): + for sensor in sensors: + if is_cpu_sensor(chip, sensor): + return sensor.current + + return None + +def get_cpu_temp(sensor: str = AUTO) -> float | None: + """ + Returns the temperature of the selected sensor in celsius, or None if it is not available. + A sensor is stored as "chip:index", anything else falls back to the preferred sensor. + """ + + temperatures = psutil.sensors_temperatures() + + if sensor and sensor != AUTO: + chip, _, index = sensor.rpartition(":") + sensors = temperatures.get(chip, []) + + if index.isdigit() and int(index) < len(sensors): + return sensors[int(index)].current + + # The picked sensor is gone, a missing reading is worse than a different one + return get_auto_temp(temperatures) + +def convert_temp(celsius: float, unit: str) -> float: + if unit == "F": + return celcius_to_fahrenheit(celsius) + return celsius diff --git a/CPUUsage.py b/CPUUsage.py new file mode 100644 index 0000000..e5f59e5 --- /dev/null +++ b/CPUUsage.py @@ -0,0 +1,69 @@ +from GtkHelper.ComboRow import SimpleComboRowItem + +import threading +import time +import psutil + +# The methods that can be selected in the "Usage Method" row of the cpu actions +USAGE_METHODS = ["average", "max-core"] + +SAMPLE_INTERVAL = 1 +FIRST_SAMPLE_INTERVAL = 0.15 + +def get_usage_method_items() -> list[SimpleComboRowItem]: + return [SimpleComboRowItem("average", "Average of all cores"), + SimpleComboRowItem("max-core", "Highest single core")] + +class CPUSampler(threading.Thread): + """ + Samples the cpu usage on its own thread and hands the last reading to every cpu action. + + psutil measures the usage since the last call made by the *calling thread*, so letting each + action call it directly makes the readings depend on what the other actions did in between. + Two actions on the same key are ticked one after the other in the same thread, which left the + second one measuring nothing but the work of the first (the graph rendering, for example). + """ + + def __init__(self): + super().__init__(daemon=True, name="OSPluginCPUSampler") + + self.lock = threading.Lock() + self.average = 0.0 + self.max_core = 0.0 + + def run(self): + # The first reading of each is meaningless, psutil needs a previous one to compare against + psutil.cpu_percent() + psutil.cpu_percent(percpu=True) + + # The first window is kept short so the actions don't show 0% for a whole tick on startup + interval = FIRST_SAMPLE_INTERVAL + + while True: + time.sleep(interval) + interval = SAMPLE_INTERVAL + + average = psutil.cpu_percent() + per_core = psutil.cpu_percent(percpu=True) + + with self.lock: + self.average = average + self.max_core = max(per_core) if per_core else 0.0 + + def get_percent(self, method: str) -> float: + with self.lock: + return self.max_core if method == "max-core" else self.average + +_sampler: CPUSampler | None = None +_sampler_lock = threading.Lock() + +def get_sampler() -> CPUSampler: + global _sampler + with _sampler_lock: + if _sampler is None: + _sampler = CPUSampler() + _sampler.start() + return _sampler + +def get_cpu_percent(method: str) -> float: + return get_sampler().get_percent(method) diff --git a/CPU_Graph.py b/CPU_Graph.py index 094cce5..2a6c8dc 100644 --- a/CPU_Graph.py +++ b/CPU_Graph.py @@ -1,17 +1,122 @@ +from GtkHelper.ComboRow import SimpleComboRowItem +from GtkHelper.GenerativeUI.ComboRow import ComboRow +from plugins.com_core447_OSPlugin.CPUTemperature import AUTO, convert_temp, get_cpu_temp, get_sensor_items, get_unit_items +from plugins.com_core447_OSPlugin.CPUUsage import get_cpu_percent, get_usage_method_items from plugins.com_core447_OSPlugin.GraphBase import GraphBase from src.backend.DeckManagement.DeckController import DeckController from src.backend.PageManagement.Page import Page from src.backend.PluginManager.PluginBase import PluginBase -import psutil - from PIL import Image +# The y-axis of a temperature graph, cpus report celsius so fahrenheit needs a taller axis +MAX_TEMP_C = 100 +MAX_TEMP_F = 212 + class CPU_Graph(GraphBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = False + self.temp_available = True + + self.usage_method_row = ComboRow( + action_core=self, + var_name="usage_method", + default_value="average", + items=get_usage_method_items(), + title="Usage Method", + can_reset=False + ) + + self.unit_row = ComboRow( + action_core=self, + var_name="unit", + default_value="C", + items=get_unit_items(), + title="Unit", + can_reset=False, + on_change=lambda *args: self.on_source_change() + ) + + self.sensor_row = ComboRow( + action_core=self, + var_name="sensor", + default_value=AUTO, + items=get_sensor_items(), + title="Sensor", + can_reset=False, + on_change=lambda *args: self.on_source_change() + ) + + # Created last so its on_change always finds the rows it enables and disables + self.graph_type_row = ComboRow( + action_core=self, + var_name="graph_type", + default_value="usage", + items=[SimpleComboRowItem("usage", "CPU Usage"), + SimpleComboRowItem("temp", "CPU Temperature")], + title="Graph Type", + can_reset=False, + on_change=self.on_graph_type_change + ) + + def shows_temp(self) -> bool: + return self.graph_type_row.get_value() == "temp" + + def on_graph_type_change(self, widget=None, new_item=None, old_item=None): + self.update_row_sensitivity() + + old_value = old_item.get_value() if old_item is not None else None + new_value = new_item.get_value() if new_item is not None else self.graph_type_row.get_value() + + # A history of percentages means nothing on a temperature axis and the other way around + if old_value is not None and old_value != new_value: + self.percentages.clear() + + if self.on_ready_called: + self.show_graph() + + def on_source_change(self): + # Readings of another sensor or in another unit cannot share a history either + self.percentages.clear() + + if self.on_ready_called: + self.show_graph() + + def update_row_sensitivity(self): + shows_temp = self.shows_temp() + self.unit_row.set_sensitive(shows_temp) + self.sensor_row.set_sensitive(shows_temp) + self.usage_method_row.set_sensitive(not shows_temp) + + def get_y_max(self) -> float: + if not self.shows_temp(): + return 100 + return MAX_TEMP_F if self.unit_row.get_value() == "F" else MAX_TEMP_C + + def get_current_value(self) -> float: + if not self.shows_temp(): + self.temp_available = True + return get_cpu_percent(self.usage_method_row.get_value()) + + temperature = get_cpu_temp(self.sensor_row.get_value()) + self.temp_available = temperature is not None + if temperature is None: + return 0.0 + + return convert_temp(temperature, self.unit_row.get_value()) + + def get_label_text(self) -> str: + if not self.shows_temp(): + return super().get_label_text() + + if not self.temp_available: + return "N/A" + + value = round(self.percentages[-1]) if self.percentages else 0 + return f"{value} °{self.unit_row.get_value()}" + def on_tick(self): - self.percentages.append(psutil.cpu_percent()) - self.show_graph() \ No newline at end of file + self.percentages.append(self.get_current_value()) + self.show_graph() diff --git a/GraphBase.py b/GraphBase.py index b150ed9..32d176e 100644 --- a/GraphBase.py +++ b/GraphBase.py @@ -1,4 +1,5 @@ from threading import Thread +from plugins.com_core447_OSPlugin.LabelPosition import OFF, create_position_row, set_positioned_label from src.backend.PluginManager.ActionBase import ActionBase from src.backend.DeckManagement.DeckController import DeckController from src.backend.PageManagement.Page import Page @@ -31,6 +32,14 @@ def __init__(self, *args, **kwargs): self.percentages: list[float] = [] + self.label_position_row = create_position_row( + self, + on_change=lambda *args: self.show_label(), + default_value=OFF, + include_off=True, + title="Value Label Position" + ) + self.task_queue = Queue() self.result_queue = Queue() # self.process = Process(target=self.worker, args=(self.task_queue, self.result_queue), name="GraphBaseCreator") @@ -52,12 +61,18 @@ def set_percentages_lenght(self, length: int): return self.percentages + def get_y_max(self) -> float: + """The top of the y-axis, overwrite this for values that are not a percentage.""" + return 100 + def get_graph(self) -> Image: ## Get vars - settings = self.get_settings() + settings = dict(self.get_settings()) time_period = settings.get("time-period", 15) self.set_percentages_lenght(time_period) + settings["y-max"] = self.get_y_max() + self.task_queue.put((settings, self.percentages)) img = self.result_queue.get() @@ -68,6 +83,14 @@ def show_graph(self): if image is None: return self.set_media(image=image) + self.show_label() + + def get_label_text(self) -> str: + percent = round(self.percentages[-1]) if self.percentages else 0 + return f"{percent}%" + + def show_label(self): + set_positioned_label(self, self.label_position_row, self.get_label_text(), fallback=OFF) def get_custom_config_area(self): return Gtk.Label(label=self.plugin_base.lm.get("actions.graph-base.memory-warning"), css_classes=["destructive-action"]) @@ -243,9 +266,9 @@ def generate_graph(self, settings: dict, percentages: list[float]): ax.margins(0) ax.axis('off') - # Set the y-axis to range from 0 to 100 + # Set the y-axis to range from 0 to the maximum the action can report if not dynamic_scaling: - ax.set_ylim(0, 100) + ax.set_ylim(0, settings.get("y-max", 100)) # Draw the canvas and retrieve the buffer canvas.draw() diff --git a/LabelPosition.py b/LabelPosition.py new file mode 100644 index 0000000..1b6d67c --- /dev/null +++ b/LabelPosition.py @@ -0,0 +1,56 @@ +from GtkHelper.ComboRow import SimpleComboRowItem +from GtkHelper.GenerativeUI.ComboRow import ComboRow + +# The label rows an action can write its value to, plus the option to not show one at all +POSITIONS = ["top", "center", "bottom"] +OFF = "off" + +def get_position_items(include_off: bool = False) -> list[SimpleComboRowItem]: + items = [] + if include_off: + items.append(SimpleComboRowItem(OFF, "Off")) + + items.append(SimpleComboRowItem("top", "Top")) + items.append(SimpleComboRowItem("center", "Center")) + items.append(SimpleComboRowItem("bottom", "Bottom")) + + return items + +def create_position_row(action_core, on_change: callable = None, default_value: str = "center", + include_off: bool = False, title: str = "Label Position") -> ComboRow: + return ComboRow( + action_core=action_core, + var_name="label_position", + default_value=default_value, + items=get_position_items(include_off), + title=title, + can_reset=False, + on_change=on_change + ) + +def set_positioned_label(action_core, position_row: ComboRow, text: str, + center_font_size: int = 18, side_font_size: int = 15, + fallback: str = "center"): + """ + Writes text to the label row the user selected and clears the other rows, so the value does + not stay behind after a position change. The side rows get a smaller font than the center one + because they are a lot shorter. + """ + + # The generative ui fires its on_change before the action is ready + if not action_core.on_ready_called: + return + + position = position_row.get_value() + if position not in POSITIONS + [OFF]: + position = fallback + + for other in POSITIONS: + if other != position: + action_core.set_label(text="", position=other) + + if position == OFF: + return + + font_size = center_font_size if position == "center" else side_font_size + action_core.set_label(text=text, position=position, font_size=font_size) diff --git a/actions/CPU/CPU.py b/actions/CPU/CPU.py index efba089..bcf1e72 100644 --- a/actions/CPU/CPU.py +++ b/actions/CPU/CPU.py @@ -1,3 +1,6 @@ +from plugins.com_core447_OSPlugin.CPUUsage import get_cpu_percent, get_usage_method_items +from plugins.com_core447_OSPlugin.LabelPosition import create_position_row, set_positioned_label +from GtkHelper.GenerativeUI.ComboRow import ComboRow from src.backend.DeckManagement.DeckController import DeckController from src.backend.PageManagement.Page import Page from src.backend.PluginManager.ActionBase import ActionBase @@ -5,7 +8,6 @@ import time import os -import psutil # Import gtk modules import gi @@ -17,13 +19,25 @@ class CPU(ActionBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = False - + + self.usage_method_row = ComboRow( + action_core=self, + var_name="usage_method", + default_value="average", + items=get_usage_method_items(), + title="Usage Method", + can_reset=False, + on_change=lambda *args: self.update() + ) + + self.position_row = create_position_row(self, on_change=lambda *args: self.update()) + def on_ready(self): self.update() - + def on_tick(self): self.update() def update(self): - percent = round(psutil.cpu_percent()) - self.set_center_label(text=f"{percent}%", font_size=24) \ No newline at end of file + percent = round(get_cpu_percent(self.usage_method_row.get_value())) + set_positioned_label(self, self.position_row, f"{percent}%", center_font_size=24) diff --git a/actions/CPU/CPUTemp.py b/actions/CPU/CPUTemp.py index 3011f2a..036accd 100644 --- a/actions/CPU/CPUTemp.py +++ b/actions/CPU/CPUTemp.py @@ -1,5 +1,6 @@ -from GtkHelper.ComboRow import SimpleComboRowItem from GtkHelper.GenerativeUI.ComboRow import ComboRow +from plugins.com_core447_OSPlugin.CPUTemperature import AUTO, convert_temp, get_cpu_temp, get_sensor_items, get_unit_items +from plugins.com_core447_OSPlugin.LabelPosition import create_position_row, set_positioned_label from src.backend.DeckManagement.DeckController import DeckController from src.backend.PageManagement.Page import Page from src.backend.PluginManager.ActionBase import ActionBase @@ -7,7 +8,6 @@ import time import os -import psutil # Import gtk modules import gi @@ -24,41 +24,36 @@ def __init__(self, *args, **kwargs): action_core=self, var_name="unit", default_value="C", - items=[SimpleComboRowItem("C", "°C"), SimpleComboRowItem("F", "°F")], + items=get_unit_items(), title="Unit", can_reset=False, on_change=lambda *args: self.update() ) - + + self.sensor_row = ComboRow( + action_core=self, + var_name="sensor", + default_value=AUTO, + items=get_sensor_items(), + title="Sensor", + can_reset=False, + on_change=lambda *args: self.update() + ) + + self.position_row = create_position_row(self, on_change=lambda *args: self.update()) + def on_ready(self): self.update() - + def on_tick(self): self.update() - def celcius_to_fahrenheit(self, celsius): - return celsius * 1.8 + 32 - def update(self): - temperature = psutil.sensors_temperatures() - # intel cpu - if "coretemp" in temperature: - temperature = temperature.get("coretemp")[0].current - # amd cpu - elif "k10temp" in temperature: - temperature = temperature.get("k10temp") - if len(temperature) > 1: - # zen chips and newer, Tccd1 - temperature = temperature[1].current - else: - # amd chips before zen, or if only Tctl is returned - temperature = temperature[0].current - else: - self.set_center_label(text="N/A", font_size=18) + temperature = get_cpu_temp(self.sensor_row.get_value()) + if temperature is None: + set_positioned_label(self, self.position_row, "N/A") return unit_key = self.unit_row.get_value() - temp = int(temperature) - if unit_key == "F": - temp = self.celcius_to_fahrenheit(temp) - self.set_center_label(text=f"{round(temp)} °{unit_key}", font_size=18) \ No newline at end of file + temp = convert_temp(int(temperature), unit_key) + set_positioned_label(self, self.position_row, f"{round(temp)} °{unit_key}") diff --git a/actions/RAM/RAM.py b/actions/RAM/RAM.py index d230a58..265db66 100644 --- a/actions/RAM/RAM.py +++ b/actions/RAM/RAM.py @@ -1,3 +1,4 @@ +from plugins.com_core447_OSPlugin.LabelPosition import create_position_row, set_positioned_label from src.backend.DeckManagement.DeckController import DeckController from src.backend.PageManagement.Page import Page from src.backend.PluginManager.ActionBase import ActionBase @@ -17,13 +18,15 @@ class RAM(ActionBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = False - + + self.position_row = create_position_row(self, on_change=lambda *args: self.update()) + def on_ready(self): self.update() - + def on_tick(self): self.update() def update(self): percent = round(psutil.virtual_memory().percent) - self.set_center_label(text=f"{percent}%", font_size=24) \ No newline at end of file + set_positioned_label(self, self.position_row, f"{percent}%", center_font_size=24)