From fce7e1029d554aefebca937d27d2f693a435a427 Mon Sep 17 00:00:00 2001 From: Leonardo Mannini Date: Sat, 20 Jun 2026 20:20:26 +0200 Subject: [PATCH] Add automatic temperature sensor selection --- README.md | 35 +++++++----- pyproject.toml | 1 + src/epomakercontroller/cli.py | 15 +++++- .../commands/EpomakerGifCommand.py | 20 +++++-- src/epomakercontroller/configs/configs.py | 4 +- src/epomakercontroller/configs/constants.py | 6 ++- src/epomakercontroller/epomakercontroller.py | 46 ++++++++++++++-- src/epomakercontroller/utils/sensors.py | 54 +++++++++++++++++-- tests/test_sensors.py | 33 ++++++++++++ 9 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 tests/test_sensors.py diff --git a/README.md b/README.md index 8fdeb7c..049cc46 100644 --- a/README.md +++ b/README.md @@ -99,20 +99,20 @@ useful to display the temperature of some device on the host machine. You will n the label used by a sensor on your machine, which you can do by: ```console $ epomakercontroller list-temp-devices -DEVICE KEY CURRENT TEMPERATURE -acpitz-0 40.0°C -nvme-0 30.85°C -nvme-1 35.85°C -nvme-2 59.85°C -nvme-3 30.85°C -pch_skylake-0 35.0°C -coretemp-0 52.0°C -coretemp-1 52.0°C -coretemp-2 44.0°C -coretemp-3 47.0°C -coretemp-4 45.0°C -iwlwifi_1-0 30.0°C -NVIDIA-GeForce-MX150-0 36°C +DEVICE KEY CURRENT TEMPERATURE AUTO +acpitz-0 40.0°C +nvme-0 30.85°C +nvme-1 35.85°C +nvme-2 59.85°C +nvme-3 30.85°C +pch_skylake-0 35.0°C +coretemp-0 52.0°C * +coretemp-1 52.0°C +coretemp-2 44.0°C +coretemp-3 47.0°C +coretemp-4 45.0°C +iwlwifi_1-0 30.0°C +NVIDIA-GeForce-MX150-0 36°C ``` Note that an `NVIDIA` device is also listed here, which requires having NVIDIA drivers installed (eg @@ -123,6 +123,13 @@ Then you can start the daemon with the corresponding label, eg: epomakercontroller start-daemon coretemp-0 ``` +You can also let the daemon select a temperature sensor automatically: +```console +epomakercontroller start-daemon --auto-temp +``` +Automatic selection prefers CPU/SoC sensors, then other non-GPU sensors, and only falls back to GPU +sensors if no other temperature sensors are available. + Alternatively leave the label blank to disable and only do CPU usage: ```console epomakercontroller start-daemon diff --git a/pyproject.toml b/pyproject.toml index 434d742..84bfaa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ repository = "https://github.com/strodgers/EpomakerController" documentation = "https://EpomakerController.readthedocs.io" include = [ + "src/epomakercontroller/configs/default.json", "src/epomakercontroller/configs/layouts/*.json", "src/epomakercontroller/configs/keymaps/*.json", ] diff --git a/src/epomakercontroller/cli.py b/src/epomakercontroller/cli.py index e8ed064..41c6a02 100644 --- a/src/epomakercontroller/cli.py +++ b/src/epomakercontroller/cli.py @@ -145,17 +145,28 @@ def send_cpu(controller: EpomakerController, cpu: int) -> None: is_flag=True, help="Start daemon in test mode, sending random data.", ) +@click.option( + "--auto-temp", + is_flag=True, + help="Automatically select a temperature sensor.", +) @click.argument("temp_key", type=str, required=False) @wrapped_command -def start_daemon(controller: EpomakerController, temp_key: str | None, test_mode: bool) -> None: +def start_daemon( + controller: EpomakerController, + temp_key: str | None, + test_mode: bool, + auto_temp: bool, +) -> None: """Start a daemon to update the CPU usage and optionally a temperature. Args: controller (EpomakerController): Passed from wrapped_command() decorator temp_key (str): A label corresponding to the device to monitor. test_mode (bool): Send random ints instead of real values. + auto_temp (bool): Automatically select a temperature sensor. """ - controller.start_daemon(temp_key, test_mode) + controller.start_daemon(temp_key, test_mode, auto_temp) @cli.command() diff --git a/src/epomakercontroller/commands/EpomakerGifCommand.py b/src/epomakercontroller/commands/EpomakerGifCommand.py index 44bced5..b40f757 100644 --- a/src/epomakercontroller/commands/EpomakerGifCommand.py +++ b/src/epomakercontroller/commands/EpomakerGifCommand.py @@ -28,14 +28,17 @@ [7] checksum [8-63] 56 bytes Pixel data (RGB565) """ +from __future__ import annotations import math import os -from typing import override +from typing import TYPE_CHECKING, override import cv2 import numpy as np -from PIL import Image + +if TYPE_CHECKING: + from PIL import Image from .EpomakerCommand import EpomakerCommand, CommandStructure, EpomakerStreamedCommand, IEpomakerCommand from .data.constants import IMAGE_DIMENSIONS @@ -127,7 +130,12 @@ def prepare_gif(gif_path: str): return None, None, None try: - gif = Image.open(gif_path) + from PIL import Image as PILImage + + gif = PILImage.open(gif_path) + except ImportError: + Logger.log_error("GIF upload requires Pillow: install python-pillow or Pillow") + return None, None, None except Exception as e: Logger.log_error(f"Failed to open GIF: {e}") return None, None, None @@ -218,7 +226,9 @@ def _extract_composited_frames(self, gif: Image.Image) -> list[Image.Image]: """ step = self.step - canvas = Image.new("RGBA", gif.size, (0, 0, 0, 255)) + from PIL import Image as PILImage + + canvas = PILImage.new("RGBA", gif.size, (0, 0, 0, 255)) frames: list[Image.Image] = [] for i in range(self.n_frames): @@ -230,7 +240,7 @@ def _extract_composited_frames(self, gif: Image.Image) -> list[Image.Image]: disposal = gif.disposal_method if hasattr(gif, 'disposal_method') else 0 if disposal == 2: - canvas = Image.new("RGBA", gif.size, (0, 0, 0, 255)) + canvas = PILImage.new("RGBA", gif.size, (0, 0, 0, 255)) return frames diff --git a/src/epomakercontroller/configs/configs.py b/src/epomakercontroller/configs/configs.py index a7dfe6c..165f452 100644 --- a/src/epomakercontroller/configs/configs.py +++ b/src/epomakercontroller/configs/configs.py @@ -65,9 +65,7 @@ def __contains__(self, key: str) -> bool: def get_main_config_directory() -> Path: - home_dir = Path(os.path.abspath(os.curdir)) - config_dir = home_dir / CONFIG_DIRECTORY - return config_dir + return Path.home() / CONFIG_DIRECTORY def create_default_main_config(config_file: Path) -> None: diff --git a/src/epomakercontroller/configs/constants.py b/src/epomakercontroller/configs/constants.py index 974a307..d0e611a 100644 --- a/src/epomakercontroller/configs/constants.py +++ b/src/epomakercontroller/configs/constants.py @@ -2,6 +2,8 @@ and provide a single point of project configuration """ import os +import tempfile +from pathlib import Path CONFIG_DIRECTORY = ".epomaker-controller" @@ -15,7 +17,7 @@ if not os.path.exists(ROOT_FOLDER): os.mkdir(ROOT_FOLDER) -TMP_FOLDER = os.path.abspath("./.epomaker_controller") +TMP_FOLDER = os.path.join(tempfile.gettempdir(), "epomaker_controller") ETC_FOLDER = os.path.abspath(ROOT_FOLDER + "etc/") # Create folder on Windows @@ -29,6 +31,6 @@ RULE_FILE_PATH = ETC_FOLDER + "/udev/rules.d/99-epomaker-rt100.rules" TMP_FILE_PATH = TMP_FOLDER + "/99-epomaker-rt100.rules" -PATH_TO_DEFAULT_CONFIG = "src/epomakercontroller/configs/default.json" +PATH_TO_DEFAULT_CONFIG = Path(__file__).with_name("default.json") DAEMON_TIME_DELAY = 1.6 diff --git a/src/epomakercontroller/epomakercontroller.py b/src/epomakercontroller/epomakercontroller.py index 1995760..e5d6370 100644 --- a/src/epomakercontroller/epomakercontroller.py +++ b/src/epomakercontroller/epomakercontroller.py @@ -20,7 +20,7 @@ from .commands.EpomakerWirelessInitCommand import EpomakerWirelessInitCommand from .configs.constants import TMP_FILE_PATH, RULE_FILE_PATH from .logger.logger import Logger -from .utils.sensors import get_cpu_usage, get_device_temp +from .utils.sensors import get_cpu_usage, get_device_temp, select_temp_device from .utils.time_helper import TimeHelper from .utils.keyboard_keys import KeyboardKeys @@ -76,6 +76,9 @@ class HIDInfo: class EpomakerController(ControllerBase): COMMAND_MIN_DELAY = 1 / 1000 # ms. + VENDOR_USAGE_PAGE = 0xFFFF + SCREEN_USAGE = 2 + SCREEN_INTERFACE_NUMBER = 2 """EpomakerController class represents a controller for an Epomaker USB HID device. @@ -177,7 +180,12 @@ def _open_device(self, product_id: int) -> None: product_id (int): The product ID. """ try: - self.device.open(self.config.vendor_id, product_id) + device_path = self._find_device_path() + + if device_path: + self.device.open_path(device_path) + else: + self.device.open(self.config.vendor_id, product_id) except IOError as e: Logger.log_error( f"Failed to open device: {e}\n" @@ -188,6 +196,25 @@ def _open_device(self, product_id: int) -> None: ) self.device = None + def _find_device_path(self) -> Optional[bytes]: + """Find the HID endpoint used for screen/control reports.""" + for device in self.device_list: + if ( + device.get("usage_page") == self.VENDOR_USAGE_PAGE + and device.get("usage") == self.SCREEN_USAGE + and device.get("path") + ): + return device["path"] + + for device in self.device_list: + if ( + device.get("interface_number") == self.SCREEN_INTERFACE_NUMBER + and device.get("path") + ): + return device["path"] + + return None + def generate_udev_rule(self) -> None: """Generates udev rule for the connected keyboard.""" rule_content = ( @@ -447,13 +474,26 @@ def set_profile(self, profile: Profile) -> None: profile_command = EpomakerProfileCommand.EpomakerProfileCommand(profile) self._send_command(profile_command) - def start_daemon(self, temp_key: str | None, test_mode: bool) -> None: + def start_daemon( + self, + temp_key: str | None, + test_mode: bool, + auto_temp: bool = False, + ) -> None: """Start a daemon to update the CPU usage and optionally a temperature. Args: temp_key (str): A label corresponding to the device to monitor. test_mode (bool): Send random ints instead of real values. + auto_temp (bool): Automatically select a temperature sensor. """ + if auto_temp and not temp_key: + temp_key = select_temp_device() + if temp_key: + Logger.log_info(f"Automatically selected temperature sensor: {temp_key}") + else: + Logger.log_warning("No temperature sensors found; running CPU-only daemon") + # Set current time and date self.send_time() diff --git a/src/epomakercontroller/utils/sensors.py b/src/epomakercontroller/utils/sensors.py index e6c9449..7959625 100644 --- a/src/epomakercontroller/utils/sensors.py +++ b/src/epomakercontroller/utils/sensors.py @@ -5,6 +5,21 @@ from epomakercontroller.logger.logger import Logger +CPU_SENSOR_PREFIXES = ( + "coretemp", + "k10temp", + "zenpower", + "cpu_thermal", + "soc_thermal", + "acpitz", +) +GPU_SENSOR_MARKERS = ( + "nvidia", + "geforce", + "radeon", + "amdgpu", +) + def get_cpu_usage(test_mode: bool = False) -> int: """Get the current CPU usage. @@ -52,6 +67,35 @@ def get_device_temp(temp_key: str, test_mode: bool = False) -> int: return 0 +def select_temp_device(temps: dict[str, float] | None = None) -> str | None: + """Select a sensible default temperature sensor from available devices. + + Prefer CPU/SoC sensors and avoid GPU sensors by default. GPU queries can be + comparatively expensive or disruptive on some systems, so automatic daemon + mode should choose them only when no better sensor is available. + """ + if temps is None: + temps = _get_temp_devices() + + if not temps: + return None + + def sensor_rank(item: tuple[str, float]) -> tuple[int, float, str]: + key, temp = item + key_lower = key.lower() + + if any(key_lower.startswith(prefix) for prefix in CPU_SENSOR_PREFIXES): + priority = 0 + elif any(marker in key_lower for marker in GPU_SENSOR_MARKERS): + priority = 2 + else: + priority = 1 + + return priority, -float(temp), key + + return min(temps.items(), key=sensor_rank)[0] + + def _get_temp_devices() -> dict[str, float] | None: try: hw_temperatures = psutil.sensors_temperatures() @@ -93,12 +137,12 @@ def print_temp_devices() -> None: Logger.log_error("No temperature sensors found.") return - format_whitespace = len(max(temps.keys(), key=len)) + 10 + selected_key = select_temp_device(temps) + key_width = len(max(temps.keys(), key=len)) # pylint: disable=bad-builtin - print( - f"DEVICE KEY:{format_whitespace} CURRENT TEMPERATURE" - ) + print(f"{'DEVICE KEY':<{key_width}} CURRENT TEMPERATURE AUTO") for device_key, temp in temps.items(): + auto_marker = "*" if device_key == selected_key else "" # pylint: disable=bad-builtin - print(f"{device_key}:{format_whitespace} {temp}°C") + print(f"{device_key:<{key_width}} {temp}°C {auto_marker}") diff --git a/tests/test_sensors.py b/tests/test_sensors.py new file mode 100644 index 0000000..2c19fb7 --- /dev/null +++ b/tests/test_sensors.py @@ -0,0 +1,33 @@ +from epomakercontroller.utils.sensors import select_temp_device + + +def test_select_temp_device_prefers_cpu_sensor() -> None: + temps = { + "NVIDIA-GPU-0": 70.0, + "coretemp-0": 52.0, + "coretemp-1": 55.0, + } + + assert select_temp_device(temps) == "coretemp-1" + + +def test_select_temp_device_uses_generic_sensor_before_gpu() -> None: + temps = { + "NVIDIA-GPU-0": 70.0, + "nvme-0": 44.0, + } + + assert select_temp_device(temps) == "nvme-0" + + +def test_select_temp_device_falls_back_to_gpu() -> None: + temps = { + "NVIDIA-GPU-0": 70.0, + "NVIDIA-GPU-1": 72.0, + } + + assert select_temp_device(temps) == "NVIDIA-GPU-1" + + +def test_select_temp_device_returns_none_without_sensors() -> None: + assert select_temp_device({}) is None