392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""liquidctl – monitor and control liquid coolers and other devices.
|
||
|
||
Usage:
|
||
liquidctl [options] list
|
||
liquidctl [options] initialize [all]
|
||
liquidctl [options] status
|
||
liquidctl [options] set <channel> speed (<temperature> <percentage>) ...
|
||
liquidctl [options] set <channel> speed <percentage>
|
||
liquidctl [options] set <channel> color <mode> [<color>] ...
|
||
liquidctl --help
|
||
liquidctl --version
|
||
liquidctl [--config <file>] --validate
|
||
|
||
Device selection options (see: list -v):
|
||
-m, --match <substring> Filter devices by description substring
|
||
-n, --pick <number> Pick among many results for a given filter
|
||
--vendor <id> Filter devices by hexadecimal vendor ID
|
||
--product <id> Filter devices by hexadecimal product ID
|
||
--release <number> Filter devices by hexadecimal release number
|
||
--serial <number> Filter devices by serial number
|
||
--bus <bus> Filter devices by bus
|
||
--address <address> Filter devices by address in bus
|
||
--usb-port <port> Filter devices by USB port in bus
|
||
|
||
Animation options (devices/modes can support zero or more):
|
||
--speed <value> Abstract animation speed (device/mode specific)
|
||
--time-per-color <value> Time to wait on each color (seconds)
|
||
--time-off <value> Time to wait with the LED turned off (seconds)
|
||
--alert-threshold <number> Threshold temperature for a visual alert (°C)
|
||
--alert-color <color> Color used by the visual high temperature alert
|
||
--direction <string> If the pattern should move forward or backward.
|
||
--start-led <number> The first led to start the effect at
|
||
--maximum-leds <number> The number of LED's the effect should apply to
|
||
|
||
Other device options:
|
||
--single-12v-ocp Enable single rail +12V OCP
|
||
--pump-mode <mode> Set the pump mode (certain Corsair coolers)
|
||
--temperature-sensor <number> The temperature sensor number for the Commander Pro
|
||
--legacy-690lc Use Asetek 690LC in legacy mode (old Krakens)
|
||
--non-volatile Store on non-volatile controller memory
|
||
--unsafe <features> Comma-separated bleeding-edge features to enable
|
||
|
||
Other interface options:
|
||
-v, --verbose Output additional information
|
||
-g, --debug Show debug information on stderr
|
||
--version Display the version number
|
||
--help Show this message
|
||
--validate Validate that the config file format is correct
|
||
|
||
Config file options:
|
||
--config <file> Select a specific config file to use
|
||
|
||
Deprecated:
|
||
-d, --device <index> Select device by listing index
|
||
--hid <ignored> Ignored
|
||
|
||
Copyright (C) 2018–2021 Jonas Malaco, Marshall Asch, CaseySJ, Tom Frey, Andrew
|
||
Robertson and contributors
|
||
|
||
liquidctl incorporates work by leaty, Ksenija Stanojevic, Alexander Tong, Jens
|
||
Neumaier, Kristóf Jakab, Sean Nelson, Chris Griffith, notaz, realies and Thomas
|
||
Pircher.
|
||
|
||
SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||
PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||
"""
|
||
|
||
import datetime
|
||
import errno
|
||
import inspect
|
||
import logging
|
||
import os
|
||
import sys
|
||
from traceback import format_exception
|
||
|
||
import colorlog
|
||
from docopt import docopt
|
||
|
||
from liquidctl.driver import *
|
||
from liquidctl.error import NotSupportedByDevice, NotSupportedByDriver, UnsafeFeaturesNotEnabled
|
||
from liquidctl.util import color_from_str
|
||
from liquidctl.version import __version__
|
||
|
||
# conversion from CLI arg to internal option; as options as forwarded to bused
|
||
# and drivers, they must:
|
||
# - have no default value in the CLI level (not forwarded unless explicitly set);
|
||
# - and avoid unintentional conflicts with target function arguments
|
||
_PARSE_ARG = {
|
||
'--vendor': lambda x: int(x, 16),
|
||
'--product': lambda x: int(x, 16),
|
||
'--release': lambda x: int(x, 16),
|
||
'--serial': str,
|
||
'--bus': str,
|
||
'--address': str,
|
||
'--usb-port': lambda x: tuple(map(int, x.split('.'))),
|
||
'--match': str,
|
||
'--pick': int,
|
||
|
||
'--speed': str.lower,
|
||
'--time-per-color': int,
|
||
'--time-off': int,
|
||
'--alert-threshold': int,
|
||
'--alert-color': color_from_str,
|
||
'--temperature-sensor': int,
|
||
'--direction': str.lower,
|
||
'--start-led': int,
|
||
'--maximum-leds': int,
|
||
|
||
'--single-12v-ocp': bool,
|
||
'--pump-mode': str.lower,
|
||
'--legacy-690lc': bool,
|
||
'--non-volatile': bool,
|
||
'--unsafe': lambda x: x.lower().split(','),
|
||
'--verbose': bool,
|
||
'--debug': bool,
|
||
'--ignore-config': bool,
|
||
'--file': str,
|
||
}
|
||
|
||
# options that cause liquidctl.driver.find_liquidctl_devices to ommit devices
|
||
_FILTER_OPTIONS = [
|
||
'vendor',
|
||
'product',
|
||
'release',
|
||
'serial',
|
||
'bus',
|
||
'address',
|
||
'usb-port',
|
||
'match',
|
||
'pick',
|
||
# --device generates no option
|
||
]
|
||
|
||
# custom number formats for values of select units
|
||
_VALUE_FORMATS = {
|
||
'%': '.0f',
|
||
'A': '.2f',
|
||
'V': '.2f',
|
||
'W': '.2f',
|
||
'rpm': '.0f',
|
||
'°C': '.1f',
|
||
}
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
def _list_devices(devices, using_filters=False, device_id=None, verbose=False, debug=False, **opts):
|
||
for i, dev in enumerate(devices):
|
||
warnings = []
|
||
|
||
if not using_filters:
|
||
print(f'Device #{i}: {dev.description}')
|
||
elif device_id is not None:
|
||
print(f'Device #{device_id}: {dev.description}')
|
||
else:
|
||
print(f'Result #{i}: {dev.description}')
|
||
if not verbose:
|
||
continue
|
||
|
||
if dev.vendor_id:
|
||
print(f'├── Vendor ID: {dev.vendor_id:#06x}')
|
||
if dev.product_id:
|
||
print(f'├── Product ID: {dev.product_id:#06x}')
|
||
if dev.release_number:
|
||
print(f'├── Release number: {dev.release_number:#06x}')
|
||
try:
|
||
if dev.serial_number:
|
||
print(f'├── Serial number: {dev.serial_number}')
|
||
except:
|
||
msg = 'could not read the serial number'
|
||
if sys.platform.startswith('linux') and os.geteuid:
|
||
msg += ' (requires root privileges)'
|
||
elif sys.platform in ['win32', 'cygwin'] and 'Hid' not in type(dev.device).__name__:
|
||
msg += ' (device possibly requires a kernel driver)'
|
||
if debug:
|
||
_LOGGER.exception(msg.capitalize())
|
||
else:
|
||
warnings.append(msg)
|
||
|
||
print(f'├── Bus: {dev.bus}')
|
||
print(f'├── Address: {dev.address}')
|
||
if dev.port:
|
||
port = '.'.join(map(str, dev.port))
|
||
print(f'├── Port: {port}')
|
||
|
||
print(f'└── Driver: {type(dev).__name__}')
|
||
if debug:
|
||
driver_hier = [i.__name__ for i in inspect.getmro(type(dev)) if i != object]
|
||
_LOGGER.debug('hierarchy: %s', ', '.join(driver_hier[1:]))
|
||
|
||
for msg in warnings:
|
||
_LOGGER.warning(msg)
|
||
print('')
|
||
|
||
assert 'device' not in opts or len(devices) <= 1, 'too many results listed with --device'
|
||
|
||
|
||
def _print_dev_status(dev, status):
|
||
if not status:
|
||
return
|
||
print(dev.description)
|
||
tmp = []
|
||
kcols, vcols = 0, 0
|
||
for k, v, u in status:
|
||
if isinstance(v, datetime.timedelta):
|
||
v = str(v)
|
||
elif isinstance(v, bool):
|
||
v = 'Yes' if v else 'No'
|
||
elif v is None:
|
||
v = 'N/A'
|
||
else:
|
||
valfmt = _VALUE_FORMATS.get(u, '')
|
||
v = f'{v:{valfmt}}'
|
||
kcols = max(kcols, len(k))
|
||
vcols = max(vcols, len(v))
|
||
tmp.append((k, v, u))
|
||
for k, v, u in tmp[:-1]:
|
||
print(f'├── {k:<{kcols}} {v:>{vcols}} {u}')
|
||
k, v, u = tmp[-1]
|
||
print(f'└── {k:<{kcols}} {v:>{vcols}} {u}')
|
||
print('')
|
||
|
||
|
||
def _device_set_color(dev, args, **opts):
|
||
color = map(color_from_str, args['<color>'])
|
||
dev.set_color(args['<channel>'].lower(), args['<mode>'].lower(), color, **opts)
|
||
|
||
|
||
def _device_set_speed(dev, args, **opts):
|
||
if len(args['<temperature>']) > 0:
|
||
profile = zip(map(int, args['<temperature>']), map(int, args['<percentage>']))
|
||
dev.set_speed_profile(args['<channel>'].lower(), profile, **opts)
|
||
else:
|
||
dev.set_fixed_speed(args['<channel>'].lower(), int(args['<percentage>'][0]), **opts)
|
||
|
||
|
||
def _make_opts(args):
|
||
if args['--hid']:
|
||
_LOGGER.warning('ignoring --hid %s: deprecated option, API will be selected automatically',
|
||
args['--hid'])
|
||
opts = {}
|
||
for arg, val in args.items():
|
||
if val is not None and arg in _PARSE_ARG:
|
||
opt = arg.replace('--', '').replace('-', '_')
|
||
opts[opt] = _PARSE_ARG[arg](val)
|
||
return opts
|
||
|
||
|
||
def _gen_version():
|
||
extra = None
|
||
try:
|
||
from liquidctl.extraversion import __extraversion__
|
||
if __extraversion__['editable']:
|
||
extra = ['editable']
|
||
elif __extraversion__['dist_name'] and __extraversion__['dist_package']:
|
||
extra = [__extraversion__['dist_name'], __extraversion__['dist_package']]
|
||
else:
|
||
extra = [__extraversion__['commit'][:12]]
|
||
if __extraversion__['dirty']:
|
||
extra[0] += '-dirty'
|
||
except:
|
||
return f'liquidctl v{__version__}'
|
||
return f'liquidctl v{__version__} ({"; ".join(extra)})'
|
||
|
||
|
||
def main():
|
||
args = docopt(__doc__)
|
||
|
||
if args['--version']:
|
||
print(_gen_version())
|
||
sys.exit(0)
|
||
|
||
if args['--debug']:
|
||
args['--verbose'] = True
|
||
log_fmt = '%(log_color)s[%(levelname)s] %(module)s (%(funcName)s): %(message)s'
|
||
log_level = logging.DEBUG
|
||
elif args['--verbose']:
|
||
log_fmt = '%(log_color)s%(levelname)s: %(message)s'
|
||
log_level = logging.INFO
|
||
else:
|
||
log_fmt = '%(log_color)s%(levelname)s: %(message)s'
|
||
log_level = logging.WARNING
|
||
sys.tracebacklimit = 0
|
||
|
||
log_colors = {
|
||
'DEBUG': 'purple',
|
||
'INFO': 'blue',
|
||
'WARNING': 'yellow,bold',
|
||
'ERROR': 'red,bold',
|
||
'CRITICAL': 'red,bold,bg_white',
|
||
}
|
||
|
||
log_fmtter = colorlog.TTYColoredFormatter(fmt=log_fmt, stream=sys.stderr, log_colors=log_colors)
|
||
log_handler = logging.StreamHandler()
|
||
log_handler.setFormatter(log_fmtter)
|
||
logging.basicConfig(level=log_level, handlers=[log_handler])
|
||
|
||
_LOGGER.debug('running %s', _gen_version())
|
||
|
||
opts = _make_opts(args)
|
||
filter_count = sum(1 for opt in opts if opt in _FILTER_OPTIONS)
|
||
device_id = None
|
||
|
||
if not args['--device']:
|
||
selected = list(find_liquidctl_devices(**opts))
|
||
else:
|
||
_LOGGER.warning('-d/--device is deprecated, prefer --match or other selection options')
|
||
device_id = int(args['--device'])
|
||
no_filters = {opt: val for opt, val in opts.items() if opt not in _FILTER_OPTIONS}
|
||
compat = list(find_liquidctl_devices(**no_filters))
|
||
if device_id < 0 or device_id >= len(compat):
|
||
raise SystemExit('Error: device index out of bounds')
|
||
if filter_count:
|
||
# check that --device matches other filter criteria
|
||
matched_devs = [dev.device for dev in find_liquidctl_devices(**opts)]
|
||
if compat[device_id].device not in matched_devs:
|
||
raise SystemExit('Error: device index does not match remaining selection criteria')
|
||
_LOGGER.warning('mixing --device <id> with other filters is not recommended; '
|
||
'to disambiguate between results prefer --pick <result>')
|
||
selected = [compat[device_id]]
|
||
|
||
if args['list']:
|
||
_list_devices(selected, using_filters=bool(filter_count), device_id=device_id, **opts)
|
||
return
|
||
|
||
if len(selected) > 1 and not (args['status'] or args['all']):
|
||
raise SystemExit('Error: too many devices, filter or select one (see: liquidctl --help)')
|
||
elif len(selected) == 0:
|
||
raise SystemExit('Error: no devices matches available drivers and selection criteria')
|
||
|
||
errors = 0
|
||
|
||
def log_error(err, msg, append_err=False, *args):
|
||
nonlocal errors
|
||
errors += 1
|
||
_LOGGER.info('%s', err, exc_info=True)
|
||
if append_err:
|
||
exception = list(format_exception(Exception, err, None))[-1].rstrip()
|
||
_LOGGER.error(f'{msg}: {exception}', *args)
|
||
else:
|
||
_LOGGER.error(msg, *args)
|
||
|
||
for dev in selected:
|
||
_LOGGER.debug('device: %s', dev.description)
|
||
try:
|
||
with dev.connect(**opts):
|
||
if args['initialize']:
|
||
_print_dev_status(dev, dev.initialize(**opts))
|
||
elif args['status']:
|
||
_print_dev_status(dev, dev.get_status(**opts))
|
||
elif args['set'] and args['speed']:
|
||
_device_set_speed(dev, args, **opts)
|
||
elif args['set'] and args['color']:
|
||
_device_set_color(dev, args, **opts)
|
||
else:
|
||
assert False, 'unreachable'
|
||
except OSError as err:
|
||
# each backend API returns a different subtype of OSError (OSError,
|
||
# usb.core.USBError or PermissionError) for permission issues
|
||
if err.errno in [errno.EACCES, errno.EPERM]:
|
||
log_error(err, f'Error: insufficient permissions to access {dev.description}')
|
||
elif err.args == ('open failed', ):
|
||
log_error(err, f'Error: could not open {dev.description}, possibly due to insufficient permissions')
|
||
else:
|
||
log_error(err, f'Unexpected OS error with {dev.description}', append_err=True)
|
||
except NotSupportedByDevice as err:
|
||
log_error(err, f'Error: operation not supported by {dev.description}')
|
||
except NotSupportedByDriver as err:
|
||
log_error(err, f'Error: operation not supported by driver for {dev.description}')
|
||
except UnsafeFeaturesNotEnabled as err:
|
||
features = ','.join(err.args)
|
||
log_error(err, f'Error: missing --unsafe features for {dev.description}: {features!r}')
|
||
_LOGGER.error('More information is provided in the corresponding device guide')
|
||
except Exception as err:
|
||
log_error(err, f'Unexpected error with {dev.description}', append_err=True)
|
||
|
||
if errors:
|
||
sys.exit(errors)
|
||
|
||
|
||
def find_all_supported_devices(**opts):
|
||
"""Deprecated."""
|
||
_LOGGER.warning('deprecated: use liquidctl.driver.find_liquidctl_devices instead')
|
||
return find_liquidctl_devices(**opts)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|