diff --git a/README.md b/README.md
index 3a236b1..efb84ad 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,8 @@
+
+
+
+
+
# 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