Merge pull request #70 from vmvarga/feat/wd7000b-fridge-fixes
feat: laundry firmware-flag gating, fridge vendor temp writes, course…
This commit is contained in:
@@ -22,7 +22,11 @@ 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, for_device_by_resources
|
||||
from .registry.capabilities.common import merge_options_field, remote_control_enabled
|
||||
from .registry.capabilities.common import (
|
||||
merge_options_field,
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -531,7 +535,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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)."""
|
||||
block's assumption doesn't hold for every model), or the laundry
|
||||
firmware flag isModelSettingWithoutSC declares settings writable
|
||||
without Smart Control (cycle start/pause/stop on /operational/state
|
||||
still require it)."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
if write_fn is None:
|
||||
@@ -540,7 +547,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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 bypass_remote_control
|
||||
and remote_control_required_for_write(resources, href or '')
|
||||
and not remote_control_enabled(resources)
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_control_disabled",
|
||||
@@ -553,7 +564,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error,
|
||||
)
|
||||
result = write_fn(payload, rep, href)
|
||||
try:
|
||||
result = write_fn(payload, rep, href, resources)
|
||||
except TypeError:
|
||||
result = write_fn(payload, rep, href)
|
||||
if result is None:
|
||||
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
||||
return
|
||||
|
||||
@@ -33,7 +33,7 @@ REGISTRY = DeviceRegistry(
|
||||
]),
|
||||
pattern_capabilities=[
|
||||
fridge.TEMP_CURRENT_GENERIC,
|
||||
fridge.TEMP_SETPOINT_GENERIC,
|
||||
fridge.TEMP_SETPOINT,
|
||||
fridge.ICEMAKER_GENERIC,
|
||||
fridge.DOOR_GENERIC,
|
||||
],
|
||||
|
||||
@@ -59,9 +59,21 @@ def _ml_to_l(v):
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted.
|
||||
|
||||
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
|
||||
condition clears (see WD7000B diagnostics). Surface only live alarms so
|
||||
HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter
|
||||
helper that also drops ErrorCode_OFF.
|
||||
"""
|
||||
if not items or not isinstance(items, list):
|
||||
return 'none'
|
||||
codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')]
|
||||
codes = [
|
||||
i.get('x.com.samsung.da.code')
|
||||
for i in items
|
||||
if i.get('x.com.samsung.da.code')
|
||||
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
|
||||
]
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
@@ -92,6 +104,52 @@ def merge_options_field(cached, new_tokens):
|
||||
return merged
|
||||
|
||||
|
||||
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
|
||||
# life of a given board, so reading them from the /device/0 seed (no dedicated
|
||||
# poll_tier) is enough.
|
||||
_SETINFO_HREF = '/wm/setinfo/vs/0'
|
||||
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
|
||||
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
|
||||
|
||||
|
||||
def model_allows_power_on_off(resources: dict) -> bool:
|
||||
"""True unless firmware explicitly declares remote power on/off unsupported.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
|
||||
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
|
||||
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
|
||||
the writable switch -- current behavior.
|
||||
"""
|
||||
setinfo = resources.get(_SETINFO_HREF)
|
||||
if setinfo is None:
|
||||
return True
|
||||
flag = setinfo.get(_POWER_ON_OFF_FIELD)
|
||||
if flag is None:
|
||||
return True
|
||||
return str(flag).lower() != 'false'
|
||||
|
||||
|
||||
def model_setting_without_sc(resources: dict) -> bool:
|
||||
"""True when firmware declares settings writable without Smart Control.
|
||||
|
||||
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
|
||||
that accept temperature/spin/cycle-option writes while remote control is
|
||||
off. Cycle start/pause/stop still need Smart Control on those boards --
|
||||
the flag name is settings-specific, not a blanket remote-control bypass.
|
||||
"""
|
||||
setinfo = resources.get(_SETINFO_HREF) or {}
|
||||
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
|
||||
|
||||
|
||||
def _power_switch_exists(rep, resources):
|
||||
return model_allows_power_on_off(resources)
|
||||
|
||||
|
||||
def _power_sensor_exists(rep, resources):
|
||||
return not model_allows_power_on_off(resources)
|
||||
|
||||
|
||||
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
|
||||
@@ -128,10 +186,17 @@ def sensor_item_value(items, sensor_type, index=0):
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
# Writable when firmware allows remote power; otherwise a read-only
|
||||
# binary_sensor with the same key keeps HA state without a dead switch.
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
BinarySensorDesc(key='power_switch', field='value',
|
||||
device_class='power',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=_power_sensor_exists),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -141,9 +206,14 @@ POWER_VS_FALLBACK = Capability(
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
exists_fn=_power_switch_exists,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
device_class='power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
exists_fn=_power_sensor_exists),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -192,6 +262,20 @@ def remote_control_enabled(resources: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
|
||||
"""Whether a write to bound_href should be gated on Smart Control.
|
||||
|
||||
When isModelSettingWithoutSC is true, laundry firmware accepts settings
|
||||
writes (wash temp, spin, course options, buzzer, ...) with remote
|
||||
control off, but cycle start/pause/stop on /operational/state still
|
||||
need Smart Control. Absent that flag, keep the historical blanket gate.
|
||||
"""
|
||||
if not model_setting_without_sc(resources):
|
||||
return True
|
||||
href = bound_href or ''
|
||||
return href.startswith('/operational/state')
|
||||
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
poll_tier='warm',
|
||||
|
||||
@@ -62,7 +62,38 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
TEMP_SETPOINT_GENERIC = Capability(
|
||||
def _temp_setpoint_write(p, rep, href=None, resources=None):
|
||||
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
|
||||
fall back to direct OCF /temperature/desired/ write otherwise.
|
||||
|
||||
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
|
||||
/temperatures/vs/0. On some models only the vendor path commits the change;
|
||||
on others both work. Using the vendor path when present is always correct.
|
||||
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
|
||||
"""
|
||||
if not href:
|
||||
return None
|
||||
if resources and '/temperatures/vs/0' in resources:
|
||||
if '/cooler/' in href:
|
||||
item_id = '1'
|
||||
elif '/freezer/' in href:
|
||||
item_id = '0'
|
||||
else:
|
||||
return None
|
||||
return (
|
||||
['temperatures', 'vs', '0'],
|
||||
{'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.id': item_id,
|
||||
'x.com.samsung.da.desired': str(int(round(float(p))))}
|
||||
]}
|
||||
)
|
||||
return (
|
||||
[s for s in href.strip('/').split('/') if s],
|
||||
{'temperature': int(round(float(p)))}
|
||||
)
|
||||
|
||||
|
||||
TEMP_SETPOINT = Capability(
|
||||
href=None,
|
||||
href_prefix='/temperature/desired/',
|
||||
strip_prefix_in_key=True,
|
||||
@@ -73,10 +104,7 @@ TEMP_SETPOINT_GENERIC = Capability(
|
||||
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
|
||||
native_min=-20.0, native_max=50.0,
|
||||
range_field='range', entity_category='config',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
[s for s in href.strip('/').split('/') if s],
|
||||
{'temperature': int(round(float(p)))}
|
||||
) if href else None),
|
||||
write_fn=_temp_setpoint_write),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"power_state": {
|
||||
"name": "Power state"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Power"
|
||||
},
|
||||
"rapid_freezing": {
|
||||
"name": "Rapid freezing"
|
||||
},
|
||||
@@ -379,16 +382,20 @@
|
||||
"27": "Rinse+Spin",
|
||||
"28": "Drain/Spin",
|
||||
"29": "Drum Clean+",
|
||||
"2a": "Jeans",
|
||||
"2b": "AI Wash",
|
||||
"2d": "Silent Wash",
|
||||
"2e": "Baby Care",
|
||||
"2f": "Activewear",
|
||||
"30": "Cloudy Day",
|
||||
"32": "Shirts",
|
||||
"33": "Bedding",
|
||||
"34": "Mixed",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
"39": "Synthetics Dry",
|
||||
"3a": "Drum Clean",
|
||||
"53": "Heavy Duty",
|
||||
"55": "Activewear",
|
||||
"57": "Delicate",
|
||||
@@ -398,11 +405,11 @@
|
||||
"7c": "Whites",
|
||||
"7d": "Bedding/Waterproof",
|
||||
"7e": "Self-Clean",
|
||||
"7f": "Wool/Delicate",
|
||||
"86": "Deep Wash",
|
||||
"87": "Download",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber",
|
||||
"2b": "AI Wash",
|
||||
"2a": "Jeans"
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"power_state": {
|
||||
"name": "Voedingsstatus"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Voeding"
|
||||
},
|
||||
"rapid_freezing": {
|
||||
"name": "Snelvriezen"
|
||||
},
|
||||
@@ -379,16 +382,20 @@
|
||||
"27": "Spoelen+centrifugeren",
|
||||
"28": "Afpompen/centrifugeren",
|
||||
"29": "Drum Clean+",
|
||||
"2a": "Spijkerbroek",
|
||||
"2b": "AI Wash",
|
||||
"2d": "Stille was",
|
||||
"2e": "Babyverzorging",
|
||||
"2f": "Sportkleding",
|
||||
"30": "Bewolkte dag",
|
||||
"32": "Overhemden",
|
||||
"33": "Beddengoed",
|
||||
"34": "Gemengd",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
"38": "Katoen drogen",
|
||||
"39": "Synthetisch drogen",
|
||||
"3a": "Trommel reinigen",
|
||||
"53": "Intensief",
|
||||
"55": "Sportkleding",
|
||||
"57": "Fijn",
|
||||
@@ -398,11 +405,11 @@
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/Waterdicht",
|
||||
"7e": "Zelfreinigend",
|
||||
"7f": "Wol/Fijn",
|
||||
"86": "Diep wassen",
|
||||
"87": "Downloaden",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels",
|
||||
"2b": "AI Wash",
|
||||
"2a": "Spijkerbroek"
|
||||
"96": "Minder microvezels"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
|
||||
@@ -16,6 +16,7 @@ from custom_components.localthings.const import (
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
|
||||
|
||||
@@ -880,6 +881,38 @@ class TestRemoteControlEnabled:
|
||||
assert remote_control_enabled({}) is True
|
||||
|
||||
|
||||
class TestRemoteControlRequiredForWrite:
|
||||
"""isModelSettingWithoutSC auto-exempts settings writes, not cycle control."""
|
||||
|
||||
def test_without_flag_always_requires(self):
|
||||
resources = {}
|
||||
assert remote_control_required_for_write(resources, '/washer/vs/0') is True
|
||||
assert remote_control_required_for_write(
|
||||
resources, '/operational/state/vs/0') is True
|
||||
|
||||
def test_without_sc_exempts_settings_keeps_operational(self):
|
||||
resources = {
|
||||
'/wm/setinfo/vs/0': {
|
||||
'x.com.samsung.da.isModelSettingWithoutSC': 'true',
|
||||
},
|
||||
}
|
||||
assert remote_control_required_for_write(resources, '/washer/vs/0') is False
|
||||
assert remote_control_required_for_write(resources, '/course/vs/0') is False
|
||||
assert remote_control_required_for_write(resources, '/buzzersound/vs/0') is False
|
||||
assert remote_control_required_for_write(
|
||||
resources, '/operational/state/vs/0') is True
|
||||
assert remote_control_required_for_write(
|
||||
resources, '/operational/state/0') is True
|
||||
|
||||
def test_without_sc_false_still_requires(self):
|
||||
resources = {
|
||||
'/wm/setinfo/vs/0': {
|
||||
'x.com.samsung.da.isModelSettingWithoutSC': 'false',
|
||||
},
|
||||
}
|
||||
assert remote_control_required_for_write(resources, '/washer/vs/0') is True
|
||||
|
||||
|
||||
async def test_send_command_blocked_when_remote_control_disabled(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -1024,3 +1057,95 @@ async def test_send_command_bypasses_remote_control_when_option_enabled(
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
|
||||
async def test_send_command_settings_allowed_when_without_sc(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Laundry boards with isModelSettingWithoutSC=true accept settings
|
||||
writes while remote control is off -- no options-flow bypass needed."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
|
||||
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]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
source='test',
|
||||
)
|
||||
coordinator._cache.apply_rep(
|
||||
'/wm/setinfo/vs/0',
|
||||
{'x.com.samsung.da.isModelSettingWithoutSC': 'true'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': payload})
|
||||
|
||||
desc = SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
write_fn=_write_fn)
|
||||
bound = BoundEntity(
|
||||
href='/washer/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, '1000')
|
||||
|
||||
assert coordinator._cache.get('/washer/vs/0') == {
|
||||
'x.com.samsung.da.spinLevel': '1000',
|
||||
}
|
||||
|
||||
|
||||
async def test_send_command_operational_still_blocked_when_without_sc(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Start/Pause/Stop stay gated on Smart Control even when settings
|
||||
are allowed without it -- isModelSettingWithoutSC is settings-only."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import ButtonDesc
|
||||
|
||||
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]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
source='test',
|
||||
)
|
||||
coordinator._cache.apply_rep(
|
||||
'/wm/setinfo/vs/0',
|
||||
{'x.com.samsung.da.isModelSettingWithoutSC': 'true'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': payload})
|
||||
|
||||
desc = ButtonDesc(key='start', payload='Run', write_fn=_write_fn)
|
||||
bound = BoundEntity(
|
||||
href='/operational/state/vs/0',
|
||||
capability=coordinator.bound[0].capability,
|
||||
desc=desc,
|
||||
)
|
||||
|
||||
posted = False
|
||||
|
||||
def _post(*a, **k):
|
||||
nonlocal posted
|
||||
posted = True
|
||||
return (0x44, b'')
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = _post
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await coordinator.async_send_command(bound, 'Run')
|
||||
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
assert exc_info.value.translation_key == 'remote_control_disabled'
|
||||
assert posted is False
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from custom_components.localthings.registry.capabilities import common
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _reg():
|
||||
@@ -65,7 +67,7 @@ class TestMergeOptionsField:
|
||||
class TestPowerFallback:
|
||||
def test_generic_href_read_write(self):
|
||||
assert common.POWER_GENERIC.href == '/power/0'
|
||||
desc = common.POWER_GENERIC.entities[0]
|
||||
desc = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
|
||||
assert desc.value_fn(True) is True
|
||||
assert desc.value_fn(False) is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
@@ -80,13 +82,72 @@ class TestPowerFallback:
|
||||
{}, {'/power/0': {}, '/power/vs/0': {}}) is False
|
||||
|
||||
def test_vs_fallback_read_write(self):
|
||||
desc = common.POWER_VS_FALLBACK.entities[0]
|
||||
desc = next(e for e in common.POWER_VS_FALLBACK.entities if isinstance(e, SwitchDesc))
|
||||
assert desc.value_fn('On') is True
|
||||
assert desc.value_fn('Off') is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
def test_power_switch_hidden_when_firmware_disallows(self):
|
||||
switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
|
||||
sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc))
|
||||
resources = {
|
||||
'/power/0': {'value': True},
|
||||
'/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'false'},
|
||||
}
|
||||
assert switch.exists_fn(resources['/power/0'], resources) is False
|
||||
assert sensor.exists_fn(resources['/power/0'], resources) is True
|
||||
|
||||
def test_power_switch_writable_when_firmware_allows(self):
|
||||
switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
|
||||
sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc))
|
||||
resources = {
|
||||
'/power/0': {'value': True},
|
||||
'/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'true'},
|
||||
}
|
||||
assert switch.exists_fn(resources['/power/0'], resources) is True
|
||||
assert sensor.exists_fn(resources['/power/0'], resources) is False
|
||||
|
||||
def test_power_switch_default_writable_without_setinfo(self):
|
||||
switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc))
|
||||
sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc))
|
||||
resources = {'/power/0': {'value': True}}
|
||||
assert switch.exists_fn(resources['/power/0'], resources) is True
|
||||
assert sensor.exists_fn(resources['/power/0'], resources) is False
|
||||
|
||||
|
||||
class TestWmSetinfoFlags:
|
||||
def test_washer_fixture_carries_setinfo_from_device0(self):
|
||||
"""/wm/setinfo/vs/0 lands in the seed snapshot -- no dedicated capability."""
|
||||
resources = _load_device('washer')
|
||||
assert '/wm/setinfo/vs/0' in resources
|
||||
assert common.model_allows_power_on_off(resources) is False
|
||||
assert common.model_setting_without_sc(resources) is True
|
||||
|
||||
def test_dishwasher_allows_power_on_off(self):
|
||||
resources = _load_device('dishwasher')
|
||||
assert common.model_allows_power_on_off(resources) is True
|
||||
assert common.model_setting_without_sc(resources) is False
|
||||
|
||||
def test_deleted_alarms_filtered(self):
|
||||
assert common._active_alarm_codes([
|
||||
{
|
||||
'x.com.samsung.da.code': 'ErrorCode',
|
||||
'x.com.samsung.da.state': 'Deleted',
|
||||
},
|
||||
]) == 'none'
|
||||
assert common._active_alarm_codes([
|
||||
{
|
||||
'x.com.samsung.da.code': 'LE',
|
||||
'x.com.samsung.da.state': 'Triggered',
|
||||
},
|
||||
{
|
||||
'x.com.samsung.da.code': 'ErrorCode',
|
||||
'x.com.samsung.da.state': 'Deleted',
|
||||
},
|
||||
]) == 'LE'
|
||||
|
||||
|
||||
class TestKidsLockFallback:
|
||||
def test_generic_read_write(self):
|
||||
|
||||
@@ -20,9 +20,9 @@ class TestTempCurrentGeneric:
|
||||
assert desc.unit_fn({'temperature': 5.0}) == '°F'
|
||||
|
||||
|
||||
class TestTempSetpointGeneric:
|
||||
class TestTempSetpoint:
|
||||
def test_unit_reads_celsius(self):
|
||||
desc = fridge.TEMP_SETPOINT_GENERIC.entities[0]
|
||||
desc = fridge.TEMP_SETPOINT.entities[0]
|
||||
assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user