Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions custom_components/ef_ble/eflib/devicebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@
)
from .packet import Packet
from .props.raw_data_props import Literal
from .props.updatable_props import Field
from .props.updatable_props import Field, UpdatableProps

# Seconds to wait after authentication before falling back to a field's
# `default_when_missing` value (covers devices that withhold a whole message while the
# related hardware is off, e.g. the inverter heartbeat while AC output is off).
MISSING_DEFAULT_GRACE = 10


class _Listeners(ListenerRegistry):
Expand Down Expand Up @@ -94,6 +99,9 @@ def __init__(

self._manufacturer_data = adv_data.manufacturer_data[self.MANUFACTURER_KEY]

if UpdatableProps.is_props(self) and self.fields_with_missing_default():
self.on_connection_state_change(self._schedule_missing_field_defaults)

@property
def device(self):
return self.__doc__ or ""
Expand Down Expand Up @@ -163,7 +171,7 @@ def add_timer_task(
event_loop: asyncio.AbstractEventLoop | None = None,
):
def _register_timer_task(state: ConnectionState):
if state == ConnectionState.AUTHENTICATED:
if state.authenticated:
self._conn.add_timer_task(coro, interval, event_loop)

self.on_connection_state_change(_register_timer_task)
Expand Down Expand Up @@ -461,6 +469,26 @@ def notify_field[T](self, field: Field[T], value: T | None = None) -> None:
self.update_callback(name)
self.update_state(name, value)

def _schedule_missing_field_defaults(self, state: ConnectionState) -> None:
if not state.authenticated:
return

self.call_later(
MISSING_DEFAULT_GRACE,
self._apply_missing_field_defaults,
key="missing_field_defaults",
)

def _apply_missing_field_defaults(self) -> None:
# a field declared with `default_when_missing` whose message never arrived is
# still `None` here - fall back to its declared off value so it isn't left
# unavailable; a real value afterwards still overrides it
if not UpdatableProps.is_props(self):
return
for prop_field in self.fields_with_missing_default():
if self.get_value(prop_field) is None:
self.notify_field(prop_field, prop_field.missing_default)


@dataclass
class _ScanRecordV2:
Expand Down
46 changes: 16 additions & 30 deletions custom_components/ef_ble/eflib/devices/_delta2_base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData

from ..connection import ConnectionState
from ..devicebase import DeviceBase
from ..entity import controls
from ..entity.base import dynamic
Expand Down Expand Up @@ -42,11 +38,19 @@ class _BmsHeartbeatBattery2(DirectBmsMDeltaHeartbeatPack):


class Delta2Base(DeviceBase, RawDataProps):
ac_output_power = raw_field(pb_inv.output_watts)
ac_input_voltage = raw_field(pb_inv.ac_in_vol, pdiv(1000, 2))
ac_input_current = raw_field(pb_inv.ac_in_amp, pdiv(1000, 2))
ac_output_voltage = raw_field(pb_inv.inv_out_vol, pdiv(1000, 2))
ac_output_current = raw_field(pb_inv.inv_out_amp, pdiv(1000, 2))
ac_output_power = raw_field(pb_inv.output_watts).default_when_missing(0)
ac_input_voltage = raw_field(pb_inv.ac_in_vol, pdiv(1000, 2)).default_when_missing(
0
)
ac_input_current = raw_field(pb_inv.ac_in_amp, pdiv(1000, 2)).default_when_missing(
0,
)
ac_output_voltage = raw_field(
pb_inv.inv_out_vol, pdiv(1000, 2)
).default_when_missing(0)
ac_output_current = raw_field(
pb_inv.inv_out_amp, pdiv(1000, 2)
).default_when_missing(0)

battery_level_main = raw_field(pb_bms.f32_show_soc, pround(2))

Expand All @@ -72,7 +76,9 @@ class Delta2Base(DeviceBase, RawDataProps):
qc_usb1_output_power = raw_field(pb_pd.qc_usb1_watt)
qc_usb2_output_power = raw_field(pb_pd.qc_usb2_watt)

ac_ports = raw_field(pb_inv.cfg_ac_enabled, lambda x: x == 1)
ac_ports = raw_field(pb_inv.cfg_ac_enabled, lambda x: x == 1).default_when_missing(
False
)
usb_ports = raw_field(pb_pd.dc_out_state, lambda x: x == 1)

battery_charge_limit_min = raw_field(pb_ems.min_dsg_soc)
Expand All @@ -90,26 +96,6 @@ class Delta2Base(DeviceBase, RawDataProps):
dc12v_output_voltage = raw_field(pb_mppt.car_out_vol, pdiv(1000, 2))
dc12v_output_current = raw_field(pb_mppt.car_out_amp, pdiv(1000, 2))

def __init__(
self, ble_dev: BLEDevice, adv_data: AdvertisementData, sn: str
) -> None:
super().__init__(ble_dev, adv_data, sn)
self.on_connection_state_change(self._default_ac_ports_off_when_missing)

def _default_ac_ports_off_when_missing(self, state: ConnectionState) -> None:
# The inverter stops sending its heartbeat entirely while AC output is off, so
# `ac_ports` never gets a value after a fresh connect and the switch stays
# `unavailable`. Default it to off if nothing arrives within 10s - a real
# heartbeat afterwards still overrides this.
if state != ConnectionState.AUTHENTICATED:
return

def _set_off_if_unknown() -> None:
if self.ac_ports is None:
self.notify_field(Delta2Base.ac_ports, False)

self.call_later(10, _set_off_if_unknown, key="default_ac_ports_off")

@property
def pd_heart_type(self):
return BasePdHeart
Expand Down
4 changes: 2 additions & 2 deletions custom_components/ef_ble/eflib/devices/river2.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ class Device(DeviceBase, RawDataProps):

input_power = raw_field(pb_pd.watts_in_sum)
output_power = raw_field(pb_pd.watts_out_sum)
ac_input_power = raw_field(pb_inv.input_watts)
ac_output_power = raw_field(pb_inv.output_watts)
ac_input_power = raw_field(pb_inv.input_watts).default_when_missing(0)
ac_output_power = raw_field(pb_inv.output_watts).default_when_missing(0)

remaining_time_charging = raw_field(pb_ems.chg_remain_time)
remaining_time_discharging = raw_field(pb_ems.dsg_remain_time)
Expand Down
37 changes: 36 additions & 1 deletion custom_components/ef_ble/eflib/props/updatable_props.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import inspect
from collections.abc import Callable, Iterator
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar, Self, overload
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeIs, overload

if TYPE_CHECKING:
from ..entity import controls
Expand All @@ -27,6 +27,20 @@ class UpdatableProps:
_fields: ClassVar[list["Field[Any]"]] = []
_computed_fields: ClassVar[list["_ComputedField[Any]"]] = []

@staticmethod
def is_props(obj: object) -> "TypeIs[UpdatableProps]":
"""
Whether `obj` carries the updatable-props machinery (`_fields`, fields, ...)

Lets a `DeviceBase` mixin narrow `self` before touching props-only members,
since not every device is also `UpdatableProps`.
"""
return isinstance(obj, UpdatableProps)

def fields_with_missing_default(self) -> "list[Field[Any]]":
"""Fields declared with `Field.default_when_missing`"""
return [field for field in self._fields if field.has_missing_default]

@property
def updated_fields(self):
"""List of field names that were updated after calling `reset_updated`"""
Expand Down Expand Up @@ -106,11 +120,32 @@ class Skip:
"""Sentinel value for skipping assignment in field's transform function"""


_NO_DEFAULT: Any = object()


class Field[T]:
"""Descriptor for updating values only if they changed"""

transform_value: Callable[[Any], T] | None = None
sensor_type: "EntityType | None" = None
missing_default: Any = _NO_DEFAULT

@property
def has_missing_default(self) -> bool:
return self.missing_default is not _NO_DEFAULT

def default_when_missing(self, value: T) -> Self:
"""
Fall back to `value` if this field is still unset shortly after authentication

Some devices stop sending a whole message while the related hardware is off (the
inverter heartbeat while AC output is off, say), leaving its fields `None` and
their entities unavailable. Mark the measurement-style fields with their off
value (e.g. `0`, `False`); leave config/spec fields unmarked so they are not
clobbered. `value` is the resolved (post-transform) value.
"""
self.missing_default = value
return self

def __set_name__[P: UpdatableProps](
self,
Expand Down
Loading