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
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.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
detection this device type requires.
"""
from datetime import datetime, timezone
from ..capability import Capability
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(
href='/course/vs/0',
entities=(
@@ -124,6 +158,16 @@ WASHER_COURSE = Capability(
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'Course'),
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",
"delay_start_hours",
"diagnosis_status",
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"finish_time",
"firmware_update",
+8 -1
View File
@@ -1,12 +1,19 @@
"""Tests for the diagnostics platform."""
from __future__ import annotations
import json
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
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(
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["one_ui_version"] == '7.0 Refrigerator'
assert diagnostics["unbound_hrefs"] == []
assert diagnostics["integration_version"] == '0.1.0'
assert diagnostics["integration_version"] == _MANIFEST_VERSION
assert diagnostics["smartthings_local_version"]
resources = diagnostics["resources"]
+43
View File
@@ -112,6 +112,49 @@ class TestCycleOptions:
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:
def test_href(self):
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'