Skip to content

Commit

Permalink
Experimental support for controlling fans of Hydro Platinum AIOs
Browse files Browse the repository at this point in the history
  • Loading branch information
maclarsson committed Jul 28, 2022
1 parent c2bd9cc commit 5ceec2a
Show file tree
Hide file tree
Showing 19 changed files with 693 additions and 401 deletions.
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/).
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

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

Expand Down
2 changes: 1 addition & 1 deletion cfancontrol/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.1.8"
__version__ = "1.2.0"
26 changes: 15 additions & 11 deletions cfancontrol/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import argparse
import logging
import sys

from pid import PidFile, PidFileAlreadyLockedError

Expand Down Expand Up @@ -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:
Expand All @@ -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__":
Expand Down
3 changes: 1 addition & 2 deletions cfancontrol/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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')
116 changes: 68 additions & 48 deletions cfancontrol/devicesensor.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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]
return [__class__.__name__, self.device_name, self.device.product_id, self.sensor_name]
Loading

0 comments on commit 5ceec2a

Please sign in to comment.