Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f8fdf488 | ||
|
|
dccd6e1209 | ||
|
|
037dc9722f | ||
|
|
dffe1ef455 | ||
|
|
49bd4ef0cb | ||
|
|
f7faf56689 | ||
|
|
85cca70b6b | ||
|
|
020402c82f | ||
|
|
cf18cc3ecc | ||
|
|
61e6035a36 | ||
|
|
7b155aabc2 | ||
|
|
788b8f98a1 | ||
|
|
83bd158049 | ||
|
|
42a80fffe6 | ||
|
|
b121d24966 | ||
|
|
615c5d5aa9 |
No files matched your search
@@ -17,6 +17,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
|
||||
| Type | Registry |
|
||||
|---|---|
|
||||
| Air conditioner | `by_type/airconditioner.py` |
|
||||
| Air purifier | `by_type/air_purifier.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
@@ -24,6 +25,7 @@ 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,
|
||||
)
|
||||
@@ -300,6 +302,13 @@ 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})",
|
||||
@@ -384,3 +393,31 @@ 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,
|
||||
}),
|
||||
)
|
||||
@@ -12,6 +12,16 @@ 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
|
||||
|
||||
@@ -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,
|
||||
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -77,6 +77,14 @@ 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
|
||||
@@ -211,7 +219,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=35.0)
|
||||
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -526,7 +534,11 @@ 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."""
|
||||
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)."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
if write_fn is None:
|
||||
@@ -534,7 +546,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
href = bound_entity.href
|
||||
rep = self._cache.get(href or '') or {}
|
||||
resources = self._cache.snapshot()
|
||||
if not remote_control_enabled(resources):
|
||||
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
|
||||
if not bypass_remote_control and not remote_control_enabled(resources):
|
||||
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
|
||||
validate_fn = getattr(desc, 'validate_fn', None)
|
||||
if validate_fn is not None:
|
||||
@@ -547,6 +560,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 +584,49 @@ 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)
|
||||
#
|
||||
# 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
|
||||
)
|
||||
|
||||
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)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
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()
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.10.1"
|
||||
"version": "0.11.0"
|
||||
}
|
||||
@@ -115,8 +115,22 @@ 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 self._is_settling(href):
|
||||
if source != 'optimistic' and self._is_settling(href):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
|
||||
@@ -3,8 +3,8 @@ from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import (
|
||||
airconditioner, cooktop, dishwasher, dryer, oven, range as _range,
|
||||
range_hood, refrigerator, washer,
|
||||
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
|
||||
range as _range, range_hood, refrigerator, washer,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -14,6 +14,8 @@ __all__ = [
|
||||
|
||||
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airpurifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'air_conditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
@@ -111,6 +113,10 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# 'P' sits between the underscore and 'RAC' in that token).
|
||||
if key is None and '_RAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
|
||||
# oneUiVersion either, and carry the '_TVTL_' board-family token.
|
||||
if key is None and '_TVTL_' in (model_num or ''):
|
||||
key = 'air_purifier'
|
||||
model_identity = f'{model_num} {description}'.upper()
|
||||
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
|
||||
key = 'cooktop'
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
|
||||
|
||||
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
|
||||
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
|
||||
(identical field/write contract).
|
||||
"""
|
||||
from ..capabilities import air_purifier, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
*air_purifier.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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).
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
/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).
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, replace_in_options
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'Dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'Odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _consumable_state(items, name):
|
||||
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
|
||||
unlike AIR_QUALITY's {type, value} shape above."""
|
||||
for item in items or ():
|
||||
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
|
||||
return item.get('x.com.samsung.da.state')
|
||||
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).
|
||||
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',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
href='/devicespecificinfo/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
name='Device active', icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
|
||||
# why these are read-only for now.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href='/airflow/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='speed',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic'),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href='/airflow/vs/0',
|
||||
match_fn=lambda rep, resources: '/airflow/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _light_write(payload, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': replace_in_options(opts, 'Light', payload),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
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.
|
||||
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.
|
||||
COVERAGE = [
|
||||
Capability(href='/humidity/0'),
|
||||
Capability(href='/humidity/vs/0'),
|
||||
]
|
||||
@@ -23,6 +23,13 @@ def _num(v):
|
||||
return None
|
||||
|
||||
|
||||
def int_or_none(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clamp_power(v):
|
||||
n = _num(v)
|
||||
return 0.0 if (n is not None and n < 0) else n
|
||||
@@ -58,6 +65,26 @@ def _active_alarm_codes(items):
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
def sensor_item_value(items, sensor_type, index=0):
|
||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||
item is `{type, value: [...]}`; `index` picks which slot of a possibly
|
||||
multi-value reading to read (index 0 is the raw measurement on every
|
||||
family seen so far). Shared by range_hood.AIR_QUALITY and
|
||||
air_purifier.AIR_QUALITY, which read the same resource shape."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
if index < len(values):
|
||||
try:
|
||||
return int(values[index])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# These three controls exist as both a standard OCF resource (/power/0,
|
||||
|
||||
@@ -17,13 +17,7 @@ from ..entities import (
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
@@ -33,18 +27,6 @@ def _timestamp(value):
|
||||
return None
|
||||
|
||||
|
||||
def _item_value(items, sensor_type, index=0):
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
if index < len(values):
|
||||
return _int_or_none(values[index])
|
||||
return None
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
|
||||
|
||||
@@ -176,7 +158,7 @@ HOOD_FILTER = Capability(
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_int_or_none,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_status',
|
||||
@@ -193,7 +175,7 @@ HOOD_FILTER = Capability(
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
value_fn=_int_or_none,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -208,25 +190,25 @@ AIR_QUALITY = Capability(
|
||||
field='x.com.samsung.da.items',
|
||||
name='Clean level',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: _item_value(items, 'CleanLevel'),
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Dust',
|
||||
value_fn=lambda items: _item_value(items, 'Dust'),
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Fine dust',
|
||||
value_fn=lambda items: _item_value(items, 'FineDust'),
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Super fine dust',
|
||||
value_fn=lambda items: _item_value(items, 'SuperFineDust'),
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -246,6 +246,17 @@
|
||||
"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}",
|
||||
|
||||
@@ -246,6 +246,17 @@
|
||||
"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}",
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/consumable/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.state": "100",
|
||||
"x.com.samsung.da.name": "FilterProgress"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_TVTL_18K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02059A230513",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "19071703,19071706",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Blooming_0",
|
||||
"Light_On",
|
||||
"OptionCode_60282"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"11",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"9",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Odor",
|
||||
"x.com.samsung.da.type": "Odor",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"5",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"blooming_level",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"fan_direction",
|
||||
"fan_speed_level",
|
||||
"filter_progress",
|
||||
"fine_dust",
|
||||
"odor",
|
||||
"operating_mode",
|
||||
"power_energy_kwh",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"super_fine_dust"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN,
|
||||
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_HOST, CONF_PORT, DOMAIN,
|
||||
)
|
||||
|
||||
from .conftest import (
|
||||
@@ -276,3 +277,45 @@ 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
|
||||
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.common import (
|
||||
@@ -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,18 +703,150 @@ 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('/some/path', {'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
|
||||
|
||||
|
||||
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
|
||||
@@ -803,7 +947,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(
|
||||
@@ -835,3 +979,44 @@ 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}
|
||||
@@ -67,6 +67,30 @@ 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)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _purifier():
|
||||
resources = _load_device('air_purifier')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _purifier()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_model_resolves_to_air_purifier_registry():
|
||||
reg, _ = _purifier()
|
||||
assert reg is not None
|
||||
assert reg.name == 'air_purifier'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #56 dump binds or is ignored -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _purifier()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
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',
|
||||
'diagnosis_status', 'fan_speed_level', 'fan_direction',
|
||||
'display_light', 'operating_mode', 'blooming_level',
|
||||
):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_air_quality_sensor_values():
|
||||
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
|
||||
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
|
||||
state = _state()
|
||||
assert state['dust'] == 11
|
||||
assert state['fine_dust'] == 9
|
||||
assert state['super_fine_dust'] == 5
|
||||
assert state['odor'] == 0
|
||||
assert state['clean_level'] == 0
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
assert _state()['filter_progress'] == 100
|
||||
|
||||
|
||||
def test_diagnosis_reuses_dishwasher_capability():
|
||||
"""/diagnosis/vs/0 has the identical field/write contract as
|
||||
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
reg, _ = _purifier()
|
||||
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
|
||||
|
||||
|
||||
def test_light_switch_write_contract():
|
||||
"""The display-light switch RMW-replaces only the 'Light_*' entry in the
|
||||
packed /mode/vs/0 options list (via laundry.replace_in_options), leaving
|
||||
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',
|
||||
]}
|
||||
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',
|
||||
]},
|
||||
)
|
||||
|
||||
|
||||
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)."""
|
||||
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']}
|
||||
assert operating_mode.rep_fn(rep) == 'Off'
|
||||
assert blooming.rep_fn(rep) == '6'
|
||||
assert not hasattr(operating_mode, 'write_fn')
|
||||
|
||||
|
||||
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)."""
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
|
||||
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
|
||||
) is False
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
|
||||
@@ -244,6 +244,20 @@ def test_registry_reproduces_golden_state_keys_for_range():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_air_purifier():
|
||||
"""ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via
|
||||
the '_TVTL_' modelNum token fallback in for_device_by_model."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('air_purifier')
|
||||
golden = json.loads((GOLDEN / 'air_purifier.json').read_text())
|
||||
state_keys = _new_state_keys('air_purifier', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_oven():
|
||||
"""Wall oven (model TP1X_DA-KS-OVEN-0107X, issue #55) -- reports no
|
||||
oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in
|
||||
|
||||
Reference in new issue
Block a user