device: change status battery fields to Battery objects

This commit is contained in:
Peter F. Patel-Schneider 2024-03-06 17:12:22 -05:00
parent 3916c189be
commit 64d8cad81a
6 changed files with 128 additions and 183 deletions

View File

@ -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,9 +551,27 @@ 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(
@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 __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,
@ -556,8 +579,29 @@ BATTERY_STATUS = NamedInts(
slow_recharge=0x04,
invalid_battery=0x05,
thermal_error=0x06,
)
)
APPROX = NamedInts(empty=0, critical=5, low=20, good=50, full=90)
def BATTERY_OK(status):
return status not in (BATTERY_STATUS.invalid_battery, BATTERY_STATUS.thermal_error)
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 ""

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 <register> <xx> <yy> <zz> <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)

View File

@ -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)