Compare commits

...
Author SHA1 Message Date
Claude eb07af3c9a Warn against hard-coding device-specific values in adding-device-support skill
Dumps capture one unit's snapshot, not the field's universe. Add a
dedicated section pointing at the existing options_field/range_field/
native_min_fn machinery for deriving select options and number
ranges/steps live from the device rep instead of transcribing values
from a single dump into Python literals.
2026-07-26 23:48:20 +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
11 changed files with 161 additions and 32 deletions

No files matched your search

+35 -4
View File
@@ -103,7 +103,38 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
as a gap for a human, or ignore it with a documented reason — never invent an as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule). entity on a hunch (`ignored.py`'s rule).
## 5. Names and enum labels live in translations, never in Python ## 5. Never hard-code the one dump's values
A single `/device/0` dump is **one device on one firmware** — its select options,
temperature range/increment, and any other "what values are valid here" data are
**that unit's snapshot**, not the field's universe. Other units of the same model
(different region, firmware, board revision) can support more, fewer, or
differently-stepped values. If the dump reports the live option/range list, wire
the descriptor to read it live — don't transcribe what you saw into a Python
literal:
- **Selects**: use `options_field` (a resource field holding the live options
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
reads the current device's real options every time, not `options=(...)` typed
from the dump. Reach for a callable `options` only when the values require
cross-resource computation the field alone can't give you — a static tuple is
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
with a closed value set), never for vendor `supported*` lists.
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
`native_max`/`step` when the dump has no such field and the bound is genuinely
fixed by spec, not just "the only value this one unit happened to report."
- **Anywhere else** a field's presence, count, or shape looks like it could vary
by model/config (course lists, capability flags, supported-mode arrays):
check whether the resource carries its own `supported*` companion field before
assuming the observed value is exhaustive.
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
a judgement call worth a one-line comment saying why it's safe — the default
assumption should be "derive it," not "copy it."
## 6. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own catalog, keyed by `translation_key` — which defaults to the descriptor's own
@@ -142,7 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`. `tests/test_translations.py`.
## 6. Coverage discipline: bound or ignored ## 7. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`) worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
@@ -156,7 +187,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry. that another binds, scope the ignore to that family's registry.
## 7. Reuse before writing new code ## 8. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course (shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
@@ -165,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a `laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying. shared module rather than copying.
## 8. Lock it in ## 9. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json` 1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
@@ -2,7 +2,8 @@
Shared by dryer/dishwasher/oven/washer families. 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 ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -28,17 +29,41 @@ def _int(v):
return None 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): def _delay_hours(v):
"""delayStartTime is a duration until the cycle starts, not a """delayStartTime is a duration until the cycle starts, not a
wall-clock time -- "01:00" means "1 hour from when you press start", wall-clock time -- "01:00" means "1 hour from when you press start",
not "1 AM".""" not "1 AM"."""
if not v: total_seconds = _remaining_seconds(v)
return 0.0 if total_seconds is None:
try: return 0.0 if not v else None
h, m, s = v.split(':') return total_seconds / 3600.0
return int(h) + int(m) / 60 + int(s) / 3600
except Exception:
return None
def _format_delay(hours): def _format_delay(hours):
@@ -53,21 +78,33 @@ def _delay_field(rep):
wall-clock time -- see _delay_hours). Write back whichever key the wall-clock time -- see _delay_hours). Write back whichever key the
device itself is using; default to delayStartTime for hardware that device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior).""" reports neither yet (matches prior behavior)."""
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep return (
else 'x.com.samsung.da.delayStartTime') 'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(remaining_str): def _finish_time(rep):
if not remaining_str: if not _is_active(rep):
return None return None
try: total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
h, m, s = remaining_str.split(':') if not total_s:
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:
return None 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 # Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
@@ -87,6 +124,7 @@ OPERATIONAL_STATE = Capability(
device_class='enum', device_class='enum',
options=('idle', 'active', 'pause'), options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf), translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the # cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active'). # adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state. # 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' 'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress')) else _progress(rep.get('x.com.samsung.da.progress'))
)), )),
SensorDesc(key='progress_percentage', SensorDesc(key='progress_percentage',
unit='%', state_class='measurement', unit='%', state_class='measurement',
rep_fn=lambda rep: ( rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active' 0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage')) else _int(rep.get('x.com.samsung.da.progressPercentage'))
)), )),
# Only show finish time when machine is actively running. Samsung # Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and # firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'. # freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp', SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=lambda rep: ( rep_fn=_finish_time),
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
or rep.get('x.com.samsung.da.progress') == 'Finish' SensorDesc(key='completion_minutes',
else _finish_time(rep.get('x.com.samsung.da.remainingTime')) 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', NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h', device_class='duration', unit='h',
native_min=0, native_max=24, step=1, native_min=0, native_max=24, step=1,
@@ -140,6 +183,7 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'], ['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})), {'x.com.samsung.da.state': p})),
STOP_BUTTON, STOP_BUTTON,
), ),
) )
@@ -400,7 +400,9 @@
"7e": "Self-Clean", "7e": "Self-Clean",
"86": "Deep Wash", "86": "Deep Wash",
"8f": "Intense Cold", "8f": "Intense Cold",
"96": "Less Microfiber" "96": "Less Microfiber",
"2b": "AI Wash",
"2a": "Jeans"
} }
}, },
"washer_dry_level": { "washer_dry_level": {
@@ -509,6 +511,9 @@
"finish_time": { "finish_time": {
"name": "Estimated finish" "name": "Estimated finish"
}, },
"completion_minutes": {
"name": "Completion time"
},
"freezer_temperature": { "freezer_temperature": {
"name": "Freezer temperature" "name": "Freezer temperature"
}, },
@@ -400,7 +400,9 @@
"7e": "Zelfreinigend", "7e": "Zelfreinigend",
"86": "Diep wassen", "86": "Diep wassen",
"8f": "Intensief koud", "8f": "Intensief koud",
"96": "Minder microvezels" "96": "Minder microvezels",
"2b": "AI Wash",
"2a": "Spijkerbroek"
} }
}, },
"washer_dry_level": { "washer_dry_level": {
@@ -509,6 +511,9 @@
"finish_time": { "finish_time": {
"name": "Geschatte eindtijd" "name": "Geschatte eindtijd"
}, },
"completion_minutes": {
"name": "Voltooiingstijd"
},
"freezer_temperature": { "freezer_temperature": {
"name": "Vriezertemperatuur" "name": "Vriezertemperatuur"
}, },
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"auto_release_dry", "auto_release_dry",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"buzzer_sound", "buzzer_sound",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -5,6 +5,7 @@
"bubble_soak", "bubble_soak",
"buzzer_sound", "buzzer_sound",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"bubble_soak", "bubble_soak",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -2,6 +2,7 @@
"state_keys": [ "state_keys": [
"alarm_code", "alarm_code",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+2 -1
View File
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers: class TestCourseHelpers:
def test_parses_edit_course_list(self): def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96' raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
assert laundry.parse_edit_course_list(raw) == [ assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27', '1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96', '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): 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 from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -24,6 +26,42 @@ class TestProgressPercentage:
assert desc.rep_fn(rep) == 42 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: class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self): def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE 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') desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'} rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
path, body = desc.write_fn(1.5, rep) 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'}