Compare commits

...
Author SHA1 Message Date
Marc Billow e07d6ed706 Complete dryer coverage from issue #14 screenshots (DV90BB5245AES1)
Name the two remaining unidentified course codes (0x21 Hygiene Care,
0x4C Air Refresh) by matching the SmartThings app's course-list order
against editCourseList in the reporter's dump.

Also close three entity gaps the screenshots surfaced that v0.6.0
missed: dry level was read-only despite the app exposing a writable
Level 1/2/3 picker; the app's "AI pattern" toggle (AiOption_On/Off)
and "Auto cycle link" toggle (/cycleinterface/vs/0, previously
globally ignored as always-empty on washer dumps) were both unbound.
Scope that ignore to washer.py now that the dryer binds it for real.
2026-07-24 00:43:58 +00:00
Marc Billow cf18cc3ecc Merge pull request #59 from mbillow/claude/device-support-issue-56-shy2vg
Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56)
2026-07-23 18:50:06 -05:00
Marc Billow 61e6035a36 Merge pull request #58 from mbillow/claude/climate-state-freshness-s5v2bk
fix: apply optimistic writes to their actual target href, not the bound href (issues #17, #53)
2026-07-23 18:47:48 -05:00
Marc Billow 83bd158049 fix: apply the optimistic write to its actual target href, not the bound href (issues #17, #53)
An independent review turned up the real bug behind the climate lag:
async_send_command applied the optimistic value and settle guard to
bound_entity.href, but write_fn's path_segs -- the resource actually
POSTed to -- can point somewhere else entirely. The AC's composite
climate entity is bound to /mode/vs/0, yet a power/temperature/fan/
swing/preset command writes to its own sibling resource (/power/0,
/temperature/desired/0, ...), which is also what climate.py reads the
displayed state from. The optimistic value landed on /mode/vs/0
instead, so the resource the entity actually shows stayed stale until
the next unrelated read of it -- surviving the earlier optimistic-apply
fix (issue #27), which applied to the same wrong href.

Derive the write's target from path_segs and apply/guard/log against
that instead. Reverts the previous commit's settle-window-sizing
change on this branch: that was chasing a real but speculative edge
case (a confirm poll slower than the settle window) that a follow-up
review couldn't confirm matches the reported symptom, and its
early-release mechanism had its own races (a debounced refresh that
hadn't actually run yet, overlapping writes to the same href) for
marginal benefit once this fix lands. Simpler to drop it than carry
that risk for a case not in evidence.

Added a coordinator-level regression test against the real AC CLIMATE
capability (not just a synthetic mismatched-path descriptor) sending a
power command and asserting the optimistic value lands on /power/0,
not /mode/vs/0.
2026-07-23 22:42:04 +00:00
Marc Billow 615c5d5aa9 fix: size the write-settle window to outlast its own confirm poll (issues #17, #53)
mark_write_pending's window was a fixed few seconds, started before the
PUT and the confirming /device/0 refresh that follows it -- but that
refresh is a full summary poll, which can legitimately take far longer
than that on these AC devices. The window routinely expired while the
confirm poll was still in flight, so a stale read (the device's own
resource tree hadn't caught up to the instant physical change yet)
landed unprotected and reverted the optimistic write, with nothing to
correct it again until the next scheduled summary poll. That's the
20-60s lag both issues report even after the earlier optimistic-apply
fix.

Size the window to cover the PUT and confirm-poll timeouts combined,
and release it as soon as that round trip actually completes (success
or failure) instead of leaving it open for the rest of a now much
longer window.
2026-07-23 22:11:26 +00:00
13 changed files with 205 additions and 39 deletions

No files matched your search

+4 -3
View File
@@ -13,9 +13,10 @@ snapshot via `coordinator.resource(href)` -- the same cross-resource read that
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs (the
bound href is only used for logging), so one descriptor drives writes to power,
mode, temperature and wind resources.
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
+20 -4
View File
@@ -547,6 +547,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return
path_segs, body = result
# The write's actual target, not necessarily bound_entity.href. Most
# descriptors write to the same resource they're bound to, but a
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
# drives writes to several sibling resources via path_segs
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
# write_fn picks per payload (see airconditioner._climate_write).
# Applying the optimistic value and settle guard below to
# bound_entity.href instead of this target protected the wrong
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
# merge while the resource the climate entity actually displays from
# (e.g. /power/0) never got one, so HA kept showing the pre-write
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
write_href = '/' + '/'.join(path_segs)
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
# observe) through the same apply(), itself included, so flipping
@@ -555,19 +571,19 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# onto, the settle window was just delaying the real device
# confirmation for a few seconds on every write, which read exactly
# like the write being silently reverted (issue #27).
self._observe.apply(href, body, source='optimistic')
self._observe.mark_write_pending(href)
self._observe.apply(write_href, body, source='optimistic')
self._observe.mark_write_pending(write_href)
def _do_put():
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
self._log.info("PUT %s → code %#04x", href, code)
self._log.info("PUT %s → code %#04x", write_href, code)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
self._log.error("command failed for %s: %s", href, e)
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
@@ -26,5 +26,6 @@ REGISTRY = DeviceRegistry(
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
dryer.AUTO_CYCLE_LINK,
]),
)
@@ -15,5 +15,6 @@ REGISTRY = DeviceRegistry(
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
washer.CYCLE_INTERFACE_IGNORED,
]),
)
@@ -4,13 +4,15 @@ Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
the /course/vs/0 cycle select -- lives in laundry.py.
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/course/vs/0 -> DRYER_COURSE (shared cycle select, plus the AI-pattern
toggle read off the same options[] array; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
/cycleinterface/vs/0 -> AUTO_CYCLE_LINK
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
from ..entities import SelectDesc, SensorDesc, SwitchDesc
from .laundry import bool_option_switch, cycle_select
def _wrinkle_write(p, rep, href=None):
@@ -23,8 +25,15 @@ DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent'),
# supportedDryLevel values (e.g. 'None'/'Less'/'Normal'/'More') are
# already human-readable on this device, unlike the combo-unit
# numeric-minute codes washer.py's washer_dry_level select needs
# translations for -- no translation_key needed here.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent',
options_field='x.com.samsung.da.supportedDryLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
name='Dry time', icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
@@ -40,15 +49,21 @@ DRYER_SETTINGS = Capability(
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
# 2026-05-29; codes 0x21/0x4C named from the issue #14 DV90BB5245AES1
# SmartThings course-list screenshots on 2026-07-24). The /st/dryercourse/vs/0
# resource re-encodes the same selected course and is ignored (ignored.py) --
# the mirror of how /st/washercourse/vs/0 is ignored for washers.
#
# The same options[] array also carries AiOption_On/Off -- the app's "AI
# pattern" toggle ("Get cycle recommendations based on your usage patterns"),
# confirmed against the issue #14 screenshots. Same On/Off token shape as
# washer.py's bubble-soak/pre-wash/intensive toggles; not previously bound.
DRYER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
bool_option_switch('ai_pattern', 'AI pattern', 'mdi:creation', 'AiOption',
entity_category='config', gate_on_presence=True),
),
)
@@ -60,3 +75,26 @@ DRYER_DIAGNOSIS = Capability(
name='Diagnosis', entity_category='diagnostic'),
),
)
# /cycleinterface/vs/0 -- the app's "Auto cycle link" toggle ("automatically
# set to the best drying cycle by communicating with the washer cycle"; both
# devices must stay connected). Empty ({}) on every washer dump seen so far,
# which is why it used to sit in the global ignored.IGNORED list -- the
# issue #14 dryer dump is the first to populate it. washer.py scopes its own
# ignore locally now (washer.CYCLE_INTERFACE_IGNORED) to avoid a collision.
def _auto_cycle_link_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['cycleinterface', 'vs', '0'], {'x.com.samsung.da.cycleInterfaceEnabled': p}
AUTO_CYCLE_LINK = Capability(
href='/cycleinterface/vs/0',
entities=(
SwitchDesc(key='auto_cycle_link', field='x.com.samsung.da.cycleInterfaceEnabled',
name='Auto cycle link', icon='mdi:link-variant',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_auto_cycle_link_write),
),
)
@@ -117,11 +117,15 @@ IGNORED: list[Capability] = [
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control.
Capability(href='/energy/consumption/0'),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href='/cycleinterface/vs/0'),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally.
Capability(href='/drlc/0'),
# NOTE: /cycleinterface/vs/0 used to be listed here as empty ({}) on
# every washer dump seen so far. The issue #14 dryer dump populates it
# (cycleInterfaceEnabled) as the app's "Auto cycle link" toggle, so it's
# now a real capability there (dryer.AUTO_CYCLE_LINK) -- washer.py scopes
# its own ignore locally (washer.CYCLE_INTERFACE_IGNORED) to avoid a
# global-ignore/real-capability href collision.
# OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control)
@@ -354,3 +354,10 @@ WASHER_COURSE = Capability(
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
# /cycleinterface/vs/0 -- washer-to-dryer "Auto cycle link" pairing signal
# (dryer.AUTO_CYCLE_LINK). Empty ({}) on every washer/combo dump seen so far
# (plain washers, FlexWash, and washer/dryer combos alike), so it's scoped
# out here rather than left in the global ignored.IGNORED list, which would
# collide with the real dryer-side capability.
CYCLE_INTERFACE_IGNORED = Capability(href='/cycleinterface/vs/0')
+3 -1
View File
@@ -115,10 +115,12 @@
"1e": "Outdoor",
"1f": "Mixed Load",
"20": "Iron Dry",
"21": "Hygiene Care",
"23": "Quick Dry 35",
"24": "Cool Air",
"25": "Warm Air",
"27": "Time Dry"
"27": "Time Dry",
"4c": "Air Refresh"
}
},
"washer_dosing_quantity": {
@@ -115,10 +115,12 @@
"1e": "Outdoor",
"1f": "Mixed Load",
"20": "Iron Dry",
"21": "Hygiene Care",
"23": "Quick Dry 35",
"24": "Cool Air",
"25": "Warm Air",
"27": "Time Dry"
"27": "Time Dry",
"4c": "Air Refresh"
}
},
"washer_dosing_quantity": {
+2
View File
@@ -1,6 +1,8 @@
{
"state_keys": [
"ai_pattern",
"alarm_code",
"auto_cycle_link",
"buzzer_sound",
"child_lock",
"cycle",
+60 -14
View File
@@ -643,7 +643,13 @@ async def test_write_marks_href_pending_before_post(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""async_send_command must call mark_write_pending before POSTing so a
slow-to-settle device can't immediately revert the optimistic write."""
slow-to-settle device can't immediately revert the optimistic write --
and it must guard the write's actual target href (write_fn's own
path_segs), not bound_entity.href. Those differ for a composite entity
like the AC's ClimateDesc (bound to /mode/vs/0, but writing power/temp/
fan/swing/preset to their own sibling hrefs) -- guarding the bound href
protected the wrong resource and left the one the entity actually
displays from unprotected (issues #17/#53)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
@@ -662,17 +668,23 @@ async def test_write_marks_href_pending_before_post(
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
assert coordinator._observe._settle_until.get('/some/path') is not None
assert coordinator._observe._settle_until.get('/test/vs/0') is None
async def test_send_command_applies_write_optimistically_before_settling(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
) -> None:
"""The cache must reflect a write immediately, and stay put against a
stale echo for the rest of the settle window -- otherwise
mark_write_pending has nothing to protect and just delays the real
device confirmation instead, which reads to a user as the write being
silently reverted (issue #27)."""
"""The cache must reflect a write immediately at the write's actual
target href (write_fn's path_segs) -- not bound_entity.href, which
differs for a composite entity like the AC's ClimateDesc -- and stay put
against a stale echo for the rest of the settle window there.
Applying the optimistic value to bound_entity.href instead is exactly
how issues #17/#53 survived the original optimistic-apply fix (issue
#27): the bound href got a harmless (if nonsensical) optimistic merge,
while the resource the entity actually reads from for its displayed
state never got one and stayed stale until the next real read of it."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
@@ -691,16 +703,50 @@ async def test_send_command_applies_write_optimistically_before_settling(
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away, without waiting for a
# device response.
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
# The optimistic value is visible right away at the write's real
# target, not the bound href.
assert coordinator._cache.get('/some/path') == {'value': 5}
assert coordinator._cache.get('/test/vs/0') != {'value': 5}
# A stale update racing in behind the write (e.g. a poll/notify that
# was already in flight before the PUT) must not clobber it while the
# settle window is open.
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
applied = coordinator._observe.apply('/some/path', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
assert coordinator._cache.get('/some/path') == {'value': 5}
async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Regression for issues #17/#53, using the real AC capability. The
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
but a power command's write_fn (airconditioner._climate_write) targets
the sibling /power/0 -- the href climate.py's `_is_on()` actually reads.
The optimistic value and settle guard must land there, not on the bound
/mode/vs/0, or the entity never sees the write and shows stale state
until the next unrelated read of /power/0."""
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import BoundEntity
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
bound = BoundEntity(
href=airconditioner.CLIMATE.href,
capability=coordinator.bound[0].capability,
desc=airconditioner.CLIMATE.entities[0],
)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, ('power', True))
assert coordinator._cache.get('/power/0') == {'value': True}
assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True}
assert coordinator._observe._settle_until.get('/power/0') is not None
class TestRemoteControlEnabled:
@@ -803,7 +849,7 @@ async def test_send_command_allowed_when_remote_control_enabled(
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
assert coordinator._cache.get('/some/path') == {'value': 5}
async def test_send_command_remote_control_check_precedes_validate_fn(
+37 -1
View File
@@ -39,10 +39,46 @@ def test_no_unbound_hrefs():
def test_expected_entities_present():
state = _state()
for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock',
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'):
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh',
'ai_pattern', 'auto_cycle_link'):
assert key in state, key
def test_dry_level_is_a_writable_select():
"""dryLevel used to be a read-only sensor, but the app's "Dryness"
control (issue #14 screenshots) is a Level 1/2/3 picker, and the dump's
supportedDryLevel list confirms the device accepts writes -- it should
be a select, not a sensor."""
desc = next(e for e in dryer.DRYER_SETTINGS.entities if e.key == 'dry_level')
assert desc.options_field == 'x.com.samsung.da.supportedDryLevel'
assert desc.write_fn is not None
assert desc.write_fn('Less', {}) == (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': 'Less'})
assert _state()['dry_level'] == 'Normal'
def test_ai_pattern_reads_and_writes_options_token():
"""AiOption_On/Off on /course/vs/0's options[] array is the app's "AI
pattern" toggle (issue #14 screenshot: on)."""
assert _state()['ai_pattern'] is True
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'ai_pattern')
opts = ['Course_16', 'AiOption_On']
href, payload = desc.write_fn('Off', {'x.com.samsung.da.options': opts})
assert href == ['course', 'vs', '0']
assert 'AiOption_Off' in payload['x.com.samsung.da.options']
def test_auto_cycle_link_bound_to_cycleinterface():
"""cycleInterfaceEnabled on /cycleinterface/vs/0 is the app's "Auto cycle
link" toggle (issue #14 screenshot: off) -- previously globally ignored
as always-empty until this dump showed it populated on a dryer."""
assert dryer.AUTO_CYCLE_LINK.href == '/cycleinterface/vs/0'
assert _state()['auto_cycle_link'] is False
desc = next(e for e in dryer.AUTO_CYCLE_LINK.entities if e.key == 'auto_cycle_link')
assert desc.write_fn('On', {}) == (
['cycleinterface', 'vs', '0'], {'x.com.samsung.da.cycleInterfaceEnabled': 'On'})
def test_job_beginning_status_reads_current_status():
"""The dump carries x.com.samsung.da.currentStatus (not the old
jobBeginingStatus field the dryer descriptor used to read), so the sensor
+11 -1
View File
@@ -68,8 +68,18 @@ class TestWasherIgnoredHrefs:
'/wm/welcomemsg/vs/0',
'/wm/personalcourse/vs/0',
'/energy/consumption/0',
'/cycleinterface/vs/0',
'/drlc/0',
'/operational/state/0',
):
assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED"
def test_cycleinterface_is_scoped_to_washer_registry(self):
"""/cycleinterface/vs/0 is empty on every washer dump but real on the
dryer (issue #14's AUTO_CYCLE_LINK), so it's a washer-scoped ignore
(washer.CYCLE_INTERFACE_IGNORED) rather than a global one -- a global
entry would collide with the dryer's real capability."""
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.capabilities.ignored import IGNORED
assert '/cycleinterface/vs/0' not in {cap.href for cap in IGNORED}
assert '/cycleinterface/vs/0' in washer_registry.REGISTRY.capabilities