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
18 changed files with 131 additions and 360 deletions
@@ -12,7 +12,6 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
TextSelector,
@@ -25,7 +24,6 @@ from .const import (
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
@@ -302,13 +300,6 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._ca_key_pem: str = ""
self._pending_info: dict | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> LocalThingsOptionsFlow:
return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> FlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
@@ -393,31 +384,3 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
},
)
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device override of the remote-control-off write block (issue
#54). The block exists because most devices reject writes outright
while remote control is off and a clear error beats a silent
device-side rejection -- but not every model actually enforces that,
so this lets a user who's confirmed their device accepts writes anyway
turn the block off for just that device rather than it being
hardcoded on for everyone."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
}),
)
-10
View File
@@ -12,16 +12,6 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
# #54). Some devices report remote control off yet still accept certain
# writes (e.g. default detergent/softener dosing on a washer, applied even
# to the built-in programs) -- the block exists to give a clear error
# instead of a silent device-side rejection, but that assumption doesn't
# hold for every model. Defaults to False (block stays on) everywhere it's
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
+6 -49
View File
@@ -31,7 +31,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
)
_LOGGER = logging.getLogger(__name__)
@@ -77,14 +77,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# fine session shouldn't tear down a working OBSERVE subscription.
_POLL_TIMEOUT_LIMIT: int = 3
# Timeouts for the two network round trips a write triggers: the PUT
# itself (_do_put), then the confirming full /device/0 summary poll
# async_send_command requests right after (_poll_once). Named here
# (rather than left as inline literals) so the write-settle window
# below can be sized to always outlast both — see async_send_command.
_POST_TIMEOUT_S: float = 8.0
_POLL_TIMEOUT_S: float = 35.0
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# Per-device logger (module logger scoped to this device's host) so
# every log line — including the base DataUpdateCoordinator's own
@@ -219,7 +211,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# would have succeeded, not a dead session. 35s gives a slow
# blockwise transfer room to actually finish instead of
# generating a TimeoutError every cycle.
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
code, payload = sess.get(_SEED_PATH, timeout=35.0)
except TimeoutError:
raise
except Exception as e:
@@ -534,11 +526,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
user-facing message -- as opposed to write_fn's silent no-op below
-- is available to every platform for free. The remote-control
check runs first and applies to every platform unconditionally,
ahead of any description-specific validate_fn -- unless the user has
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
#54: some devices accept certain writes, e.g. a washer's default
dosing levels, even while reporting remote control off, so the
block's assumption doesn't hold for every model)."""
ahead of any description-specific validate_fn."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
@@ -546,8 +534,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
href = bound_entity.href
rep = self._cache.get(href or '') or {}
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if not bypass_remote_control and not remote_control_enabled(resources):
if not remote_control_enabled(resources):
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
@@ -584,44 +571,14 @@ 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).
#
# settle_s must outlast the PUT and the async_request_refresh()
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
# that refresh is a full /device/0 summary poll, which
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
# seconds on these devices (see _poll_once), and some writes settle
# on the device itself well after that: issue #9's washer packs
# cycle/detergent/softener selection into the same /course/vs/0
# options[] array, and picking a new value there visibly needs a
# few seconds of internal validation/dispenser movement before the
# device's own state agrees -- while /washer/vs/0's temperature/
# spin fields (plain flags, no device-side settling) confirm
# instantly on the same device. A short fixed window expired while
# the confirm poll was still in flight (or before the device had
# caught up internally), so that stale read landed unprotected and
# reverted the optimistic value, self-correcting again only once a
# later poll finally saw the real change -- read by the user as the
# write "reverting, then re-applying itself" a few seconds later.
#
# An earlier attempt at this also released the guard early, right
# after the confirming refresh completed, to avoid shutting out
# unrelated real updates (another automation, the physical remote)
# for the rest of settle_s. That was reverted: releasing the guard
# the moment one round trip finishes doesn't mean the device has
# actually caught up (exactly the slow-settling case above), and it
# introduced its own races around overlapping writes to the same
# href. Simpler and safer to just hold the guard for the full,
# generously-sized window and let it expire on its own.
self._observe.apply(write_href, body, source='optimistic')
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
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=self._POST_TIMEOUT_S)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
self._log.info("PUT %s → code %#04x", write_href, code)
try:
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.11.0"
"version": "0.10.1"
}
+1 -15
View File
@@ -115,22 +115,8 @@ class ObserveManager:
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
`source == 'optimistic'` always bypasses the settle gate below,
even while this href is already settling from an earlier write.
Several independent selects can share one href -- issue #9's washer
packs cycle, detergent, and softener settings onto the same
/course/vs/0 -- so a second, different write to the same href
while the first's settle window is still open (up to
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
coordinator.async_send_command) is an expected, normal sequence,
not a stale echo of the first write. Gating it the same as a
poll/sweep/observe update would silently drop the user's own
second selection from the cache until the first write's guard
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
@@ -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 -12
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": {
@@ -246,17 +248,6 @@
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
}
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
@@ -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": {
@@ -246,17 +248,6 @@
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
}
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
+2
View File
@@ -1,6 +1,8 @@
{
"state_keys": [
"ai_pattern",
"alarm_code",
"auto_cycle_link",
"buzzer_sound",
"child_lock",
"cycle",
+1 -44
View File
@@ -9,8 +9,7 @@ from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_PORT, DOMAIN,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN,
)
from .conftest import (
@@ -277,45 +276,3 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
) is not None
)
assert recognized is True
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
"""The bypass defaults to False, so devices that never touch this
option see no change in the remote-control write block."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'init'
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
"""Submitting the form with the toggle on stores it in entry.options,
where coordinator.async_send_command reads it (issue #54)."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
"""Reopening the form shows the currently-saved choice as the default,
not always False."""
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
+1 -140
View File
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
@@ -749,104 +749,6 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
assert coordinator._observe._settle_until.get('/power/0') is not None
async def test_send_command_survives_stale_confirm_poll(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources,
) -> None:
"""The settle window must outlast the confirm poll async_send_command
triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed
few seconds (issue #9). Some devices settle a write internally slower
than that refresh's own round trip -- issue #9's washer packs cycle/
detergent/softener selection into /course/vs/0's shared options[]
array, which visibly takes a few seconds of validation/dispenser
movement to catch up, unlike /washer/vs/0's plain temperature/spin
fields on the same device, which confirm instantly. A confirm poll that
lands before the device has caught up is stale, and a settle window
sized only to the fixed default (rather than the PUT+poll round trip)
expires before that poll even returns, so the stale read lands
unprotected and reverts the optimistic value -- self-correcting again
only once a later poll finally sees the real change. That reads to a
user as the write reverting, then reapplying itself, a few seconds
later."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
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]
def _write_fn(payload, rep, href):
return (['test', 'vs', '0'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
def _stale_confirm_poll():
# Stand-in for the write's own confirming /device/0 poll racing
# against a stale read for the same href -- the device's internal
# state hasn't caught up to the write yet, even though it will a
# little later.
coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll')
return fridge_resources
with (
patch.object(fake, 'subscribe'),
patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll),
):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away and survived the stale
# confirm-poll race triggered by the refresh above.
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
# The settle window is still open afterward -- sized to outlast the
# whole PUT + confirm-poll round trip, not released the moment that
# round trip happens to finish.
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
) -> None:
"""Regression for issue #9: /course/vs/0 backs several independent
washer selects (cycle, detergent quantity, softener quantity, ...)
sharing one href. The settle window is now sized to tens of seconds
(see test_send_command_survives_stale_confirm_poll), which makes a
second, different write to that href landing while the first write's
window is still open a routine occurrence -- e.g. a user picking a
cycle and then adjusting detergent quantity within the same minute --
not a rare edge case. The second write's own optimistic value must
still show up immediately; a guard meant to protect optimistic writes
from stale device echoes must not itself suppress a later one."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
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]
desc_a = NumberDesc(key='cycle', field='cycle',
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p}))
desc_b = NumberDesc(key='detergent', field='detergent',
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p}))
bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a)
bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound_a, 'Eco')
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'}
# Still well within the first write's settle window.
await coordinator.async_send_command(bound_b, 'High')
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'}
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
@@ -979,44 +881,3 @@ async def test_send_command_remote_control_check_precedes_validate_fn(
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 'On')
async def test_send_command_bypasses_remote_control_when_option_enabled(
hass: HomeAssistant, mock_coordinator_observe_session
) -> None:
"""Issue #54: some devices accept certain writes even while reporting
remote control off, so a user who's confirmed that for their device can
opt it out of the block entirely via the options flow
(CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data)."""
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._cache.get('/some/path') == {'value': 5}
-24
View File
@@ -67,30 +67,6 @@ def test_apply_drops_update_during_settle_window():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_optimistic_bypasses_an_in_progress_settle_window():
"""Regression for issue #9: /course/vs/0 backs several independent
washer selects (cycle, detergent quantity, softener quantity, ...).
Picking a second one while the first's settle window is still open
(now sized to tens of seconds -- see coordinator._POST_TIMEOUT_S/
_POLL_TIMEOUT_S) is a normal sequence, not a stale echo of the first
write, and must land in the cache immediately -- not get silently
dropped by a guard that exists to protect optimistic writes, not
suppress them."""
mgr = _manager()
mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed')
mgr.mark_write_pending('/course/vs/0', settle_s=30.0)
result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic')
assert result is True
assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'}
# A poll/sweep/observe update racing in right behind it is still
# gated -- the second write's own guard (re-armed by mark_write_pending,
# not exercised directly here) is what protects it going forward.
assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False
def test_apply_accepts_update_after_settle_window_elapses():
mgr = _manager()
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
+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