diff --git a/README.md b/README.md index 3a236b1..efb84ad 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + +LocalThings Logo + +LocalThings Logo + # LocalThings **A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN. diff --git a/custom_components/localthings/brand/dark_icon.png b/custom_components/localthings/brand/dark_icon.png index 3a24ee8..38c1274 100644 Binary files a/custom_components/localthings/brand/dark_icon.png and b/custom_components/localthings/brand/dark_icon.png differ diff --git a/custom_components/localthings/brand/dark_icon@2x.png b/custom_components/localthings/brand/dark_icon@2x.png index d85ce25..c96c12b 100644 Binary files a/custom_components/localthings/brand/dark_icon@2x.png and b/custom_components/localthings/brand/dark_icon@2x.png differ diff --git a/custom_components/localthings/brand/dark_logo.png b/custom_components/localthings/brand/dark_logo.png index 0ce0152..8db1d6a 100644 Binary files a/custom_components/localthings/brand/dark_logo.png and b/custom_components/localthings/brand/dark_logo.png differ diff --git a/custom_components/localthings/brand/dark_logo@2x.png b/custom_components/localthings/brand/dark_logo@2x.png index 3f3d6bc..09317ee 100644 Binary files a/custom_components/localthings/brand/dark_logo@2x.png and b/custom_components/localthings/brand/dark_logo@2x.png differ diff --git a/custom_components/localthings/brand/icon.png b/custom_components/localthings/brand/icon.png index f6f8129..38c1274 100644 Binary files a/custom_components/localthings/brand/icon.png and b/custom_components/localthings/brand/icon.png differ diff --git a/custom_components/localthings/brand/icon@2x.png b/custom_components/localthings/brand/icon@2x.png index d00463a..c96c12b 100644 Binary files a/custom_components/localthings/brand/icon@2x.png and b/custom_components/localthings/brand/icon@2x.png differ diff --git a/custom_components/localthings/brand/logo.png b/custom_components/localthings/brand/logo.png index 8bd38c1..2a8ea49 100644 Binary files a/custom_components/localthings/brand/logo.png and b/custom_components/localthings/brand/logo.png differ diff --git a/custom_components/localthings/brand/logo@2x.png b/custom_components/localthings/brand/logo@2x.png index 661bb0d..815e28f 100644 Binary files a/custom_components/localthings/brand/logo@2x.png and b/custom_components/localthings/brand/logo@2x.png differ diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 9d98d96..6970140 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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, ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index a091028..f990cc6 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 3429868..c922d82 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 2a50531..6dedaa7 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -3,6 +3,7 @@ "alarm_code", "auto_release_dry", "child_lock", + "completion_minutes", "cycle", "cycle_active", "delay_start_hours", diff --git a/tests/fixtures/golden/dryer.json b/tests/fixtures/golden/dryer.json index 97f17c5..f0dc9fb 100644 --- a/tests/fixtures/golden/dryer.json +++ b/tests/fixtures/golden/dryer.json @@ -3,6 +3,7 @@ "alarm_code", "buzzer_sound", "child_lock", + "completion_minutes", "cycle", "cycle_active", "delay_start_hours", diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index b48cd1b..ca791c5 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -5,6 +5,7 @@ "bubble_soak", "buzzer_sound", "child_lock", + "completion_minutes", "cycle", "cycle_active", "delay_start_hours", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index d2bab38..da6f20c 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -3,6 +3,7 @@ "alarm_code", "bubble_soak", "child_lock", + "completion_minutes", "cycle", "cycle_active", "delay_start_hours", diff --git a/tests/fixtures/golden/washer_flexwash.json b/tests/fixtures/golden/washer_flexwash.json index 701f2ed..e100a59 100644 --- a/tests/fixtures/golden/washer_flexwash.json +++ b/tests/fixtures/golden/washer_flexwash.json @@ -2,6 +2,7 @@ "state_keys": [ "alarm_code", "child_lock", + "completion_minutes", "cycle", "cycle_active", "delay_start_hours", diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index bb3eb16..f0fc6c0 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -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): diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 992467f..606efb6 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -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'} \ No newline at end of file