Skip to content

Commit

Permalink
Refactor: Use dataclasses and enums
Browse files Browse the repository at this point in the history
Replace unnecessary NamedInts in favour of default data types.
Simplify interfaces by reducing possible input from strings to members
of an enum.
  • Loading branch information
MattHag committed Apr 13, 2024
1 parent ccebd3b commit 4719827
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 140 deletions.
115 changes: 62 additions & 53 deletions lib/logitech_receiver/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
# Some common functions and types.

from binascii import hexlify as _hexlify
from collections import namedtuple
from dataclasses import dataclass
from enum import IntEnum
from typing import Optional
from typing import Union

Expand All @@ -28,15 +28,6 @@
from .i18n import _


def is_string(d):
return isinstance(d, str)


#
#
#


def crc16(data: bytes):
"""
CRC-16 (CCITT) implemented with a precomputed lookup table
Expand Down Expand Up @@ -314,7 +305,7 @@ class NamedInt(int):
(case-insensitive)."""

def __new__(cls, value, name):
assert is_string(name)
assert isinstance(name, str)
obj = int.__new__(cls, value)
obj.name = str(name)
return obj
Expand All @@ -329,7 +320,7 @@ def __eq__(self, other):
return int(self) == int(other) and self.name == other.name
if isinstance(other, int):
return int(self) == int(other)
if is_string(other):
if isinstance(other, str):
return self.name.lower() == other.lower()
# this should catch comparisons with bytes in Py3
if other is not None:
Expand Down Expand Up @@ -430,7 +421,7 @@ def __getitem__(self, index):
self._sort_values()
return value

elif is_string(index):
elif isinstance(index, str):
if index in self.__dict__:
return self.__dict__[index]
return next((x for x in self._values if str(x) == index), None)
Expand Down Expand Up @@ -469,7 +460,7 @@ def __setitem__(self, index, name):
if isinstance(name, NamedInt):
assert int(index) == int(name), repr(index) + " " + repr(name)
value = name
elif is_string(name):
elif isinstance(name, str):
value = NamedInt(index, name)
else:
raise TypeError("name must be a string")
Expand All @@ -490,7 +481,7 @@ def __contains__(self, value):
return self[value] == value
elif isinstance(value, int):
return value in self._indexed
elif is_string(value):
elif isinstance(value, str):
return value in self.__dict__ or value in self._values

def __iter__(self):
Expand Down Expand Up @@ -550,63 +541,81 @@ def __getattr__(self, k):
return self.args[0].get(k) # was self.args[0][k]


"""Firmware information."""
FirmwareInfo = namedtuple("FirmwareInfo", ["kind", "name", "version", "extras"])
@dataclass
class FirmwareInfo:
kind: str
name: str
version: str
extras: str


class BatteryStatus(IntEnum):
DISCHARGING = 0x00
RECHARGING = 0x01
ALMOST_FULL = 0x02
FULL = 0x03
SLOW_RECHARGE = 0x04
INVALID_BATTERY = 0x05
THERMAL_ERROR = 0x06


class BatteryLevelApproximation(IntEnum):
EMPTY = 0
CRITICAL = 5
LOW = 20
GOOD = 50
FULL = 90


@dataclass
class Battery:
"""Information about the current state of a battery"""

level: Optional[Union[NamedInt, int]]
ATTENTION_LEVEL = 5

level: Optional[Union[BatteryLevelApproximation, int]]
next_level: Optional[Union[NamedInt, int]]
status: Optional[NamedInt]
status: Optional[BatteryStatus]
voltage: Optional[int]
light_level: Optional[int] = None # light level for devices with solaar recharging
light_level: Optional[int] = None # light level for devices with solaar RECHARGING

def __post_init__(self):
if self.level is None: # infer level from status if needed and possible
if self.status == Battery.STATUS.full:
self.level = Battery.APPROX.full
elif self.status in (Battery.STATUS.almost_full, Battery.STATUS.recharging):
self.level = Battery.APPROX.good
elif self.status == Battery.STATUS.slow_recharge:
self.level = Battery.APPROX.low

STATUS = NamedInts(
discharging=0x00,
recharging=0x01,
almost_full=0x02,
full=0x03,
slow_recharge=0x04,
invalid_battery=0x05,
thermal_error=0x06,
)

APPROX = NamedInts(empty=0, critical=5, low=20, good=50, full=90)

ATTENTION_LEVEL = 5

