Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Vendored
+2
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user