From 64d8cad81a177482e438742dc33e350abb7abfa8 Mon Sep 17 00:00:00 2001 From: "Peter F. Patel-Schneider" Date: Wed, 6 Mar 2024 17:12:22 -0500 Subject: [PATCH] device: change status battery fields to Battery objects --- lib/logitech_receiver/common.py | 68 +++++++++--- lib/logitech_receiver/device.py | 4 +- lib/logitech_receiver/hidpp10.py | 37 ++++--- lib/logitech_receiver/hidpp20.py | 35 +++---- lib/logitech_receiver/notifications.py | 28 ++--- lib/logitech_receiver/status.py | 139 +++++-------------------- 6 files changed, 128 insertions(+), 183 deletions(-) diff --git a/lib/logitech_receiver/common.py b/lib/logitech_receiver/common.py index 011f3108..37ab98a6 100644 --- a/lib/logitech_receiver/common.py +++ b/lib/logitech_receiver/common.py @@ -1,4 +1,5 @@ ## Copyright (C) 2012-2013 Daniel Pavel +## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by @@ -18,9 +19,13 @@ from binascii import hexlify as _hexlify from collections import namedtuple +from dataclasses import dataclass +from typing import Optional import yaml as _yaml +from .i18n import _ + def is_string(d): return isinstance(d, str) @@ -546,18 +551,57 @@ class KwException(Exception): """Firmware information.""" FirmwareInfo = namedtuple("FirmwareInfo", ["kind", "name", "version", "extras"]) -BATTERY_APPROX = NamedInts(empty=0, critical=5, low=20, good=50, full=90) -BATTERY_STATUS = NamedInts( - discharging=0x00, - recharging=0x01, - almost_full=0x02, - full=0x03, - slow_recharge=0x04, - invalid_battery=0x05, - thermal_error=0x06, -) +@dataclass +class Battery: + """Information about the current state of a battery""" + level: Optional[NamedInt | int] + next_level: Optional[NamedInt | int] + status: Optional[NamedInt] + voltage: Optional[int] + light_level: Optional[int] = None # light level for devices with solaar recharging -def BATTERY_OK(status): - return status not in (BATTERY_STATUS.invalid_battery, BATTERY_STATUS.thermal_error) + 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 ( + self.level is None or self.level > Battery.ATTENTION_LEVEL + ) + + def charging(self): + return self.status in ( + Battery.STATUS.recharging, + Battery.STATUS.almost_full, + Battery.STATUS.full, + Battery.STATUS.slow_recharge, + ) + + def to_str(self): + if isinstance(self.level, NamedInt): + return _("Battery: %(level)s (%(status)s)") % {"level": _(self.level), "status": _(self.status)} + elif isinstance(self.level, int): + return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(self.status)} + else: + return "" diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py index 75b3d5ee..e6ef12f8 100644 --- a/lib/logitech_receiver/device.py +++ b/lib/logitech_receiver/device.py @@ -342,10 +342,10 @@ class Device: if battery_feature != 0: result = _hidpp20.get_battery(self, battery_feature) try: - feature, level, next, status, voltage = result + feature, battery = result if self.persister and battery_feature is None: self.persister["_battery"] = feature - return level, next, status, voltage + return battery except Exception: if self.persister and battery_feature is None: self.persister["_battery"] = result diff --git a/lib/logitech_receiver/hidpp10.py b/lib/logitech_receiver/hidpp10.py index 85d27053..9da59c5d 100644 --- a/lib/logitech_receiver/hidpp10.py +++ b/lib/logitech_receiver/hidpp10.py @@ -16,8 +16,7 @@ import logging -from .common import BATTERY_APPROX as _BATTERY_APPROX -from .common import BATTERY_STATUS as _BATTERY_STATUS +from .common import Battery as _Battery from .common import FirmwareInfo as _FirmwareInfo from .common import bytes2int as _bytes2int from .common import int2bytes as _int2bytes @@ -122,17 +121,17 @@ class Hidpp10: return if battery_level is not None: - if battery_level < _BATTERY_APPROX.critical: + if battery_level < _Battery.APPROX.critical: # 1 orange, and force blink v1, v2 = 0x22, 0x00 warning = True - elif battery_level < _BATTERY_APPROX.low: + elif battery_level < _Battery.APPROX.low: # 1 orange v1, v2 = 0x22, 0x00 - elif battery_level < _BATTERY_APPROX.good: + elif battery_level < _Battery.APPROX.good: # 1 green v1, v2 = 0x20, 0x00 - elif battery_level < _BATTERY_APPROX.full: + elif battery_level < _Battery.APPROX.full: # 2 greens v1, v2 = 0x20, 0x02 else: @@ -196,38 +195,38 @@ def parse_battery_status(register, reply): charge = ord(reply[:1]) status_byte = ord(reply[2:3]) & 0xF0 status_text = ( - _BATTERY_STATUS.discharging + _Battery.STATUS.discharging if status_byte == 0x30 - else _BATTERY_STATUS.recharging + else _Battery.STATUS.recharging if status_byte == 0x50 - else _BATTERY_STATUS.full + else _Battery.STATUS.full if status_byte == 0x90 else None ) - return charge, None, status_text, None + return _Battery(charge, None, status_text, None) if register == REGISTERS.battery_status: status_byte = ord(reply[:1]) charge = ( - _BATTERY_APPROX.full + _Battery.APPROX.full if status_byte == 7 # full - else _BATTERY_APPROX.good + else _Battery.APPROX.good if status_byte == 5 # good - else _BATTERY_APPROX.low + else _Battery.APPROX.low if status_byte == 3 # low - else _BATTERY_APPROX.critical + else _Battery.APPROX.critical if status_byte == 1 # critical # pure 'charging' notifications may come without a status - else _BATTERY_APPROX.empty + else _Battery.APPROX.empty ) charging_byte = ord(reply[1:2]) if charging_byte == 0x00: - status_text = _BATTERY_STATUS.discharging + status_text = _Battery.STATUS.discharging elif charging_byte & 0x21 == 0x21: - status_text = _BATTERY_STATUS.recharging + status_text = _Battery.STATUS.recharging elif charging_byte & 0x22 == 0x22: - status_text = _BATTERY_STATUS.full + status_text = _Battery.STATUS.full else: logger.warning("could not parse 0x07 battery status: %02X (level %02X)", charging_byte, status_byte) status_text = None @@ -237,4 +236,4 @@ def parse_battery_status(register, reply): charge = None # Return None for next charge level and voltage as these are not in HID++ 1.0 spec - return charge, None, status_text, None + return _Battery(charge, None, status_text, None) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index bd82938a..463e8822 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -27,8 +27,7 @@ import yaml as _yaml from . import exceptions, special_keys from . import hidpp10_constants as _hidpp10_constants -from .common import BATTERY_APPROX as _BATTERY_APPROX -from .common import BATTERY_STATUS as _BATTERY_STATUS +from .common import Battery from .common import FirmwareInfo as _FirmwareInfo from .common import NamedInt as _NamedInt from .common import NamedInts as _NamedInts @@ -1730,31 +1729,31 @@ battery_functions = { def decipher_battery_status(report): discharge, next, status = _unpack("!BBB", report[:3]) discharge = None if discharge == 0 else discharge - status = _BATTERY_STATUS[status] + status = Battery.STATUS[status] if logger.isEnabledFor(logging.DEBUG): logger.debug("battery status %s%% charged, next %s%%, status %s", discharge, next, status) - return FEATURE.BATTERY_STATUS, discharge, next, status, None + return FEATURE.BATTERY_STATUS, Battery(discharge, next, status, None) def decipher_battery_voltage(report): voltage, flags = _unpack(">HB", report[:3]) - status = _BATTERY_STATUS.discharging + status = Battery.STATUS.discharging charge_sts = ERROR.unknown charge_lvl = CHARGE_LEVEL.average charge_type = CHARGE_TYPE.standard if flags & (1 << 7): - status = _BATTERY_STATUS.recharging + status = Battery.STATUS.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 = Battery.STATUS.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 = Battery.STATUS.slow_recharge elif flags & (1 << 5): charge_lvl = CHARGE_LEVEL.critical for level in battery_voltage_remaining: @@ -1771,26 +1770,26 @@ def decipher_battery_voltage(report): charge_lvl, charge_type, ) - return FEATURE.BATTERY_VOLTAGE, charge_lvl, None, status, voltage + return FEATURE.BATTERY_VOLTAGE, Battery(charge_lvl, None, status, voltage) def decipher_battery_unified(report): discharge, level, status, _ignore = _unpack("!BBBB", report[:4]) - status = _BATTERY_STATUS[status] + status = Battery.STATUS[status] if logger.isEnabledFor(logging.DEBUG): logger.debug("battery unified %s%% charged, level %s, charging %s", discharge, level, status) level = ( - _BATTERY_APPROX.full + Battery.APPROX.full if level == 8 # full - else _BATTERY_APPROX.good + else Battery.APPROX.good if level == 4 # good - else _BATTERY_APPROX.low + else Battery.APPROX.low if level == 2 # low - else _BATTERY_APPROX.critical + else Battery.APPROX.critical if level == 1 # critical - else _BATTERY_APPROX.empty + else Battery.APPROX.empty ) - return FEATURE.UNIFIED_BATTERY, discharge if discharge else level, None, status, None + return FEATURE.UNIFIED_BATTERY, Battery(discharge if discharge else level, None, status, None) def decipher_adc_measurement(report): @@ -1801,5 +1800,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 - return FEATURE.ADC_MEASUREMENT, charge_level, None, status, adc + status = Battery.STATUS.recharging if flags & 0x02 else Battery.STATUS.discharging + return FEATURE.ADC_MEASUREMENT, Battery(charge_level, None, status, adc) diff --git a/lib/logitech_receiver/notifications.py b/lib/logitech_receiver/notifications.py index c4a37c48..bf2d3ba3 100644 --- a/lib/logitech_receiver/notifications.py +++ b/lib/logitech_receiver/notifications.py @@ -29,7 +29,7 @@ from . import hidpp10_constants as _hidpp10_constants from . import hidpp20_constants as _hidpp20_constants from . import settings_templates as _st from .base import DJ_MESSAGE_ID as _DJ_MESSAGE_ID -from .common import BATTERY_STATUS as _BATTERY_STATUS +from .common import Battery as _Battery from .common import strhex as _strhex from .i18n import _ from .status import ALERT as _ALERT @@ -218,11 +218,9 @@ def _process_hidpp10_custom_notification(device, status, n): logger.debug("%s (%s) custom notification %s", device, device.protocol, n) if n.sub_id in (_R.battery_status, _R.battery_charge): - # message layout: 10 ix <00> assert n.data[-1:] == b"\x00" data = chr(n.address).encode() + n.data - charge, next_charge, status_text, voltage = hidpp10.parse_battery_status(n.sub_id, data) - status.set_battery_info(charge, next_charge, status_text, voltage) + status.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data)) return True logger.warning("%s: unrecognized %s", device, n) @@ -296,8 +294,7 @@ def _process_feature_notification(device, status, n, feature): if feature == _F.BATTERY_STATUS: if n.address == 0x00: - _ignore, discharge_level, discharge_next_level, battery_status, voltage = hidpp20.decipher_battery_status(n.data) - status.set_battery_info(discharge_level, discharge_next_level, battery_status, voltage) + status.set_battery_info(hidpp20.decipher_battery_status(n.data)[1]) elif n.address == 0x10: if logger.isEnabledFor(logging.INFO): logger.info("%s: spurious BATTERY status %s", device, n) @@ -306,15 +303,13 @@ def _process_feature_notification(device, status, n, feature): elif feature == _F.BATTERY_VOLTAGE: if n.address == 0x00: - _ignore, level, nextl, battery_status, voltage = hidpp20.decipher_battery_voltage(n.data) - status.set_battery_info(level, nextl, battery_status, voltage) + status.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1]) else: logger.warning("%s: unknown VOLTAGE %s", device, n) elif feature == _F.UNIFIED_BATTERY: if n.address == 0x00: - _ignore, level, nextl, battery_status, voltage = hidpp20.decipher_battery_unified(n.data) - status.set_battery_info(level, nextl, battery_status, voltage) + status.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1]) else: logger.warning("%s: unknown UNIFIED BATTERY %s", device, n) @@ -322,8 +317,7 @@ def _process_feature_notification(device, status, n, feature): if n.address == 0x00: result = hidpp20.decipher_adc_measurement(n.data) if result: - _ignore, level, nextl, battery_status, voltage = result - status.set_battery_info(level, nextl, battery_status, voltage) + status.set_battery_info(result[1]) else: # this feature is used to signal device becoming inactive status.changed(active=False) else: @@ -334,15 +328,13 @@ def _process_feature_notification(device, status, n, feature): charge, lux, adc = _unpack("!BHH", n.data[:5]) # guesstimate the battery voltage, emphasis on 'guess' # status_text = '%1.2fV' % (adc * 2.67793237653 / 0x0672) - status_text = _BATTERY_STATUS.discharging + status_text = _Battery.STATUS.discharging if n.address == 0x00: - status[_K.LIGHT_LEVEL] = None - status.set_battery_info(charge, None, status_text, None) + status.set_battery_info(_Battery(charge, None, status_text, None)) elif n.address == 0x10: - status[_K.LIGHT_LEVEL] = lux if lux > 200: - status_text = _BATTERY_STATUS.recharging - status.set_battery_info(charge, None, status_text, None) + status_text = _Battery.STATUS.recharging + status.set_battery_info(_Battery(charge, None, status_text, None, lux)) elif n.address == 0x20: if logger.isEnabledFor(logging.DEBUG): logger.debug("%s: Light Check button pressed", device) diff --git a/lib/logitech_receiver/status.py b/lib/logitech_receiver/status.py index 07475e30..d90260fe 100644 --- a/lib/logitech_receiver/status.py +++ b/lib/logitech_receiver/status.py @@ -1,4 +1,5 @@ ## Copyright (C) 2012-2013 Daniel Pavel +## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by @@ -20,11 +21,7 @@ from . import hidpp10 from . import hidpp10_constants as _hidpp10_constants from . import hidpp20_constants as _hidpp20_constants from . import settings as _settings -from .common import BATTERY_APPROX as _BATTERY_APPROX -from .common import BATTERY_OK as _BATTERY_OK -from .common import BATTERY_STATUS as _BATTERY_STATUS -from .common import NamedInt as _NamedInt -from .common import NamedInts as _NamedInts +from .common import Battery, NamedInts from .i18n import _, ngettext logger = logging.getLogger(__name__) @@ -33,27 +30,9 @@ _R = _hidpp10_constants.REGISTERS _hidpp10 = hidpp10.Hidpp10() -# -# -# +ALERT = NamedInts(NONE=0x00, NOTIFICATION=0x01, SHOW_WINDOW=0x02, ATTENTION=0x04, ALL=0xFF) -ALERT = _NamedInts(NONE=0x00, NOTIFICATION=0x01, SHOW_WINDOW=0x02, ATTENTION=0x04, ALL=0xFF) - -KEYS = _NamedInts( - BATTERY_LEVEL=1, - BATTERY_CHARGING=2, - BATTERY_STATUS=3, - LIGHT_LEVEL=4, - LINK_ENCRYPTED=5, - NOTIFICATION_FLAGS=6, - ERROR=7, - BATTERY_NEXT_LEVEL=8, - BATTERY_VOLTAGE=9, -) - -# If the battery charge is under this percentage, trigger an attention event -# (blink systray icon/notification/whatever). -_BATTERY_ATTENTION_LEVEL = 5 +KEYS = NamedInts(LINK_ENCRYPTED=5, NOTIFICATION_FLAGS=6, ERROR=7) def attach_to(device, changed_callback): @@ -67,11 +46,6 @@ def attach_to(device, changed_callback): device.status = DeviceStatus(device, changed_callback) -# -# -# - - class ReceiverStatus(dict): """The 'runtime' status of a receiver, mostly about the pairing process -- is the pairing lock open or closed, any pairing errors, etc. @@ -111,15 +85,10 @@ class ReceiverStatus(dict): self._changed_callback(self._receiver, alert=alert, reason=reason) -# -# -# - - class DeviceStatus(dict): - """Holds the 'runtime' status of a peripheral -- things like - active/inactive, battery charge, lux, etc. It updates them mostly by - processing incoming notification events from the device itself. + """Holds the 'runtime' status of a peripheral + Currently _active, battery -- dict entries are being moved to attributs + Updates mostly come from incoming notification events from the device itself. """ def __init__(self, device, changed_callback): @@ -128,19 +97,10 @@ class DeviceStatus(dict): assert changed_callback self._changed_callback = changed_callback self._active = None # is the device active? + self.battery = None def to_string(self): - status = "" - battery_level = self.get(KEYS.BATTERY_LEVEL) - if battery_level is not None: - if isinstance(battery_level, _NamedInt): - status = _("Battery: %(level)s") % {"level": _(str(battery_level))} - else: - status = _("Battery: %(percent)d%%") % {"percent": battery_level} - battery_status = self.get(KEYS.BATTERY_STATUS) - if battery_status is not None: - status += " (%s)" % _(str(battery_status)) - return status + return self.battery.to_str() if self.battery is not None else "" def __repr__(self): return "{" + ", ".join("'%s': %r" % (k, v) for k, v in self.items()) + "}" @@ -150,81 +110,38 @@ class DeviceStatus(dict): __nonzero__ = __bool__ - def set_battery_info(self, level, nextLevel, status, voltage): + def set_battery_info(self, info): if logger.isEnabledFor(logging.DEBUG): - logger.debug("%s: battery %s, %s", self._device, level, status) + logger.debug("%s: battery %s, %s", self._device, info.level, info.status) + if info.level is None and self.battery: # use previous level if missing from new information + info.level = self.battery.level - if level is None: - # Some notifications may come with no battery level info, just - # charging state info, so do our best to infer a level (even if it is just the last level) - # It is not always possible to do this well - if status == _BATTERY_STATUS.full: - level = _BATTERY_APPROX.full - elif status in (_BATTERY_STATUS.almost_full, _BATTERY_STATUS.recharging): - level = _BATTERY_APPROX.good - elif status == _BATTERY_STATUS.slow_recharge: - level = _BATTERY_APPROX.low - else: - level = self.get(KEYS.BATTERY_LEVEL) - else: - assert isinstance(level, int) + changed = self.battery != info + self.battery = info - # TODO: this is also executed when pressing Fn+F7 on K800. - old_level, self[KEYS.BATTERY_LEVEL] = self.get(KEYS.BATTERY_LEVEL), level - old_status, self[KEYS.BATTERY_STATUS] = self.get(KEYS.BATTERY_STATUS), status - self[KEYS.BATTERY_NEXT_LEVEL] = nextLevel - old_voltage, self[KEYS.BATTERY_VOLTAGE] = self.get(KEYS.BATTERY_VOLTAGE), voltage - - charging = status in ( - _BATTERY_STATUS.recharging, - _BATTERY_STATUS.almost_full, - _BATTERY_STATUS.full, - _BATTERY_STATUS.slow_recharge, - ) - old_charging, self[KEYS.BATTERY_CHARGING] = self.get(KEYS.BATTERY_CHARGING), charging - - changed = old_level != level or old_status != status or old_charging != charging or old_voltage != voltage alert, reason = ALERT.NONE, None - - if _BATTERY_OK(status) and (level is None or level > _BATTERY_ATTENTION_LEVEL): + if info.ok(): self[KEYS.ERROR] = None else: - logger.warning("%s: battery %d%%, ALERT %s", self._device, level, status) - if self.get(KEYS.ERROR) != status: - self[KEYS.ERROR] = status - # only show the notification once + logger.warning("%s: battery %d%%, ALERT %s", self._device, info.level, info.status) + if self.get(KEYS.ERROR) != info.status: + self[KEYS.ERROR] = info.status alert = ALERT.NOTIFICATION | ALERT.ATTENTION - if isinstance(level, _NamedInt): - reason = _("Battery: %(level)s (%(status)s)") % {"level": _(level), "status": _(status)} - else: - reason = _("Battery: %(percent)d%% (%(status)s)") % {"percent": level, "status": status.name} + reason = info.to_str() if changed or reason or not self._active: # a battery response means device is active # update the leds on the device, if any - _hidpp10.set_3leds(self._device, level, charging=charging, warning=bool(alert)) + _hidpp10.set_3leds(self._device, info.level, charging=info.charging(), warning=bool(alert)) self.changed(active=True, alert=alert, reason=reason) # Retrieve and regularize battery status def read_battery(self): if self._active: - assert self._device battery = self._device.battery() - self.set_battery_keys(battery) - - def set_battery_keys(self, battery): - if battery is not None: - level, nextLevel, status, voltage = battery - self.set_battery_info(level, nextLevel, status, voltage) - elif self.get(KEYS.BATTERY_STATUS, None) is not None: - self[KEYS.BATTERY_STATUS] = None - self[KEYS.BATTERY_CHARGING] = None - self[KEYS.BATTERY_VOLTAGE] = None - self.changed() + 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): - assert self._changed_callback d = self._device - # assert d # may be invalid when processing the 'unpaired' notification if active is not None: d.online = active @@ -236,12 +153,10 @@ class DeviceStatus(dict): # goes idle, and we can't tell the difference right now). if d.protocol < 2.0: self[KEYS.NOTIFICATION_FLAGS] = d.enable_connection_notifications() - # battery information may have changed so try to read it now self.read_battery() - # Push settings for new devices (was_active is None), - # when devices request software reconfiguration + # Push settings for new devices when devices request software reconfiguration # and when devices become active if they don't have wireless device status feature, if ( was_active is None @@ -253,10 +168,6 @@ class DeviceStatus(dict): logger.info("%s pushing device settings %s", d, d.settings) _settings.apply_all_settings(d) - else: - if was_active: # don't clear status when devices go inactive - pass - - # if logger.isEnabledFor(logging.DEBUG): - # logger.debug("device %d changed: active=%s %s", d.number, self._active, dict(self)) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("device %d changed: active=%s %s", d.number, self._active, self.battery) self._changed_callback(d, alert, reason)