def ok(self):
return self.status not in (Battery.STATUS.invalid_battery, Battery.STATUS.thermal_error) and (
if self.status == BatteryStatus.FULL:
self.level = BatteryLevelApproximation.FULL
elif self.status in (BatteryStatus.ALMOST_FULL, BatteryStatus.RECHARGING):
self.level = BatteryLevelApproximation.GOOD
elif self.status == BatteryStatus.SLOW_RECHARGE:
self.level = BatteryLevelApproximation.LOW

def ok(self) -> bool:
return self.status not in (BatteryStatus.INVALID_BATTERY, BatteryStatus.THERMAL_ERROR) and (
self.level is None or self.level > Battery.ATTENTION_LEVEL
)

def charging(self):
def charging(self) -> bool:
return self.status in (
Battery.STATUS.recharging,
Battery.STATUS.almost_full,
Battery.STATUS.full,
Battery.STATUS.slow_recharge,
BatteryStatus.RECHARGING,
BatteryStatus.ALMOST_FULL,
BatteryStatus.FULL,
BatteryStatus.SLOW_RECHARGE,
)

def to_str(self):
if isinstance(self.level, NamedInt):
return _("Battery: %(level)s (%(status)s)") % {"level": _(self.level), "status": _(self.status)}
def to_str(self) -> str:
if isinstance(self.level, BatteryLevelApproximation):
level = self.level.name.lower()
status = self.status.name.lower().replace("_", " ")
return _("Battery: %(level)s (%(status)s)") % {"level": _(level), "status": _(status)}
elif isinstance(self.level, int):
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(self.status)}
status = self.status.name.lower().replace("_", " ")
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(status)}
else:
return ""


ALERT = NamedInts(NONE=0x00, NOTIFICATION=0x01, SHOW_WINDOW=0x02, ATTENTION=0x04, ALL=0xFF)
class Alert(IntEnum):
NONE = 0x00
NOTIFICATION = 0x01
SHOW_WINDOW = 0x02
ATTENTION = 0x04
ALL = 0xFF
8 changes: 4 additions & 4 deletions lib/logitech_receiver/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from . import hidpp20
from . import hidpp20_constants
from . import settings
from .common import ALERT
from .common import Alert
from .common import Battery
from .settings_templates import check_feature_settings as _check_feature_settings

Expand Down Expand Up @@ -377,11 +377,11 @@ def set_battery_info(self, info):
changed = self.battery_info != info
self.battery_info, old_info = info, self.battery_info

alert, reason = ALERT.NONE, None
alert, reason = Alert.NONE, None
if not info.ok():
logger.warning("%s: battery %d%%, ALERT %s", self, info.level, info.status)
if old_info.status != info.status:
alert = ALERT.NOTIFICATION | ALERT.ATTENTION
alert = Alert.NOTIFICATION | Alert.ATTENTION
reason = info.to_str()

if changed or reason:
Expand Down Expand Up @@ -419,7 +419,7 @@ def enable_connection_notifications(self, enable=True):
logger.info("%s: device notifications %s %s", self, "enabled" if enable else "disabled", flag_names)
return flag_bits if ok else None

def changed(self, active=None, alert=ALERT.NONE, reason=None, push=False):
def changed(self, active=None, alert=Alert.NONE, reason=None, push=False):
"""The status of the device had changed, so invoke the status callback.
Also push notifications and settings to the device when necessary."""
if active is not None:
Expand Down
22 changes: 11 additions & 11 deletions lib/logitech_receiver/hidpp10.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from typing_extensions import Protocol

from .common import Battery
from .common import BatteryChargeApproximation
from .common import BatteryLevelApproximation
from .common import BatteryStatus
from .common import FirmwareInfo as _FirmwareInfo
from .common import bytes2int as _bytes2int
Expand Down Expand Up @@ -156,17 +156,17 @@ def set_3leds(self, device: DeviceProtocol, battery_level=None, charging=None, w
return

if battery_level is not None:
if battery_level < BatteryChargeApproximation.CRITICAL:
if battery_level < BatteryLevelApproximation.CRITICAL:
# 1 orange, and force blink
v1, v2 = 0x22, 0x00
warning = True
elif battery_level < BatteryChargeApproximation.LOW:
elif battery_level < BatteryLevelApproximation.LOW:
# 1 orange
v1, v2 = 0x22, 0x00
elif battery_level < BatteryChargeApproximation.GOOD:
elif battery_level < BatteryLevelApproximation.GOOD:
# 1 green
v1, v2 = 0x20, 0x00
elif battery_level < BatteryChargeApproximation.FULL:
elif battery_level < BatteryLevelApproximation.FULL:
# 2 greens
v1, v2 = 0x20, 0x02
else:
Expand Down Expand Up @@ -226,18 +226,18 @@ def _get_register(self, device: DeviceProtocol, register):


def parse_battery_status(register, reply) -> Battery | None:
def status_byte_to_charge(status_byte_: int) -> BatteryChargeApproximation:
def status_byte_to_charge(status_byte_: int) -> BatteryLevelApproximation:
if status_byte_ == 7:
charge_ = BatteryChargeApproximation.FULL
charge_ = BatteryLevelApproximation.FULL
elif status_byte_ == 5:
charge_ = BatteryChargeApproximation.GOOD
charge_ = BatteryLevelApproximation.GOOD
elif status_byte_ == 3:
charge_ = BatteryChargeApproximation.LOW
charge_ = BatteryLevelApproximation.LOW
elif status_byte_ == 1:
charge_ = BatteryChargeApproximation.CRITICAL
charge_ = BatteryLevelApproximation.CRITICAL
else:
# pure 'charging' notifications may come without a status
charge_ = BatteryChargeApproximation.EMPTY
charge_ = BatteryLevelApproximation.EMPTY
return charge_

def status_byte_to_battery_status(status_byte_: int) -> BatteryStatus:
Expand Down
58 changes: 34 additions & 24 deletions lib/logitech_receiver/hidpp20.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@

from struct import pack as _pack
from struct import unpack as _unpack
from typing import Any
from typing import List
from typing import Optional
from typing import Tuple

import yaml as _yaml

Expand All @@ -32,6 +34,8 @@
from . import hidpp10_constants as _hidpp10_constants
from . import special_keys
from .common import Battery
from .common import BatteryLevelApproximation
from .common import BatteryStatus
from .common import FirmwareInfo as _FirmwareInfo
from .common import NamedInt as _NamedInt
from .common import NamedInts as _NamedInts
Expand All @@ -51,6 +55,8 @@

logger = logging.getLogger(__name__)

FixedBytes5 = bytes

KIND_MAP = {kind: _hidpp10_constants.DEVICE_KIND[str(kind)] for kind in DEVICE_KIND}


Expand Down Expand Up @@ -1777,34 +1783,37 @@ def config_change(self, device, configuration, no_reply=False):
}


