refactor: share remote-control read between sensor and write guard, poll it warm

Move the on/off interpretation into a single remote_control_enabled()
in registry/capabilities/common.py so the write-guard added in the
previous commit can't silently drift from the Smart Control binary
sensor's own reading of the same hrefs. Also promotes both
/remotectrl hrefs to poll_tier='warm' so the coordinator's cached
state backing that write guard doesn't lag up to a full 30s cold
summary poll behind the device's actual toggle state.
This commit is contained in:
Marc Billow committed 2026-07-23 02:29:03 +00:00
1 parent 6e5fee32b8
commit 2f6b7d54e3
4 files changed
+48 -28

No files matched your search

+2 -15
View File
@@ -22,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model
from .registry.capabilities.common import remote_control_enabled
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -44,20 +45,6 @@ _REMOTE_CONTROL_DISABLED_MESSAGE = (
)
def _remote_control_disabled(resources: dict) -> bool:
"""True only when the device explicitly reports remote control off.
Mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair in
registry/capabilities/common.py. Fails open (False) when neither href
is present -- most device types don't report this capability at all."""
generic = resources.get('/remotectrl/0')
if generic is not None:
return not bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
if fallback is not None:
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() != 'true'
return False
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a
@@ -545,7 +532,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
href = bound_entity.href
rep = self._cache.get(href or '') or {}
resources = self._cache.snapshot()
if _remote_control_disabled(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:
@@ -119,8 +119,30 @@ KIDS_LOCK_VS_FALLBACK = Capability(
),
)
def remote_control_enabled(resources: dict) -> bool:
"""Single source of truth for the /remotectrl on/off signal, mirroring
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
below. Used both to render the read-only Smart Control binary_sensor
(via those two descriptors) and, from coordinator.async_send_command,
to block writes outright when remote control is off. Both hrefs are
poll_tier='warm' below so that gate reads recent state (subscribed
when observe is live, subpolled every ~6s otherwise) rather than a
once-per-30s cold summary poll. True (assume enabled) when neither
href is present -- most device types don't report this capability
at all."""
generic = resources.get('/remotectrl/0')
if generic is not None:
return bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
if fallback is not None:
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
return True
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', device_class='connectivity',
@@ -131,6 +153,7 @@ REMOTE_CONTROL_GENERIC = Capability(
REMOTE_CONTROL_VS_FALLBACK = Capability(
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
poll_tier='warm',
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
+15 -13
View File
@@ -13,8 +13,9 @@ from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
LocalThingsCoordinator, _remote_control_disabled,
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
)
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
@@ -639,36 +640,37 @@ async def test_write_marks_href_pending_before_post(
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
class TestRemoteControlDisabled:
"""_remote_control_disabled mirrors REMOTE_CONTROL_GENERIC/_VS_FALLBACK's
OCF-standard-preferred read (registry/capabilities/common.py) directly
on a raw resources snapshot."""
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
the Smart Control binary_sensor descriptors and the coordinator's
write guard alike."""
def test_vs_fallback_href_true_is_enabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}}
assert _remote_control_disabled(resources) is False
assert remote_control_enabled(resources) is True
def test_vs_fallback_href_false_is_disabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}}
assert _remote_control_disabled(resources) is True
assert remote_control_enabled(resources) is False
def test_generic_href_true_is_enabled(self):
resources = {'/remotectrl/0': {'value': True}}
assert _remote_control_disabled(resources) is False
assert remote_control_enabled(resources) is True
def test_generic_href_false_is_disabled(self):
resources = {'/remotectrl/0': {'value': False}}
assert _remote_control_disabled(resources) is True
assert remote_control_enabled(resources) is False
def test_generic_href_wins_when_both_present(self):
resources = {
'/remotectrl/0': {'value': True},
'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'},
}
assert _remote_control_disabled(resources) is False
assert remote_control_enabled(resources) is True
def test_fails_open_when_capability_absent(self):
assert _remote_control_disabled({}) is False
def test_assumes_enabled_when_capability_absent(self):
assert remote_control_enabled({}) is True
async def test_send_command_blocked_when_remote_control_disabled(
+8
View File
@@ -84,6 +84,14 @@ class TestRemoteControlFallback:
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
def test_polled_warm_so_write_gating_stays_fresh(self):
"""coordinator.async_send_command blocks writes on this signal, so
it can't sit in the default 'cold' tier (refreshed only once per
30s summary poll) -- it needs the subscribe/subpoll cadence 'warm'
and 'hot' hrefs get instead."""
assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm'
assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm'
# ---------------------------------------------------------------------------
# Energy meter. instantaneousPower clamps negatives to 0, but the constant