Skip to content

Commit eab3186

Browse files
authored
Default inverter fields to off when the inverter heartbeat is missing (#361)
This is a followup of #356 - in there, we only updated switch, but all other fields are also unavailable so this PR adds a functionality that allows setting defaults for all fields. This also addresses a comment in #351 (comment) that reported this issue happens for River 2. This adds a small, generic mechanism rather than patching one field. A field can declare its off value with `Field.default_when_missing(value)`. When a device has any such field, `DeviceBase` schedules a one-shot fallback after authentication (`MISSING_DEFAULT_GRACE = 10s`): for each marked field that is still unset once the grace period elapses, it applies the declared value through `notify_field`. A real message arriving at any point - before or after - still wins. The field iteration and a `TypeIs` guard live on `UpdatableProps` (`is_props` / `fields_with_missing_default`), so `DeviceBase` never reaches into props internals. Config / spec fields (configured charge speed, rated power, etc.) are deliberately left unmarked - an empty inverter reading should not clobber a setpoint to `0`, so those stay unavailable until the device actually reports them.
1 parent 79a5267 commit eab3186

4 files changed

Lines changed: 84 additions & 35 deletions

File tree

custom_components/ef_ble/eflib/devicebase.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929
)
3030
from .packet import Packet
3131
from .props.raw_data_props import Literal
32-
from .props.updatable_props import Field
32+
from .props.updatable_props import Field, UpdatableProps
33+
34+
# Seconds to wait after authentication before falling back to a field's
35+
# `default_when_missing` value (covers devices that withhold a whole message while the
36+
# related hardware is off, e.g. the inverter heartbeat while AC output is off).
37+
MISSING_DEFAULT_GRACE = 10
3338

3439