def decipher_battery_status(report):
discharge, next, status = _unpack("!BBB", report[:3])
discharge = None if discharge == 0 else discharge
status = Battery.STATUS[status]
def decipher_battery_status(report: FixedBytes5) -> Tuple[Any, Battery]:
battery_discharge_level, battery_discharge_next_level, battery_status = _unpack("!BBB", report[:3])
if battery_discharge_level == 0:
battery_discharge_level = None
status = BatteryStatus(battery_status)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("battery status %s%% charged, next %s%%, status %s", discharge, next, status)
return FEATURE.BATTERY_STATUS, Battery(discharge, next, status, None)
logger.debug(
"battery status %s%% charged, next %s%%, status %s", battery_discharge_level, battery_discharge_next_level, status
)
return FEATURE.BATTERY_STATUS, Battery(battery_discharge_level, battery_discharge_next_level, status, None)


def decipher_battery_voltage(report):
voltage, flags = _unpack(">HB", report[:3])
status = Battery.STATUS.discharging
status = BatteryStatus.DISCHARGING
charge_sts = ERROR.unknown
charge_lvl = CHARGE_LEVEL.average
charge_type = CHARGE_TYPE.standard
if flags & (1 << 7):
status = Battery.STATUS.recharging
status = BatteryStatus.RECHARGING
charge_sts = CHARGE_STATUS[flags & 0x03]
if charge_sts is None:
charge_sts = ERROR.unknown
elif charge_sts == CHARGE_STATUS.full:
charge_lvl = CHARGE_LEVEL.full
status = Battery.STATUS.full
status = BatteryStatus.FULL
if flags & (1 << 3):
charge_type = CHARGE_TYPE.fast
elif flags & (1 << 4):
charge_type = CHARGE_TYPE.slow
status = Battery.STATUS.slow_recharge
status = BatteryStatus.SLOW_RECHARGE
elif flags & (1 << 5):
charge_lvl = CHARGE_LEVEL.critical
for level in battery_voltage_remaining:
Expand All @@ -1825,21 +1834,22 @@ def decipher_battery_voltage(report):


def decipher_battery_unified(report):
discharge, level, status, _ignore = _unpack("!BBBB", report[:4])
status = Battery.STATUS[status]
discharge, level, status_byte, _ignore = _unpack("!BBBB", report[:4])
status = BatteryStatus(status_byte)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("battery unified %s%% charged, level %s, charging %s", discharge, level, status)
level = (
Battery.APPROX.full
if level == 8 # full
else Battery.APPROX.good
if level == 4 # good
else Battery.APPROX.low
if level == 2 # low
else Battery.APPROX.critical
if level == 1 # critical
else Battery.APPROX.empty
)

if level == 8:
level = BatteryLevelApproximation.FULL
elif level == 4:
level = BatteryLevelApproximation.GOOD
elif level == 2:
level = BatteryLevelApproximation.LOW
elif level == 1:
level = BatteryLevelApproximation.CRITICAL
else:
level = BatteryLevelApproximation.EMPTY

return FEATURE.UNIFIED_BATTERY, Battery(discharge if discharge else level, None, status, None)


Expand All @@ -1851,5 +1861,5 @@ def decipher_adc_measurement(report):
charge_level = level[1]
break
if flags & 0x01:
status = Battery.STATUS.recharging if flags & 0x02 else Battery.STATUS.discharging
status = BatteryStatus.RECHARGING if flags & 0x02 else BatteryStatus.DISCHARGING
return FEATURE.ADC_MEASUREMENT, Battery(charge_level, None, status, adc)
Loading

0 comments on commit 4719827

Please sign in to comment.