"""Assorted utilities used by drivers and the CLI. Copyright Jonas Malaco and contributors SPDX-License-Identifier: GPL-3.0-or-later """ import colorsys import logging from ast import literal_eval from enum import Enum, EnumMeta, unique from typing import Optional from functools import lru_cache import crcmod.predefined from liquidctl.error import UnsafeFeaturesNotEnabled _LOGGER = logging.getLogger(__name__) HUE2_MAX_ACCESSORIES_IN_CHANNEL = 6 @unique class Hue2Accessory(Enum): """Mapping of HUE 2 accessory IDs and names. >>> Hue2Accessory(4) >>> str(Hue2Accessory(4)) 'HUE 2 LED Strip 300 mm' Unknown IDs are automatically translated to equivalent pseudo-names. >>> Hue2Accessory(59) >>> Hue2Accessory(59).value == Hue2Accessory(59).value True >>> Hue2Accessory(59) != Hue2Accessory(58) True """ HUE_PLUS_LED_STRIP = (0x01, 'HUE+ LED Strip') AER_RGB1_FAN = (0x02, 'AER RGB 1') HUE2_LED_STRIP_300 = (0x04, 'HUE 2 LED Strip 300 mm') HUE2_LED_STRIP_250 = (0x05, 'HUE 2 LED Strip 250 mm') HUE2_LED_STRIP_200 = (0x06, 'HUE 2 LED Strip 200 mm') HUE2_CABLE_COMB = (0x07, 'HUE 2 Cable Comb') HUE2_UNDERGLOW_300 = (0x09, 'HUE 2 Underglow 300 mm') HUE2_UNDERGLOW_200 = (0x0a, 'HUE 2 Underglow 200 mm') AER_RGB2_120 = (0x0b, 'AER RGB 2 120 mm') AER_RGB2_140 = (0x0c, 'AER RGB 2 140 mm') KRAKENX_GEN4_RING = (0x10, 'Kraken X (X53, X63 or X73) Pump Ring') KRAKENX_GEN4_LOGO = (0x11, 'Kraken X (X53, X63 or X73) Pump Logo') F120_RGB = (0x13, 'F120 RGB') F140_RGB = (0x14, 'F140 RGB') F140_RGB_CORE = (0x17, 'F140 RGB Core') F240_RGB_CORE = (0x1b, 'F240 RGB Core') F360_RGB_CORE = (0x1d, 'F360 RGB Core') def __new__(cls, value, pretty_name): member = object.__new__(cls) member.pretty_name = pretty_name member._value_ = value return member @classmethod def _missing_(cls, value): dummy = object.__new__(cls) dummy.pretty_name = 'Unknown' dummy._name_ = f'UNKNOWN_{value}' dummy._value_ = value return dummy def __str__(self): return self.pretty_name def __eq__(self, other): return self.value == other.value class LazyHexRepr: """Wrap an indexed collection of bytes with a lazy hex __repr__. This is useful for logging, which uses `%` string formatting to lazily generate the messages, only when needed. >>> '%r' % LazyHexRepr(b'abc') '61:62:63' Start and end indices may also be specified. >>> '%r' % LazyHexRepr(b'abc', start=1) '62:63' >>> '%r' % LazyHexRepr(b'abc', end=-1) '61:62' """ def __init__(self, data, start=None, end=None, sep=':'): self.data = data self.start = start self.end = end self.sep = sep def __repr__(self): hexvals = map(lambda x: f'{x:02x}', self.data[self.start: self.end]) return self.sep.join(hexvals) class _RelaxedNamesEnum(EnumMeta): def __getitem__(cls, name): return super().__getitem__(name.upper()) class RelaxedNamesEnum(Enum, metaclass=_RelaxedNamesEnum): """Enum class where name lookup is case insensitive.""" pass def rpadlist(list, width, fillitem=0): """Pad `list` with `fillitem` to `width`. >>> rpadlist([1, 2, 3], 5) [1, 2, 3, 0, 0] >>> rpadlist([1, 2, 3], 5, fillitem=None) [1, 2, 3, None, None] """ pad_width = width - len(list) list.extend([fillitem] * pad_width) return list def clamp(value, clampmin, clampmax): """Clamp numeric `value` to interval [`clampmin`, `clampmax`].""" clamped = max(clampmin, min(clampmax, value)) if clamped != value: _LOGGER.debug('clamped %s to interval [%s, %s]', value, clampmin, clampmax) return clamped def fraction_of_byte(ratio=None, percentage=None): """Return `ratio` xor `percentage` expressed as a fraction of 255. >>> fraction_of_byte(ratio=.8) 204 >>> fraction_of_byte(percentage=20) 51 """ if percentage is not None: ratio = percentage / 100 if ratio is not None: if ratio < 0 or ratio > 1: raise ValueError('cannot express ratios outside of [0, 1]') return round(ratio * 255) raise ValueError('either ratio or percentage must not be None') def u16le_from(buffer, offset=0): """Read an unsigned 16-bit little-endian integer from `buffer`. >>> u16le_from(b'\x45\x05\x03') 1349 >>> u16le_from(b'\x45\x05\x03', offset=1) 773 """ return int.from_bytes(buffer[offset: offset + 2], byteorder='little') def u16be_from(buffer, offset=0): """Read an unsigned 16-bit big-endian integer from `buffer`. >>> u16be_from(b'\x45\x05\x03') 17669 >>> u16be_from(b'\x45\x05\x03', offset=1) 1283 """ return int.from_bytes(buffer[offset: offset + 2], byteorder='big') def delta(profile): """Compute a profile's Δx and Δy.""" return [(cur[0]-prev[0], cur[1]-prev[1]) for cur, prev in zip(profile[1:], profile[:-1])] def normalize_profile(profile, critx, max_value=100): """Normalize a [(x:int, y:int), ...] profile. The normalized profile will ensure that: - the profile is a monotonically increasing function (i.e. for every i, i > 1, x[i] - x[i-1] > 0 and y[i] - y[i-1] >= 0) - the profile is sorted - a (critx, 100) failsafe is enforced - only the first point that sets y := 100 is kept >>> normalize_profile([(30, 40), (25, 25), (35, 30), (40, 35), (40, 80)], 60) [(25, 25), (30, 40), (35, 40), (40, 80), (60, 100)] >>> normalize_profile([(30, 40), (25, 25), (35, 30), (40, 100)], 60) [(25, 25), (30, 40), (35, 40), (40, 100)] >>> normalize_profile([(30, 40), (25, 25), (35, 100), (40, 100)], 60) [(25, 25), (30, 40), (35, 100)] >>> normalize_profile([], 60) [(60, 100)] >>> normalize_profile([], 60, 300) [(60, 300)] """ profile = sorted(list(profile) + [(critx, max_value)], key=lambda p: (p[0], -p[1])) mono = profile[0:1] for (x, y), (xb, yb) in zip(profile[1:], profile[:-1]): if x == xb: continue if y < yb: y = yb mono.append((x, y)) if y == max_value: break return mono def interpolate_profile(profile, x): """Interpolate y given x and a [(x: int, y: int), ...] profile. Requires the profile to be sorted by x, with no duplicate x values (see normalize_profile). Expects profiles with integer x and y values, and returns duty rounded to the nearest integer. >>> interpolate_profile([(20, 50), (50, 70), (60, 100)], 33) 59 >>> interpolate_profile([(20, 50), (50, 70)], 19) 50 >>> interpolate_profile([(20, 50), (50, 70)], 51) 70 >>> interpolate_profile([(20, 50)], 20) 50 """ lower, upper = profile[0], profile[-1] for step in profile: if step[0] <= x: lower = step if step[0] >= x: upper = step break if lower[0] == upper[0]: return lower[1] return round(lower[1] + (x - lower[0])/(upper[0] - lower[0])*(upper[1] - lower[1])) def color_from_str(x): """Parse a color, and, if necessary, translate it into the RGB model. The input string can be encoded in several formats: - ffffff: hexadecimal RGB implicit tuple (with or without the prefix '0x') - rgb(255, 255, 255): explicit RGB, R,G,B ∊ [0, 255] - hsv(360, 100, 100): explicit HSV, H ∊ [0, 360], SV ∊ [0, 100] - hsl(360, 100, 100): explicit HSL, H ∊ [0, 360], SV ∊ [0, 100] >>> color_from_str('fF7f3f') [255, 127, 63] >>> color_from_str('0xfF7f3f') [255, 127, 63] >>> color_from_str('0XfF7f3f') [255, 127, 63] >>> color_from_str('#fF7f3f') [255, 127, 63] >>> color_from_str('Rgb(255, 127, 63)') [255, 127, 63] >>> color_from_str('Hsv(20, 75, 100)') [255, 128, 64] >>> color_from_str('Hsl(20, 100, 62)') [255, 126, 61] >>> color_from_str('fF7f3f1f') Traceback (most recent call last): ... ValueError: cannot parse color: fF7f3f1f >>> color_from_str('0bff00ff') Traceback (most recent call last): ... ValueError: cannot parse color: 0bff00ff >>> color_from_str('rgb()') Traceback (most recent call last): ... ValueError: expected 3-element triple: rgb() >>> color_from_str('rgb(255)') Traceback (most recent call last): ... ValueError: expected 3-element triple: rgb(255) >>> color_from_str('rgb(300, 255, 255)') Traceback (most recent call last): ... ValueError: expected value in range [0, 255]: 300 in rgb(300, 255, 255) >>> color_from_str('hsv(360, 150, 100)') Traceback (most recent call last): ... ValueError: expected value in range [0, 100]: 150 in hsv(360, 150, 100) >>> color_from_str('hsl(360, 100, 150)') Traceback (most recent call last): ... ValueError: expected value in range [0, 100]: 150 in hsl(360, 100, 150) """ def parse_triple(sub, maxvalues): literal = literal_eval(sub) if not isinstance(literal, tuple) or len(literal) != 3: raise ValueError(f'expected 3-element triple: {x}') for value, maxvalue in zip(literal, maxvalues): if not isinstance(value, int) and not isinstance(value, float): raise ValueError(f'expected float or int: {value} in {x}') if value < 0 or value > maxvalue: raise ValueError(f'expected value in range [0, {maxvalue}]: {value} in {x}') return literal xl = x.lower() if xl.startswith('rgb('): r, g, b = parse_triple(x[3:], (255, 255, 255)) return [r, g, b] elif xl.startswith('hsv('): h, s, v = parse_triple(x[3:], (360, 100, 100)) return list(map(lambda b: round(b*255), colorsys.hsv_to_rgb(h/360, s/100, v/100))) elif xl.startswith('hsl('): h, s, l = parse_triple(x[3:], (360, 100, 100)) return list(map(lambda b: round(b*255), colorsys.hls_to_rgb(h/360, l/100, s/100))) elif len(x) == 6: return list(bytes.fromhex(x)) elif len(x) == 7 and x.startswith('#'): return list(bytes.fromhex(x[1:])) elif len(x) == 8 and xl.startswith('0x'): return list(bytes.fromhex(x[2:])) else: raise ValueError(f'cannot parse color: {x}') def check_unsafe(*reqs, unsafe=None, error=False, **kwargs): """Check if unsafe feature requirements are met. Unstable. Checks if the requirements in the positional arguments (`*reqs`) are all met by the `unsafe` string list of enabled features. >>> check_unsafe('foo', unsafe='foo,bar') True >>> check_unsafe('foo', 'bar', unsafe='foo,bar') True >>> check_unsafe('foo', unsafe=None) False >>> check_unsafe('foo', 'baz', unsafe='foo,bar') False If `error=True` and some requirements have not been met, raises `liquidctl.error.UnsafeFeaturesNotEnabled`. In the default `error=False` mode, a boolean is return indicating whether all requirements were met. >>> check_unsafe('foo', 'baz', unsafe='foo,bar', error=True) Traceback (most recent call last): ... liquidctl.error.UnsafeFeaturesNotEnabled: required unsafe features have not been enabled: baz In driver code, `unsafe` is normally passed in `**kwargs`. >>> kwargs = {'unsafe': 'foo,bar'} >>> check_unsafe('foo', 'bar', **kwargs) True >>> check_unsafe('foo', 'baz', error=True, **kwargs) Traceback (most recent call last): ... liquidctl.error.UnsafeFeaturesNotEnabled: required unsafe features have not been enabled: baz """ if unsafe: reqs = tuple(filter(lambda x: x not in unsafe, reqs)) if not reqs: return True if error: raise UnsafeFeaturesNotEnabled(reqs) return False def map_direction(direction, forward=None, backward=None): """Check a `direction` option and run the appropriate closure. Unstable. Accepts both US and UK spellings. Raises a `ValueError` if the option value is neither forward[s] nor backward[s]. >>> map_direction('forward', 3, 42) 3 >>> map_direction('forwards', 3, 42) 3 >>> map_direction('backward', 3, 42) 42 >>> map_direction('backwards', 3, 42) 42 >>> map_direction('fooowwd', 3, 42) Traceback (most recent call last): ... ValueError: invalid direction: 'fooowwd' """ if 'forwards'.startswith(direction): return forward elif 'backwards'.startswith(direction): return backward else: raise ValueError(f'invalid direction: {direction!r}') def fan_mode_parser(value: Optional[str], max_fans: Optional[int] = None) -> dict: """Convert the --fan-mode=:[,...] options into a {key: value, ....} dictionary. The default is an empty dictionary. Unstable. >>> fan_mode_parser(None, 5) {} >>> fan_mode_parser('', 5) {} >>> fan_mode_parser('1:dc', 5) {'1': 'dc'} >>> fan_mode_parser('2:auto', 5) {'2': 'auto'} >>> fan_mode_parser('3:off', 5) {'3': 'off'} >>> fan_mode_parser('1:DC', 5) {'1': 'dc'} >>> fan_mode_parser('2:AUTO', 5) {'2': 'auto'} >>> fan_mode_parser('3:OFF', 5) {'3': 'off'} >>> fan_mode_parser('5:OFF', 5) {'5': 'off'} >>> fan_mode_parser('5:OFF') {'5': 'off'} >>> fan_mode_parser('1:dc,2:auto,3:off,4:pwm', 5) {'1': 'dc', '2': 'auto', '3': 'off', '4': 'pwm'} >>> fan_mode_parser('1:dc, 2:auto, 3:off', 5) {'1': 'dc', '2': 'auto', '3': 'off'} >>> fan_mode_parser('4:dc, 3:auto, 2:off', 5) {'2': 'off', '3': 'auto', '4': 'dc'} >>> fan_mode_parser('1 :dc, 2: auto, 3 : off, 4 : auto ', 5) {'1': 'dc', '2': 'auto', '3': 'off', '4': 'auto'} >>> fan_mode_parser('1:dc:dc', 5) Traceback (most recent call last): ... ValueError: invalid format, should be ':' >>> fan_mode_parser('-1:dc', 5) Traceback (most recent call last): ... ValueError: invalid fan number: '-1' >>> fan_mode_parser('0:pwm', 5) Traceback (most recent call last): ... ValueError: invalid fan number: '0' >>> fan_mode_parser('5:dc', 4) Traceback (most recent call last): ... ValueError: invalid fan number: '5' >>> fan_mode_parser('a:dc', 5) Traceback (most recent call last): ... ValueError: invalid fan number: 'a' >>> fan_mode_parser('1:PMW', 5) Traceback (most recent call last): ... ValueError: invalid fan mode: 'PMW' """ if not value: return {} parts = value.split(',') opts = {} for p in parts: p2 = p.split(':') if len(p2) != 2: raise ValueError("invalid format, should be ':'") [key, val] = [i.strip() for i in p2] try: key_val = int(key, 10) except ValueError: raise ValueError(f"invalid fan number: '{key}'") if key_val <= 0 or (max_fans is not None and key_val > max_fans): raise ValueError(f"invalid fan number: '{key}'") if val.lower() not in ['off', 'auto', 'dc', 'pwm']: raise ValueError(f"invalid fan mode: '{val}'") opts.update({key: val.lower()}) return dict(sorted(opts.items(), key=lambda item: item[0])) @lru_cache(maxsize=None) def mkCrcFun(crc_name): """Efficiently construct a predefined CRC function. Unstable. For the available algorithms, see . This function implements memoization, only constructing each requested CRC algorithm implementation once. """ return crcmod.predefined.mkCrcFun(crc_name)