Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee72bebb8 |
No files matched your search
@@ -13,10 +13,9 @@ 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 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.
|
||||
`(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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -547,22 +547,6 @@ 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
|
||||
@@ -571,19 +555,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(write_href, body, source='optimistic')
|
||||
self._observe.mark_write_pending(write_href)
|
||||
self._observe.apply(href, body, source='optimistic')
|
||||
self._observe.mark_write_pending(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", write_href, code)
|
||||
self._log.info("PUT %s → code %#04x", href, code)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.error("command failed for %s: %s", write_href, e)
|
||||
self._log.error("command failed for %s: %s", href, e)
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
@@ -26,6 +26,5 @@ REGISTRY = DeviceRegistry(
|
||||
dryer.DRYER_SETTINGS,
|
||||
dryer.DRYER_COURSE,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
dryer.AUTO_CYCLE_LINK,
|
||||
]),
|
||||
)
|
||||
@@ -15,6 +15,5 @@ REGISTRY = DeviceRegistry(
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
washer.CYCLE_INTERFACE_IGNORED,
|
||||
]),
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||
(model AX60R5080WD/SE, issue #56 -- verified against two independent
|
||||
diagnostics dumps of the same internal model).
|
||||
(model AX60R5080WD/SE, issue #56).
|
||||
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
||||
@@ -8,32 +7,39 @@ pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
||||
|
||||
Two things are deliberately left as raw, unwritable diagnostic sensors rather
|
||||
than modeled as real controls, per the "don't guess" rule:
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW contract
|
||||
laundry.py's option_value/replace_in_options already model for
|
||||
/course/vs/0's options[] (reused directly below, just against this family's
|
||||
own href). Per issue #56's follow-up (five diagnostics dumps captured with
|
||||
the physical unit set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
original guess); exposed read-only since its
|
||||
actual purpose is still unconfirmed.
|
||||
OptionCode_60282 -- confirmed opaque/not user-facing in the
|
||||
SmartThings app; not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the same
|
||||
href).
|
||||
Blooming_* -- confirmed to have no corresponding SmartThings
|
||||
app setting; dropped entirely rather than kept
|
||||
as an unexplained diagnostic (it did track 1:1
|
||||
with Sleep mode across the five dumps -- 0 in
|
||||
Sleep, 6 otherwise -- so it's plausibly an
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
|
||||
/airflow/0, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/
|
||||
direction, both zeroed/'Off' on every dump seen (device was off in both).
|
||||
No supportedSpeed/supportedModes list is present anywhere in either dump
|
||||
to confirm the valid range, so a write-capable fan/select isn't safe to
|
||||
build yet -- see the issue #56 request for a running-state dump.
|
||||
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW
|
||||
contract laundry.py's option_value/replace_in_options already model for
|
||||
/course/vs/0's options[] (reused directly below, just against this
|
||||
family's own href). Of the tokens seen:
|
||||
Light_On / Light_Off -- read as a plain on/off flag; MODE below
|
||||
models it as a real switch, RMW-
|
||||
replacing just that one list entry.
|
||||
Comode_Off -- never seen non-'Off' on these dumps;
|
||||
likely the fan operating mode the issue
|
||||
describes (Auto/Sleep/1/2/3), but
|
||||
unconfirmed -- exposed read-only.
|
||||
Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only.
|
||||
OptionCode_60282 -- opaque, unchanged across both dumps;
|
||||
not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the
|
||||
same href).
|
||||
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
|
||||
fan-speed control: across the same five dumps it read 0 for both Auto *and*
|
||||
High, and 3 for Low/Medium/*and* Sleep -- not a monotonic mapping to any
|
||||
selectable level, and the dumps were all captured within about three minutes
|
||||
of each other (only one poll cycle apart at this integration's 30s summary
|
||||
interval), so the values may not have settled after each change before the
|
||||
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
|
||||
stable capture -- see the issue #56 discussion for what's needed.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
@@ -68,16 +74,18 @@ def _consumable_state(items, name):
|
||||
return None
|
||||
|
||||
|
||||
# FilterProgress is a raw 0-100 percentage in both dumps (100 and 62); which
|
||||
# end of that scale means "replace me" isn't confirmed from the dump alone,
|
||||
# so the entity is named after the raw field rather than asserting a
|
||||
# direction (see issue #56 follow-up questions).
|
||||
# FilterProgress is a 0-100 percentage counting down as the filter wears --
|
||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
||||
# once this reaches the low end, so 100 means a fresh filter, not "100% worn".
|
||||
# Named 'Filter life' (matching the direction) rather than the 'Filter usage'
|
||||
# convention used elsewhere in this codebase (AC/range_hood's filterUsage
|
||||
# counts up instead), so the two aren't confused.
|
||||
FILTER = Capability(
|
||||
href='/consumable/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
name='Filter progress', unit='%', state_class='measurement',
|
||||
SensorDesc(key='filter_life', field='x.com.samsung.da.items',
|
||||
name='Filter life', unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
@@ -142,22 +150,20 @@ MODE = Capability(
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
# Read-only pending issue #56 follow-up -- see module docstring.
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
SensorDesc(key='blooming_level', name='Blooming level', icon='mdi:flower',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Blooming'),
|
||||
exists_fn=bool_option_exists('Blooming')),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on every dump seen, and issue
|
||||
# #56 confirms this model has no humidity sensor at all (dust/odor only) --
|
||||
# covered here (not globally, per ignored.py's module docstring) since those
|
||||
# hrefs collide with fridge/AC schemas elsewhere. Same two hrefs and
|
||||
# reasoning as airconditioner.py's _AC_IGNORED.
|
||||
COVERAGE = [
|
||||
Capability(href='/humidity/0'),
|
||||
Capability(href='/humidity/vs/0'),
|
||||
|
||||
@@ -4,15 +4,13 @@ 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, plus the AI-pattern
|
||||
toggle read off the same options[] array; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
/cycleinterface/vs/0 -> AUTO_CYCLE_LINK
|
||||
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
|
||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
@@ -25,15 +23,8 @@ DRYER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
# 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_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:water-percent'),
|
||||
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',
|
||||
@@ -49,21 +40,15 @@ 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/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.
|
||||
# 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.
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -75,26 +60,3 @@ 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,15 +117,11 @@ 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,10 +354,3 @@ 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')
|
||||
@@ -115,12 +115,10 @@
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed Load",
|
||||
"20": "Iron Dry",
|
||||
"21": "Hygiene Care",
|
||||
"23": "Quick Dry 35",
|
||||
"24": "Cool Air",
|
||||
"25": "Warm Air",
|
||||
"27": "Time Dry",
|
||||
"4c": "Air Refresh"
|
||||
"27": "Time Dry"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
|
||||
@@ -115,12 +115,10 @@
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed Load",
|
||||
"20": "Iron Dry",
|
||||
"21": "Hygiene Care",
|
||||
"23": "Quick Dry 35",
|
||||
"24": "Cool Air",
|
||||
"25": "Warm Air",
|
||||
"27": "Time Dry",
|
||||
"4c": "Air Refresh"
|
||||
"27": "Time Dry"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"blooming_level",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
"diagnosis_status",
|
||||
@@ -13,7 +12,7 @@
|
||||
"energy_this_month_kwh",
|
||||
"fan_direction",
|
||||
"fan_speed_level",
|
||||
"filter_progress",
|
||||
"filter_life",
|
||||
"fine_dust",
|
||||
"odor",
|
||||
"operating_mode",
|
||||
|
||||
Vendored
-2
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_pattern",
|
||||
"alarm_code",
|
||||
"auto_cycle_link",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
|
||||
@@ -643,13 +643,7 @@ 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 --
|
||||
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)."""
|
||||
slow-to-settle device can't immediately revert the optimistic write."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
@@ -668,23 +662,17 @@ 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('/some/path') is not None
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is None
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not 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 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."""
|
||||
"""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)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
@@ -703,50 +691,16 @@ 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 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}
|
||||
# The optimistic value is visible right away, without waiting for a
|
||||
# device response.
|
||||
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('/some/path', {'value': 0}, source='observe')
|
||||
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
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
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
|
||||
class TestRemoteControlEnabled:
|
||||
@@ -849,7 +803,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._cache.get('/some/path') == {'value': 5}
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_remote_control_check_precedes_validate_fn(
|
||||
|
||||
@@ -40,9 +40,9 @@ def test_expected_entities_present():
|
||||
state = _state()
|
||||
for key in (
|
||||
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
|
||||
'odor', 'clean_level', 'filter_progress', 'device_active',
|
||||
'odor', 'clean_level', 'filter_life', 'device_active',
|
||||
'diagnosis_status', 'fan_speed_level', 'fan_direction',
|
||||
'display_light', 'operating_mode', 'blooming_level',
|
||||
'display_light', 'operating_mode',
|
||||
):
|
||||
assert key in state, key
|
||||
|
||||
@@ -58,8 +58,10 @@ def test_air_quality_sensor_values():
|
||||
assert state['clean_level'] == 0
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
assert _state()['filter_progress'] == 100
|
||||
def test_filter_life_reads_named_consumable_item():
|
||||
"""FilterProgress is confirmed (issue #56) to count down as the filter
|
||||
wears -- 100 means fresh, not "100% used"."""
|
||||
assert _state()['filter_life'] == 100
|
||||
|
||||
|
||||
def test_diagnosis_reuses_dishwasher_capability():
|
||||
@@ -76,29 +78,36 @@ def test_light_switch_write_contract():
|
||||
the other flags and the list order untouched."""
|
||||
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
|
||||
rep = {'x.com.samsung.da.options': [
|
||||
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
|
||||
'Comode_Off', 'Light_On', 'OptionCode_60282',
|
||||
]}
|
||||
assert desc.rep_fn(rep) is True
|
||||
assert desc.write_fn('Off', rep) == (
|
||||
['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': [
|
||||
'Comode_Off', 'Blooming_0', 'Light_Off', 'OptionCode_60282',
|
||||
'Comode_Off', 'Light_Off', 'OptionCode_60282',
|
||||
]},
|
||||
)
|
||||
|
||||
|
||||
def test_mode_tokens_are_read_only_diagnostics():
|
||||
"""Comode_/Blooming_ tokens surface as raw diagnostic sensors rather than
|
||||
a select/control -- their valid value ranges aren't confirmed yet (see
|
||||
the air_purifier.py module docstring and the issue #56 follow-up)."""
|
||||
def test_operating_mode_is_a_read_only_diagnostic():
|
||||
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
|
||||
control -- issue #56's five running-state dumps confirmed it reads 'Off'
|
||||
regardless of the device's actual fan setting, ruling out the original
|
||||
guess that it was the fan-speed selector; its real purpose is still
|
||||
unconfirmed (see the air_purifier.py module docstring)."""
|
||||
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
|
||||
blooming = next(e for e in air_purifier.MODE.entities if e.key == 'blooming_level')
|
||||
rep = {'x.com.samsung.da.options': ['Comode_Off', 'Blooming_6']}
|
||||
rep = {'x.com.samsung.da.options': ['Comode_Off']}
|
||||
assert operating_mode.rep_fn(rep) == 'Off'
|
||||
assert blooming.rep_fn(rep) == '6'
|
||||
assert not hasattr(operating_mode, 'write_fn')
|
||||
|
||||
|
||||
def test_blooming_not_modeled():
|
||||
"""Confirmed (issue #56) to have no corresponding SmartThings app
|
||||
setting -- dropped entirely rather than kept as an unexplained
|
||||
diagnostic."""
|
||||
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
|
||||
|
||||
|
||||
def test_airflow_vs_fallback_only_binds_without_generic():
|
||||
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
|
||||
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
|
||||
|
||||
@@ -39,46 +39,10 @@ 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',
|
||||
'ai_pattern', 'auto_cycle_link'):
|
||||
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'):
|
||||
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
|
||||
|
||||
@@ -68,18 +68,8 @@ 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
|
||||
Reference in new issue
Block a user