3540
class _Listeners(ListenerRegistry):
@@ -94,6 +99,9 @@ def __init__(
9499

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

102+
if UpdatableProps.is_props(self) and self.fields_with_missing_default():
103+
self.on_connection_state_change(self._schedule_missing_field_defaults)
104+
97105
@property
98106
def device(self):
99107
return self.__doc__ or ""
@@ -163,7 +171,7 @@ def add_timer_task(
163171
event_loop: asyncio.AbstractEventLoop | None = None,
164172
):
165173
def _register_timer_task(state: ConnectionState):
166-
if state == ConnectionState.AUTHENTICATED:
174+
if state.authenticated:
167175
self._conn.add_timer_task(coro, interval, event_loop)
168176

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

472+
def _schedule_missing_field_defaults(self, state: ConnectionState) -> None:
473+
if not state.authenticated:
474+
return
475+
476+
self.call_later(
477+
MISSING_DEFAULT_GRACE,
478+
self._apply_missing_field_defaults,
479+
key="missing_field_defaults",
480+
)
481+
482+
def _apply_missing_field_defaults(self) -> None:
483+
# a field declared with `default_when_missing` whose message never arrived is
484+
# still `None` here - fall back to its declared off value so it isn't left
485+
# unavailable; a real value afterwards still overrides it
486+
if not UpdatableProps.is_props(self):
487+
return
488+
for prop_field in self.fields_with_missing_default():
489+
if self.get_value(prop_field) is None:
490+
self.notify_field(prop_field, prop_field.missing_default)
491+
464492

465493
@dataclass
466494
class _ScanRecordV2:

custom_components/ef_ble/eflib/devices/_delta2_base.py

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
from bleak.backends.device import BLEDevice
2-
from bleak.backends.scanner import AdvertisementData
3-
4-
from ..connection import ConnectionState
51
from ..devicebase import DeviceBase
62
from ..entity import controls
73
from ..entity.base import dynamic
@@ -42,11 +38,19 @@ class _BmsHeartbeatBattery2(DirectBmsMDeltaHeartbeatPack):
4238

4339

4440
class Delta2Base(DeviceBase, RawDataProps):
45-
ac_output_power = raw_field(pb_inv.output_watts)
46-
ac_input_voltage = raw_field(pb_inv.ac_in_vol, pdiv(1000, 2))
47-
ac_input_current = raw_field(pb_inv.ac_in_amp, pdiv(1000, 2))
48-
ac_output_voltage = raw_field(pb_inv.inv_out_vol, pdiv(1000, 2))
49-
ac_output_current = raw_field(pb_inv.inv_out_amp, pdiv(1000, 2))
41+
ac_output_power = raw_field(pb_inv.output_watts).default_when_missing(0)
42+
ac_input_voltage = raw_field(pb_inv.ac_in_vol, pdiv(1000, 2)).default_when_missing(
43+
0
44+
)
45+
ac_input_current = raw_field(pb_inv.ac_in_amp, pdiv(1000, 2)).default_when_missing(
46+
0,
47+
)
48+
ac_output_voltage = raw_field(
49+
pb_inv.inv_out_vol, pdiv(1000, 2)
50+
).default_when_missing(0)
51+
ac_output_current = raw_field(
52+
pb_inv.inv_out_amp, pdiv(1000, 2)
53+
).default_when_missing(0)
5054

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

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

75-
ac_ports = raw_field(pb_inv.cfg_ac_enabled, lambda x: x == 1)
79+
ac_ports = raw_field(pb_inv.cfg_ac_enabled, lambda x: x == 1).default_when_missing(
80+
False
81+
)
7682
usb_ports = raw_field(pb_pd.dc_out_state, lambda x: x == 1)
7783

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

93-
def __init__(
94-
self, ble_dev: BLEDevice, adv_data: AdvertisementData, sn: str
95-
) -> None:
96-
super().__init__(ble_dev, adv_data, sn)
97-
self.on_connection_state_change(self._default_ac_ports_off_when_missing)
98-
99-
def _default_ac_ports_off_when_missing(self, state: ConnectionState) -> None:
100-
# The inverter stops sending its heartbeat entirely while AC output is off, so
101-
# `ac_ports` never gets a value after a fresh connect and the switch stays
102-
# `unavailable`. Default it to off if nothing arrives within 10s - a real
103-
# heartbeat afterwards still overrides this.
104-
if state != ConnectionState.AUTHENTICATED:
105-
return
106-
107-
def _set_off_if_unknown() -> None:
108-
if self.ac_ports is None:
109-
self.notify_field(Delta2Base.ac_ports, False)
110-
111-
self.call_later(10, _set_off_if_unknown, key="default_ac_ports_off")
112-
11399
@property
114100
def pd_heart_type(self):
115101
return BasePdHeart

custom_components/ef_ble/eflib/devices/river2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ class Device(DeviceBase, RawDataProps):
5555

5656
input_power = raw_field(pb_pd.watts_in_sum)
5757
output_power = raw_field(pb_pd.watts_out_sum)
58-
ac_input_power = raw_field(pb_inv.input_watts)
59-
ac_output_power = raw_field(pb_inv.output_watts)
58+
ac_input_power = raw_field(pb_inv.input_watts).default_when_missing(0)
59+
ac_output_power = raw_field(pb_inv.output_watts).default_when_missing(0)
6060

6161
remaining_time_charging = raw_field(pb_ems.chg_remain_time)
6262
remaining_time_discharging = raw_field(pb_ems.dsg_remain_time)

custom_components/ef_ble/eflib/props/updatable_props.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import inspect
22
from collections.abc import Callable, Iterator
33
from functools import cached_property
4-
from typing import TYPE_CHECKING, Any, ClassVar, Self, overload
4+
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeIs, overload
55

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

30+
@staticmethod
31+
def is_props(obj: object) -> "TypeIs[UpdatableProps]":
32+
"""
33+
Whether `obj` carries the updatable-props machinery (`_fields`, fields, ...)
34+
35+
Lets a `DeviceBase` mixin narrow `self` before touching props-only members,
36+
since not every device is also `UpdatableProps`.
37+
"""
38+
return isinstance(obj, UpdatableProps)
39+
40+
def fields_with_missing_default(self) -> "list[Field[Any]]":
41+
"""Fields declared with `Field.default_when_missing`"""
42+
return [field for field in self._fields if field.has_missing_default]
43+
3044
@property
3145
def updated_fields(self):
3246
"""List of field names that were updated after calling `reset_updated`"""
@@ -106,11 +120,32 @@ class Skip:
106120
"""Sentinel value for skipping assignment in field's transform function"""
107121

108122

123+
_NO_DEFAULT: Any = object()
124+
125+
109126
class Field[T]:
110127
"""Descriptor for updating values only if they changed"""
111128

112129
transform_value: Callable[[Any], T] | None = None
113130
sensor_type: "EntityType | None" = None
131+
missing_default: Any = _NO_DEFAULT
132+
133+
@property
134+
def has_missing_default(self) -> bool:
135+
return self.missing_default is not _NO_DEFAULT
136+
137+
def default_when_missing(self, value: T) -> Self:
138+
"""
139+
Fall back to `value` if this field is still unset shortly after authentication
140+
141+
Some devices stop sending a whole message while the related hardware is off (the
142+
inverter heartbeat while AC output is off, say), leaving its fields `None` and
143+
their entities unavailable. Mark the measurement-style fields with their off
144+
value (e.g. `0`, `False`); leave config/spec fields unmarked so they are not
145+
clobbered. `value` is the resolved (post-transform) value.
146+
"""
147+
self.missing_default = value
148+
return self
114149

115150
def __set_name__[P: UpdatableProps](
116151
self,

0 commit comments

Comments
 (0)