Stop showing permanently-unsupported energy/self-check sensors as Unknown
ENERGY_METER's five sensors (and SELF_CHECK's selfcheck_error, and
cooktop.py's per-burner state) all carried a `not rep or <field check>`
exists_fn -- a deliberate stub carve-out so an entity isn't dropped
just because /device/0's first poll can hand back an empty {} for a
resource that populates moments later (see entity._is_included's
docstring). But an empty {} rep and a permanently-unsupported resource
look identical from content alone: a fridge whose
/energy/consumption/vs/0 is genuinely always {} (issue #78, spotted via
its screenshot showing every energy/power sensor stuck at "Unknown")
got all five sensors created anyway, since `not {}` is True regardless
of which case it actually is.
Drop the `not rep or` prefix from all six call sites, so exists_fn goes
back to a plain field-presence check. This is the same tradeoff
AI_ENERGY_LEVEL already makes deliberately (see
TestAiEnergyLevelStubDoesNotDecideThePlatform) -- an entity that's
unlucky on first-poll timing stays absent until a reload sees real
data, rather than every genuinely-unsupported resource showing a
permanent phantom sensor. entity.py's own default field-presence gate
(no explicit exists_fn) is untouched -- that's a much larger blast
radius across every plain-field descriptor in the codebase and isn't
what issue #78 actually hit.
Regenerates air_purifier's golden fixture, the one existing device
whose /energy/consumption/vs/0 is genuinely empty -- it now correctly
drops the six energy/power keys instead of shipping them as unusable
sensors.
This commit is contained in:
@@ -237,32 +237,33 @@ _DEAD_INSTANTANEOUS_POWER = '-500'
|
|||||||
ENERGY_METER = Capability(
|
ENERGY_METER = Capability(
|
||||||
href='/energy/consumption/vs/0',
|
href='/energy/consumption/vs/0',
|
||||||
entities=(
|
entities=(
|
||||||
# `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
|
# No `not rep` stub carve-out (issue #78: a fridge whose
|
||||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
# /energy/consumption/vs/0 is permanently {} -- it just doesn't
|
||||||
# entity when /device/0 returns a not-yet-fetched stub. On a populated
|
# report energy data -- got all five sensors created anyway and
|
||||||
# rep, hide power only for the dead sentinel or an absent field.
|
# stuck at "Unknown" forever, since an empty {} rep and a
|
||||||
|
# not-yet-fetched stub are indistinguishable from content alone).
|
||||||
|
# Same tradeoff AI_ENERGY_LEVEL below already makes deliberately:
|
||||||
|
# an entity that's unlucky on first-poll timing stays absent until
|
||||||
|
# a reload sees real data, rather than every genuinely-unsupported
|
||||||
|
# resource showing a permanent phantom sensor.
|
||||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||||
device_class='power', state_class='measurement',
|
device_class='power', state_class='measurement',
|
||||||
unit='W', value_fn=clamp_power,
|
unit='W', value_fn=clamp_power,
|
||||||
exists_fn=lambda rep, resources: not rep or (
|
exists_fn=lambda rep, resources: (
|
||||||
rep.get('x.com.samsung.da.instantaneousPower')
|
rep.get('x.com.samsung.da.instantaneousPower')
|
||||||
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
||||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||||
device_class='energy',
|
device_class='energy',
|
||||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep),
|
||||||
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
|
|
||||||
# cumulativeConsumption is a second, independently-varying running
|
# cumulativeConsumption is a second, independently-varying running
|
||||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
# total alongside cumulativePower -- some fridges (issue #26) report
|
||||||
# both. Self-gates off where only cumulativePower is present. `not
|
# both. Self-gates off where only cumulativePower is present.
|
||||||
# rep or` keeps the same empty-{} stub carve-out as power_watts/
|
|
||||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
|
||||||
# entity if setup happens to land on a not-yet-fetched stub.
|
|
||||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||||
device_class='energy',
|
device_class='energy',
|
||||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||||
@@ -270,7 +271,7 @@ ENERGY_METER = Capability(
|
|||||||
device_class='energy',
|
device_class='energy',
|
||||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||||
# Monthly billing-cycle totals -- the completed prior month and the
|
# Monthly billing-cycle totals -- the completed prior month and the
|
||||||
# in-progress current month. Not ever-increasing (each resets at
|
# in-progress current month. Not ever-increasing (each resets at
|
||||||
# month boundary), so no state_class.
|
# month boundary), so no state_class.
|
||||||
@@ -278,12 +279,12 @@ ENERGY_METER = Capability(
|
|||||||
device_class='energy',
|
device_class='energy',
|
||||||
unit='kWh', value_fn=wh_to_kwh,
|
unit='kWh', value_fn=wh_to_kwh,
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||||
device_class='energy',
|
device_class='energy',
|
||||||
unit='kWh', value_fn=wh_to_kwh,
|
unit='kWh', value_fn=wh_to_kwh,
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: (
|
||||||
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,12 +415,12 @@ SELF_CHECK = Capability(
|
|||||||
icon='mdi:clipboard-check-outline',
|
icon='mdi:clipboard-check-outline',
|
||||||
entity_category='diagnostic'),
|
entity_category='diagnostic'),
|
||||||
# List of error codes from the last self-check; joined for display.
|
# List of error codes from the last self-check; joined for display.
|
||||||
# Not every fridge reports the field, hence the exists_fn.
|
# Not every fridge reports the field, hence the exists_fn (no stub
|
||||||
|
# carve-out -- see ENERGY_METER above).
|
||||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||||
icon='mdi:alert-circle-outline',
|
icon='mdi:alert-circle-outline',
|
||||||
entity_category='diagnostic',
|
entity_category='diagnostic',
|
||||||
exists_fn=lambda rep, resources: (
|
exists_fn=lambda rep, resources: 'x.com.samsung.da.error' in rep,
|
||||||
not rep or 'x.com.samsung.da.error' in rep),
|
|
||||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||||
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
||||||
icon='mdi:play-circle-outline',
|
icon='mdi:play-circle-outline',
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ COOKTOP_MODE = Capability(
|
|||||||
options, f'OperationState{slot}'
|
options, f'OperationState{slot}'
|
||||||
),
|
),
|
||||||
exists_fn=lambda rep, resources, slot=slot: (
|
exists_fn=lambda rep, resources, slot=slot: (
|
||||||
not rep or _option_value(
|
_option_value(
|
||||||
rep.get('x.com.samsung.da.options'),
|
rep.get('x.com.samsung.da.options'),
|
||||||
f'OperationState{slot}',
|
f'OperationState{slot}',
|
||||||
) is not None
|
) is not None
|
||||||
|
|||||||
-6
@@ -6,19 +6,13 @@
|
|||||||
"diagnosis_status",
|
"diagnosis_status",
|
||||||
"display_light",
|
"display_light",
|
||||||
"dust",
|
"dust",
|
||||||
"energy_kwh",
|
|
||||||
"energy_last_month_kwh",
|
|
||||||
"energy_saved_kwh",
|
|
||||||
"energy_this_month_kwh",
|
|
||||||
"fan_direction",
|
"fan_direction",
|
||||||
"fan_speed_level",
|
"fan_speed_level",
|
||||||
"filter_progress",
|
"filter_progress",
|
||||||
"fine_dust",
|
"fine_dust",
|
||||||
"odor",
|
"odor",
|
||||||
"operating_mode",
|
"operating_mode",
|
||||||
"power_energy_kwh",
|
|
||||||
"power_switch",
|
"power_switch",
|
||||||
"power_watts",
|
|
||||||
"super_fine_dust"
|
"super_fine_dust"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,13 +156,17 @@ class TestEnergyMeter:
|
|||||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||||
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
|
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
|
||||||
|
|
||||||
def test_both_entities_included_on_empty_stub(self):
|
def test_both_entities_hidden_on_empty_rep(self):
|
||||||
"""An empty {} rep means the resource exists but data isn't fetched yet
|
"""Issue #78: a permanently-{} rep (a fridge that just doesn't report
|
||||||
(see entity._is_included) -- include both so sub-polls populate them."""
|
energy data) must not spawn a phantom sensor stuck at "Unknown"
|
||||||
|
forever -- an empty {} rep and a not-yet-fetched stub are
|
||||||
|
indistinguishable from content alone, so this deliberately drops the
|
||||||
|
old stub carve-out (same tradeoff AI_ENERGY_LEVEL already makes,
|
||||||
|
see TestAiEnergyLevelStubDoesNotDecideThePlatform)."""
|
||||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||||
assert pw.exists_fn({}, {}) is True
|
assert pw.exists_fn({}, {}) is False
|
||||||
assert kwh.exists_fn({}, {}) is True
|
assert kwh.exists_fn({}, {}) is False
|
||||||
|
|
||||||
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
|
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
|
||||||
"""A populated rep that lacks instantaneousPower must not spawn a
|
"""A populated rep that lacks instantaneousPower must not spawn a
|
||||||
@@ -319,11 +323,11 @@ class TestSelfCheckError:
|
|||||||
desc = self._desc()
|
desc = self._desc()
|
||||||
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
||||||
|
|
||||||
def test_exists_for_empty_stub_rep(self):
|
def test_hidden_for_empty_rep(self):
|
||||||
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
"""Issue #78: no stub carve-out -- see ENERGY_METER's
|
||||||
must be included-for-now, same as ENERGY_METER's fields."""
|
test_both_entities_hidden_on_empty_rep for why."""
|
||||||
desc = self._desc()
|
desc = self._desc()
|
||||||
assert desc.exists_fn({}, {}) is True
|
assert desc.exists_fn({}, {}) is False
|
||||||
|
|
||||||
def test_value_joins_list(self):
|
def test_value_joins_list(self):
|
||||||
desc = self._desc()
|
desc = self._desc()
|
||||||
|
|||||||
Reference in New Issue
Block a user