Compare commits

..
4 Commits
Author SHA1 Message Date
Marc Billow 9b48ca0e82 Merge pull request #5 from mbillow/manifest-version-bump
chore: bump manifest version to 0.2.1
2026-07-11 20:27:19 -05:00
Marc Billow e66f4fd2ff chore: bump manifest version to 0.2.1
Was left at 0.1.0 across the last two releases. Also de-hardcode the
diagnostics test's expected version so this doesn't happen again --
it now reads manifest.json directly instead of a copy-pasted literal.
2026-07-11 20:26:52 -05:00
Marc Billow bf3b3a8bbd Merge pull request #4 from mbillow/washer-drum-clean
feat(washer): expose Drum Clean+ maintenance status
2026-07-11 20:20:12 -05:00
Marc Billow dd541b19a1 feat(washer): expose Drum Clean+ maintenance status
Adds cycles-until-due and last-cleaned sensors decoded from the same
options[] array the course selector already reads (DrumCleanProposal_N -
WashingTimes_N for cycles remaining, DrumCleanLog_<iso> for last-cleaned),
verified byte-for-byte against a live app screenshot ("Potreba cistenia po
37 cykloch" / "Naposledy cistene pred 10 dnami").
2026-07-11 20:18:28 -05:00
5 changed files with 98 additions and 2 deletions

No files matched your search

+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0", "pyOpenSSL>=23.0",
"smartthings-local>=0.1.0" "smartthings-local>=0.1.0"
], ],
"version": "0.1.0" "version": "0.2.1"
} }
@@ -6,6 +6,8 @@ family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback `registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires. detection this device type requires.
""" """
from datetime import datetime, timezone
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
@@ -114,6 +116,38 @@ def _cycle_write(p, rep, href=None):
} }
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> is the count since the last one --
# their difference is exactly the "N cycles until due" figure the Samsung
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
# is the last-clean timestamp (verified against the same screenshot's "10
# days ago"); no explicit timezone field accompanies it on this resource,
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = _option_value(opts, 'DrumCleanProposal')
washed = _option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
def _drum_clean_last_cleaned(rep):
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
except ValueError:
return None
WASHER_COURSE = Capability( WASHER_COURSE = Capability(
href='/course/vs/0', href='/course/vs/0',
entities=( entities=(
@@ -124,6 +158,16 @@ WASHER_COURSE = Capability(
rep_fn=lambda rep: _option_value( rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'Course'), rep.get('x.com.samsung.da.options'), 'Course'),
write_fn=_cycle_write), write_fn=_cycle_write),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
icon='mdi:calendar-clock', device_class='timestamp',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
), ),
) )
+2
View File
@@ -7,6 +7,8 @@
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
"diagnosis_status", "diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh", "energy_kwh",
"finish_time", "finish_time",
"firmware_update", "firmware_update",
+8 -1
View File
@@ -1,12 +1,19 @@
"""Tests for the diagnostics platform.""" """Tests for the diagnostics platform."""
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from custom_components.localthings.const import DOMAIN from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED from custom_components.localthings.registry.redact import REDACTED
_MANIFEST_VERSION = json.loads(
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
)['version']
async def test_diagnostics_shape_and_redaction( async def test_diagnostics_shape_and_redaction(
hass: HomeAssistant, mock_entry, mock_coordinator_session hass: HomeAssistant, mock_entry, mock_coordinator_session
@@ -19,7 +26,7 @@ async def test_diagnostics_shape_and_redaction(
assert diagnostics["device_type"] == 'refrigerator' assert diagnostics["device_type"] == 'refrigerator'
assert diagnostics["one_ui_version"] == '7.0 Refrigerator' assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
assert diagnostics["unbound_hrefs"] == [] assert diagnostics["unbound_hrefs"] == []
assert diagnostics["integration_version"] == '0.1.0' assert diagnostics["integration_version"] == _MANIFEST_VERSION
assert diagnostics["smartthings_local_version"] assert diagnostics["smartthings_local_version"]
resources = diagnostics["resources"] resources = diagnostics["resources"]
+43
View File
@@ -112,6 +112,49 @@ class TestCycleOptions:
assert washer._cycle_options(resources) == [] assert washer._cycle_options(resources) == []
class TestDrumClean:
def test_cycles_remaining(self):
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
app screenshot's 'Potreba cistenia po 37 cykloch'."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 37
def test_cycles_remaining_never_negative(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']}
assert desc.rep_fn(rep) == 0
def test_cycles_remaining_missing_fields(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
def test_cycles_remaining_exists_only_when_computable(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_cycles_remaining')
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
assert desc.exists_fn(rep, {}) is True
def test_last_cleaned(self):
"""DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime,
matching the same screenshot's '10 days ago' (as of 2026-07-11)."""
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']}
from datetime import datetime, timezone
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc)
def test_last_cleaned_missing(self):
desc = next(e for e in washer.WASHER_COURSE.entities
if e.key == 'drum_clean_last_cleaned')
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
class TestBuzzerSound: class TestBuzzerSound:
def test_href(self): def test_href(self):
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0' assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'