Compare commits

...
Author SHA1 Message Date
Marc Billow 8504682d9b 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.
2026-07-27 01:03:01 +00:00
Marc Billow aff7647460 Merge pull request #65 from Metal-Eagle/feature/add-completion-time
Feature/add completion time
2026-07-24 23:46:47 -05:00
Jeroen Hof 705da781f1 Merge branch 'main' into feature/add-completion-time 2026-07-25 06:41:17 +02:00
Jeroen Hof 6de1a4090e i18n: simplify completion time translation in English and Dutch 2026-07-25 06:02:45 +02:00
Jeroen Hof ef75db6697 i18n: update completion time translation in English and Dutch 2026-07-25 05:55:47 +02:00
Jeroen Hof 9e290f27ee refactor: update sensor descriptions for completion time and delay start 2026-07-25 05:24:10 +02:00
Jeroen Hof 37bfff152d refactor: remove unused strings.json file 2026-07-25 05:15:34 +02:00
Jeroen Hof 317b7d0c1b fix: add missing newline at end of en.json file 2026-07-25 05:09:55 +02:00
Jeroen Hof ba8e41d310 Implement code changes to enhance functionality and improve performance 2026-07-25 05:08:18 +02:00
Jeroen Hof 724686c42c i18n: add translations for AI Wash and Jeans 2026-07-25 05:01:40 +02:00
Jeroen Hof edccb6ae3f i18n: add new wash options for AI Wash and Jeans 2026-07-25 04:59:37 +02:00
Jeroen Hof 4a91f62a36 i18n: add new translations for AI Wash and Spijkerbroek 2026-07-25 04:49:40 +02:00
Jeroen Hof 1d4d38db14 Merge branch 'main' into feature/add-completion-time 2026-07-25 04:47:22 +02:00
Jeroen Hof 365a1c4722 feat: enhance operational capabilities by adding completion_minutes and updating related logic; remove completion_time references 2026-07-24 15:35:17 +02:00
Jeroen Hof 7be91b56e8 Merge remote-tracking branch 'upstream/main' into feature/add-completion-time 2026-07-24 08:23:42 +02:00
Jeroen Hof 2c283218b0 feat: add completion_minutes and completion_time to state_keys in JSON fixtures 2026-07-24 08:10:17 +02:00
Jeroen Hof ab874797fc Merge branch 'main' into feature/add-completion-time 2026-07-24 07:48:57 +02:00
Jeroen Hof f5d4e2219a fix: remove redundant course codes from parse_edit_course_list test case 2026-07-24 07:47:53 +02:00
Jeroen Hof 86f07abf30 fix: correct expected output for parse_edit_course_list test case 2026-07-24 06:29:53 +02:00
Jeroen Hof b00390c093 fix: correct key for Jeans cycle in strings and translations 2026-07-24 06:27:28 +02:00
Jeroen Hof e913a3c2c0 feat: add new washer cycle options for AI Wash and Jeans 2026-07-24 06:26:24 +02:00
Jeroen Hof b2db4bf357 feat: add completion time and minutes sensors with parsing logic 2026-07-24 06:23:53 +02:00
14 changed files with 159 additions and 62 deletions
@@ -237,32 +237,33 @@ _DEAD_INSTANTANEOUS_POWER = '-500'
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
# `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
# entity when /device/0 returns a not-yet-fetched stub. On a populated
# rep, hide power only for the dead sentinel or an absent field.
# No `not rep` stub carve-out (issue #78: a fridge whose
# /energy/consumption/vs/0 is permanently {} -- it just doesn't
# report energy data -- got all five sensors created anyway and
# 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',
device_class='power', state_class='measurement',
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')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep),
# cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. `not
# 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.
# both. Self-gates off where only cumulativePower is present.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
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
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
@@ -270,7 +271,7 @@ ENERGY_METER = Capability(
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
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
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
@@ -278,12 +279,12 @@ ENERGY_METER = Capability(
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
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',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
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',
entity_category='diagnostic'),
# 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',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.error' in rep),
exists_fn=lambda rep, resources: 'x.com.samsung.da.error' in rep,
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', payload='Start',
icon='mdi:play-circle-outline',
@@ -97,7 +97,7 @@ COOKTOP_MODE = Capability(
options, f'OperationState{slot}'
),
exists_fn=lambda rep, resources, slot=slot: (
not rep or _option_value(
_option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
@@ -2,7 +2,8 @@
Shared by dryer/dishwasher/oven/washer families.
"""
from datetime import datetime, timezone, timedelta
import math
from datetime import datetime, timedelta, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -28,17 +29,41 @@ def _int(v):
return None
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
def _remaining_seconds(raw):
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
except (ValueError, TypeError):
return None
if len(parts) == 3:
h, m, s = parts
elif len(parts) == 2:
h, m, s = 0, *parts
else:
return None
return h * 3600 + m * 60 + s
def _delay_hours(v):
"""delayStartTime is a duration until the cycle starts, not a
wall-clock time -- "01:00" means "1 hour from when you press start",
not "1 AM"."""
if not v:
return 0.0
try:
h, m, s = v.split(':')
return int(h) + int(m) / 60 + int(s) / 3600
except Exception:
return None
total_seconds = _remaining_seconds(v)
if total_seconds is None:
return 0.0 if not v else None
return total_seconds / 3600.0
def _format_delay(hours):
@@ -53,21 +78,33 @@ def _delay_field(rep):
wall-clock time -- see _delay_hours). Write back whichever key the
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime')
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(remaining_str):
if not remaining_str:
def _finish_time(rep):
if not _is_active(rep):
return None
try:
h, m, s = remaining_str.split(':')
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
except Exception:
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
@@ -87,6 +124,7 @@ OPERATIONAL_STATE = Capability(
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
@@ -106,21 +144,26 @@ OPERATIONAL_STATE = Capability(
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=lambda rep: (
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
or rep.get('x.com.samsung.da.progress') == 'Finish'
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
)),
rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
@@ -140,6 +183,7 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
STOP_BUTTON,
),
)
@@ -400,7 +400,9 @@
"7e": "Self-Clean",
"86": "Deep Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"2b": "AI Wash",
"2a": "Jeans"
}
},
"washer_dry_level": {
@@ -509,6 +511,9 @@
"finish_time": {
"name": "Estimated finish"
},
"completion_minutes": {
"name": "Completion time"
},
"freezer_temperature": {
"name": "Freezer temperature"
},
@@ -400,7 +400,9 @@
"7e": "Zelfreinigend",
"86": "Diep wassen",
"8f": "Intensief koud",
"96": "Minder microvezels"
"96": "Minder microvezels",
"2b": "AI Wash",
"2a": "Spijkerbroek"
}
},
"washer_dry_level": {
@@ -509,6 +511,9 @@
"finish_time": {
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
},
-6
View File
@@ -6,19 +6,13 @@
"diagnosis_status",
"display_light",
"dust",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"fan_direction",
"fan_speed_level",
"filter_progress",
"fine_dust",
"odor",
"operating_mode",
"power_energy_kwh",
"power_switch",
"power_watts",
"super_fine_dust"
]
}
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"auto_release_dry",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -5,6 +5,7 @@
"bubble_soak",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code",
"bubble_soak",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+1
View File
@@ -2,6 +2,7 @@
"state_keys": [
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
+13 -9
View File
@@ -156,13 +156,17 @@ class TestEnergyMeter:
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
def test_both_entities_included_on_empty_stub(self):
"""An empty {} rep means the resource exists but data isn't fetched yet
(see entity._is_included) -- include both so sub-polls populate them."""
def test_both_entities_hidden_on_empty_rep(self):
"""Issue #78: a permanently-{} rep (a fridge that just doesn't report
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')
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
assert pw.exists_fn({}, {}) is True
assert kwh.exists_fn({}, {}) is True
assert pw.exists_fn({}, {}) is False
assert kwh.exists_fn({}, {}) is False
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
"""A populated rep that lacks instantaneousPower must not spawn a
@@ -319,11 +323,11 @@ class TestSelfCheckError:
desc = self._desc()
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
def test_exists_for_empty_stub_rep(self):
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
must be included-for-now, same as ENERGY_METER's fields."""
def test_hidden_for_empty_rep(self):
"""Issue #78: no stub carve-out -- see ENERGY_METER's
test_both_entities_hidden_on_empty_rep for why."""
desc = self._desc()
assert desc.exists_fn({}, {}) is True
assert desc.exists_fn({}, {}) is False
def test_value_joins_list(self):
desc = self._desc()
+2 -1
View File
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
'2B', '2A'
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
+39 -1
View File
@@ -1,3 +1,5 @@
"""Unit tests for operational state capabilities."""
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -24,6 +26,42 @@ class TestProgressPercentage:
assert desc.rep_fn(rep) == 42
class TestCompletionMinutes:
"""Unit tests for completion_minutes entity."""
def test_completion_minutes_parsing(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
# 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins
rep = {'x.com.samsung.da.remainingTime': '01:25:30'}
assert desc.rep_fn(rep) == 86
# Exact minutes: 1 hour 30 mins 00 secs -> 90 mins
rep_exact = {'x.com.samsung.da.remainingTime': '01:30:00'}
assert desc.rep_fn(rep_exact) == 90
def test_completion_minutes_fallback_key(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {'remainingTime': '00:45:00'}
assert desc.rep_fn(rep) == 45
def test_completion_minutes_stale_finish_gated(self):
"""Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'.
Should return 0 to prevent stuck values."""
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {
'x.com.samsung.da.progress': 'Finish',
'x.com.samsung.da.remainingTime': '00:01:00',
}
assert desc.rep_fn(rep) == 0
def test_completion_minutes_missing_or_invalid(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {}
assert desc.rep_fn(rep) is None
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -53,4 +91,4 @@ class TestDelayFieldFallback:
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
path, body = desc.write_fn(1.5, rep)
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}