From 5ceec2af676ce1c7f3f271b2d70eeff6743d0cbb Mon Sep 17 00:00:00 2001 From: maclarsson Date: Thu, 28 Jul 2022 16:03:41 +0200 Subject: [PATCH] Experimental support for controlling fans of Hydro Platinum AIOs --- CHANGELOG.md | 43 ++++++ README.md | 12 +- cfancontrol/__init__.py | 2 +- cfancontrol/__main__.py | 26 ++-- cfancontrol/app.py | 3 +- cfancontrol/devicesensor.py | 116 ++++++++------- cfancontrol/fancontroller.py | 258 +++++++++++++++++++--------------- cfancontrol/fanmanager.py | 164 ++++++++++++--------- cfancontrol/graphs.py | 27 +++- cfancontrol/gui.py | 196 ++++++++++++++++---------- cfancontrol/hwsensor.py | 10 +- cfancontrol/log.py | 51 ++++--- cfancontrol/nvidiasensor.py | 18 +-- cfancontrol/profilemanager.py | 18 ++- cfancontrol/pwmfan.py | 23 +-- cfancontrol/sensor.py | 4 +- cfancontrol/sensormanager.py | 12 +- cfancontrol/ui/cfanmain.py | 35 +++-- cfancontrol/ui/cfanmain.ui | 76 +++++++--- 19 files changed, 693 insertions(+), 401 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0c6f9ca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## [1.2.0] – 2022-07-28 + +### Changes since 1.1.8 + +Added: + +- Initial support for multiple fan controllers in the system +- Selection box for detected fan controllers +- Experimental support for controlling the fans of Corsair Hydro Platinum AIOs +- 'Cancel' button to fan mode configuration +- Copy and paste functionality for fan curves via context menu +- This change log + +Changed: + +- Prevent the start of the fan control update daemon if no fan controller is detected in the system +- Move 'Apply' button to the bottom right corner +- New format of the profile files including a version tag (current: "1") +- Streamline logging levels and messages +- Some refactoring with the sensor and device classes + +Fixed: + +- Correctly refresh profiles list after a profile is added or removed +- Don't use native file dialog for load/save profile as this caused problems on GNOME 42 + +Removed: + +- + +### Know issues + +- + + + +## About the changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). diff --git a/README.md b/README.md index 767796e..c044801 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Commander Fan Control (cfancontrol) +# Commander² Fan Control (cfancontrol) -A manager for the Corsair Commander Pro fan controller for Linux written in Python with a GUI based on the QT framework. Manage the speeds of all connected fans individually and based on different temperature probes. +A manager primarily for the Corsair Commander Pro fan controller for Linux written in Python with a GUI based on the QT framework. +Support for other fan controllers is currently experimental and limited to the fans of the Corsair Hydro Platinum AIOs. +Manage the speeds of all connected fans individually and based on different temperature probes. A driver for the Corsair Commander Pro was added to the kernel in version 5.9, so this will be the minimal Linux version required. @@ -34,7 +36,7 @@ cfancontrol itself requires the following additional Python libraries in their r - PyQt5~=5.12.3 - pyqtgraph~=0.12.4 -- liquidctl~=1.8.1 +- liquidctl~=1.10.0 - numpy~=1.17.4 - PyYAML~=5.3.1 - PySensors~=0.0.4 @@ -43,6 +45,8 @@ cfancontrol itself requires the following additional Python libraries in their r ## Installation +### From Source + The installation is best done via pip from inside the downloaded repository: ```bash @@ -85,7 +89,7 @@ Device #1: NZXT Kraken X (X53, X63 or X73) Device #2: Gigabyte RGB Fusion 2.0 5702 Controller ``` -**Note**: Supported devices right now are the 'NZXT Kraken X3' and 'Corsair Hydro' series of AIOs. Other devices supported by liquidctl may easily be added, but I do not have them for proper testing. +**Note**: Supported devices right now are the 'NZXT Kraken X3' and 'Corsair Hydro Platinum' series of AIOs. Other devices supported by liquidctl may easily be added, but I do not have them for proper testing. ## Usage diff --git a/cfancontrol/__init__.py b/cfancontrol/__init__.py index 3d30a5f..c68196d 100644 --- a/cfancontrol/__init__.py +++ b/cfancontrol/__init__.py @@ -1 +1 @@ -__version__ = "1.1.8" +__version__ = "1.2.0" diff --git a/cfancontrol/__main__.py b/cfancontrol/__main__.py index b4a2737..3d1e194 100644 --- a/cfancontrol/__main__.py +++ b/cfancontrol/__main__.py @@ -1,6 +1,7 @@ import os import argparse import logging +import sys from pid import PidFile, PidFileAlreadyLockedError @@ -47,7 +48,7 @@ def main(): args = parse_settings() LogManager.set_log_level(Config.log_level) - LogManager.logger.info(f'Starting cfancontrol version {__version__} with configuration: {repr(Config.get_settings())}') + LogManager.logger.info(f'Starting {Environment.APP_FANCY_NAME} version {__version__} with configuration: {repr(Config.get_settings())}') try: with PidFile(Environment.APP_NAME, piddir=Environment.pid_path) as pid: @@ -56,22 +57,25 @@ def main(): if args.mode == "gui": app.main(manager, not Config.auto_start, Config.theme) else: - if Config.profile_file and Config.profile_file != '': - manager.set_profile(os.path.splitext(os.path.basename(Config.profile_file))[0]) - manager.toggle_manager(True) - manager.manager_thread.join() - else: + if not manager.has_controller(): + LogManager.logger.critical(f"No supported fan controller found -> please check system configuration and restart {Environment.APP_FANCY_NAME}") + return + if not Config.profile_file or Config.profile_file == '': LogManager.logger.critical(f"No profile file specified for daemon mode -> please us -p option to specify a profile") + return + manager.set_profile(os.path.splitext(os.path.basename(Config.profile_file))[0]) + manager.toggle_manager(True) + manager.manager_thread.join() except PidFileAlreadyLockedError: if args.mode == "gui": app.warning_already_running() - LogManager.logger.critical(f"PID file '{Environment.pid_path}/{Environment.APP_NAME}.pid' already exists - cfancontrol is already running or was not completed properly -> STOPPING") - except RuntimeError as err: - LogManager.logger.exception(f"Program stopped with runtime error") + LogManager.logger.critical(f"PID file '{Environment.pid_path}/{Environment.APP_NAME}.pid' already exists - cfancontrol is already running or was not completed properly before -> STOPPING") + except RuntimeError: + LogManager.logger.exception(f"{Environment.APP_FANCY_NAME} stopped with runtime error") except BaseException: - LogManager.logger.exception(f"Program stopped with unknown error") + LogManager.logger.exception(f"{Environment.APP_FANCY_NAME} stopped with unknown error") else: - LogManager.logger.info(f"Program stopped normally") + LogManager.logger.info(f"{Environment.APP_FANCY_NAME} ended normally") if __name__ == "__main__": diff --git a/cfancontrol/app.py b/cfancontrol/app.py index caf6d30..a94e114 100644 --- a/cfancontrol/app.py +++ b/cfancontrol/app.py @@ -37,7 +37,7 @@ def main(manager: FanManager, show_app=True, theme='light'): def warning_already_running(): app = QtWidgets.QApplication(sys.argv) response = QtWidgets.QMessageBox.warning(None, Environment.APP_FANCY_NAME, - f"Cannot start {Environment.APP_NAME} as an instance is already running.\n\n" + f"Cannot start {Environment.APP_FANCY_NAME} as an instance is already running.\n\n" f"Check '{Environment.pid_path}/{Environment.APP_NAME}.pid' and remove it if necessary.", QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok) @@ -69,6 +69,5 @@ def load_color_scheme(scheme: str) -> QtGui.QPalette: if __name__ == "__main__": - # the_settings = Settings() the_manager = FanManager() main(the_manager, show_app=True, theme='light') diff --git a/cfancontrol/devicesensor.py b/cfancontrol/devicesensor.py index eb637b2..d482b47 100644 --- a/cfancontrol/devicesensor.py +++ b/cfancontrol/devicesensor.py @@ -1,4 +1,5 @@ -from typing import ContextManager +import threading +from typing import ContextManager, Optional from liquidctl.driver.kraken3 import KrakenX3 from liquidctl.driver.hydro_platinum import HydroPlatinum @@ -9,44 +10,41 @@ class AIODeviceSensor(Sensor, ContextManager): - def __init__(self, aio_device, device_name: str) -> None: + is_valid: bool + device: Optional[any] + device_name: str + sensor_name: str + + def __init__(self) -> None: super().__init__() + self._lock = threading.Lock() self.is_valid = False self.current_temp = 0.0 - self.sensor_name = device_name - init_args = {} - if device_name == "Kraken X3": - self.device: KrakenX3 = aio_device - elif device_name == "H100i Pro XT": - self.device: HydroPlatinum = aio_device - init_args = {"pump_mode": "quiet"} - else: + # self.sensor_name = device_name + if not hasattr(self, "device"): self.device = None - if self.device is not None: + if self.device: try: self.device.connect() - LogManager.logger.info(f"{device_name} connected") - self.device.initialize(**init_args) self.is_valid = True - LogManager.logger.info(f"{device_name} successfully initialized") - except Exception as err: - LogManager.logger.exception(f"Error opening {device_name}") - raise RuntimeError(f"Cannot initialize AIO device '{device_name}'") - else: - LogManager.logger.warning(f"{device_name} not connected") + self.device.disconnect() + LogManager.logger.info(f"AIO device initialized {repr({'device': self.sensor_name})}") + except BaseException: + self.is_valid = False + LogManager.logger.exception(f"Error in initializing AIO device {repr({'device': self.sensor_name})}") def __enter__(self): if self.device: - LogManager.logger.debug(f"Context manager for device {self.sensor_name} started") - return self + return self + else: + return None def __exit__(self, exc_type, exc_val, exc_tb): - LogManager.logger.debug(f"Closing context for device {self.sensor_name}") if self.is_valid: self.device.disconnect() del self.device self.is_valid = False - LogManager.logger.info(f"{self.sensor_name} disconnected and reference removed") + LogManager.logger.debug(f"AIO Device disconnected and reference removed {repr({'device': self.sensor_name})}") return None def get_temperature(self) -> float: @@ -55,52 +53,74 @@ def get_temperature(self) -> float: def get_signature(self) -> list: raise NotImplementedError() + def _safe_call_aio_function(self, function): + self._lock.acquire() + try: + self.device.connect() + result = function() + except Exception: + raise + finally: + self.device.disconnect() + self._lock.release() + return result + class KrakenX3Sensor(AIODeviceSensor): def __init__(self, device: KrakenX3): - super(KrakenX3Sensor, self).__init__(device, "Kraken X3") + self.device = device + self.device_name = device.description + self.sensor_name = "Kraken X3" + super(KrakenX3Sensor, self).__init__() def get_temperature(self) -> float: - LogManager.logger.debug(f"Reading temperature from {self.sensor_name} sensor") self.current_temp = 0.0 if self.is_valid: - ret = self.device._read() - part1 = int(ret[15]) - part2 = int(ret[16]) - LogManager.logger.debug(f"{self.sensor_name} read-out: {ret}") - if (0 <= part1 <= 100) and (0 <= part2 <= 90): - self.current_temp = float(part1) + float(part2 / 10) - else: - LogManager.logger.warning(f"Invalid sensor data from {self.sensor_name}: {part1}.{part2}") + try: + ret = self._safe_call_aio_function(lambda: self.device._read()) + part1 = int(ret[15]) + part2 = int(ret[16]) + if (0 <= part1 <= 100) and (0 <= part2 <= 90): + self.current_temp = float(part1) + float(part2 / 10) + LogManager.logger.trace(f"Getting sensor temperature {repr({'sensor': self.sensor_name, 'temperature': round(self.current_temp, 1)})}") + else: + LogManager.logger.warning(f"Invalid sensor data {repr({'sensor': self.sensor_name, 'part 1': part1, 'part 2': part2})}") + except BaseException: + LogManager.logger.exception(f"Unexpected error in getting sensor data {repr({'sensor': self.sensor_name})}") return self.current_temp def get_signature(self) -> list: - return [__class__.__name__, self.device.description, self.device.product_id, self.sensor_name] + return [__class__.__name__, self.device_name, self.device.product_id, self.sensor_name] class HydroPlatinumSensor(AIODeviceSensor): # Details: https://github.com/liquidctl/liquidctl/blob/main/liquidctl/driver/hydro_platinum.py def __init__(self, device: HydroPlatinum): - self.device_description: str = device.description - self.device_name = "Corsair Hydro " - self.device_model = self.device_description.split(self.device_name, 1)[1] - super(HydroPlatinumSensor, self).__init__(device, self.device_model) + self.device = device + self.device_name = device.description + device_prefix = "Corsair Hydro " + self.sensor_name = self.device_name.split(device_prefix, 1)[1] + super(HydroPlatinumSensor, self).__init__() def get_temperature(self) -> float: - LogManager.logger.debug(f"Reading temperature from {self.sensor_name} sensor") self.current_temp = 0.0 if self.is_valid: - res = self.device._send_command(0b00, 0xff) - part1 = int(res[8]) - part2 = int(res[7]) - LogManager.logger.debug(f"{self.sensor_name} read-out: {res}") - if (0 <= part1 <= 100) and (0 <= part2 <= 255): - self.current_temp = float(part1) + float(part2 / 255) - else: - LogManager.logger.warning(f"Invalid sensor data from {self.sensor_name}: {part1}.{part2}") + try: + ret = self._safe_call_aio_function(lambda: self.device._send_command(0b00, 0xff)) + part1 = int(ret[8]) + part2 = int(ret[7]) + if (0 <= part1 <= 100) and (0 <= part2 <= 255): + self.current_temp = float(part1) + float(part2 / 255) + LogManager.logger.trace(f"Getting sensor temperature {repr({'sensor': self.sensor_name, 'temperature': round(self.current_temp, 1)})}") + else: + LogManager.logger.warning(f"Invalid sensor data {repr({'sensor': self.sensor_name, 'part 1': part1, 'part 2': part2})}") + except ValueError as verr: + LogManager.logger.error(f"Problem in getting sensor data {repr({'sensor': self.sensor_name, 'error': repr(verr)})}") + except BaseException: + LogManager.logger.exception(f"Unexpected error in getting sensor data {repr({'sensor': self.sensor_name})}") return self.current_temp def get_signature(self) -> list: - return [__class__.__name__, self.device.description, self.device.product_id, self.sensor_name] \ No newline at end of file + return [__class__.__name__, self.device_name, self.device.product_id, self.sensor_name] diff --git a/cfancontrol/fancontroller.py b/cfancontrol/fancontroller.py index fe5b686..f1fb563 100644 --- a/cfancontrol/fancontroller.py +++ b/cfancontrol/fancontroller.py @@ -3,171 +3,207 @@ from typing import Dict, Optional, List, ContextManager import liquidctl.driver.commander_pro +import liquidctl.driver.hydro_platinum from liquidctl import find_liquidctl_devices -# from liquidctl.driver.commander_pro import CommanderPro from .log import LogManager +from .pwmfan import PWMFan +from .fancurve import FanCurve, FanMode +from .sensor import Sensor, DummySensor class FanController(ContextManager): - def __init__(self): - pass - - def __enter__(self): - pass - - def __exit__(self, exc_type, exc_val, exc_tb): - pass - - def detect_channels(self) -> List[str]: - return [] - - def get_channel_speed(self, channel: str) -> int: - raise NotImplementedError() - - def set_channel_speed(self, channel: str, new_pwm: int, current_percent: int, new_percent: int, temperature: float) -> bool: - raise NotImplementedError() - - def stop_channel(self, channel: str, current_percent: int) -> bool: - raise NotImplementedError() - - -class ControllerManager(object): - fan_controller: List[FanController] = [] - - @staticmethod - def identify_fan_controllers(): - devices = find_liquidctl_devices() - for dev in devices: - if type(dev) == liquidctl.driver.commander_pro.CommanderPro: - LogManager.logger.info(f"Fan controller '{dev.description}' found") - ControllerManager.fan_controller.append(CommanderPro(dev)) - - -class CommanderPro(FanController, ContextManager): - RPM_STEP: int = 10 - RPM_INTERVAL: float = 0.1 + RPM_INTERVAL: float = 0.025 - @classmethod - def get_commander(cls) -> Optional[liquidctl.driver.commander_pro.CommanderPro]: - devices = find_liquidctl_devices() - for dev in devices: - # if 'Commander' in dev.description: - if type(dev) == CommanderPro: - LogManager.logger.info(f"Fan controller '{dev.description}' found") - return dev - return None + channels: Dict[str, PWMFan] + is_valid: bool + device: Optional[any] + device_name: str - def __init__(self, device: liquidctl.driver.commander_pro.CommanderPro) -> None: - super().__init__() - self.is_valid = False + def __init__(self): self._lock = threading.Lock() - # self.commander = self.get_commander() - self.commander = device - if self.commander: + self.channels = {} + self.is_valid = False + if not hasattr(self, "device"): + self.device = None + self.device_name = "" + if self.device: try: - self.commander.connect() - LogManager.logger.info("Fan controller connected") - init_message = self.commander.initialize() + self.device_name = self.device.description + self.device.connect() + self.device.disconnect() self.is_valid = True - self.commander.disconnect() - LogManager.logger.info(f"Fan controller successfully initialized {init_message}") - except Exception as err: - LogManager.logger.exception(f"Error in opening fan controller") - raise RuntimeError("Cannot initialize fan controller") + self.detect_channels() + LogManager.logger.info(f"Fan controller initialized {repr({'controller': self.device_name})}") + except BaseException: + self.is_valid = False + LogManager.logger.exception(f"Error in initializing fan controller {repr({'controller': self.device_name})}") finally: - self.commander.disconnect() - else: - LogManager.logger.critical("Fan controller not found -> STOPPING!") - raise RuntimeError("No Commander Pro controller found") + self.device.disconnect() def __enter__(self): - if self.commander: - LogManager.logger.debug(f"Context manager for 'Commander Pro' started") - return self + if self.device: + return self + else: + return None def __exit__(self, exc_type, exc_value, exc_tb): - LogManager.logger.debug(f"Closing context for fan controller") if self.is_valid: - self.commander.disconnect() - del self.commander + self.device.disconnect() + del self.device self.is_valid = False - LogManager.logger.info("Fan controller disconnected and reference removed") - return None + LogManager.logger.debug(f"Fan controller disconnected and reference removed {repr({'controller': self.device_name})}") - def detect_channels(self) -> List[str]: - channel_list = [] - LogManager.logger.info("Detecting fan channels of fan controller") - if self.is_valid: - try: - fan_modes = self._safe_call_controller_function(lambda: self.commander._data.load(key='fan_modes', default=[0] * self.commander._fan_count)) - LogManager.logger.debug(f"Following fan channels detected: {fan_modes}") - for i, fan_mode in enumerate(fan_modes): - if fan_mode == 0x02: - channel = f"fan{i + 1}" - channel_list.append(channel) - except Exception: - LogManager.logger.exception("Error in detecting fan channels") - LogManager.logger.debug(f"Channels: {channel_list}") - return channel_list + def get_name(self) -> str: + return self.device_name + + def is_initialized(self) -> bool: + return self.is_valid + + def detect_channels(self): + return [] + + def reset_channels(self, sensor: Sensor): + for channel in self.channels: + self.channels[channel] = PWMFan(channel, FanCurve.zero_rpm_curve(), sensor) + + def get_channel_status(self, channel: str) -> (bool, FanMode, int, int, int, float): + fan: PWMFan = self.channels.get(channel) + if fan: + speed = self.get_channel_speed(channel) + return True, *fan.get_fan_status(), speed + return False, FanMode.Off, 0, 0, 0, 0.0 def get_channel_speed(self, channel: str) -> int: - if self.is_valid: - fan_index = int(channel[-1]) - 1 - LogManager.logger.debug(f"Getting fan speed for channel '{channel}' with index {fan_index}") - try: - rpm = self._safe_call_controller_function(lambda: self.commander._get_fan_rpm(fan_num=fan_index)) - return rpm - except Exception as err: - LogManager.logger.exception(f"Problem in getting speed for channel '{channel}'") - return 0 + pass - def set_channel_speed(self, channel: str, new_pwm: int, current_percent: int, new_percent:int, temperature: float) -> bool: + def set_channel_speed(self, channel: str, new_pwm: int, current_percent: int, new_percent: int, temperature: float) -> bool: if self.is_valid: - LogManager.logger.info(f"Setting fan speed of channel '{channel}' to PWM {new_pwm} / {str(new_percent)}% for temperature {temperature}°C") + LogManager.logger.info(f"Setting fan speed {repr({'controller': self.device.description, 'channel': channel, 'pwm': new_pwm, 'duty': new_percent, 'temperature': round(temperature, 1)})}") if self._set_channel_speed_smoothly(channel, current_percent, new_percent): - LogManager.logger.debug("Channel speed changed") return True return False + def stop_all_channels(self) -> bool: + result = True + for channel, fan in self.channels.items(): + result = result and self.stop_channel(channel, fan.get_current_pwm_as_percentage()) + fan.pwm = 0 + return result + def stop_channel(self, channel: str, current_percent: int) -> bool: if self.is_valid: - LogManager.logger.info(f"Stopping channel '{channel}'") + LogManager.logger.info(f"Stopping fan {repr({'controller': self.device.description, 'channel': channel})}") if self._set_channel_speed_smoothly(channel, current_percent, 0): - LogManager.logger.debug("Channel stopped") return True return False def _set_channel_speed_smoothly(self, channel: str, current_percent: int, new_percent: int) -> bool: try: - LogManager.logger.debug(f"Current fan speed {current_percent} / new fan sped {new_percent}") if new_percent >= current_percent: factor = 1 else: factor = -1 - steps = int((new_percent - current_percent) / self.RPM_STEP * factor) + steps = int(((new_percent - current_percent) * factor) / self.RPM_STEP) + 1 for i in range(1, steps): duty = int(current_percent + i * factor * self.RPM_STEP) - LogManager.logger.debug(f"Setting fan speed of channel '{channel}' to {str(duty)}%") - self._safe_call_controller_function(lambda: self.commander.set_fixed_speed(channel=channel, duty=duty)) + LogManager.logger.trace(f"Adjusting fan speed {repr({'controller': self.device.description, 'channel': channel, 'duty': duty})}") + self._safe_call_controller_function(lambda: self.device.set_fixed_speed(channel=channel, duty=duty)) time.sleep(self.RPM_INTERVAL) - LogManager.logger.debug(f"Setting fan speed of channel '{channel}' to {str(new_percent)}%") - self._safe_call_controller_function(lambda: self.commander.set_fixed_speed(channel=channel, duty=new_percent)) + LogManager.logger.trace(f"Adjusting fan speed {repr({'controller': self.device.description, 'channel': channel, 'duty': new_percent})}") + self._safe_call_controller_function(lambda: self.device.set_fixed_speed(channel=channel, duty=new_percent)) return True except BaseException: - LogManager.logger.exception(f"Problem in setting speed for channel {channel}") + LogManager.logger.exception(f"Error in setting fan speed {repr({'controller': self.device.description, 'channel': channel})}") return False def _safe_call_controller_function(self, function): self._lock.acquire() try: - self.commander.connect() + self.device.connect() result = function() except Exception: raise finally: - self.commander.disconnect() + self.device.disconnect() self._lock.release() return result + + +class ControllerManager(object): + fan_controller: List[FanController] = [] + + @staticmethod + def identify_fan_controllers(): + devices = find_liquidctl_devices() + index = 0 + for dev in devices: + controller: FanController = FanController() + if type(dev) == liquidctl.driver.commander_pro.CommanderPro: + LogManager.logger.info(f"Fan controller found {repr({'index': index, 'controller': dev.description})}") + controller = CommanderProController(dev) + elif type(dev) == liquidctl.driver.hydro_platinum.HydroPlatinum: + LogManager.logger.info(f"Fan controller found {repr({'index': index, 'controller': dev.description})}") + controller = HydroPlatinumController(dev) + if controller.is_initialized(): + ControllerManager.fan_controller.append(controller) + index += 1 + + +class CommanderProController(FanController, ContextManager): + + def __init__(self, device: liquidctl.driver.commander_pro.CommanderPro): + self.device: liquidctl.driver.commander_pro.CommanderPro = device + super().__init__() + + def detect_channels(self): + if self.is_valid: + try: + fan_modes = self._safe_call_controller_function(lambda: self.device._data.load(key='fan_modes', default=[0] * self.device._fan_count)) + LogManager.logger.debug(f"Detected fan channels {repr({'controller': self.device.description, 'fan_modes': fan_modes})}") + for i, fan_mode in enumerate(fan_modes): + if fan_mode == 0x02: + channel = f"fan{i + 1}" + self.channels[channel] = PWMFan(channel, FanCurve.zero_rpm_curve(), DummySensor()) + except BaseException: + LogManager.logger.exception(f"Error in detecting fan channels {repr({'controller': self.device.description})}") + + def get_channel_speed(self, channel: str) -> int: + if self.is_valid: + fan_index = int(channel[-1]) - 1 + LogManager.logger.trace(f"Getting fan speed {repr({'controller': self.device.description, 'channel': channel, 'index': fan_index})}") + try: + rpm = self._safe_call_controller_function(lambda: self.device._get_fan_rpm(fan_num=fan_index)) + return rpm + except Exception as err: + LogManager.logger.exception(f"Error in getting fan speed {repr({'controller': self.device.description, 'channel': channel})}") + return 0 + + +class HydroPlatinumController(FanController, ContextManager): + + def __init__(self, device: liquidctl.driver.hydro_platinum.HydroPlatinum): + self.channel_offsets = {'fan1': 14, 'fan2': 21, 'fan3': 42} + self.device: liquidctl.driver.hydro_platinum.HydroPlatinum = device + super().__init__() + + def detect_channels(self): + if self.is_valid: + for i in range(len(self.device._fan_names)): + channel = f"fan{i + 1}" + self.channels[channel] = PWMFan(channel, FanCurve.zero_rpm_curve(), DummySensor()) + LogManager.logger.debug(f"Detected fan channels {repr({'controller': self.device.description, 'channels': self.channels.keys()})}") + + def get_channel_speed(self, channel: str) -> int: + if self.is_valid: + LogManager.logger.trace(f"Getting fan speed {repr({'controller': self.device.description, 'channel': channel})}") + try: + res = self._safe_call_controller_function(lambda: self.device._send_command(0b00, 0xff)) + offset = self.channel_offsets[channel] + 1 + rpm = int.from_bytes(res[offset:offset+2], byteorder='little') + return rpm + except BaseException: + LogManager.logger.exception(f"Error in getting fan speed {repr({'controller': self.device.description, 'channel': channel})}") + return 0 diff --git a/cfancontrol/fanmanager.py b/cfancontrol/fanmanager.py index 0da9de8..6f01bde 100644 --- a/cfancontrol/fanmanager.py +++ b/cfancontrol/fanmanager.py @@ -6,10 +6,10 @@ from .log import LogManager from .settings import Environment, Config -from .fancontroller import ControllerManager, FanController, CommanderPro +from .fancontroller import ControllerManager, FanController, CommanderProController from .fancurve import FanCurve, FanMode from .pwmfan import PWMFan -from .sensor import Sensor, DummySensor +from .sensor import Sensor from .sensormanager import SensorManager from .devicesensor import AIODeviceSensor from .profilemanager import ProfileManager @@ -18,10 +18,10 @@ class FanManager: _is_running: bool - _controller: Optional[FanController] + _active_controller: Optional[FanController] + _fan_controller: Dict[int, FanController] _interval: float manager_thread: threading.Thread - _channels: Dict[str, PWMFan] def __init__(self): self._interval = Config.interval @@ -44,28 +44,20 @@ def __init__(self): # get all profiles ProfileManager.enum_profiles(Environment.settings_path) - # get active channels from controller - # self._controller: CommanderPro = CommanderPro() - # self._controller_channels = self._controller.detect_channels() - # self.reset_channels(self._controller_channels) + # identify fan controllers ControllerManager.identify_fan_controllers() - if ControllerManager.fan_controller: - self._controller = ControllerManager.fan_controller[0] - self._controller_channels = self._controller.detect_channels() - self.reset_channels(self._controller_channels) - else: - self._controller = FanController() - self._controller_channels = self._controller.detect_channels() - self.reset_channels(self._controller_channels) + self._fan_controller = {i: j for i, j in enumerate(ControllerManager.fan_controller)} + if not self.has_controller(): + Config.auto_start = False - def __enter__(self): # reusable + def __enter__(self): self._stack = ExitStack() try: for sensor in self._sensors: if isinstance(sensor, AIODeviceSensor): self._stack.enter_context(sensor) - if self._controller: - self._stack.enter_context(self._controller) + for controller in self._fan_controller.values(): + self._stack.enter_context(controller) except Exception: self._stack.close() raise @@ -76,19 +68,35 @@ def __exit__(self, exc_type, exc_value, exc_tb): self._stack.close() return None - def get_controller(self) -> Optional[FanController]: - return self._controller + def get_active_controller(self) -> Optional[FanController]: + return self._active_controller + + def set_controller(self, index: int) -> bool: + if self._fan_controller.get(index): + self._active_controller = self._fan_controller[index] + return True + else: + self._active_controller = FanController() + LogManager.logger.warning(f"Fan controller with index '{index}' not found in the system") + return False - def reset_channels(self, channels: List[str]): - self._channels = dict() - for channel in channels: - self._channels[channel] = PWMFan(channel, FanCurve.zero_rpm_curve(), DummySensor()) + def has_controller(self) -> bool: + if self._fan_controller: + return True + return False + + def controller_count(self) -> int: + if self._fan_controller: + return len(self._fan_controller) + return 0 + + def get_controller_names(self) -> List[str]: + return [c.get_name() for c in self._fan_controller.values()] def set_callback(self, callback): self._callback = callback def run(self) -> bool: - self.tick() aborted: bool = False while not self._signals.wait_for_term_queued(self._interval): @@ -96,14 +104,16 @@ def run(self) -> bool: self.tick() except Exception: LogManager.logger.exception(f"Unhandled exception in fan manager") - LogManager.logger.critical("Aborting manager") + LogManager.logger.critical("Aborting fan manager") self._is_running = False aborted = True break - for channel, fan in self._channels.items(): - self._controller.stop_channel(channel, fan.get_current_pwm_as_percentage()) - fan.pwm = 0 + try: + for controller in self._fan_controller.values(): + controller.stop_all_channels() + except BaseException: + LogManager.logger.exception("Error while stopping fan channels") self._signals.reset() @@ -119,19 +129,18 @@ def toggle_manager(self, mode: bool): self.stop() def start(self): - LogManager.logger.info("Starting fan manager") if not self.is_manager_running(): - LogManager.logger.info("Creating fan manager thread") self.manager_thread = threading.Thread(target=self.run) self.manager_thread.start() + LogManager.logger.info("Fan manager thread started") else: - LogManager.logger.warning("Cannot start fan manager - fan manager is already running") + LogManager.logger.warning("Cannot start fan manager - fan manager thread is already running") def stop(self): if self.is_manager_running(): - LogManager.logger.info("Stopping fan manager") self._signals.sigterm(None, None) self.manager_thread.join() + LogManager.logger.info("Fan manager thread stopped") def is_manager_running(self) -> bool: if self.manager_thread is not None and self.manager_thread.is_alive(): @@ -141,21 +150,18 @@ def is_manager_running(self) -> bool: def tick(self) -> None: if self.is_manager_running(): - for channel, fan in self._channels.items(): - if fan: - update, new_pwm, new_percent, temperature = fan.update_pwm() + for controller in self._fan_controller.values(): + for channel, fan in controller.channels.items(): + update, new_pwm, new_percent, temperature = fan.update_pwm(controller.get_channel_speed(channel)) if update: - if self._controller.set_channel_speed(channel, new_pwm, fan.get_current_pwm_as_percentage(), new_percent, temperature): + if controller.set_channel_speed(channel, new_pwm, fan.get_current_pwm_as_percentage(), new_percent, temperature): fan.set_current_pwm(new_pwm) - else: - LogManager.logger.warning(f"No fan for channel {channel} available") def update_interval(self, interval: float): self._interval = interval def apply_fan_mode(self, channel: str, sensor: int, curve_data: FanCurve, profile=None): - # fan: PWMFan = self._controller.get_pwm_fan(channel) - fan: PWMFan = self._channels.get(channel) + fan: PWMFan = self._active_controller.channels.get(channel) if fan: fan.temp_sensor = self._sensors[sensor] fan.fan_curve = curve_data @@ -163,24 +169,23 @@ def apply_fan_mode(self, channel: str, sensor: int, curve_data: FanCurve, profil if profile: self.save_profile(profile) + def get_active_channels(self) -> int: + return len(self._active_controller.channels) + def get_pwm_fan(self, channel: str): - return self._channels.get(channel) + return self._active_controller.channels.get(channel) - def get_channel_status(self, channel: str) -> (bool, int, int, int, float): - fan: PWMFan = self._channels.get(channel) - if fan: - speed = self._controller.get_channel_speed(channel) - return True, fan.fan_curve.get_fan_mode(), fan.get_current_pwm(), fan.get_current_pwm_as_percentage(), speed, fan.get_current_temp() - return False, FanMode.Off, 0, 0, 0, 0.0 + def get_channel_status(self, channel: str) -> (bool, FanMode, int, int, int, float): + return self._active_controller.get_channel_status(channel) def get_channel_sensor(self, channel: str) -> int: - fan: PWMFan = self._channels.get(channel) + fan: PWMFan = self._active_controller.channels.get(channel) if fan: return self._sensors.index(fan.temp_sensor) return 0 def get_channel_fancurve(self, channel: str) -> Optional[FanCurve]: - fan: PWMFan = self._channels.get(channel) + fan: PWMFan = self._active_controller.channels.get(channel) if fan: return fan.fan_curve return None @@ -191,38 +196,71 @@ def load_profile(file_name: str) -> str: return profile_name def save_profile(self, profile_name: str) -> (bool, str): - success, saved_profile = ProfileManager.save_profile(profile_name, self.serialize_to_json()) + success, saved_profile = ProfileManager.save_profile(profile_name, self._serialize_profile_to_json()) return success, saved_profile def set_profile(self, profile_name: str) -> (bool, str): - self.reset_channels(self._controller_channels) + for controller in self._fan_controller.values(): + controller.reset_channels(self._sensors[0]) if profile_name: profile_data = ProfileManager.get_profile_data(profile_name) if profile_data: Config.profile_file = ProfileManager.profiles[profile_name] - self.deserialize_from_json(profile_data) + self._deserialize_profile_from_json(profile_data) self.tick() return True, os.path.basename(Config.profile_file) Config.profile_file = '' self.tick() return False, '' - def serialize_to_json(self): - channel_dict: Dict[str, dict] = dict() - for channel, fan in self._channels.items(): - channel_dict[channel] = {"curve": fan.fan_curve.get_graph_points_from_curve(), "sensor": fan.temp_sensor.get_signature()} - return channel_dict - - def deserialize_from_json(self, profile_data: dict): + def _serialize_profile_to_json(self) -> Dict[str, dict]: + profile_dict: Dict[str, any] = dict() + profile_dict["version"] = "1" + controllers_list: List[dict] = list() + index = 0 + for controller in self._fan_controller.values(): + channel_dict: Dict[str, dict] = dict() + for channel, fan in controller.channels.items(): + channel_dict[channel] = {"curve": fan.fan_curve.get_graph_points_from_curve(), "sensor": fan.temp_sensor.get_signature()} + controller_dict: Dict[str, any] = dict() + controller_dict["id"] = index + controller_dict["name"] = controller.get_name() + controller_dict["class"] = controller.__class__.__name__ + controller_dict["channels"] = channel_dict + controllers_list.append(controller_dict) + index += 1 + profile_dict["controllers"] = controllers_list + return profile_dict + + def _deserialize_profile_from_json(self, profile_data: dict): if profile_data: - for channel, fan in self._channels.items(): - channel_config = profile_data.get(channel) + version = profile_data.get("version") + if version == "1": + saved_controllers_list = profile_data.get("controllers") + for index, controller in self._fan_controller.items(): + if len(saved_controllers_list) > index: + saved_controller = saved_controllers_list[index] + if saved_controller: + if saved_controller["class"] == controller.__class__.__name__: + saved_channels = saved_controller.get("channels") + self._deserialize_channel_config(saved_channels, controller.channels) + continue + else: + for index, controller in self._fan_controller.items(): + if type(controller) == CommanderProController: + self._deserialize_channel_config(profile_data, controller.channels) + + def _deserialize_channel_config(self, saved_channels, controller_channels): + if saved_channels: + for channel, fan in controller_channels.items(): + channel_config = saved_channels.get(channel) if channel_config: sensor_config = channel_config["sensor"] sensor = [s for s in self._sensors if s.get_signature() == sensor_config] if sensor: fan.temp_sensor = sensor[0] fan.fan_curve.set_curve_from_graph_points(channel_config["curve"]) + continue class Signals: diff --git a/cfancontrol/graphs.py b/cfancontrol/graphs.py index 13a9c24..ad4e03e 100644 --- a/cfancontrol/graphs.py +++ b/cfancontrol/graphs.py @@ -1,3 +1,5 @@ +from typing import List + import numpy as np import math import pyqtgraph as pg @@ -16,6 +18,7 @@ def __init__(self, parent: QtWidgets.QFrame, **keywords): self._graph: EditableGraph = None self._line_temp: pg.InfiniteLine = None self._line_fan: pg.InfiniteLine = None + self._copy_data: List = [] keywords_default = { 'menuEnabled': False, @@ -66,8 +69,20 @@ def __init__(self, parent: QtWidgets.QFrame, **keywords): minYRange=data['limits'][1], maxYRange=data['limits'][1] ) - # self.setLimits(xMin=-3, yMin=-5, xMax=107, yMax=105, minXRange=110, maxXRange=110, minYRange=110, maxYRange=110) - # self.sizePolicy() + + def contextMenuEvent(self, event): + if self._graph.pointCount() > 2: + menu = QtWidgets.QMenu() + copy_action = menu.addAction('Copy') + paste_action = menu.addAction('Paste') + paste_action.setEnabled(False) + if self._copy_data: + paste_action.setEnabled(True) + res = menu.exec_(event.globalPos()) + if res == copy_action: + self._copy_data = self.get_graph_data() + elif res == paste_action: + self._graph.updateData(self._copy_data) def reset_graph(self): if self._graph is not None: @@ -88,7 +103,7 @@ def set_graph(self, graph_data: list, draw_lines: bool, accent_color: QtGui.QCol def get_graph_data(self) -> list: data = list() if self._graph is not None: - data = self._graph.data['pos'] + data = self._graph.data['pos'].tolist() return data def draw_lines(self, label_color: QtGui.QColor, line_color: QtGui.QColor): @@ -187,6 +202,9 @@ def setData(self, **kwds): self.updateGraph() + def updateData(self, data: list): + self.setData(pos=np.stack(data)) + def updateGraph(self): super().setData(**self.data) @@ -239,6 +257,9 @@ def removePoint(self): del flat[index] self.setData(pos=np.stack(flat)) + def pointCount(self) -> int: + return len(self.data['pos']) + def getPointDistance(self, p1, p2): return math.sqrt( math.pow(self.data['pos'][p2][0] - self.data['pos'][p1][0], 2) + diff --git a/cfancontrol/gui.py b/cfancontrol/gui.py index 8040719..b5152d8 100644 --- a/cfancontrol/gui.py +++ b/cfancontrol/gui.py @@ -23,6 +23,9 @@ class MainWindow(QtWidgets.QMainWindow): def __init__(self, fan_manager: FanManager, palette: QtGui.QPalette): super(MainWindow, self).__init__() + self._update_timer = None + self._active_button = None + self._palette = palette self._accent_color: QtGui.QColor = palette.highlight().color() if Config.theme == 'light': @@ -63,13 +66,15 @@ def __init__(self, fan_manager: FanManager, palette: QtGui.QPalette): def show(self): super(MainWindow, self).show() self.activateWindow() + self._update_ui() def closeEvent(self, event: QtGui.QCloseEvent) -> None: if event is False: # Exit in the menu was pressed - if self._update_timer is not None and self._update_timer.is_alive(): - self._update_timer.cancel() - self._update_timer.join() + if self._update_timer: + if self._update_timer.is_alive(): + self._update_timer.cancel() + self._update_timer.join() if self.ui.switch_daemon.isChecked(): Config.auto_start = True else: @@ -92,18 +97,19 @@ def showEvent(self, event: QtGui.QShowEvent) -> None: def _init_pyqt_signals(self): """Assign QT signals to UI elements and actions""" - self.ui.pushButton_fan1.clicked.connect(lambda: self._fan_config_button('fan1')) - self.ui.pushButton_fan2.clicked.connect(lambda: self._fan_config_button('fan2')) - self.ui.pushButton_fan3.clicked.connect(lambda: self._fan_config_button('fan3')) - self.ui.pushButton_fan4.clicked.connect(lambda: self._fan_config_button('fan4')) - self.ui.pushButton_fan5.clicked.connect(lambda: self._fan_config_button('fan5')) - self.ui.pushButton_fan6.clicked.connect(lambda: self._fan_config_button('fan6')) + self.ui.pushButton_fan1.clicked.connect(lambda: self._fan_button_clicked('fan1')) + self.ui.pushButton_fan2.clicked.connect(lambda: self._fan_button_clicked('fan2')) + self.ui.pushButton_fan3.clicked.connect(lambda: self._fan_button_clicked('fan3')) + self.ui.pushButton_fan4.clicked.connect(lambda: self._fan_button_clicked('fan4')) + self.ui.pushButton_fan5.clicked.connect(lambda: self._fan_button_clicked('fan5')) + self.ui.pushButton_fan6.clicked.connect(lambda: self._fan_button_clicked('fan6')) self.ui.radioButton_off.clicked.connect(self._change_fan_mode) self.ui.radioButton_fixed.clicked.connect(self._change_fan_mode) self.ui.spinBox_fixed.valueChanged.connect(self._change_fan_mode) self.ui.radioButton_curve.clicked.connect(self._change_fan_mode) self.ui.pushButton_apply.clicked.connect(self._apply_fan_mode) + self.ui.pushButton_cancel.clicked.connect(lambda: self._fan_button_clicked('all')) self.channel_elements = { 'fan1': [self.ui.icon_fan1, self.ui.pushButton_fan1, self.ui.mode_fan1, self.ui.speed_fan1, @@ -153,7 +159,7 @@ def _init_tray_menu(self): self._option_show.triggered.connect(self.show) self._option_manager = QtWidgets.QAction("Run Update Daemon") self._option_manager.setCheckable(True) - self._option_manager.triggered.connect(lambda: self._toggle_manager(self._option_manager.isChecked())) + self._option_manager.triggered.connect(lambda event, source=self.ui.switch_daemon: self._daemon_switch_clicked(event, source)) self._option_exit = QtWidgets.QAction("Exit") self._option_exit.triggered.connect(self.closeEvent) @@ -181,29 +187,9 @@ def _init_ui(self): self.ui.switch_daemon.set_colors(self._accent_color) - for i in range(1, 7): - channel = f"fan{i}" - icon: QtWidgets.QLabel = self.channel_elements.get(channel)[0] - button: QtWidgets.QPushButton = self.channel_elements.get(channel)[1] - mode: QtWidgets.QLabel = self.channel_elements.get(channel)[2] - speed: QtWidgets.QLabel = self.channel_elements.get(channel)[3] - fan = self.manager.get_pwm_fan(channel) - if fan: - if Config.theme == 'light': - icon.setPixmap(QtGui.QPixmap(":/fans/fan_connected_dark.png")) - else: - icon.setPixmap(QtGui.QPixmap(":/fans/fan_connected_grey.png")) - button.setEnabled(True) - mode.setText("Off") - speed.setText("0 rpm") - else: - if Config.theme == 'light': - icon.setPixmap(QtGui.QPixmap(":/fans/fan_disconnected_light.png")) - else: - icon.setPixmap(QtGui.QPixmap(":/fans/fan_disconnected_grey.png")) - button.setEnabled(False) - mode.setText("-") - speed.setText("") + self._select_fan_controller(0) + self._set_controller_combobox() + self.ui.comboBox_controller.currentIndexChanged.connect(lambda: self._select_fan_controller(self.ui.comboBox_controller.currentIndex())) self.ui.comboBox_sensors.clear() for sensor in SensorManager.system_sensors: @@ -215,9 +201,13 @@ def _init_ui(self): def _init_graphview(self): """Initializes the graph widgets""" + def _set_curve_icon(button: QtWidgets.QPushButton, icon_file: str): + pixmap: QtGui.QPixmap = QtGui.QPixmap(icon_file) + button_icon: QtGui.QIcon = QtGui.QIcon(pixmap) + button.setIcon(button_icon) + pyqtgraph.setConfigOptions(antialias=True) - # del self.ui.graphicsView_fancurve self.ui.graphicsView_fancurve = FanCurveWidget( self.ui.frame_fancurve, labels={'left': ('Fan Speed', '%'), 'bottom': ("Temperature", '°C')}, @@ -232,21 +222,15 @@ def _init_graphview(self): self.ui.pushButton_remove.clicked.connect(self.ui.graphicsView_fancurve.remove_point) self.ui.pushButton_linear_curve.clicked.connect(self._set_fancurve_preset) - self._set_curve_icon(self.ui.pushButton_linear_curve, ":/curves/curve_linear.png") + _set_curve_icon(self.ui.pushButton_linear_curve, ":/curves/curve_linear.png") self.ui.pushButton_exponential_curve.clicked.connect(self._set_fancurve_preset) - self._set_curve_icon(self.ui.pushButton_exponential_curve, ":/curves/curve_exponential.png") + _set_curve_icon(self.ui.pushButton_exponential_curve, ":/curves/curve_exponential.png") self.ui.pushButton_logistic_curve.clicked.connect(self._set_fancurve_preset) - self._set_curve_icon(self.ui.pushButton_logistic_curve, ":/curves/curve_logistic.png") + _set_curve_icon(self.ui.pushButton_logistic_curve, ":/curves/curve_logistic.png") self.ui.pushButton_semi_exp_curve.clicked.connect(self._set_fancurve_preset) - self._set_curve_icon(self.ui.pushButton_semi_exp_curve, ":/curves/curve_semi_exp.png") + _set_curve_icon(self.ui.pushButton_semi_exp_curve, ":/curves/curve_semi_exp.png") self.ui.pushButton_semi_logistic_curve.clicked.connect(self._set_fancurve_preset) - self._set_curve_icon(self.ui.pushButton_semi_logistic_curve, ":/curves/curve_semi_log.png") - - @staticmethod - def _set_curve_icon(button: QtWidgets.QPushButton, icon_file: str): - pixmap: QtGui.QPixmap = QtGui.QPixmap(icon_file) - button_icon: QtGui.QIcon = QtGui.QIcon(pixmap) - button.setIcon(button_icon) + _set_curve_icon(self.ui.pushButton_semi_logistic_curve, ":/curves/curve_semi_log.png") def _init_settings(self): """Apply settings to UI elements""" @@ -269,31 +253,42 @@ def _init_settings(self): self.ui.action_Warning.triggered.connect(lambda: self._set_log_level(logging.WARNING)) self.ui.action_Error.triggered.connect(lambda: self._set_log_level(logging.ERROR)) - self.ui.switch_daemon.setChecked(Config.auto_start) - self.ui.switch_daemon.clicked.connect(lambda: self._toggle_manager(self.ui.switch_daemon.isChecked())) - + self._set_profiles_combobox() self.ui.comboBox_profiles.currentIndexChanged.connect(lambda: self._apply_profile(self.ui.comboBox_profiles.currentText(), self.ui.switch_daemon.isChecked())) - self._set_profiles_combobox(ProfileManager.get_profile_from_file_name(Config.profile_file)) + + self.ui.switch_daemon.setChecked(Config.auto_start) + self.ui.switch_daemon.mousePressEvent = lambda event, source=self.ui.switch_daemon: self._daemon_switch_clicked(event, source) + self._select_profile_in_combobox(ProfileManager.get_profile_from_file_name(Config.profile_file)) + + def _daemon_switch_clicked(self, event: QtGui.QMouseEvent, source: QtWidgets.QAbstractButton): + if not source.isChecked(): + if self.ui.comboBox_profiles.currentIndex() == 0: + self._show_warning_dialog("Please select a profile before starting the update daemon.") + self._toggle_manager(False) + return + elif not self.manager.has_controller(): + self._show_warning_dialog("Cannot start daemon - no compatible fan controller in the system.") + self._toggle_manager(False) + return + source.setChecked(not source.isChecked()) + self._toggle_manager(source.isChecked()) + + def _show_warning_dialog(self, warning_message: str): + if self.isHidden(): + self.show() + QtWidgets.QMessageBox.warning(self, Environment.APP_FANCY_NAME, warning_message, QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok) def _toggle_manager(self, mode: bool): - if mode and self.ui.comboBox_profiles.currentIndex() == 0: - response = QtWidgets.QMessageBox.warning(self, Environment.APP_FANCY_NAME, - f"Please select a profile before starting the update daemon.", - QtWidgets.QMessageBox.Ok, QtWidgets.QMessageBox.Ok) - self.ui.slider_interval.setEnabled(False) - self.ui.label_Interval.setEnabled(False) - self.ui.switch_daemon.setChecked(False) - self._option_manager.setChecked(False) - else: - self.ui.slider_interval.setEnabled(mode) - self.ui.label_Interval.setEnabled(mode) - self._option_manager.setChecked(mode) - self.manager.toggle_manager(mode) + if self._active_button is not None: + self._deselect_button(self._active_button, True) + self.ui.switch_daemon.setChecked(mode) + self._option_manager.setChecked(mode) + QtWidgets.QApplication.processEvents() + self.manager.toggle_manager(mode) def manager_callback(self, aborted: bool): - LogManager.logger.debug("Fan manager thread callback") if aborted: - LogManager.logger.warning("Fan manager was aborted") + LogManager.logger.debug("Fan manager thread was aborted - resetting GUI") palette = self.ui.slider_interval.palette() Config.auto_start = False self.ui.switch_daemon.setChecked(False) @@ -302,10 +297,51 @@ def manager_callback(self, aborted: bool): self._option_manager.setChecked(False) palette.setColor(palette.Highlight, palette.dark().color()) self.ui.slider_interval.setPalette(palette) + self._show_warning_dialog("Fan manager daemon stopped unexpectedly.\nCheck log file for details.") else: - LogManager.logger.info("Fan manager thread ended normally") + LogManager.logger.debug("Fan manager thread ended normally") + + def _set_controller_combobox(self): + self.ui.comboBox_controller.clear() + available_controllers = self.manager.get_controller_names() + if available_controllers: + self.ui.comboBox_controller.addItems(self.manager.get_controller_names()) + else: + self.ui.comboBox_controller.addItem("") + + def _select_fan_controller(self, index: int): + if self._active_button: + self._deselect_button(self._active_button, True) + self.manager.set_controller(index) + for i in range(1, 7): + channel = f"fan{i}" + icon: QtWidgets.QLabel = self.channel_elements.get(channel)[0] + button: QtWidgets.QPushButton = self.channel_elements.get(channel)[1] + mode: QtWidgets.QLabel = self.channel_elements.get(channel)[2] + speed: QtWidgets.QLabel = self.channel_elements.get(channel)[3] + fan = self.manager.get_pwm_fan(channel) + if fan: + if Config.theme == 'light': + icon.setPixmap(QtGui.QPixmap(":/fans/fan_connected_dark.png")) + else: + icon.setPixmap(QtGui.QPixmap(":/fans/fan_connected_grey.png")) + button.setEnabled(True) + mode.setText("Off") + speed.setText("0 rpm") + else: + if Config.theme == 'light': + icon.setPixmap(QtGui.QPixmap(":/fans/fan_disconnected_light.png")) + else: + icon.setPixmap(QtGui.QPixmap(":/fans/fan_disconnected_grey.png")) + button.setEnabled(False) + mode.setText("-") + speed.setText("") + self._update_ui() - def _fan_config_button(self, channel: str): + def _fan_button_clicked(self, channel: str): + if channel == 'all': + self._deselect_button(self._active_button, True) + return button: QtWidgets.QPushButton = self.sender() if self.ui.comboBox_profiles.currentIndex() == 0: response = QtWidgets.QMessageBox.warning(self, Environment.APP_FANCY_NAME, @@ -475,21 +511,22 @@ def _show_fan_graph(self, draw_lines: bool): graph_data = self._active_curve.get_graph_points_from_curve() self.ui.graphicsView_fancurve.set_graph(graph_data, draw_lines, accent_color=self._accent_color, label_color=self._label_color, line_color=self._line_color) if draw_lines: - valid, _, _, fan_percent, _, fan_temperature = self.manager.get_channel_status(self._active_channel) + valid, _, _, fan_percent, fan_temperature, _ = self.manager.get_channel_status(self._active_channel) if valid: self.ui.graphicsView_fancurve.update_line('currTemp', round(fan_temperature, 1)) self.ui.graphicsView_fancurve.update_line('currFan', fan_percent) - def _set_profiles_combobox(self, select_profile: str): + def _set_profiles_combobox(self): self.ui.comboBox_profiles.clear() self.ui.comboBox_profiles.addItems(ProfileManager.profiles) self.ui.comboBox_profiles.model().sort(0) + + def _select_profile_in_combobox(self, select_profile: str): if select_profile: self.ui.comboBox_profiles.setCurrentText(select_profile) def _apply_profile(self, profile_name: str, auto_start: bool): if self._active_button is not None: - # self._active_button.click() self._deselect_button(self._active_button, True) self._toggle_manager(mode=False) window_title = Environment.APP_FANCY_NAME @@ -503,16 +540,16 @@ def _apply_profile(self, profile_name: str, auto_start: bool): self.ui.comboBox_profiles.setCurrentIndex(0) self.ui.pushButton_remove_profile.setEnabled(False) self.setWindowTitle(window_title) + self._update_ui() def _load_profile_dialog(self): - files = QtWidgets.QFileDialog.getOpenFileName(self, 'Open Profile', Environment.settings_path, "Profile files (*.cfp)") + files = QtWidgets.QFileDialog.getOpenFileName(self, 'Open Profile', Environment.settings_path, "Profile files (*.cfp)", options=QtWidgets.QFileDialog.DontUseNativeDialog) if files[0] != '': - # new_profile = ProfileManager.add_profile(files[0]) new_profile = self.manager.load_profile(files[0]) - self._set_profiles_combobox(new_profile) + self._select_profile_in_combobox(new_profile) def _save_profile_dialog(self): - files = QtWidgets.QFileDialog.getSaveFileName(self, 'Save Profile', Environment.settings_path, "Profile files (*.cfp)") + files = QtWidgets.QFileDialog.getSaveFileName(self, 'Save Profile', Environment.settings_path, "Profile files (*.cfp)", options=QtWidgets.QFileDialog.DontUseNativeDialog) if files[0] != '': profile_name = os.path.splitext(os.path.basename(files[0]))[0] self._save_profile(profile_name) @@ -525,7 +562,9 @@ def _add_profile_dialog(self): def _save_profile(self, profile_name): success, _ = self.manager.save_profile(profile_name) if success: - self._set_profiles_combobox(profile_name) + self.ui.comboBox_profiles.addItem(profile_name) + self.ui.comboBox_profiles.model().sort(0) + self._select_profile_in_combobox(profile_name) def _remove_profile(self): profile = self.ui.comboBox_profiles.currentText() @@ -537,7 +576,9 @@ def _remove_profile(self): QtWidgets.QMessageBox.Cancel) if response == QtWidgets.QMessageBox.Yes: if ProfileManager.remove_profile(profile): - self._set_profiles_combobox(profile) + self.ui.comboBox_profiles.removeItem(self.ui.comboBox_profiles.currentIndex()) + self.ui.comboBox_profiles.model().sort(0) + self._select_profile_in_combobox(profile) @staticmethod def _set_log_level(log_level: int): @@ -560,6 +601,7 @@ def _set_theme(self, theme: str): Config.theme = theme def _update_ui_loop(self): + # return self._update_timer = threading.Timer(1.9, self._update_ui_loop) self._update_timer.daemon = True self._update_timer.start() @@ -568,14 +610,14 @@ def _update_ui_loop(self): def _update_ui(self): if self.manager: - LogManager.logger.debug("Updating UI") + LogManager.logger.trace("Updating UI") for i in range(1, 7): channel = f"fan{i}" mode: QtWidgets.QLabel = self.channel_elements.get(channel)[2] speed: QtWidgets.QLabel = self.channel_elements.get(channel)[3] pwm: QtWidgets.QLabel = self.channel_elements.get(channel)[4] temp: QtWidgets.QLabel = self.channel_elements.get(channel)[5] - valid, fan_mode, fan_pwm, fan_percent, fan_rpm, fan_temperature = self.manager.get_channel_status(channel) + valid, fan_mode, fan_pwm, fan_percent, fan_temperature, fan_rpm = self.manager.get_channel_status(channel) if valid: if fan_mode == FanMode.Off: mode.setText("Off") diff --git a/cfancontrol/hwsensor.py b/cfancontrol/hwsensor.py index 6471f0d..ce28124 100644 --- a/cfancontrol/hwsensor.py +++ b/cfancontrol/hwsensor.py @@ -19,10 +19,11 @@ def __init__(self, chip_name: str, sensor_path: str, feature: str, feature_label def get_temperature(self) -> float: temp, success = self.get_sensor_data() if success: + LogManager.logger.debug(f"Getting sensor temperature {repr({'sensor': self.sensor_name, 'temperature': temp})}") if self.current_temp == 0.0 or (10.0 < temp < 99.0): self.current_temp = temp else: - LogManager.logger.warning(f"Sensor data out of range to update sensor {self.sensor_name} ('{self.sensor_file}'): last temp: {self.current_temp} / new temp: {temp}") + LogManager.logger.warning(f"Sensor temperature data out of range {repr({'sensor': self.sensor_name, 'last temp': self.current_temp, 'new temp': temp})}") return self.current_temp def get_sensor_data(self) -> (float, bool): @@ -33,19 +34,18 @@ def get_sensor_data(self) -> (float, bool): value = float(raw) / 1000 ret = True else: - LogManager.logger.warning(f"Sensor data invalid: {raw}") + LogManager.logger.warning(f"Invalid sensor data {repr({'sensor': self.sensor_name, 'sensor file': self.sensor_file, 'data': raw})}") return value, ret def get_signature(self) -> list: return [self.__class__.__name__, self.chip_name, self.sensor_folder, self.sensor_feature, self.sensor_name] - @staticmethod - def get_file_data(file_name: str) -> Optional[str]: + def get_file_data(self, file_name: str) -> Optional[str]: value: str = None try: with open(file_name, 'r') as file: value = file.read().strip() except OSError: - LogManager.logger.exception(f"Error getting sensor data from '{file_name}'") + LogManager.logger.exception(f"Error getting sensor data {repr({'sensor': self.sensor_name, 'sensor file': file_name})}") return value diff --git a/cfancontrol/log.py b/cfancontrol/log.py index ea493f7..d0b3d43 100644 --- a/cfancontrol/log.py +++ b/cfancontrol/log.py @@ -2,41 +2,58 @@ import logging.config from logging.handlers import RotatingFileHandler +TRACE_LEVEL: int = 5 + + +class ExtendedLogger(logging.Logger): + + def __init__(self, name: str): + super().__init__(name) + logging.addLevelName(TRACE_LEVEL, 'TRACE') + setattr(logging, 'TRACE', TRACE_LEVEL) + + self.addHandler(self.get_file_handler()) + self.addHandler(self.get_console_handler()) + + @staticmethod + def get_file_handler() -> logging.FileHandler: + log_file_handler = logging.handlers.RotatingFileHandler(LogManager.log_file, maxBytes=1048576, backupCount=2) + log_file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)-8s | %(module)-20s | %(funcName)-30s | %(lineno)-4d | %(message)s")) + return log_file_handler + + @staticmethod + def get_console_handler() -> logging.StreamHandler: + log_console_handler = logging.StreamHandler() + log_console_handler.setFormatter(logging.Formatter("[%(levelname)-8s] %(module)-20s | %(message)s")) + return log_console_handler + + def trace(self, message, *args, **kwargs): + if self.isEnabledFor(TRACE_LEVEL): + self._log(TRACE_LEVEL, message, args, **kwargs) + class LogManager: LOGGER_NAME: str = 'cfancontrol' + log_file: str = '' log_level: int = 0 log_file_handler: logging.handlers.RotatingFileHandler log_console_handler: logging.StreamHandler - logger: logging.Logger + logger: ExtendedLogger @classmethod def init_logging(cls, log_file: str, log_level: int): LogManager.log_level = log_level LogManager.log_file = log_file + logging.setLoggerClass(ExtendedLogger) cls.logger = logging.getLogger(LogManager.LOGGER_NAME) - cls.logger.addHandler(LogManager.get_file_handler()) - cls.logger.addHandler(LogManager.get_console_handler()) - cls.set_log_level(log_level) - LogManager.logger.debug(f"Logger initialized with log level: {log_level}") - - @staticmethod - def get_file_handler() -> logging.FileHandler: - log_file_handler = logging.handlers.RotatingFileHandler(LogManager.log_file, maxBytes=2097152, backupCount=3) - # log_file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)s:%(name)s:%(message)s")) - log_file_handler.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)-8s] %(module)-20s: %(funcName)-30s: %(lineno)-4d : %(message)s")) - return log_file_handler - @staticmethod - def get_console_handler() -> logging.StreamHandler: - log_console_handler = logging.StreamHandler() - log_console_handler.setFormatter(logging.Formatter("[%(levelname)-8s] %(module)-20s: %(message)s")) - return log_console_handler + LogManager.logger.debug(f"Logger initialized with log level: {log_level}") @classmethod def set_log_level(cls, log_level: int): cls.logger.log(cls.logger.level, f"Setting logging to level {log_level}") cls.logger.setLevel(log_level) + diff --git a/cfancontrol/nvidiasensor.py b/cfancontrol/nvidiasensor.py index d9dce4a..11573f5 100644 --- a/cfancontrol/nvidiasensor.py +++ b/cfancontrol/nvidiasensor.py @@ -20,8 +20,6 @@ def __init__(self, index: int, device_name: str): self.current_temp = 0.0 def get_temperature(self) -> float: - LogManager.logger.debug(f"Reading temperature from {self.sensor_name}") - self.current_temp = 0.0 try: command_result: CompletedProcess = subprocess.run(SMI_SATUS_COMMAND, capture_output=True, check=True, text=True) result_lines = str(command_result.stdout).splitlines() @@ -30,14 +28,16 @@ def get_temperature(self) -> float: continue values = line.split(', ') if int(values[0]) == self.index: - LogManager.logger.debug(f"{self.sensor_name} read-out: {line}") temp = int(values[2]) - if 0 <= temp <= 100: + if self.current_temp == 0.0 or (10.0 <= temp <= 100.0): self.current_temp = float(temp) + LogManager.logger.trace(f"Getting sensor temperature {repr({'sensor': self.sensor_name, 'temperature': self.current_temp})}") else: - LogManager.logger.warning(f"Invalid sensor data from {self.sensor_name}: {temp}") - except CalledProcessError: - LogManager.logger.warning(f"Problem getting status from nVidia GPU '{self.device_name}'") + LogManager.logger.warning(f"Sensor temperature data out of range {repr({'sensor': self.sensor_name, 'last temp': self.current_temp, 'new temp': temp})}") + except CalledProcessError as cpe: + LogManager.logger.warning(f"Problem getting sensor data {repr({'sensor': self.sensor_name, 'error': cpe.output})}") + except BaseException: + LogManager.logger.exception(f"Error getting sensor data {repr({'sensor': self.sensor_name})}") return self.current_temp def get_signature(self) -> list: @@ -49,12 +49,12 @@ def detect_gpus() -> List['NvidiaSensor']: try: command_result: CompletedProcess = subprocess.run(SMI_DETECT_COMMAND, capture_output=True, check=True, text=True) result_lines = str(command_result.stdout).splitlines() - LogManager.logger.debug(f"Result of nVidia GPU detection: {result_lines}") + LogManager.logger.trace(f"Result of nVidia GPU detection: {result_lines}") for line in result_lines: if not line.strip(): continue values = line.split(', ') detected_gpus.append(NvidiaSensor(int(values[0]), values[1])) except CalledProcessError: - LogManager.logger.debug(f"No nVidia GPU found") + LogManager.logger.trace(f"No nVidia GPU found") return detected_gpus diff --git a/cfancontrol/profilemanager.py b/cfancontrol/profilemanager.py index 2187a57..dfc809f 100644 --- a/cfancontrol/profilemanager.py +++ b/cfancontrol/profilemanager.py @@ -1,19 +1,24 @@ import os import json import pprint -from typing import Optional, Dict, List +from typing import Optional, Dict +from .fancontroller import FanController from .log import LogManager class Profile(object): + version: int label: str - file_name: str + controller: FanController + channel_data: Optional[dict] - def __init__(self, label, file_name): + def __init__(self, version, label, controller, channels): + self.version = version self.label = label - self.file_name = file_name + self.controller = controller + self.channel_data = channels class ProfileManager(object): @@ -26,7 +31,8 @@ def enum_profiles(cls, profile_path): cls.profiles[""] = "" cls.profiles_path = profile_path for profile in [f for f in os.listdir(profile_path) if f.endswith(".cfp")]: - cls.profiles[os.path.splitext(os.path.basename(profile))[0]] = os.path.join(profile_path, profile) + # cls.profiles[os.path.splitext(os.path.basename(profile))[0]] = os.path.join(profile_path, profile) + cls.add_profile(os.path.join(profile_path, profile)) @classmethod def add_profile(cls, file_name) -> str: @@ -91,7 +97,7 @@ def _write_profile(file_name: str, profile_data: dict) -> [bool, str]: file_name = file_name + '.cfp' try: LogManager.logger.info(f"Saving profile '{file_name}'") - json_data = pprint.pformat(profile_data).replace("'", '"') + json_data = pprint.pformat(profile_data, indent=1, width=120, compact=False, sort_dicts=False).replace("'", '"') with open(file_name, 'w') as json_file: json_file.write(json_data) success = True diff --git a/cfancontrol/pwmfan.py b/cfancontrol/pwmfan.py index 10ef061..93164ee 100644 --- a/cfancontrol/pwmfan.py +++ b/cfancontrol/pwmfan.py @@ -1,6 +1,6 @@ from .sensor import Sensor -from .fancurve import FanCurve, FanMode, TempRange +from .fancurve import FanCurve, FanMode, TempRange, MAXPWM from .log import LogManager @@ -27,15 +27,23 @@ def get_current_temp(self) -> float: self.temperature = self.temp_sensor.get_temperature() return self.temperature - def update_pwm(self) -> (bool, int, int, float): + def get_fan_status(self) -> (FanMode, int, int, float): + return self.fan_curve.get_fan_mode(), self.get_current_pwm(), self.get_current_pwm_as_percentage(), self.get_current_temp() + + def update_pwm(self, current_pwm: int) -> (bool, int, int, float): new_pwm: int - pwm_percent: float + pwm_percent: int temp: float temp_range: TempRange + if self.pwm != current_pwm: + if 0 < current_pwm <= MAXPWM: + LogManager.logger.warning(f"Fan speed changed unexpectedly {repr({'fan': self.fan_name, 'expected pwm': self.pwm, 'reported pwm': current_pwm})}") + self.pwm = current_pwm + if self.fan_curve.get_fan_mode() == FanMode.Off: new_pwm = 0 - pwm_percent = 0.0 + pwm_percent = 0 temp = 0.0 return (new_pwm != self.pwm), new_pwm, pwm_percent, temp @@ -51,10 +59,9 @@ def update_pwm(self) -> (bool, int, int, float): temp_range = self.fan_curve.get_range_from_temp(temp) if temp_range is None: - LogManager.logger.warning(f"'{self.fan_name}': no suitable range for temp [" + str(temp) + "] found") + LogManager.logger.warning(f"No suitable temperature range found {repr({'fan': self.fan_name, 'temperature': str(temp)})}") return False, 0, 0 - LogManager.logger.debug(f"{self.fan_name}': current temp " + str(temp) + "°C (+" + str(temp_range.hysteresis) + "°C hysteresis) in range [" + str(temp_range.low_temp) + "°C] to [" + str(temp_range.high_temp) + "°C]") if temp < temp_range.low_temp: temp = temp_range.low_temp if temp_range.hysteresis > 0.0: @@ -66,9 +73,7 @@ def update_pwm(self) -> (bool, int, int, float): pwm_percent = self.fan_curve.pwm_to_percentage(new_pwm) if new_pwm != self.pwm: - LogManager.logger.debug(f"'{self.fan_name}': new target PWM {new_pwm} in range [{str(temp_range.pwm_start)}] to [{str(temp_range.pwm_end)}]") - else: - LogManager.logger.debug(f"'{self.fan_name}': new target PWM same as current PWM") + LogManager.logger.debug(f"Changing PWM {repr({'fan': self.fan_name, 'current pwm': self.pwm, 'target pwm': new_pwm, 'target range': f'[{temp_range.pwm_start}-{temp_range.pwm_end}]', 'temperature range': f'[{temp_range.low_temp}-{temp_range.high_temp}]°C'})}") return (new_pwm != self.pwm), new_pwm, pwm_percent, temp diff --git a/cfancontrol/sensor.py b/cfancontrol/sensor.py index bd3b883..e59bebe 100644 --- a/cfancontrol/sensor.py +++ b/cfancontrol/sensor.py @@ -1,8 +1,10 @@ class Sensor(object): + sensor_name: str + def __init__(self): - self.sensor_name = None + pass def get_name(self) -> str: return self.sensor_name diff --git a/cfancontrol/sensormanager.py b/cfancontrol/sensormanager.py index 6ba14ca..893030e 100644 --- a/cfancontrol/sensormanager.py +++ b/cfancontrol/sensormanager.py @@ -17,11 +17,11 @@ class SensorManager(object): @staticmethod def identify_system_sensors(): - # get sensors via PySensors and libsensors.so (part of lm_sensors) -> config in /etc/sensors3.conf + # get sensors via PySensors and libsensors.so (part of lm_sensors) -> config in /.config/cfancontrol/sensors3.conf or /etc/sensors3.conf sensors.init(bytes(Environment.sensors_config_file, "utf-8")) try: for chip in sensors.iter_detected_chips(): - LogManager.logger.info(f"System sensor {repr(chip)} found") + LogManager.logger.info(f"System sensor found {repr({'name': chip.prefix.decode('utf-8'), 'chip': repr(chip)})}") for feature in chip: # is it a temp sensor on the chip if feature.type == 2: @@ -30,7 +30,7 @@ def identify_system_sensors(): if name == label: # no label set for feature, so add prefix label = chip.prefix.decode('utf-8') + "_" + feature.label - LogManager.logger.info(f"Adding feature '{name}' as sensor '{label}'") + LogManager.logger.debug(f"Adding feature {repr({'chip': chip.prefix.decode('utf-8'), 'feature name': name, 'label': label})}") SensorManager.system_sensors.append(HwSensor(str(chip), chip.path.decode('utf-8'), name, label)) finally: sensors.cleanup() @@ -39,16 +39,16 @@ def identify_system_sensors(): devices = liquidctl.find_liquidctl_devices() for dev in devices: if type(dev) == liquidctl.driver.kraken3.KrakenX3: - LogManager.logger.info(f"'{dev.description}' found") + LogManager.logger.info(f"AIO device found {repr({'device': dev.description})}") SensorManager.system_sensors.append(KrakenX3Sensor(dev)) elif type(dev) == liquidctl.driver.hydro_platinum.HydroPlatinum: - LogManager.logger.info(f"'{dev.description}' found") + LogManager.logger.info(f"AIO device found {repr({'device': dev.description})}") SensorManager.system_sensors.append(HydroPlatinumSensor(dev)) # append sensors of GPUs (if found) nvidia_gpus: List[NvidiaSensor] = NvidiaSensor.detect_gpus() for gpu in nvidia_gpus: - LogManager.logger.info(f"nVidia GPU #{gpu.index} with name '{gpu.device_name}' found") + LogManager.logger.info(f"nVidia GPU found {repr({'id': gpu.index, 'device': gpu.device_name})}") SensorManager.system_sensors.append(gpu) @staticmethod diff --git a/cfancontrol/ui/cfanmain.py b/cfancontrol/ui/cfanmain.py index cc05be5..77780bb 100644 --- a/cfancontrol/ui/cfanmain.py +++ b/cfancontrol/ui/cfanmain.py @@ -13,7 +13,7 @@ class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") - MainWindow.resize(897, 583) + MainWindow.resize(897, 618) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(24) sizePolicy.setVerticalStretch(24) @@ -24,16 +24,15 @@ def setupUi(self, MainWindow): font.setFamily("Ubuntu") font.setPointSize(10) MainWindow.setFont(font) - MainWindow.setStyleSheet("") self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") self.groupBox_control = QtWidgets.QGroupBox(self.centralwidget) - self.groupBox_control.setGeometry(QtCore.QRect(20, 210, 421, 321)) + self.groupBox_control.setGeometry(QtCore.QRect(20, 240, 421, 331)) self.groupBox_control.setFlat(False) self.groupBox_control.setCheckable(False) self.groupBox_control.setObjectName("groupBox_control") self.gridLayoutWidget = QtWidgets.QWidget(self.groupBox_control) - self.gridLayoutWidget.setGeometry(QtCore.QRect(20, 37, 381, 271)) + self.gridLayoutWidget.setGeometry(QtCore.QRect(20, 40, 381, 271)) self.gridLayoutWidget.setObjectName("gridLayoutWidget") self.gridLayout_control = QtWidgets.QGridLayout(self.gridLayoutWidget) self.gridLayout_control.setContentsMargins(0, 0, 0, 0) @@ -305,10 +304,10 @@ def setupUi(self, MainWindow): self.gridLayout_control.addWidget(self.temp_fan6, 6, 5, 1, 1) self.groupBox_mode = QtWidgets.QGroupBox(self.centralwidget) self.groupBox_mode.setEnabled(False) - self.groupBox_mode.setGeometry(QtCore.QRect(460, 20, 421, 511)) + self.groupBox_mode.setGeometry(QtCore.QRect(460, 20, 421, 551)) self.groupBox_mode.setObjectName("groupBox_mode") self.gridLayoutWidget_2 = QtWidgets.QWidget(self.groupBox_mode) - self.gridLayoutWidget_2.setGeometry(QtCore.QRect(20, 30, 301, 112)) + self.gridLayoutWidget_2.setGeometry(QtCore.QRect(20, 30, 381, 112)) self.gridLayoutWidget_2.setObjectName("gridLayoutWidget_2") self.gridLayout_mode = QtWidgets.QGridLayout(self.gridLayoutWidget_2) self.gridLayout_mode.setContentsMargins(0, 0, 0, 0) @@ -348,14 +347,14 @@ def setupUi(self, MainWindow): sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.comboBox_sensors.sizePolicy().hasHeightForWidth()) self.comboBox_sensors.setSizePolicy(sizePolicy) - self.comboBox_sensors.setMinimumSize(QtCore.QSize(140, 0)) + self.comboBox_sensors.setMinimumSize(QtCore.QSize(200, 0)) self.comboBox_sensors.setFrame(False) self.comboBox_sensors.setObjectName("comboBox_sensors") self.comboBox_sensors.addItem("") self.comboBox_sensors.addItem("") self.gridLayout_mode.addWidget(self.comboBox_sensors, 2, 1, 1, 1) self.pushButton_apply = QtWidgets.QPushButton(self.groupBox_mode) - self.pushButton_apply.setGeometry(QtCore.QRect(340, 107, 61, 31)) + self.pushButton_apply.setGeometry(QtCore.QRect(330, 510, 71, 31)) self.pushButton_apply.setFlat(False) self.pushButton_apply.setObjectName("pushButton_apply") self.frame_fancurve = QtWidgets.QFrame(self.groupBox_mode) @@ -463,8 +462,12 @@ def setupUi(self, MainWindow): self.label_segments.setFont(font) self.label_segments.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.label_segments.setObjectName("label_segments") + self.pushButton_cancel = QtWidgets.QPushButton(self.groupBox_mode) + self.pushButton_cancel.setGeometry(QtCore.QRect(250, 510, 71, 31)) + self.pushButton_cancel.setFlat(False) + self.pushButton_cancel.setObjectName("pushButton_cancel") self.groupBox_daemon = QtWidgets.QGroupBox(self.centralwidget) - self.groupBox_daemon.setGeometry(QtCore.QRect(20, 20, 421, 151)) + self.groupBox_daemon.setGeometry(QtCore.QRect(20, 20, 421, 191)) self.groupBox_daemon.setObjectName("groupBox_daemon") self.label_daemon = QtWidgets.QLabel(self.groupBox_daemon) self.label_daemon.setGeometry(QtCore.QRect(10, 35, 111, 16)) @@ -508,7 +511,7 @@ def setupUi(self, MainWindow): self.switch_daemon.setGeometry(QtCore.QRect(140, 33, 40, 20)) self.switch_daemon.setToolTip("") self.switch_daemon.setWhatsThis("") - self.switch_daemon.setProperty("checked", False) + self.switch_daemon.setChecked(False) self.switch_daemon.setProperty("track_radius", 10) self.switch_daemon.setProperty("thumb_radius", 8) self.switch_daemon.setObjectName("switch_daemon") @@ -521,12 +524,20 @@ def setupUi(self, MainWindow): self.slider_interval.setSliderPosition(1) self.slider_interval.setOrientation(QtCore.Qt.Horizontal) self.slider_interval.setObjectName("slider_interval") + self.comboBox_controller = QtWidgets.QComboBox(self.groupBox_daemon) + self.comboBox_controller.setGeometry(QtCore.QRect(140, 150, 224, 24)) + self.comboBox_controller.setFrame(True) + self.comboBox_controller.setObjectName("comboBox_controller") + self.label_controller = QtWidgets.QLabel(self.groupBox_daemon) + self.label_controller.setGeometry(QtCore.QRect(10, 155, 111, 16)) + self.label_controller.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.label_controller.setObjectName("label_controller") MainWindow.setCentralWidget(self.centralwidget) self.statusbar = QtWidgets.QStatusBar(MainWindow) self.statusbar.setObjectName("statusbar") MainWindow.setStatusBar(self.statusbar) self.menuBar = QtWidgets.QMenuBar(MainWindow) - self.menuBar.setGeometry(QtCore.QRect(0, 0, 897, 28)) + self.menuBar.setGeometry(QtCore.QRect(0, 0, 897, 21)) self.menuBar.setObjectName("menuBar") self.menuFile = QtWidgets.QMenu(self.menuBar) self.menuFile.setObjectName("menuFile") @@ -665,12 +676,14 @@ def retranslateUi(self, MainWindow): self.pushButton_add.setText(_translate("MainWindow", "+")) self.label_presets.setText(_translate("MainWindow", "Presets")) self.label_segments.setText(_translate("MainWindow", "Segments")) + self.pushButton_cancel.setText(_translate("MainWindow", "Cancel")) self.groupBox_daemon.setTitle(_translate("MainWindow", "Fan Manager")) self.label_daemon.setText(_translate("MainWindow", "Update Daemon")) self.label_Interval.setText(_translate("MainWindow", "Update Interval")) self.label_profiles.setText(_translate("MainWindow", "Active Profile")) self.pushButton_add_profile.setText(_translate("MainWindow", "+")) self.pushButton_remove_profile.setText(_translate("MainWindow", "-")) + self.label_controller.setText(_translate("MainWindow", "Fan Controller")) self.menuFile.setTitle(_translate("MainWindow", "&File")) self.menuOptions.setTitle(_translate("MainWindow", "&Options")) self.menuTheme.setTitle(_translate("MainWindow", "&Theme")) diff --git a/cfancontrol/ui/cfanmain.ui b/cfancontrol/ui/cfanmain.ui index a2f80a6..6f2c0f7 100644 --- a/cfancontrol/ui/cfanmain.ui +++ b/cfancontrol/ui/cfanmain.ui @@ -7,7 +7,7 @@ 0 0 897 - 583 + 618 @@ -31,17 +31,14 @@ Commander Pro Commander - - - 20 - 210 + 240 421 - 321 + 331 @@ -57,7 +54,7 @@ 20 - 37 + 40 381 271 @@ -675,7 +672,7 @@ 460 20 421 - 511 + 551 @@ -686,7 +683,7 @@ 20 30 - 301 + 381 112 @@ -768,7 +765,7 @@ - 140 + 200 0 @@ -798,9 +795,9 @@ - 340 - 107 - 61 + 330 + 510 + 71 31 @@ -1095,6 +1092,22 @@ + + + + 250 + 510 + 71 + 31 + + + + Cancel + + + false + + @@ -1102,7 +1115,7 @@ 20 20 421 - 151 + 191 @@ -1231,7 +1244,7 @@ false - + 140 @@ -1246,7 +1259,7 @@ - + false @@ -1284,6 +1297,35 @@ Qt::Horizontal + + + + 140 + 150 + 224 + 24 + + + + true + + + + + + 10 + 155 + 111 + 16 + + + + Fan Controller + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + @@ -1293,7 +1335,7 @@ 0 0 897 - 28 + 21