Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
15 changes: 13 additions & 2 deletions src/epomakercontroller/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 15 additions & 5 deletions src/epomakercontroller/commands/EpomakerGifCommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
4 changes: 1 addition & 3 deletions src/epomakercontroller/configs/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/epomakercontroller/configs/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
and provide a single point of project configuration
"""
import os
import tempfile
from pathlib import Path


CONFIG_DIRECTORY = ".epomaker-controller"
Expand All @@ -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
Expand All @@ -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
46 changes: 43 additions & 3 deletions src/epomakercontroller/epomakercontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"
Expand All @@ -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 = (
Expand Down Expand Up @@ -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()

Expand Down
54 changes: 49 additions & 5 deletions src/epomakercontroller/utils/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}")
33 changes: 33 additions & 0 deletions tests/test_sensors.py
Original file line number Diff line number Diff line change
@@ -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
Loading