Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
No files matched your search
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in new issue
Block a user