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 May 15, 2024
1 parent 4569088 commit 3362457
Show file tree
Hide file tree
Showing 10 changed files with 173 additions and 139 deletions.
113 changes: 61 additions & 52 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 solaar.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

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 @@ -34,7 +34,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 @@ -375,11 +375,11 @@ def set_battery_info(self, info):
if old_info is None:
old_info = Battery(None, None, None, None)

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 All @@ -393,7 +393,7 @@ def read_battery(self):
battery = self.battery()
self.set_battery_info(battery if battery is not None else Battery(None, None, None, 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 @@ -22,7 +22,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 @@ -158,17 +158,17 @@ def set_3leds(self, device: Device, battery_level=None, charging=None, warning=N
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 @@ -228,18 +228,18 @@ def _get_register(self, device: Device, 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
Loading

0 comments on commit 3362457

Please sign in to comment.