Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc570533a0 | ||
|
|
6239706065 | ||
|
|
33786ad584 | ||
|
|
e60fdf26d2 | ||
|
|
9011f84787 | ||
|
|
0640ac3477 | ||
|
|
b47dabeb31 | ||
|
|
cc3ce12aa4 | ||
|
|
5055d6bb72 | ||
|
|
265d94eded | ||
|
|
e9281aaead | ||
|
|
c19800ac00 | ||
|
|
ed5e8e57c8 | ||
|
|
f063666291 | ||
|
|
5af9d951c6 | ||
|
|
a1f14cd633 | ||
|
|
2be742489b | ||
|
|
d4583b3240 | ||
|
|
0ebcfb00ff |
@@ -25,7 +25,8 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
|
||||
| Air purifier | `by_type/air_purifier.py` |
|
||||
| Dehumidifier | `by_type/dehumidifier.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven (including combi microwaves) | `by_type/oven.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Microwave | `by_type/microwave.py` |
|
||||
| Gas cooktop (read-only burner status) | `by_type/cooktop.py` |
|
||||
| Range hood | `by_type/range_hood.py` |
|
||||
| Range | `by_type/range.py` |
|
||||
|
||||
@@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
||||
PROBE_GET_TIMEOUT_S = 10.0
|
||||
|
||||
# Base for the local (client-side) DTLS source port, distinct from the
|
||||
# destination probe ports above. See coordinator._local_source_port for why a
|
||||
# fixed per-device source port matters and how the per-device offset is
|
||||
# derived. Base mirrors the upstream smartthings-local reference bridge.
|
||||
# Requires smartthings-local >= 0.1.1.
|
||||
DTLS_LOCAL_PORT_BASE = 49700
|
||||
|
||||
SUMMARY_INTERVAL_S = 30.0
|
||||
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -37,6 +39,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,
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -54,6 +57,29 @@ class _NoOpDescriptor:
|
||||
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
|
||||
|
||||
|
||||
def _local_source_port(host: str) -> int:
|
||||
"""Deterministic UDP source port for this device's DTLS socket.
|
||||
|
||||
Binding the same source port on every (re)connect keeps the client on one
|
||||
5-tuple, so the appliance evicts an orphaned session left by a previous run
|
||||
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
|
||||
§4.2.8, instead of holding it for 5-15 min while the new session's reads
|
||||
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
|
||||
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
|
||||
|
||||
The port must be stable across restarts and unique per device on this HA
|
||||
host: the library's socket is unconnected (recvfrom), so two devices
|
||||
sharing a source port would mis-demux each other's datagrams. For the usual
|
||||
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
|
||||
anything else folds a stable CRC32 into the same 256-wide window.
|
||||
"""
|
||||
try:
|
||||
offset = int(ipaddress.IPv4Address(host)) & 0xFF
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
offset = zlib.crc32(host.encode()) & 0xFF
|
||||
return DTLS_LOCAL_PORT_BASE + offset
|
||||
|
||||
|
||||
def _is_placeholder_serial(serial: str) -> bool:
|
||||
"""True for a non-empty serialNum that isn't actually a real identity.
|
||||
|
||||
@@ -182,7 +208,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
|
||||
|
||||
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
|
||||
on_notification=self._observe.on_notification)
|
||||
on_notification=self._observe.on_notification,
|
||||
local_port=_local_source_port(host))
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
self._session = sess
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from .registry.adapter import _key
|
||||
from .registry.batch import is_stub_rep
|
||||
from .registry.discovery import BoundEntity, _snake_to_title
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -21,9 +22,17 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
require that field to be present in the resource rep so that optional
|
||||
fields on shared resources don't create phantom entities.
|
||||
|
||||
An empty rep ({}) means /device/0 returned a stub for this resource —
|
||||
the resource exists on the device but data hasn't been fetched yet.
|
||||
In that case we include the entity so it can be populated by sub-polls.
|
||||
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
|
||||
yet" marker) is included anyway so it can be populated by sub-polls. A
|
||||
genuinely empty {} rep is included too by this default gate -- whether
|
||||
empty means "not populated yet" or "permanently unsupported" needs
|
||||
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
|
||||
{} is fridge.py's documented *normal* no-alarm state (see
|
||||
_active_alarm_codes), not an absence signal, and it's far from the only
|
||||
resource like that. Only a capability whose author has actually verified
|
||||
a field is genuinely never populated on unsupported hardware opts into
|
||||
stricter gating with its own is_stub_rep-based exists_fn (see
|
||||
common.ENERGY_METER, issue #127) -- this default stays permissive.
|
||||
"""
|
||||
rep = coordinator.last_resources.get(bound.href)
|
||||
if rep is None:
|
||||
@@ -31,7 +40,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
if bound.desc.exists_fn is not None:
|
||||
return bound.desc.exists_fn(rep, coordinator.last_resources)
|
||||
if bound.desc.field:
|
||||
if not rep: # stub — resource known to exist, data not yet fetched
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
return bound.desc.field in rep
|
||||
return True # rep_fn or no-field entities (ButtonDesc) are always included
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Fan platform for Samsung range hoods and air purifiers.
|
||||
|
||||
Two FanDesc-bound hrefs exist, one per family, dispatched by href in
|
||||
async_setup_entry below since they need different HA fan semantics: the
|
||||
range hood's fan speed is an ordered set of numeric levels (SET_SPEED), while
|
||||
the newer air-purifier board family's modes (Smart/Max/Mid/WindFree/Sleep,
|
||||
issue #130) are named behaviors with no linear order (PRESET_MODE)."""
|
||||
Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
|
||||
below since each needs different HA fan semantics: the range hood's fan
|
||||
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
|
||||
Medium/High (issue #56) are both an ordered set of numeric levels
|
||||
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
|
||||
air_purifier.py's module docstring, with no named-mode list to preserve
|
||||
since this board never self-reports one. The newer TP1X air-purifier
|
||||
family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named
|
||||
behaviors with no linear order (PRESET_MODE), reported directly by that
|
||||
board's own supportedModes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,6 +27,7 @@ from homeassistant.util.percentage import (
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.capabilities.air_purifier import HREF_AIRFLOW
|
||||
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
|
||||
from .registry.entities import FanDesc
|
||||
|
||||
@@ -31,6 +37,8 @@ POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
||||
_OFF_SPEED_CODE = '0'
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
@@ -48,13 +56,32 @@ async def async_setup_entry(
|
||||
continue
|
||||
if bound.href == AIR_PURIFIER_FAN_HREF:
|
||||
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
|
||||
elif bound.href == HREF_AIRFLOW:
|
||||
entities.append(LocalThingsAirflowFan(coordinator, bound))
|
||||
else:
|
||||
entities.append(LocalThingsRangeHoodFan(coordinator, bound))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources."""
|
||||
"""A hood fan combining sibling power and fan-speed resources.
|
||||
|
||||
Some boards that reuse this capability (built-in microwave vent fans,
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||
resource at all -- fan speed 0 is itself the off state there, with no
|
||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
||||
shape from the hood resource's own settableMinFanSpeed/
|
||||
supportedFanSpeed fields and switches every method below to drive
|
||||
off/on purely through the fanSpeed field, including '0' in the
|
||||
ordered speed codes as the off step instead of assuming every
|
||||
advertised code is an active speed.
|
||||
|
||||
This is deliberately not the same question as `_has_separate_power`,
|
||||
which only proves *some* power resource exists on the device -- on a
|
||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
||||
belong to the cavity, not the vent fan, and toggling it from here
|
||||
would turn off the whole appliance instead of just the fan.
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
@@ -70,15 +97,35 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _has_separate_power(self) -> bool:
|
||||
return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
|
||||
|
||||
def _speed_zero_is_off(self) -> bool:
|
||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||
separate power resource to toggle. The board says so directly:
|
||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
||||
standalone hood's codes start at 14 and it carries a real /power
|
||||
resource instead, so this is False there."""
|
||||
rep = self._rep(self._bound.href)
|
||||
return (
|
||||
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
|
||||
or _OFF_SPEED_CODE in self._all_speed_codes()
|
||||
)
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
# Power is carried by the separate /power resource. fanSpeed retains
|
||||
# the selected setting while power is off (as the lamp's `current`
|
||||
# field does), so every advertised code is an active ordered speed.
|
||||
return self._all_speed_codes()
|
||||
codes = self._all_speed_codes()
|
||||
if self._speed_zero_is_off():
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
return codes
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
@@ -88,6 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
if self._speed_zero_is_off():
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
||||
return current not in ('', _OFF_SPEED_CODE)
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
@@ -113,6 +163,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
if self.is_on:
|
||||
# Already running: no percentage given means "just turn on",
|
||||
# not "reset to the lowest speed".
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if codes:
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
@@ -120,6 +182,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
)
|
||||
@@ -131,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
return
|
||||
if not self.is_on:
|
||||
if not self._speed_zero_is_off() and not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
@@ -214,3 +279,87 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
"%s: %r is not a valid preset mode (supported: %s)",
|
||||
self.entity_id, preset_mode, self.preset_modes,
|
||||
)
|
||||
|
||||
|
||||
_AIRFLOW_SPEED_FIELD = 'speed'
|
||||
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
|
||||
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
|
||||
# as plain strings, same as _all_speed_codes above, so
|
||||
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
|
||||
# it exactly like the range hood's numeric levels -- no named-preset table
|
||||
# needed since this board never reports mode names to hang one off of.
|
||||
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
|
||||
|
||||
|
||||
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
|
||||
"""ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric
|
||||
speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
|
||||
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
|
||||
harmless for the TP1X board because it never reports /power/0 at
|
||||
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
|
||||
(the power_switch entity) is unconditionally bound to /power/0
|
||||
whenever it's present, so writing here to /power/vs/0 first would
|
||||
leave power_switch and this fan reading/writing two different
|
||||
resources -- disagreeing until the next poll refreshes the other
|
||||
one (the same optimistic-apply lag coordinator.py's own comments
|
||||
warn about)."""
|
||||
resources = self.coordinator.last_resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
power = self._rep(POWER_HREF)
|
||||
if 'value' in power:
|
||||
return bool(power.get('value'))
|
||||
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
return len(_AIRFLOW_SPEED_CODES)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
|
||||
if current not in _AIRFLOW_SPEED_CODES:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
if percentage <= 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
if not self.is_on:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
"smartthings-local>=0.1.1"
|
||||
],
|
||||
"version": "0.13.0"
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_stub_rep(rep: dict) -> bool:
|
||||
"""True for the device's "resource exists, no data fetched yet" marker --
|
||||
an echoed {"href": "..."} with no other fields.
|
||||
|
||||
Distinct from a genuinely empty {} rep, which is the device's confirmed
|
||||
(if empty) answer -- e.g. an unsupported resource on this model that will
|
||||
never populate. Conflating the two used to make every field-gated entity
|
||||
on a permanently-empty resource look like a not-yet-fetched stub forever,
|
||||
creating phantom always-"unknown" entities (issue #127)."""
|
||||
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
|
||||
|
||||
|
||||
def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
"""Extract {href: rep} from a /device/0 CBOR list response."""
|
||||
"""Extract {href: rep} from a /device/0 CBOR list response.
|
||||
|
||||
A stub rep is passed through unchanged rather than collapsed to {} --
|
||||
downstream code (entity._is_included, capability exists_fns) uses
|
||||
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
|
||||
"""
|
||||
out = {}
|
||||
for entry in device0[1:]: # skip [0] (device-level rep)
|
||||
if not isinstance(entry, dict):
|
||||
@@ -12,8 +29,6 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
|
||||
rep = entry.get('rep')
|
||||
if not href:
|
||||
continue
|
||||
# rep == {"href": "..."} is a stub (resource present, no current data).
|
||||
# Include it as {} so capabilities still bind and the entity exists.
|
||||
if isinstance(rep, dict):
|
||||
out[href] = {} if set(rep.keys()) == {'href'} else rep
|
||||
out[href] = rep
|
||||
return out
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Optional
|
||||
from ._base import DeviceRegistry
|
||||
from . import (
|
||||
air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer,
|
||||
induction_cooktop, oven, range as _range, range_hood, refrigerator,
|
||||
vacuum_station, washer, water_purifier,
|
||||
induction_cooktop, microwave, oven, range as _range, range_hood,
|
||||
refrigerator, vacuum_station, washer, water_purifier,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -24,6 +24,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'induction_cooktop': induction_cooktop.REGISTRY,
|
||||
'microwave': microwave.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'hood': range_hood.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
@@ -232,15 +233,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
|
||||
if key is None and '-OVEN-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
# Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same
|
||||
# board family as the wall oven above (an '/oven/vs/0' cavity resource
|
||||
# with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a
|
||||
# different cavity. Reports no oneUiVersion and doesn't match the
|
||||
# washer/dryer/dishwasher prefix map either. Binds cleanly against the
|
||||
# existing oven registry once routed here (confirmed against the issue
|
||||
# #121 dump) -- no microwave-specific device type needed.
|
||||
# Microwaves, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121)
|
||||
# and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board
|
||||
# family as the wall oven above (an '/oven/vs/0' cavity resource, same
|
||||
# /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode
|
||||
# vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds
|
||||
# from the oven registry, plus a powerLevel field ovens don't report --
|
||||
# its own device type rather than folded into 'oven' (issue #121 shipped
|
||||
# it onto the oven registry initially; split out per user feedback).
|
||||
# Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher
|
||||
# prefix map either.
|
||||
if key is None and '-MICROWAVE-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
key = 'microwave'
|
||||
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
|
||||
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
|
||||
# as the range combo above, but no oven attached at all. Distinct from
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Microwave device registry (combi and plain microwaves, issues #66/#121).
|
||||
|
||||
Shares the oven board family's cavity/cook-cycle resource shape, so the
|
||||
operational-state, door, cloud-connected, and quick-recipe-display
|
||||
Capability objects are reused directly from oven.py rather than duplicated.
|
||||
Cooking mode, setpoint, cavity power level, and lamp are genuinely
|
||||
different for this family (different mode vocabulary, different setpoint
|
||||
bounds, an extra powerLevel field, a differently-named lamp option) and are
|
||||
defined fresh in capabilities/microwave.py -- see that module's docstring.
|
||||
|
||||
Some combi units (built-in over-the-range microwaves, issues #137/#142)
|
||||
also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
|
||||
shape a standalone range hood reports it in -- reused directly from
|
||||
range_hood.py rather than duplicated. Unlike a standalone hood, this dump
|
||||
has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
|
||||
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
||||
case (see its `_speed_zero_is_off` check).
|
||||
"""
|
||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='microwave',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
microwave.MICROWAVE_CAVITY,
|
||||
microwave.MICROWAVE_SETPOINT,
|
||||
microwave.MICROWAVE_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]),
|
||||
)
|
||||
@@ -11,6 +11,7 @@ REGISTRY = DeviceRegistry(
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.AFTER_RUN,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
|
||||
@@ -36,5 +36,7 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.TEMP_SETPOINT,
|
||||
fridge.ICEMAKER_GENERIC,
|
||||
fridge.DOOR_GENERIC,
|
||||
fridge.KIMCHI_ZONE,
|
||||
fridge.KIMCHI_DOOR_GENERIC,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -39,14 +39,23 @@ set to Auto/Sleep/Low/Medium/High):
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
|
||||
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
|
||||
fan-speed control: across the same five dumps it read 0 for both Auto *and*
|
||||
High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
|
||||
selectable level, and the dumps were all captured within about three minutes
|
||||
of each other (only one poll cycle apart at this integration's 30s summary
|
||||
interval), so the values may not have settled after each change before the
|
||||
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
|
||||
stable capture -- see the issue #56 discussion for what's needed.
|
||||
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
|
||||
The first round of five dumps above wasn't conclusive -- it read 0 for both
|
||||
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
|
||||
were captured within about a minute of each other, faster than this
|
||||
integration's own ~30s poll cycle could settle each change. A second round,
|
||||
captured 60-90s apart per setting on two independent units, confirmed a
|
||||
clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4.
|
||||
AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4
|
||||
range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's
|
||||
percentage steps straight onto the raw code, no named-preset table needed
|
||||
(unlike the TP1X family's FAN, which exposes real named modes because its
|
||||
board actually reports a supportedModes list to hang names off of).
|
||||
|
||||
/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it
|
||||
was unreliable on both units in that second round (Low/Medium collided on
|
||||
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
|
||||
stays a plain read-only diagnostic even after this change.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
@@ -64,6 +73,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
|
||||
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
|
||||
# are mutually exclusive via this presence check rather than colliding.
|
||||
HREF_MODE = '/mode/vs/0'
|
||||
HREF_AIRFLOW = '/airflow/0'
|
||||
|
||||
|
||||
def _has_top_level_modes(rep, resources):
|
||||
@@ -126,21 +136,52 @@ DEVICE_ACTIVE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
|
||||
# why these are read-only for now.
|
||||
def _airflow_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/vs/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
if kind == 'speed':
|
||||
return ['airflow', '0'], {'speed': int(value)}
|
||||
return None
|
||||
|
||||
|
||||
# Confirmed via issue #56's second, properly-spaced round of diagnostics
|
||||
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
|
||||
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
|
||||
# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same
|
||||
# SET_SPEED shape as the range hood's) instead of a read-only sensor --
|
||||
# no named-preset table needed, since HA's percentage steps map onto the
|
||||
# raw 0-4 code directly, the same way the range hood's numeric levels do.
|
||||
# `direction` stays a plain diagnostic: every dump seen (both rounds, both
|
||||
# units) reads 'Off' for it regardless of fan setting, so there's nothing
|
||||
# confirmed to control there yet.
|
||||
#
|
||||
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
|
||||
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
|
||||
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
|
||||
# only *empirically* mutually exclusive (every dump seen has one board
|
||||
# generation's shape or the other, never both), not architecturally
|
||||
# enforced the way same-href caps are by _build()'s match_fn check -- a
|
||||
# same key would collide if a future board ever reported both.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href='/airflow/0',
|
||||
href=HREF_AIRFLOW,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='speed',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic'),
|
||||
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# Left exactly as a read-only fallback -- speedLevel is NOT the same
|
||||
# confirmed-reliable field as /airflow/0's speed above (see module
|
||||
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
|
||||
# on the other in the same properly-spaced round.
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href='/airflow/vs/0',
|
||||
match_fn=lambda rep, resources: '/airflow/0' not in resources,
|
||||
|
||||
@@ -12,6 +12,7 @@ against live device dumps:
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
@@ -380,32 +381,35 @@ _DEAD_INSTANTANEOUS_POWER = '-500'
|
||||
ENERGY_METER = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
entities=(
|
||||
# `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
|
||||
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
|
||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
||||
# entity when /device/0 returns a not-yet-fetched stub. On a populated
|
||||
# rep, hide power only for the dead sentinel or an absent field.
|
||||
# entity when /device/0 returns a not-yet-fetched stub. A genuinely
|
||||
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
|
||||
# answer, so it falls through to the normal field/sentinel checks like
|
||||
# any populated rep. On a populated rep, hide power only for the dead
|
||||
# sentinel or an absent field.
|
||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||
device_class='power', state_class='measurement',
|
||||
unit='W', value_fn=clamp_power,
|
||||
exists_fn=lambda rep, resources: not rep or (
|
||||
exists_fn=lambda rep, resources: is_stub_rep(rep) or (
|
||||
rep.get('x.com.samsung.da.instantaneousPower')
|
||||
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
# cumulativeConsumption is a second, independently-varying running
|
||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
||||
# both. Self-gates off where only cumulativePower is present. `not
|
||||
# rep or` keeps the same empty-{} stub carve-out as power_watts/
|
||||
# both. Self-gates off where only cumulativePower is present. The
|
||||
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
@@ -413,7 +417,7 @@ ENERGY_METER = Capability(
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
# Monthly billing-cycle totals -- the completed prior month and the
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
@@ -421,12 +425,12 @@ ENERGY_METER = Capability(
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -499,7 +503,7 @@ AI_ENERGY_LEVEL = Capability(
|
||||
href='/energy/ailevel/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
# No `not rep` stub carve-out on either side, unlike most exists_fn
|
||||
# No is_stub_rep carve-out on either side, unlike most exists_fn
|
||||
# gates in this file -- entity creation only ever runs once, against
|
||||
# whichever snapshot happens to be current the moment platforms are
|
||||
# set up (see entity._is_included / __init__.py's
|
||||
@@ -562,7 +566,7 @@ SELF_CHECK = Capability(
|
||||
icon='mdi:alert-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.error' in rep),
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.error' in rep),
|
||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
|
||||
@@ -9,6 +9,7 @@ cooktop must not be remotely ignited by an automation.
|
||||
|
||||
import re
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
@@ -97,7 +98,7 @@ COOKTOP_MODE = Capability(
|
||||
options, f'OperationState{slot}'
|
||||
),
|
||||
exists_fn=lambda rep, resources, slot=slot: (
|
||||
not rep or _option_value(
|
||||
is_stub_rep(rep) or _option_value(
|
||||
rep.get('x.com.samsung.da.options'),
|
||||
f'OperationState{slot}',
|
||||
) is not None
|
||||
|
||||
@@ -590,8 +590,17 @@ FLEX_ZONE = Capability(
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedOptions')),
|
||||
# A nonempty supportedOptions alone isn't sufficient: the
|
||||
# kimchi-refrigerator family (issue #26) also populates
|
||||
# /mode/vs/0's modes/supportedOptions with real data, but
|
||||
# its tokens carry a "_[n]:[n]" parameter suffix on
|
||||
# supportedOptions that modes never repeats, so no item
|
||||
# ever overlaps -- the RF9000/Bespoke-class overlap this
|
||||
# capability was built for never happens there. Require an
|
||||
# actual resolvable value instead of just a populated
|
||||
# list, so this stays absent on that family rather than
|
||||
# showing a select permanently stuck on "unknown".
|
||||
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
||||
rep_fn=_flex_zone_current,
|
||||
write_fn=_flex_zone_write),
|
||||
),
|
||||
@@ -625,6 +634,105 @@ DOOR_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
|
||||
# units, issue #26) -- top/middle/bottom each report their own storage mode
|
||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
|
||||
# an identical shape; modeled as a pattern capability the same way
|
||||
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
|
||||
# key and {instance_name} from the href's top/middle/bottom segment. Only
|
||||
# the top compartment's door has been seen reported separately (kimchidoors);
|
||||
# middle/bottom apparently have no contact switch of their own, so that's
|
||||
# its own narrower pattern cap rather than assumed universal.
|
||||
#
|
||||
# The same state is also mirrored -- packed into single tokens like
|
||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
|
||||
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
|
||||
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
|
||||
# supportMode fields are unpacked and self-describing, so that's what this
|
||||
# binds to instead.
|
||||
#
|
||||
# Write path is unconfirmed (no live write against a real unit) -- same
|
||||
# "write the same field back to the entity's own href" convention as
|
||||
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
|
||||
#
|
||||
# translations/en.json's kimchi_zone_mode state labels were translated
|
||||
# directly from the reporter's own (Korean-language) SmartThings app
|
||||
# screenshots, not guessed from the codes or from their English paraphrase.
|
||||
# Cross-checking the screenshots against supportMode confirms the on-screen
|
||||
# option order matches the array order everywhere it's verifiable: the top
|
||||
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
|
||||
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
|
||||
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
|
||||
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
|
||||
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
|
||||
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
|
||||
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
|
||||
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
|
||||
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
|
||||
# and BUY (구입, "purchased") are also confirmed exact matches, not
|
||||
# abbreviation guesses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _kimchi_mode_write(p, rep, href=None):
|
||||
if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
|
||||
return None
|
||||
return [s for s in href.strip('/').split('/') if s], {
|
||||
'x.com.samsung.da.currentMode': p,
|
||||
}
|
||||
|
||||
|
||||
KIMCHI_ZONE = Capability(
|
||||
href=None,
|
||||
href_prefix='/status/kimchi/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='mode', field='x.com.samsung.da.currentMode',
|
||||
use_instance_name=True, icon='mdi:fridge-outline',
|
||||
translation_key='kimchi_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportMode',
|
||||
write_fn=_kimchi_mode_write),
|
||||
SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
|
||||
use_instance_name=True, icon='mdi:progress-clock',
|
||||
translation_key='kimchi_ripening_status',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
|
||||
use_instance_name=True, icon='mdi:timer-sand',
|
||||
translation_key='kimchi_ripening_remaining',
|
||||
entity_category='diagnostic',
|
||||
# No dump has this nonzero (ripeStatus is always "Off" so
|
||||
# far) -- device-reported unit unconfirmed, so this stays
|
||||
# a bare number rather than asserting minutes or hours.
|
||||
value_fn=_int),
|
||||
SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount',
|
||||
use_instance_name=True, icon='mdi:tray-full',
|
||||
translation_key='kimchi_rack_count',
|
||||
entity_category='diagnostic', enabled_default=False,
|
||||
value_fn=_int),
|
||||
),
|
||||
)
|
||||
|
||||
KIMCHI_DOOR_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/kimchidoors/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
||||
# that doesn't share this compartment's "top" instance numbering --
|
||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
||||
# switch reported twice.
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregate-resource fallbacks
|
||||
#
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-*
|
||||
class boards, both combi units and plain microwaves).
|
||||
|
||||
Shares the oven board family's cavity/cook-cycle resource shape
|
||||
(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`,
|
||||
`/recipe/cook/vs/0`) -- those Capability objects are reused directly from
|
||||
oven.py in by_type/microwave.py rather than duplicated. What's genuinely
|
||||
different from an oven, and defined fresh here:
|
||||
|
||||
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
|
||||
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
|
||||
some shared-sounding modes differently than oven.py's own constants
|
||||
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
|
||||
mode list, not oven.OVEN_MODE.
|
||||
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
|
||||
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
|
||||
30-270 C range (verified against a different, bake-oven-class board).
|
||||
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
|
||||
on the MicroWave mode's powerListData) that plain ovens don't report --
|
||||
exposed as its own sensor.
|
||||
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
|
||||
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
|
||||
the combi dump (issue #121), so it's gated with exists_fn rather than
|
||||
assumed universal like oven.py's lamp switch.
|
||||
|
||||
Note: cooking-mode writes are unproven here, same caveat as oven.py's
|
||||
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
|
||||
is also the test.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
from .laundry import option_value, option_write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Union of every mode seen across the two known dumps: issue #121's combi
|
||||
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
|
||||
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
|
||||
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
|
||||
# every mode below on one device -- the select surfaces whatever a given
|
||||
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
|
||||
# never sends just never gets picked.
|
||||
_MICROWAVE_MODES = (
|
||||
'NoOperation',
|
||||
'MicroWave',
|
||||
'MicroWaveGrill',
|
||||
'MicroWaveConvection',
|
||||
'Convection',
|
||||
'AirFryer',
|
||||
'Grill',
|
||||
'Autocook',
|
||||
'AutocookCustom',
|
||||
'Deodorization',
|
||||
'KeepWarm',
|
||||
)
|
||||
|
||||
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
|
||||
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
|
||||
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
|
||||
# against issue #44's range dump), there's nothing to verify a microwave's
|
||||
# Fahrenheit bounds against, so this module only exposes the setpoint
|
||||
# control when the live unit is Celsius (see _microwave_temp_unit below).
|
||||
SETPOINT_MIN_C = 40
|
||||
SETPOINT_MAX_C = 200
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
|
||||
def _microwave_temp_unit(rep):
|
||||
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
|
||||
carries a per-item x.com.samsung.da.unit field. Both known dumps for
|
||||
this family report 'Celsius'; kept live rather than hardcoded per the
|
||||
fridge/oven convention (issue #7)."""
|
||||
items = rep.get('x.com.samsung.da.items') or []
|
||||
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
||||
return normalize_temp_unit(unit, default='°C')
|
||||
|
||||
|
||||
def _setpoint_write(p, rep, href=None):
|
||||
"""RMW write to /temperatures/vs/0 items array -- unproven for this
|
||||
family (no live write confirmed against a real unit), same "exposed for
|
||||
fidelity" caveat as the mode select."""
|
||||
try:
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
if not items:
|
||||
return None
|
||||
items = [dict(it) for it in items]
|
||||
items[0]['x.com.samsung.da.desired'] = str(temp_i)
|
||||
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
|
||||
|
||||
|
||||
def _power_level_watts(v):
|
||||
"""'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts."""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
if s.upper().endswith('W'):
|
||||
s = s[:-1]
|
||||
return int_or_none(s)
|
||||
|
||||
|
||||
def _mode_write(p, rep, href=None):
|
||||
if p not in _MICROWAVE_MODES:
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
|
||||
|
||||
|
||||
def _sound_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Sound', p),
|
||||
}
|
||||
|
||||
|
||||
def _lamp_exists(rep, resources):
|
||||
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
|
||||
|
||||
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Lamp', p),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MICROWAVE_CAVITY = Capability(
|
||||
href='/oven/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
|
||||
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
|
||||
unit='W', value_fn=_power_level_watts),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
|
||||
device_class='temperature', unit_fn=_microwave_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
|
||||
value_fn=lambda items: int_or_none(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
device_class='temperature',
|
||||
state_class='measurement', unit_fn=_microwave_temp_unit,
|
||||
value_fn=lambda items: int_or_none(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
),
|
||||
)
|
||||
|
||||
MICROWAVE_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
|
||||
icon='mdi:tune',
|
||||
options=_MICROWAVE_MODES,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_mode_write),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_sound_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
icon='mdi:track-light',
|
||||
exists_fn=_lamp_exists,
|
||||
value_fn=lambda opts: option_value(opts, 'Lamp') == 'On',
|
||||
write_fn=_lamp_write),
|
||||
),
|
||||
)
|
||||
@@ -9,9 +9,11 @@ independent fields.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..batch import is_stub_rep
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc,
|
||||
ButtonDesc,
|
||||
FanDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
@@ -99,6 +101,11 @@ HOOD_FAN = Capability(
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
# Absent on the microwave family's built-in vent fan (issue
|
||||
# #137) -- this board has no auto-ventilation mode, unlike the
|
||||
# standalone range hood this capability was written for.
|
||||
exists_fn=lambda rep, resources: (
|
||||
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
@@ -179,6 +186,50 @@ HOOD_FILTER = Capability(
|
||||
)
|
||||
|
||||
|
||||
# After Run (issue #147): the hood keeps the fan running at low speed for a
|
||||
# while after it's switched off, to clear residual cooking smoke -- a
|
||||
# feature a user actively watches and cancels, not passive diagnostics, so
|
||||
# none of the three entities below carry entity_category. No
|
||||
# supported-values list is advertised for activationState, so it's modeled
|
||||
# read-only (monitoring, not an invented "enable" write) per the 'don't
|
||||
# guess' rule; runningCancel's only observed value is the command name
|
||||
# itself ('Cancel'), the same self-describing command-field shape as
|
||||
# operational.STOP_BUTTON. runningProgress's own name states its domain
|
||||
# (a percentage of the cycle completed), so it's modeled as one rather than
|
||||
# left an opaque passthrough -- unlike activationState/runningCancel, there's
|
||||
# no ambiguous field name or missing-write-contract question here to hedge on.
|
||||
AFTER_RUN = Capability(
|
||||
href='/afterrun/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='after_run_active',
|
||||
field='x.com.samsung.da.activationState',
|
||||
icon='mdi:fan-clock',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='after_run_progress',
|
||||
field='x.com.samsung.da.runningProgress',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:fan-clock',
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
ButtonDesc(
|
||||
key='after_run_cancel',
|
||||
field='',
|
||||
payload='Cancel',
|
||||
icon='mdi:fan-off',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['afterrun', 'vs', '0'],
|
||||
{'x.com.samsung.da.runningCancel': p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
|
||||
@@ -102,20 +102,64 @@ FAVORITE_CAPACITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Coffee-capable variant (issue #107) -- same "favorite" enable-toggle +
|
||||
# supported-list select shape as FAVORITE_CAPACITY above, but for the hot
|
||||
# water dispensed alongside brewing rather than the pour capacity.
|
||||
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
|
||||
# for the hot water dispensed alongside brewing, same shape as
|
||||
# FAVORITE_CAPACITY above.
|
||||
|
||||
|
||||
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
|
||||
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
|
||||
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
|
||||
still ambiguous:
|
||||
|
||||
- href entirely absent from this device -> definitely no clash, the
|
||||
switchHotwater fallback below may claim the entity.
|
||||
- href present but an unfetched stub ({}) -> outcome pending, *not* a
|
||||
confirmed absence. LOCK's own exists_fn optimistically includes itself
|
||||
through a stub (matching entity.py's default), so returning True here
|
||||
too would register both descriptors -- as SwitchDescs sharing one key,
|
||||
with identical unique_ids -- until the next poll resolves it.
|
||||
- href present and fetched -> the real answer."""
|
||||
rep = resources.get('/status/lock/vs/0')
|
||||
if rep is None:
|
||||
return True
|
||||
if not rep:
|
||||
return False
|
||||
return 'x.com.samsung.da.hotwaterLock' not in rep
|
||||
|
||||
|
||||
FAVORITE_HOTWATER = Capability(
|
||||
href='/favorite/hotwater/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='favorite_hotwater_enabled', field='x.com.samsung.da.switchHotwater',
|
||||
icon='mdi:star-outline',
|
||||
# Despite the resource/field naming, switchHotwater's value domain is
|
||||
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
|
||||
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
|
||||
# this href on boards that don't populate /status/lock/vs/0's
|
||||
# hotwaterLock field. Shares that descriptor's key so only one "Hot
|
||||
# water lock" entity ever appears.
|
||||
#
|
||||
# Both halves of this fallback pair need an exists_fn, not just this
|
||||
# one: adapter.flatten() (the coordinator.data source every entity's
|
||||
# is_on reads) only ever honours exists_fn, never entity.py's
|
||||
# implicit "require own field present" default that gates plain
|
||||
# registration. Two same-keyed descriptors with only one of them
|
||||
# gated still both land in flatten()'s output dict -- whichever is
|
||||
# processed last silently wins, decided by device-reported href
|
||||
# order, not by which one is actually correct. So this exists_fn
|
||||
# also re-asserts its own field's presence (switchHotwater), the
|
||||
# gate a bare `field=` used to get for free before it had to share a
|
||||
# key with LOCK's descriptor.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Locked',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
exists_fn=lambda rep, resources: (
|
||||
'x.com.samsung.da.switchHotwater' in rep
|
||||
and _status_lock_definitely_lacks_hotwater_field(resources)),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['favorite', 'hotwater', 'vs', '0'],
|
||||
{'x.com.samsung.da.switchHotwater': 'Unlocked' if p == 'On' else 'Locked'})),
|
||||
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
SelectDesc(key='favorite_hotwater_temperature',
|
||||
field='x.com.samsung.da.favorite.defaultTemperature',
|
||||
icon='mdi:thermometer',
|
||||
@@ -150,10 +194,19 @@ LOCK = Capability(
|
||||
href='/status/lock/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||
# above (issue #144); see the comment there for why this half also
|
||||
# needs an explicit exists_fn now that the two share a key in
|
||||
# adapter.flatten()'s output. A stub rep ({}) still counts as
|
||||
# "present" here (matches entity.py's own default for a field-less
|
||||
# gate) since the alternative -- treating an unfetched resource as
|
||||
# confirmed-absent -- is what let both descriptors register at once.
|
||||
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
|
||||
device_class='lock',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != 'Unlocked',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"after_run_active": {
|
||||
"name": "After run active"
|
||||
},
|
||||
"any_burner_active": {
|
||||
"name": "Any burner active"
|
||||
},
|
||||
@@ -93,6 +96,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"after_run_cancel": {
|
||||
"name": "Cancel after run"
|
||||
},
|
||||
"diagnosis_start": {
|
||||
"name": "Start diagnosis"
|
||||
},
|
||||
@@ -165,6 +171,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Setpoint"
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "Sound volume"
|
||||
},
|
||||
@@ -344,6 +353,22 @@
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"cooking_mode": {
|
||||
"name": "Cooking mode",
|
||||
"state": {
|
||||
"no_operation": "No operation",
|
||||
"micro_wave": "Microwave",
|
||||
"micro_wave_grill": "Microwave + grill",
|
||||
"micro_wave_convection": "Microwave + convection",
|
||||
"convection": "Convection",
|
||||
"air_fryer": "Air fry",
|
||||
"grill": "Grill",
|
||||
"autocook": "Auto cook",
|
||||
"autocook_custom": "Auto cook (custom)",
|
||||
"deodorization": "Deodorize",
|
||||
"keep_warm": "Keep warm"
|
||||
}
|
||||
},
|
||||
"oven_mode": {
|
||||
"name": "Cooking mode",
|
||||
"state": {
|
||||
@@ -362,6 +387,37 @@
|
||||
"operating_mode": {
|
||||
"name": "Operating mode"
|
||||
},
|
||||
"kimchi_zone_mode": {
|
||||
"name": "{instance_name} storage mode",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"kimchi_storage_normal": "Kimchi",
|
||||
"kimchi_storage_cold": "Kimchi, strong",
|
||||
"kimchi_storage_warm": "Kimchi, weak",
|
||||
"kimchi_storage_low_salt_normal": "Low-salt kimchi",
|
||||
"kimchi_storage_low_salt_cold": "Low-salt kimchi, strong",
|
||||
"kimchi_storage_low_salt_warm": "Low-salt kimchi, weak",
|
||||
"kimchi_storage_crunfch": "Crisp kimchi",
|
||||
"kimchi_storage_buy": "Purchased kimchi",
|
||||
"storage_fridge_normal": "Fridge",
|
||||
"storage_fridge_cold": "Fridge, strong",
|
||||
"storage_fridge_warm": "Fridge, weak",
|
||||
"storage_freezer_normal": "Freezer",
|
||||
"storage_freezer_cold": "Freezer, strong",
|
||||
"storage_freezer_warm": "Freezer, weak",
|
||||
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
||||
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
||||
"kimchi_ripe_dongchimi": "Dongchimi ripening",
|
||||
"meat_ripe_normal": "Meat ripening",
|
||||
"storage_meat": "Meat & fish",
|
||||
"storage_fridge_vegetables_fruit": "Fruit & vegetables",
|
||||
"storage_fresh_cereal": "Grains",
|
||||
"storage_fridge_drink": "Beverages",
|
||||
"storage_fresh_wine": "Wine",
|
||||
"storage_fresh_potato_banana": "Potato & banana"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"name": "Pantry zone mode",
|
||||
"state": {
|
||||
@@ -510,6 +566,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"after_run_progress": {
|
||||
"name": "After run progress"
|
||||
},
|
||||
"air_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
@@ -659,6 +718,15 @@
|
||||
"fridge_temperature": {
|
||||
"name": "Fridge temperature"
|
||||
},
|
||||
"kimchi_ripening_status": {
|
||||
"name": "{instance_name} ripening status"
|
||||
},
|
||||
"kimchi_ripening_remaining": {
|
||||
"name": "{instance_name} ripening time remaining"
|
||||
},
|
||||
"kimchi_rack_count": {
|
||||
"name": "{instance_name} rack count"
|
||||
},
|
||||
"hood_filter_capacity": {
|
||||
"name": "Filter capacity"
|
||||
},
|
||||
@@ -704,6 +772,12 @@
|
||||
"oven_state": {
|
||||
"name": "Cavity state"
|
||||
},
|
||||
"cavity_state": {
|
||||
"name": "Cavity state"
|
||||
},
|
||||
"power_level": {
|
||||
"name": "Power level"
|
||||
},
|
||||
"paired_hood_fan_speed": {
|
||||
"name": "Paired hood fan speed"
|
||||
},
|
||||
@@ -861,9 +935,6 @@
|
||||
"favorite_coffee_enabled": {
|
||||
"name": "Favorite coffee"
|
||||
},
|
||||
"favorite_hotwater_enabled": {
|
||||
"name": "Favorite hot water"
|
||||
},
|
||||
"fridge_sound": {
|
||||
"name": "Sound"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"after_run_active": {
|
||||
"name": "Nadraaien actief"
|
||||
},
|
||||
"any_burner_active": {
|
||||
"name": "Een brander actief"
|
||||
},
|
||||
@@ -93,6 +96,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"after_run_cancel": {
|
||||
"name": "Nadraaien annuleren"
|
||||
},
|
||||
"diagnosis_start": {
|
||||
"name": "Diagnose starten"
|
||||
},
|
||||
@@ -165,6 +171,9 @@
|
||||
"oven_setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "Geluidsvolume"
|
||||
},
|
||||
@@ -344,6 +353,22 @@
|
||||
"high": "Hoog"
|
||||
}
|
||||
},
|
||||
"cooking_mode": {
|
||||
"name": "Bereidingsmodus",
|
||||
"state": {
|
||||
"no_operation": "Niet actief",
|
||||
"micro_wave": "Magnetron",
|
||||
"micro_wave_grill": "Magnetron + grill",
|
||||
"micro_wave_convection": "Magnetron + hetelucht",
|
||||
"convection": "Hetelucht",
|
||||
"air_fryer": "Airfryen",
|
||||
"grill": "Grillen",
|
||||
"autocook": "Automatisch koken",
|
||||
"autocook_custom": "Automatisch koken (aangepast)",
|
||||
"deodorization": "Geurverwijdering",
|
||||
"keep_warm": "Warmhouden"
|
||||
}
|
||||
},
|
||||
"oven_mode": {
|
||||
"name": "Bereidingsmodus",
|
||||
"state": {
|
||||
@@ -362,6 +387,37 @@
|
||||
"operating_mode": {
|
||||
"name": "Bedrijfsmodus"
|
||||
},
|
||||
"kimchi_zone_mode": {
|
||||
"name": "{instance_name} opslagmodus",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"kimchi_storage_normal": "Kimchi",
|
||||
"kimchi_storage_cold": "Kimchi, sterk",
|
||||
"kimchi_storage_warm": "Kimchi, zwak",
|
||||
"kimchi_storage_low_salt_normal": "Kimchi met weinig zout",
|
||||
"kimchi_storage_low_salt_cold": "Kimchi met weinig zout, sterk",
|
||||
"kimchi_storage_low_salt_warm": "Kimchi met weinig zout, zwak",
|
||||
"kimchi_storage_crunfch": "Knapperige kimchi",
|
||||
"kimchi_storage_buy": "Gekochte kimchi",
|
||||
"storage_fridge_normal": "Koelkast",
|
||||
"storage_fridge_cold": "Koelkast, sterk",
|
||||
"storage_fridge_warm": "Koelkast, zwak",
|
||||
"storage_freezer_normal": "Vriezer",
|
||||
"storage_freezer_cold": "Vriezer, sterk",
|
||||
"storage_freezer_warm": "Vriezer, zwak",
|
||||
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
||||
"kimchi_ripe_normal_temp": "Kimchi rijpen, kamertemperatuur",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi rijpen",
|
||||
"kimchi_ripe_dongchimi": "Dongchimi rijpen",
|
||||
"meat_ripe_normal": "Vlees rijpen",
|
||||
"storage_meat": "Vlees en vis",
|
||||
"storage_fridge_vegetables_fruit": "Groenten en fruit",
|
||||
"storage_fresh_cereal": "Granen",
|
||||
"storage_fridge_drink": "Dranken",
|
||||
"storage_fresh_wine": "Wijn",
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"name": "Modus voorraadzone",
|
||||
"state": {
|
||||
@@ -510,6 +566,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"after_run_progress": {
|
||||
"name": "Voortgang nadraaien"
|
||||
},
|
||||
"air_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
@@ -659,6 +718,15 @@
|
||||
"fridge_temperature": {
|
||||
"name": "Koelkasttemperatuur"
|
||||
},
|
||||
"kimchi_ripening_status": {
|
||||
"name": "{instance_name} rijpingsstatus"
|
||||
},
|
||||
"kimchi_ripening_remaining": {
|
||||
"name": "{instance_name} resterende rijptijd"
|
||||
},
|
||||
"kimchi_rack_count": {
|
||||
"name": "{instance_name} aantal rekken"
|
||||
},
|
||||
"hood_filter_capacity": {
|
||||
"name": "Filtercapaciteit"
|
||||
},
|
||||
@@ -704,6 +772,12 @@
|
||||
"oven_state": {
|
||||
"name": "Status ovenruimte"
|
||||
},
|
||||
"cavity_state": {
|
||||
"name": "Status ovenruimte"
|
||||
},
|
||||
"power_level": {
|
||||
"name": "Vermogensniveau"
|
||||
},
|
||||
"paired_hood_fan_speed": {
|
||||
"name": "Ventilatorsnelheid gekoppelde afzuigkap"
|
||||
},
|
||||
@@ -861,9 +935,6 @@
|
||||
"favorite_coffee_enabled": {
|
||||
"name": "Favoriete koffie"
|
||||
},
|
||||
"favorite_hotwater_enabled": {
|
||||
"name": "Favoriet warm water"
|
||||
},
|
||||
"fridge_sound": {
|
||||
"name": "Geluid"
|
||||
},
|
||||
|
||||
+1
-7
@@ -1,24 +1,18 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"airflow_fan",
|
||||
"alarm_code",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cavity_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooking_mode",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"fan",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"power_level",
|
||||
"progress_percentage",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
+5
-7
@@ -1,25 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cavity_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooking_mode",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"fast_preheat",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_level",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"setpoint",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"after_run_active",
|
||||
"after_run_progress",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_ventilation_action",
|
||||
|
||||
@@ -5,16 +5,10 @@
|
||||
"defrost_delay",
|
||||
"diagnosis_status",
|
||||
"door_onedoorfreezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"ice_maker_enabled",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode"
|
||||
|
||||
@@ -8,14 +8,8 @@
|
||||
"diagnosis_status",
|
||||
"door_cooler_open",
|
||||
"door_onedoorfreezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"firmware_update",
|
||||
"ice_maker_enabled",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"bottom_mode",
|
||||
"bottom_rack_count",
|
||||
"bottom_ripening_remaining",
|
||||
"bottom_ripening_status",
|
||||
"diagnosis_status",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"middle_mode",
|
||||
"middle_rack_count",
|
||||
"middle_ripening_remaining",
|
||||
"middle_ripening_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"top_mode",
|
||||
"top_open",
|
||||
"top_rack_count",
|
||||
"top_ripening_remaining",
|
||||
"top_ripening_status"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
"favorite_capacity",
|
||||
"favorite_capacity_enabled",
|
||||
"favorite_coffee_enabled",
|
||||
"favorite_hotwater_enabled",
|
||||
"favorite_hotwater_temperature",
|
||||
"filter_clean_remain_time",
|
||||
"filter_door_status",
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"flashingProgress": "",
|
||||
"otnStatus": "None",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
|
||||
"versions": ["40241114"],
|
||||
"visVersion": "241114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07424047334140473241",
|
||||
"versions": ["24071000", "23120700"],
|
||||
"visVersion": "240710"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "074240475443FFFFFFFF",
|
||||
"versions": ["23120800", "FFFFFFFF"],
|
||||
"visVersion": "231208"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": ["x.com.samsung.da.connected"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
],
|
||||
"rt": ["x.com.samsung.da.doors"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000",
|
||||
"x.com.samsung.da.description": "ME7500D-/AA1",
|
||||
"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": "24111400",
|
||||
"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": "04733A24071000, 04732A23120700",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04754C23120800",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": ["errCode", "dump"],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "KM5",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"],
|
||||
"x.com.samsung.da.modes": ["NoOperation"],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_ME7500D-/AA1",
|
||||
"TimeAutoSync_On",
|
||||
"weight_LBS",
|
||||
"TimeSystem_12",
|
||||
"Sound_On",
|
||||
"RemindBeep_On",
|
||||
"FilterRemind_Off",
|
||||
"Lamp_Off"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "MicroWave",
|
||||
"rt": ["x.com.samsung.da.mode"],
|
||||
"if": ["oic.if.baseline", "oic.if.a"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "0",
|
||||
"rt": ["x.com.samsung.da.operation"],
|
||||
"if": ["oic.if.baseline", "oic.if.a"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": ["x.com.samsung.da.alarms"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"],
|
||||
"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": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-27T20:03:10"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.recipe": "00000000000000",
|
||||
"x.com.samsung.da.powerLevel": "0",
|
||||
"rt": ["x.com.samsung.da.oven"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/New_York",
|
||||
"offset": "-04:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "3400",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/fanspeed/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hood.fanSpeed": "0",
|
||||
"x.com.samsung.da.hood.supportedFanSpeed": ["0", "1", "2", "3", "4"],
|
||||
"x.com.samsung.da.hood.settableMaxFanSpeed": "4",
|
||||
"x.com.samsung.da.hood.settableMinFanSpeed": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
@@ -160,6 +160,14 @@
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {"x.com.samsung.da.softwarereset": "false"}
|
||||
},
|
||||
{
|
||||
"href": "/afterrun/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningProgress": "0",
|
||||
"x.com.samsung.da.runningCancel": "Cancel",
|
||||
"x.com.samsung.da.activationState": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hoodfilter/vs/0",
|
||||
"rep": {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "5",
|
||||
"x.com.samsung.da.cumulativePower": "1733313",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "61",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL",
|
||||
"KIMCHIT_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIM_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIB_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[6]",
|
||||
"KIMCHIM_BOX_COUNT_[4]",
|
||||
"KIMCHIB_BOX_COUNT_[2]",
|
||||
"KIMCHI_BOX_COUNT_[0]"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"KIMCHIT_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_COLD_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_WARM_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_COLD_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_WARM_[0]:[0]",
|
||||
"KIMCHIT_OFF_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
|
||||
"KIMCHIT_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHIM_MEAT_RIPE_NORMAL_[3]:[0]",
|
||||
"KIMCHIM_STORAGE_MEAT_[0]:[0]",
|
||||
"KIMCHIM_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
|
||||
"KIMCHIM_STORAGE_FRESH_CEREAL_[0]:[0]",
|
||||
"KIMCHIM_OFF_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
|
||||
"KIMCHIM_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRIDGE_DRINK_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRESH_WINE_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRESH_POTATO_BANANA_[0]:[0]",
|
||||
"KIMCHIB_OFF_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_RIPE_KKAKDUGI_[4]:[5]",
|
||||
"KIMCHIB_KIMCHI_RIPE_DONGCHIMI_[7]:[6]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL",
|
||||
"KIMCHIT_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIM_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIB_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[6]",
|
||||
"KIMCHIM_BOX_COUNT_[4]",
|
||||
"KIMCHIB_BOX_COUNT_[2]",
|
||||
"KIMCHI_BOX_COUNT_[0]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_REF_20K|00135941|10010221011411010103642021000000",
|
||||
"x.com.samsung.da.description": "TP2X_REF_20K",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02144A220110",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "20082109,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kimchidoors/top/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/top/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "STORAGE_FREEZER_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "6",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"STORAGE_FRIDGE_NORMAL",
|
||||
"STORAGE_FRIDGE_COLD",
|
||||
"STORAGE_FRIDGE_WARM",
|
||||
"STORAGE_FREEZER_NORMAL",
|
||||
"STORAGE_FREEZER_COLD",
|
||||
"STORAGE_FREEZER_WARM",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/middle/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "4",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"MEAT_RIPE_NORMAL",
|
||||
"STORAGE_MEAT",
|
||||
"STORAGE_FRIDGE_VEGETABLES_FRUIT",
|
||||
"STORAGE_FRESH_CEREAL",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/bottom/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "2",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"STORAGE_FRIDGE_VEGETABLES_FRUIT",
|
||||
"STORAGE_FRIDGE_DRINK",
|
||||
"STORAGE_FRESH_WINE",
|
||||
"STORAGE_FRESH_POTATO_BANANA",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_KKAKDUGI",
|
||||
"KIMCHI_RIPE_DONGCHIMI"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,9 +11,12 @@ 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_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE,
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import (
|
||||
LocalThingsCoordinator, _local_source_port,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
@@ -1174,3 +1177,32 @@ async def test_send_command_operational_still_blocked_when_without_sc(
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
assert exc_info.value.translation_key == 'remote_control_disabled'
|
||||
assert posted is False
|
||||
|
||||
|
||||
def test_local_source_port_stable_and_in_range() -> None:
|
||||
"""Same host always maps to the same port (stability is the whole point:
|
||||
a reconnect must reuse the 5-tuple), and it stays in the documented window."""
|
||||
for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'):
|
||||
port = _local_source_port(host)
|
||||
assert port == _local_source_port(host)
|
||||
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
|
||||
|
||||
|
||||
def test_local_source_port_unique_per_host_on_a_24() -> None:
|
||||
"""Distinct last octets -> distinct ports, so two devices on the same HA
|
||||
host never share a source port (the lib's socket is unconnected, so a
|
||||
shared port would cross-deliver datagrams)."""
|
||||
ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)}
|
||||
assert len(ports) == 254
|
||||
|
||||
|
||||
def test_local_source_port_ipv4_uses_last_octet() -> None:
|
||||
"""The IPv4 fast path is the last octet, not a hash."""
|
||||
assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217
|
||||
|
||||
|
||||
def test_local_source_port_non_ipv4_falls_back_to_hash() -> None:
|
||||
"""A non-IPv4 host still yields a deterministic in-range port."""
|
||||
port = _local_source_port('some-hostname')
|
||||
assert port == _local_source_port('some-hostname')
|
||||
assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56)."""
|
||||
|
||||
from custom_components.localthings.fan import LocalThingsAirflowFan
|
||||
from custom_components.localthings.registry.by_type import air_purifier
|
||||
from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-AIRFLOW-SERIAL'
|
||||
device_info = {}
|
||||
data = {}
|
||||
|
||||
def __init__(self, resources):
|
||||
self.last_resources = resources
|
||||
self.commands = []
|
||||
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
def _entity(resources, coordinator=None):
|
||||
bound = discover(
|
||||
resources,
|
||||
air_purifier.REGISTRY.capabilities,
|
||||
air_purifier.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
fan_bound = next(
|
||||
item for item in bound
|
||||
if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
|
||||
)
|
||||
return LocalThingsAirflowFan(
|
||||
coordinator or _FakeCoordinator(resources), fan_bound,
|
||||
)
|
||||
|
||||
|
||||
def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
|
||||
entity = _entity(_load_device('air_purifier'))
|
||||
assert entity.is_on is False
|
||||
assert entity.speed_count == 5
|
||||
assert entity.percentage == 0
|
||||
|
||||
|
||||
def test_active_codes_map_to_ordered_percentages():
|
||||
"""Confirmed via issue #56's second, properly-spaced diagnostics round:
|
||||
/airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High."""
|
||||
resources = _load_device('air_purifier')
|
||||
resources['/power/0']['value'] = True
|
||||
|
||||
for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)):
|
||||
resources['/airflow/0']['speed'] = code
|
||||
assert _entity(resources).percentage == expected_percentage
|
||||
|
||||
|
||||
async def test_power_write_prefers_standard_resource_when_both_exist():
|
||||
"""Both /power/0 and /power/vs/0 are present on this family's dumps --
|
||||
/power/0 must win, matching common.POWER_GENERIC's own preference (the
|
||||
power_switch entity is unconditionally bound to /power/0 whenever it's
|
||||
present, so writing here to /power/vs/0 first would leave the two
|
||||
entities disagreeing until the next poll)."""
|
||||
resources = _load_device('air_purifier')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
|
||||
|
||||
|
||||
async def test_power_write_falls_back_to_vendor_resource():
|
||||
resources = _load_device('air_purifier')
|
||||
resources.pop('/power/0')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
|
||||
|
||||
|
||||
async def test_set_percentage_writes_raw_speed_code():
|
||||
resources = _load_device('air_purifier')
|
||||
resources['/power/0']['value'] = True
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(60)
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', 2)
|
||||
|
||||
|
||||
async def test_set_percentage_zero_turns_off():
|
||||
resources = _load_device('air_purifier')
|
||||
resources['/power/0']['value'] = True
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(0)
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', False, '/power/0')
|
||||
@@ -41,7 +41,7 @@ def test_expected_entities_present():
|
||||
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',
|
||||
'diagnosis_status', 'airflow_fan', 'fan_direction',
|
||||
'display_light', 'operating_mode',
|
||||
):
|
||||
assert key in state, key
|
||||
@@ -114,3 +114,27 @@ def test_airflow_vs_fallback_only_binds_without_generic():
|
||||
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
|
||||
) is False
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
|
||||
|
||||
|
||||
def test_airflow_fan_write_contract():
|
||||
"""Confirmed via issue #56's second, properly-spaced diagnostics round
|
||||
(two independent units, 60-90s apart per setting): /airflow/0's `speed`
|
||||
is a clean, monotonic 0-4 code, so the write is a plain int passthrough
|
||||
-- no named-preset table needed (see fan.py's LocalThingsAirflowFan)."""
|
||||
fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'airflow_fan')
|
||||
assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3})
|
||||
assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == (
|
||||
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
|
||||
)
|
||||
assert fan_desc.write_fn(('power', False, '/power/0'), {}) == (
|
||||
['power', '0'], {'value': False},
|
||||
)
|
||||
|
||||
|
||||
def test_airflow_speed_reads_from_dump():
|
||||
"""The fixture's /airflow/0 (power-off snapshot) reads speed=0, and
|
||||
fan_direction stays a plain diagnostic (every dump seen reads 'Off'
|
||||
regardless of fan setting)."""
|
||||
state = _state()
|
||||
assert state['airflow_fan'] == 0
|
||||
assert state['fan_direction'] == 'Off'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for registry.batch — the /device/0 sweep parser and its stub marker.
|
||||
|
||||
issue #127: a device whose /energy/consumption/vs/0 is permanently
|
||||
unsupported reports a genuinely empty {} rep for it. The previous parser
|
||||
collapsed /device/0's own {"href": "..."} "not fetched yet" marker to that
|
||||
same {} shape, so downstream exists_fn checks couldn't tell "confirmed
|
||||
empty" apart from "haven't polled it yet" and created phantom always-
|
||||
"unknown" entities either way. is_stub_rep/parse_device0_batch now keep the
|
||||
two shapes distinct.
|
||||
"""
|
||||
from custom_components.localthings.registry.batch import is_stub_rep, parse_device0_batch
|
||||
|
||||
|
||||
class TestIsStubRep:
|
||||
def test_true_for_bare_href_marker(self):
|
||||
assert is_stub_rep({'href': '/energy/consumption/vs/0'}) is True
|
||||
|
||||
def test_false_for_genuinely_empty_rep(self):
|
||||
assert is_stub_rep({}) is False
|
||||
|
||||
def test_false_for_populated_rep(self):
|
||||
assert is_stub_rep({'x.com.samsung.da.cumulativePower': '58900'}) is False
|
||||
|
||||
def test_false_for_href_plus_data(self):
|
||||
"""A real, populated rep may legitimately echo 'href' alongside
|
||||
actual fields -- only a rep with *no other keys* is the stub."""
|
||||
assert is_stub_rep({'href': '/x/0', 'value': True}) is False
|
||||
|
||||
|
||||
class TestParseDevice0Batch:
|
||||
def test_stub_rep_kept_distinct_from_genuine_empty(self):
|
||||
device0 = [
|
||||
{},
|
||||
{'href': '/energy/consumption/vs/0', 'rep': {'href': '/energy/consumption/vs/0'}},
|
||||
{'href': '/sabbath/vs/0', 'rep': {}},
|
||||
]
|
||||
resources = parse_device0_batch(device0)
|
||||
assert is_stub_rep(resources['/energy/consumption/vs/0']) is True
|
||||
assert is_stub_rep(resources['/sabbath/vs/0']) is False
|
||||
assert resources['/sabbath/vs/0'] == {}
|
||||
|
||||
def test_populated_rep_passes_through_unchanged(self):
|
||||
device0 = [
|
||||
{},
|
||||
{'href': '/door/cooler/0', 'rep': {'openState': 'Close'}},
|
||||
]
|
||||
resources = parse_device0_batch(device0)
|
||||
assert resources['/door/cooler/0'] == {'openState': 'Close'}
|
||||
|
||||
def test_skips_entries_without_href(self):
|
||||
device0 = [{}, {'rep': {'a': 1}}]
|
||||
assert parse_device0_batch(device0) == {}
|
||||
|
||||
def test_skips_non_dict_rep(self):
|
||||
device0 = [{}, {'href': '/x/0', 'rep': 'not-a-dict'}]
|
||||
assert parse_device0_batch(device0) == {}
|
||||
@@ -329,19 +329,19 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'airconditioner'
|
||||
|
||||
def test_oven_via_microwave_token(self):
|
||||
"""Issue #121: a combi microwave (MW7300B-/EU1) reports no
|
||||
def test_microwave_via_microwave_token(self):
|
||||
"""Issue #121/#66: a combi microwave (MW7300B-/EU1) reports no
|
||||
oneUiVersion and an unrecognized consumer token; falls back to the
|
||||
'-MICROWAVE-' token in modelNum onto the *existing* oven registry
|
||||
(same '/oven/vs/0' cavity + cook-mode shape), not a new device
|
||||
type."""
|
||||
'-MICROWAVE-' token in modelNum onto its own microwave registry
|
||||
(initially folded into 'oven' for issue #121, split out into a
|
||||
distinct device type per user feedback)."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000',
|
||||
'MW7300B-/EU1',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'oven'
|
||||
assert reg.name == 'microwave'
|
||||
|
||||
def test_vacuum_station_via_vskr_token(self):
|
||||
"""Issue #131: a stick-vacuum clean/auto-empty station
|
||||
|
||||
@@ -253,13 +253,24 @@ class TestEnergyMeter:
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
|
||||
|
||||
def test_both_entities_included_on_empty_stub(self):
|
||||
"""An empty {} rep means the resource exists but data isn't fetched yet
|
||||
(see entity._is_included) -- include both so sub-polls populate them."""
|
||||
def test_both_entities_included_on_true_stub(self):
|
||||
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
|
||||
marker (see registry.batch.is_stub_rep) -- means the resource exists
|
||||
but data isn't fetched yet; include both so sub-polls populate them."""
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert pw.exists_fn({}, {}) is True
|
||||
assert kwh.exists_fn({}, {}) is True
|
||||
stub = {'href': '/energy/consumption/vs/0'}
|
||||
assert pw.exists_fn(stub, {}) is True
|
||||
assert kwh.exists_fn(stub, {}) is True
|
||||
|
||||
def test_both_entities_hidden_on_genuinely_empty_rep(self):
|
||||
"""A real {} rep (no 'href' key) is the device's confirmed -- if
|
||||
empty -- answer, not a stub, so a model that never populates this
|
||||
resource doesn't get a phantom always-"unknown" entity (issue #127)."""
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert pw.exists_fn({}, {}) is False
|
||||
assert kwh.exists_fn({}, {}) is False
|
||||
|
||||
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
|
||||
"""A populated rep that lacks instantaneousPower must not spawn a
|
||||
@@ -388,13 +399,20 @@ class TestAiEnergyLevelStubDoesNotDecideThePlatform:
|
||||
if a stub carve-out let one of them win at setup time while the other
|
||||
wins once real data lands, flatten() would feed the already-instantiated
|
||||
entity a value shaped for the other platform (e.g. a bool into a Select
|
||||
expecting a string option). Neither side gets a `not rep` carve-out, so
|
||||
an unfetched stub can never win entity creation for either platform --
|
||||
expecting a string option). Neither side gets an is_stub_rep carve-out,
|
||||
so an unfetched stub can never win entity creation for either platform --
|
||||
the entity simply doesn't appear until a reload happens with real data,
|
||||
same as any other exists_fn-gated entity in this codebase that's unlucky
|
||||
on first-poll timing, instead of appearing as the wrong widget type."""
|
||||
|
||||
def test_neither_widget_exists_on_empty_stub_rep(self):
|
||||
def test_neither_widget_exists_on_true_stub_rep(self):
|
||||
switch = _ai_energy_level_desc('SwitchDesc')
|
||||
select = _ai_energy_level_desc('SelectDesc')
|
||||
stub = {'href': '/energy/ailevel/vs/0'}
|
||||
assert switch.exists_fn(stub, {}) is False
|
||||
assert select.exists_fn(stub, {}) is False
|
||||
|
||||
def test_neither_widget_exists_on_genuinely_empty_rep(self):
|
||||
switch = _ai_energy_level_desc('SwitchDesc')
|
||||
select = _ai_energy_level_desc('SelectDesc')
|
||||
assert switch.exists_fn({}, {}) is False
|
||||
@@ -416,11 +434,17 @@ class TestSelfCheckError:
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
||||
|
||||
def test_exists_for_empty_stub_rep(self):
|
||||
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
||||
must be included-for-now, same as ENERGY_METER's fields."""
|
||||
def test_exists_for_true_stub_rep(self):
|
||||
"""A true stub ({"href": "..."}) is /device/0's not-yet-fetched
|
||||
marker -- must be included-for-now, same as ENERGY_METER's fields."""
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({}, {}) is True
|
||||
assert desc.exists_fn({'href': '/selfcheck/vs/0'}, {}) is True
|
||||
|
||||
def test_hidden_for_genuinely_empty_rep(self):
|
||||
"""A real {} rep is the device's confirmed empty answer, not a stub --
|
||||
must NOT be force-included (issue #127's phantom-entity pattern)."""
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
|
||||
def test_value_joins_list(self):
|
||||
desc = self._desc()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for entity._is_included -- the one-time entity-creation gate run
|
||||
per platform at async_setup_entry (see sensor.py etc.), separate from
|
||||
adapter.flatten()'s per-poll state values. issue #127 review: this default
|
||||
gate has to stay permissive on a genuinely empty {} rep, not just a true
|
||||
is_stub_rep stub -- a resource like /alarms/vs/0 is validly empty in its
|
||||
normal, no-alarm state (fridge._active_alarm_codes), so excluding the
|
||||
entity there would silently drop working sensors on first-poll timing,
|
||||
not just fix phantom ones.
|
||||
"""
|
||||
from custom_components.localthings.entity import _is_included
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
|
||||
def _bound(desc, href):
|
||||
capability = Capability(href=href, entities=(desc,))
|
||||
return BoundEntity(href=href, capability=capability, desc=desc)
|
||||
|
||||
|
||||
class TestDefaultFieldGate:
|
||||
"""No explicit exists_fn -- the field-presence default in entity.py."""
|
||||
|
||||
def _bound(self):
|
||||
return _bound(SensorDesc(key='x', field='x.com.samsung.da.value'), '/x/vs/0')
|
||||
|
||||
def test_included_when_field_present(self):
|
||||
bound = self._bound()
|
||||
coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.value': '1'}})
|
||||
assert _is_included(bound, coord) is True
|
||||
|
||||
def test_excluded_when_field_absent_from_populated_rep(self):
|
||||
bound = self._bound()
|
||||
coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.other': '1'}})
|
||||
assert _is_included(bound, coord) is False
|
||||
|
||||
def test_included_on_true_stub(self):
|
||||
bound = self._bound()
|
||||
coord = _FakeCoordinator({'/x/vs/0': {'href': '/x/vs/0'}})
|
||||
assert _is_included(bound, coord) is True
|
||||
|
||||
def test_included_on_genuinely_empty_rep(self):
|
||||
"""The default gate stays permissive on a real {} -- see
|
||||
/alarms/vs/0, whose empty rep is the normal no-alarm state, not a
|
||||
signal the hardware is unsupported. Only a capability-specific
|
||||
exists_fn (e.g. common.ENERGY_METER) opts into excluding on
|
||||
confirmed-empty, after verifying that's actually safe for its field."""
|
||||
bound = self._bound()
|
||||
coord = _FakeCoordinator({'/x/vs/0': {}})
|
||||
assert _is_included(bound, coord) is True
|
||||
|
||||
def test_excluded_when_href_missing_from_resources(self):
|
||||
bound = self._bound()
|
||||
coord = _FakeCoordinator({})
|
||||
assert _is_included(bound, coord) is False
|
||||
|
||||
|
||||
class TestExplicitExistsFnGate:
|
||||
def test_explicit_exists_fn_overrides_default_field_gate(self):
|
||||
desc = SensorDesc(key='x', field='x.com.samsung.da.value',
|
||||
exists_fn=lambda rep, resources: rep.get('flag') is True)
|
||||
bound = _bound(desc, '/x/vs/0')
|
||||
coord = _FakeCoordinator({'/x/vs/0': {'flag': True}})
|
||||
assert _is_included(bound, coord) is True
|
||||
coord = _FakeCoordinator({'/x/vs/0': {'flag': False, 'x.com.samsung.da.value': '1'}})
|
||||
assert _is_included(bound, coord) is False
|
||||
|
||||
|
||||
class TestNoFieldEntities:
|
||||
def test_rep_fn_entity_always_included(self):
|
||||
desc = SensorDesc(key='x', rep_fn=lambda rep: rep.get('x.com.samsung.da.value'))
|
||||
bound = _bound(desc, '/x/vs/0')
|
||||
coord = _FakeCoordinator({'/x/vs/0': {}})
|
||||
assert _is_included(bound, coord) is True
|
||||
@@ -163,6 +163,26 @@ class TestFlexZone:
|
||||
assert 'CV_FDR_MEAT' not in modes
|
||||
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
|
||||
|
||||
def test_exists_only_when_a_current_value_resolves(self):
|
||||
"""Issue #26's kimchi-refrigerator family also populates /mode/vs/0's
|
||||
modes/supportedOptions, but its supportedOptions tokens carry a
|
||||
"_[n]:[n]" suffix modes never repeats, so no item ever overlaps --
|
||||
the entity used to bind anyway (supportedOptions is nonempty) and
|
||||
get stuck on "unknown" forever."""
|
||||
no_overlap_rep = {
|
||||
'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'],
|
||||
}
|
||||
desc = fridge.FLEX_ZONE.entities[0]
|
||||
assert desc.exists_fn(no_overlap_rep, {}) is False
|
||||
|
||||
overlap_rep = {
|
||||
'x.com.samsung.da.modes': ['CV_FDR_MEAT'],
|
||||
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
assert desc.exists_fn(overlap_rep, {}) is True
|
||||
|
||||
|
||||
class TestTp1xNativeDuplicateResources:
|
||||
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
|
||||
@@ -210,6 +230,97 @@ class TestPantryZone:
|
||||
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
|
||||
|
||||
|
||||
class TestKimchiZone:
|
||||
"""Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
|
||||
top/middle/bottom each report an identically-shaped currentMode/
|
||||
supportMode resource under /status/kimchi/<slot>/vs/0."""
|
||||
|
||||
def test_href_prefix(self):
|
||||
assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/'
|
||||
assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/'
|
||||
|
||||
def test_write_derives_path_from_href(self):
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
path, body = desc.write_fn(
|
||||
'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0')
|
||||
assert path == ['status', 'kimchi', 'middle', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'}
|
||||
|
||||
def test_write_without_href_is_rejected(self):
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None
|
||||
|
||||
def test_write_rejects_value_outside_supportmode(self):
|
||||
"""A value the compartment never advertised is rejected rather than
|
||||
written blind -- this write path is unconfirmed against real
|
||||
hardware (module docstring above KIMCHI_ZONE), so a bad value here
|
||||
is a food-safety-adjacent outcome, not just a cosmetic one."""
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
assert desc.write_fn(
|
||||
'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0',
|
||||
) is None
|
||||
|
||||
def test_ripening_status_passes_through_device_value(self):
|
||||
"""No device_class='enum' catalog entry exists for this sensor, so
|
||||
lowercasing it would only make the raw device token un-translatable
|
||||
by HA -- pass the device's own casing straight through instead."""
|
||||
desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status')
|
||||
assert desc.value_fn('Off') == 'Off'
|
||||
|
||||
def test_door_reuses_open_state_helper(self):
|
||||
desc = fridge.KIMCHI_DOOR_GENERIC.entities[0]
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
|
||||
|
||||
async def test_zone_mode_select_round_trips_through_display_casing(self):
|
||||
"""kimchi_zone_mode's displayed value (lowercase, catalog-translated)
|
||||
and the raw device code it writes back can silently drift apart --
|
||||
this is the one place that casing conversion could break. Runs
|
||||
through the real discovery/select pipeline against the tp2x_ref_20k
|
||||
kimchi fixture rather than a hand-built descriptor, so it also
|
||||
catches use_instance_name key derivation going wrong."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.select import LocalThingsSelect
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
bound = discover(
|
||||
resources, refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
mode_bound = next(
|
||||
b for b in bound
|
||||
if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0'
|
||||
)
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-SERIAL'
|
||||
|
||||
def __init__(self, resources, data):
|
||||
self.last_resources = resources
|
||||
self.data = data
|
||||
self.commands = []
|
||||
|
||||
async def async_send_command(self, bound, value):
|
||||
self.commands.append(value)
|
||||
|
||||
coordinator = _FakeCoordinator(resources, flatten(bound, resources))
|
||||
entity = LocalThingsSelect(coordinator, mode_bound)
|
||||
|
||||
assert entity.current_option == 'kimchi_storage_normal'
|
||||
assert 'kimchi_storage_cold' in entity.options
|
||||
|
||||
await entity.async_select_option('kimchi_storage_cold')
|
||||
|
||||
assert coordinator.commands == ['KIMCHI_STORAGE_COLD']
|
||||
|
||||
|
||||
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
|
||||
the incomplete-capability-coverage repair; both must resolve to zero
|
||||
@@ -253,6 +364,33 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
state = flatten(bound, resources)
|
||||
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
||||
|
||||
def test_tp2x_ref_20k_kimchi(self):
|
||||
"""A different physical unit sharing the same modelNum string (issue
|
||||
#26, second reporter) -- a 3-compartment kimchi refrigerator instead
|
||||
of the flex-zone fridge above. /kimchidoors/top/vs/0 and
|
||||
/status/kimchi/{top,middle,bottom}/vs/0 were previously unbound."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL'
|
||||
assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL'
|
||||
assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL'
|
||||
assert state['top_open'] is False
|
||||
assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported
|
||||
assert 'flex_zone_mode' not in state # no resolvable overlap on this family
|
||||
|
||||
|
||||
class TestArtik051DongleRefFixtureCoverage:
|
||||
"""Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves
|
||||
|
||||
@@ -300,6 +300,23 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi():
|
||||
"""A different physical unit reporting the same "TP2X_REF_20K" modelNum
|
||||
string as the fixture above (issue #26's second reporter) -- a
|
||||
3-compartment kimchi refrigerator with no flex zone, doors/icemaker, or
|
||||
freezer/cooler split, but its own /status/kimchi/<slot>/vs/0 and
|
||||
/kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC)."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text())
|
||||
state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', 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_ac_tp1x_da_ac_rac_01011():
|
||||
"""Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model
|
||||
TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0
|
||||
@@ -568,19 +585,44 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_oven_mw7300b():
|
||||
def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
|
||||
"""TP1X_DA-KS-MICROWAVE-01041 combi microwave (model MW7300B, issue
|
||||
#121) -- reports no oneUiVersion; resolved via the '-MICROWAVE-'
|
||||
modelNum token fallback onto the *existing* oven registry rather than
|
||||
a new device type, since it shares the same '/oven/vs/0' cavity and
|
||||
'/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*).
|
||||
The only href the oven registry didn't already cover was
|
||||
/recipe/cook/vs/0 (oven.OVEN_RECIPE_COOK, an empty quick-recipe-display
|
||||
blob with no entity)."""
|
||||
modelNum token fallback onto its own microwave registry. Shares the
|
||||
oven board family's operational-state/door/connected/recipe-cook
|
||||
Capability objects (reused directly from oven.py), but has its own
|
||||
cooking-mode vocabulary, setpoint bounds, and cavity power-level sensor
|
||||
(capabilities/microwave.py) -- this device's initial routing folded it
|
||||
into the oven registry (issue #121); split into its own device type per
|
||||
user feedback that microwaves shouldn't show up as ovens."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('oven_mw7300b')
|
||||
golden = json.loads((GOLDEN / 'oven_mw7300b.json').read_text())
|
||||
state_keys = _new_state_keys('oven_mw7300b', resources)
|
||||
resources = _load_device('microwave_mw7300b')
|
||||
golden = json.loads((GOLDEN / 'microwave_mw7300b.json').read_text())
|
||||
state_keys = _new_state_keys('microwave_mw7300b', 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_microwave_me7500d():
|
||||
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
|
||||
#137/#142) -- the same microwave registry as MW7300B above, but this
|
||||
board also reports the built-in vent fan's `/hood/fanspeed/vs/0`
|
||||
resource, previously unbound. Bound via range_hood.HOOD_FAN (reused
|
||||
directly, same resource shape a standalone range hood reports); unlike
|
||||
a standalone hood this board has no sibling `/power/0` or
|
||||
`/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats
|
||||
fan speed 0 as the off state instead of writing a separate power
|
||||
resource -- see its `_speed_zero_is_off` check. This board also has
|
||||
no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation`
|
||||
field, unlike MW7300B, so `setpoint`/`current_temp_c` and
|
||||
`automatic_operation` are correctly absent here."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_me7500d')
|
||||
golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text())
|
||||
state_keys = _new_state_keys('microwave_me7500d', 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"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Unit tests for the microwave-family capabilities (issue #121/#66 split
|
||||
into their own device type instead of being folded into oven.py)."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import microwave
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-type detection + full-dump coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_mw7300b')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
assert reg is not None
|
||||
assert reg.name == 'microwave'
|
||||
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
"""Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in
|
||||
vent fan) was previously unbound on this family."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_me7500d')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
assert reg is not None
|
||||
assert reg.name == 'microwave'
|
||||
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_microwave_setpoint_write_is_read_modify_write():
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
|
||||
path, body = desc.write_fn(180, rep)
|
||||
assert path == ['temperatures', 'vs', '0']
|
||||
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
|
||||
|
||||
|
||||
def test_microwave_setpoint_rmw_preserves_other_item_fields():
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
rep = {'x.com.samsung.da.items': [{
|
||||
'x.com.samsung.da.current': '150',
|
||||
'x.com.samsung.da.desired': '150',
|
||||
}]}
|
||||
path, body = desc.write_fn(180, rep)
|
||||
item = body['x.com.samsung.da.items'][0]
|
||||
assert item['x.com.samsung.da.desired'] == '180'
|
||||
assert item['x.com.samsung.da.current'] == '150'
|
||||
|
||||
|
||||
def test_microwave_setpoint_clamps_to_step():
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
|
||||
_, body = desc.write_fn(182, rep) # nearest 5 = 180
|
||||
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
|
||||
|
||||
|
||||
def test_microwave_setpoint_rejects_out_of_range():
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]}
|
||||
assert desc.write_fn(20, rep) is None # below min (40)
|
||||
assert desc.write_fn(210, rep) is None # above max (200)
|
||||
|
||||
|
||||
def test_microwave_setpoint_rejects_missing_items():
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
assert desc.write_fn(180, {}) is None
|
||||
|
||||
|
||||
def test_microwave_setpoint_exists_only_for_celsius():
|
||||
"""No Fahrenheit dump exists for this family (unlike oven.py's, verified
|
||||
against issue #44) -- the writable setpoint stays hidden rather than
|
||||
showing unverified bounds under the wrong unit."""
|
||||
desc = microwave.MICROWAVE_SETPOINT.entities[0]
|
||||
celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
|
||||
fahrenheit_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Fahrenheit'}]}
|
||||
assert desc.exists_fn(celsius_rep, {}) is True
|
||||
assert desc.exists_fn(fahrenheit_rep, {}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_CAVITY — power_level sensor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_power_level_parses_watt_suffix():
|
||||
"""Issue #121's combi dump reports e.g. '0W'."""
|
||||
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
|
||||
assert desc.value_fn('900W') == 900
|
||||
|
||||
|
||||
def test_power_level_parses_bare_number():
|
||||
"""Issue #137's plain microwave reports the bare number, no 'W' suffix."""
|
||||
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
|
||||
assert desc.value_fn('0') == 0
|
||||
|
||||
|
||||
def test_power_level_handles_missing_value():
|
||||
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
|
||||
assert desc.value_fn(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_MODE — SelectDesc with non-empty, family-specific options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_microwave_mode_options_nonempty():
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
assert len(desc.options) > 0
|
||||
assert 'MicroWave' in desc.options
|
||||
assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry'
|
||||
|
||||
|
||||
def test_microwave_mode_write_round_trips():
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
path, body = desc.write_fn('MicroWave', {})
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body['x.com.samsung.da.modes'] == ['MicroWave']
|
||||
|
||||
|
||||
def test_microwave_mode_rejects_unknown():
|
||||
desc = microwave.MICROWAVE_MODE.entities[0]
|
||||
assert desc.write_fn('SpaghettiMode', {}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_MODE — lamp/sound options-array writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sound_write_is_single_token():
|
||||
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
|
||||
rep = {'x.com.samsung.da.options': ['Sound_On']}
|
||||
path, body = desc.write_fn('Off', rep)
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.options': ['Sound_Off']}
|
||||
|
||||
|
||||
def test_lamp_gated_absent_when_no_lamp_option():
|
||||
"""Issue #121's combi dump has no 'Lamp_*' token at all -- unlike
|
||||
oven.py's lamp switch (assumed universal), this one self-gates off."""
|
||||
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']}
|
||||
assert desc.exists_fn(rep, {}) is False
|
||||
|
||||
|
||||
def test_lamp_gated_present_when_lamp_option_reported():
|
||||
"""Issue #137's plain microwave reports 'Lamp_Off'."""
|
||||
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
|
||||
rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']}
|
||||
assert desc.exists_fn(rep, {}) is True
|
||||
|
||||
|
||||
def test_lamp_write_is_single_token():
|
||||
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
|
||||
rep = {'x.com.samsung.da.options': ['Lamp_Off']}
|
||||
path, body = desc.write_fn('On', rep)
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.options': ['Lamp_On']}
|
||||
|
||||
|
||||
def test_lamp_write_requires_existing_options():
|
||||
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
|
||||
assert desc.write_fn('On', {}) is None
|
||||
@@ -61,6 +61,8 @@ def test_range_hood_fixture_values():
|
||||
assert state['periodic_air_sensing'] is False
|
||||
assert state['air_sensing_state'] == 'NonProcessing'
|
||||
assert state['last_air_sensing_level'] == 'Kr2'
|
||||
assert state['after_run_active'] is False
|
||||
assert state['after_run_progress'] == 0
|
||||
|
||||
|
||||
def test_one_composite_fan_is_bound():
|
||||
@@ -114,3 +116,19 @@ def test_lamp_write_contract():
|
||||
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'},
|
||||
)
|
||||
assert brightness.write_fn('Unsupported', rep) is None
|
||||
|
||||
|
||||
def test_after_run_cancel_write_contract():
|
||||
"""issue #147: /afterrun/vs/0 was previously unbound. runningCancel's
|
||||
only observed value is the command name itself, so cancel is modeled as
|
||||
a ButtonDesc that writes that value back."""
|
||||
active, progress, cancel = range_hood.AFTER_RUN.entities
|
||||
assert cancel.write_fn('Cancel', {}) == (
|
||||
['afterrun', 'vs', '0'], {'x.com.samsung.da.runningCancel': 'Cancel'},
|
||||
)
|
||||
|
||||
|
||||
def test_after_run_active_reads_on_off():
|
||||
active, progress, cancel = range_hood.AFTER_RUN.entities
|
||||
assert active.value_fn('On') is True
|
||||
assert active.value_fn('Off') is False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""HA fan-entity mapping tests for the range hood."""
|
||||
|
||||
from custom_components.localthings.fan import LocalThingsRangeHoodFan
|
||||
from custom_components.localthings.registry.by_type import range_hood
|
||||
from custom_components.localthings.registry.by_type import microwave, range_hood
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
from tests.conftest import _load_device
|
||||
@@ -23,11 +23,11 @@ class _FakeCoordinator:
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
def _entity(resources, coordinator=None):
|
||||
def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
|
||||
bound = discover(
|
||||
resources,
|
||||
range_hood.REGISTRY.capabilities,
|
||||
range_hood.REGISTRY.pattern_capabilities,
|
||||
registry.capabilities,
|
||||
registry.pattern_capabilities,
|
||||
)
|
||||
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
|
||||
return LocalThingsRangeHoodFan(
|
||||
@@ -35,6 +35,10 @@ def _entity(resources, coordinator=None):
|
||||
)
|
||||
|
||||
|
||||
def _microwave_entity(resources, coordinator=None):
|
||||
return _entity(resources, coordinator, registry=microwave.REGISTRY)
|
||||
|
||||
|
||||
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
|
||||
entity = _entity(_load_device('range_hood'))
|
||||
assert entity.is_on is False
|
||||
@@ -86,3 +90,131 @@ async def test_power_write_falls_back_to_vendor_resource():
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
|
||||
# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
|
||||
# itself the off state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_standalone_hood_has_separate_power():
|
||||
"""Explicit converse of the microwave case below: guards the
|
||||
discriminator itself, not just its downstream effects, so a future
|
||||
change to it fails loudly here instead of only via behavioral drift."""
|
||||
entity = _entity(_load_device('range_hood'))
|
||||
assert entity._has_separate_power() is True
|
||||
assert entity._speed_zero_is_off() is False
|
||||
|
||||
|
||||
def test_microwave_vent_fan_has_no_separate_power_resource():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
assert '/power/0' not in resources
|
||||
assert '/power/vs/0' not in resources
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity._has_separate_power() is False
|
||||
assert entity._speed_zero_is_off() is True
|
||||
|
||||
|
||||
async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off():
|
||||
"""A combi over-the-range microwave can report a /power/0 resource for
|
||||
the cavity while the vent fan still has no power resource of its own
|
||||
(settableMinFanSpeed '0' -- same board shape as microwave_me7500d).
|
||||
_speed_zero_is_off must key off the hood resource itself, not merely
|
||||
"some power resource exists on this device", so turning the fan off
|
||||
writes fan speed rather than the shared cavity power resource."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/power/0'] = {'value': True}
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
assert entity._has_separate_power() is True
|
||||
assert entity._speed_zero_is_off() is True
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
|
||||
def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
|
||||
"""supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
|
||||
standalone hood's codes, which never include an off entry."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity.is_on is False
|
||||
assert entity.percentage == 0
|
||||
assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
|
||||
|
||||
|
||||
def test_microwave_vent_fan_nonzero_speed_reads_as_on():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity.is_on is True
|
||||
assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '1')
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_set_percentage_writes_speed_only():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(100)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('speed', '4'))]
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on(percentage=75)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('speed', '3'))]
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop():
|
||||
"""A scene or automation calling fan.turn_on on an already-running vent
|
||||
fan must not reset it to the lowest speed."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands == []
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_set_percentage_zero_turns_off():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(0)
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
@@ -126,8 +126,10 @@ def test_no_catalog_carries_unresolved_core_references():
|
||||
|
||||
|
||||
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
|
||||
# so it presents as the device itself, and never reads a catalog name.
|
||||
UNNAMED_DESCRIPTORS = {("fan", "fan")}
|
||||
# so it presents as the device itself, and never reads a catalog name. Same
|
||||
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
|
||||
# levels, no presets, same _attr_name = None treatment.
|
||||
UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")}
|
||||
|
||||
|
||||
def test_every_descriptor_has_an_entity_catalog_entry():
|
||||
@@ -208,3 +210,27 @@ def test_every_ac_convenient_mode_code_has_a_preset_label():
|
||||
missing.append((path.name, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_kimchi_zone_supportmode_code_has_a_state_label():
|
||||
"""Same guard as the AC preset one above, for KIMCHI_ZONE's
|
||||
kimchi_zone_mode select (fridge.py, issue #26): the write path resolved
|
||||
from options_field is fully dynamic too, so an unlabelled supportMode
|
||||
code across any /status/kimchi/<slot>/vs/0 resource would silently
|
||||
render as its raw device token instead of the translated state.
|
||||
"""
|
||||
state_labels = set(
|
||||
_load("en")["entity"]["select"]["kimchi_zone_mode"]["state"]
|
||||
)
|
||||
fixtures_dir = Path(__file__).parent / "fixtures"
|
||||
missing = []
|
||||
for path in sorted(fixtures_dir.glob("*_device.json")):
|
||||
dump = json.loads(path.read_text())
|
||||
for item in dump.get("device0", []):
|
||||
href = item.get("href", "")
|
||||
if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")):
|
||||
continue
|
||||
for code in item["rep"].get("x.com.samsung.da.supportMode", []):
|
||||
if code.lower() not in state_labels:
|
||||
missing.append((path.name, href, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
@@ -169,6 +169,14 @@ def _desc_coffee(key):
|
||||
return next(b.desc for b in bound if b.desc.key == key)
|
||||
|
||||
|
||||
def _desc_coffee_by_href(key, href):
|
||||
"""Like _desc_coffee, but disambiguates descriptors that share a key
|
||||
across hrefs (hotwater_lock: LOCK's hotwaterLock field and
|
||||
FAVORITE_HOTWATER's switchHotwater fallback, issue #144)."""
|
||||
bound, _ = _bound_coffee()
|
||||
return next(b.desc for b in bound if b.desc.key == key and b.href == href)
|
||||
|
||||
|
||||
def test_coffee_variant_no_unbound_hrefs():
|
||||
"""Every resource in the issue #107 dump binds or is covered, including
|
||||
the four coffee-recipe hrefs not present in issue #90's dump."""
|
||||
@@ -181,7 +189,7 @@ def test_coffee_variant_no_unbound_hrefs():
|
||||
def test_coffee_variant_expected_state_keys_present():
|
||||
state = _state_coffee()
|
||||
for key in ('favorite_coffee_enabled', 'coffee_brew_status',
|
||||
'favorite_hotwater_enabled', 'favorite_hotwater_temperature'):
|
||||
'hotwater_lock', 'favorite_hotwater_temperature'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
@@ -193,12 +201,84 @@ def test_favorite_coffee_write_contract():
|
||||
['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'})
|
||||
|
||||
|
||||
def test_favorite_hotwater_write_contract():
|
||||
enabled = _desc_coffee('favorite_hotwater_enabled')
|
||||
assert enabled.write_fn('On', {}) == (
|
||||
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
|
||||
assert enabled.write_fn('Off', {}) == (
|
||||
def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag():
|
||||
"""issue #144: switchHotwater's value domain is Locked/Unlocked, not an
|
||||
enable flag, and it's misspelled as "Favorite hot water" -- it's the same
|
||||
hot-water lock as LOCK.hotwater_lock, just surfaced through this href on
|
||||
boards (like this fixture's) that don't populate /status/lock/vs/0's
|
||||
hotwaterLock field."""
|
||||
lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
|
||||
assert lock.write_fn('On', {}) == (
|
||||
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'})
|
||||
assert lock.write_fn('Off', {}) == (
|
||||
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
|
||||
assert lock.value_fn('Unlocked') is False
|
||||
assert lock.value_fn('Locked') is True
|
||||
|
||||
|
||||
def test_favorite_hotwater_lock_wins_in_flattened_state():
|
||||
"""adapter.flatten() -- the actual source of coordinator.data every
|
||||
switch's is_on reads -- only honours exists_fn, never entity.py's
|
||||
implicit own-field-presence default. So it's not enough for the
|
||||
*registered* entity to resolve correctly (test_expected_state_keys_present
|
||||
territory); the shared 'hotwater_lock' key in the flattened dict itself
|
||||
must reflect the live switchHotwater value, not a stale phantom from
|
||||
LOCK's ungated hotwaterLock read (issue #144). This fixture's
|
||||
switchHotwater reads 'Unlocked'; without exists_fn on *both* sides of the
|
||||
pair, LOCK's descriptor computes None != 'Unlocked' == True regardless,
|
||||
and flatten() would pick whichever of the two entities happens to be
|
||||
processed last."""
|
||||
state = _state_coffee()
|
||||
assert state['hotwater_lock'] is False
|
||||
|
||||
|
||||
def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state():
|
||||
"""Both LOCK.hotwater_lock and FAVORITE_HOTWATER's switchHotwater
|
||||
fallback are always bound on this fixture (their hrefs are both always
|
||||
present) -- discrimination happens entirely in exists_fn. Exactly one of
|
||||
the two must ever pass, regardless of iteration order, or two switch
|
||||
entities would be registered with the same unique_id."""
|
||||
bound, resources = _bound_coffee()
|
||||
candidates = [b for b in bound if b.desc.key == 'hotwater_lock']
|
||||
assert len(candidates) == 2
|
||||
included = [b for b in candidates
|
||||
if b.desc.exists_fn(resources.get(b.href) or {}, resources)]
|
||||
assert len(included) == 1
|
||||
assert included[0].href == '/favorite/hotwater/vs/0'
|
||||
|
||||
|
||||
def test_hotwater_lock_fallback_gating_across_status_lock_states():
|
||||
"""The fallback (FAVORITE_HOTWATER's switchHotwater descriptor) must
|
||||
activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock --
|
||||
never while that resource is an unfetched stub ({}), and never when it
|
||||
does carry the field. LOCK's own descriptor is the mirror image."""
|
||||
fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
|
||||
primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0')
|
||||
own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'}
|
||||
|
||||
# /status/lock/vs/0 absent entirely -- device genuinely lacks it.
|
||||
assert fallback.exists_fn(own_rep, {}) is True
|
||||
|
||||
# /status/lock/vs/0 present but not yet fetched (a stub): outcome
|
||||
# pending, so the fallback must defer to LOCK rather than assume absence.
|
||||
stub_resources = {'/status/lock/vs/0': {}}
|
||||
assert fallback.exists_fn(own_rep, stub_resources) is False
|
||||
assert primary.exists_fn({}, stub_resources) is True
|
||||
|
||||
# /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this
|
||||
# fixture's actual shape) -- the fallback wins.
|
||||
absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}}
|
||||
assert fallback.exists_fn(own_rep, absent_resources) is True
|
||||
assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False
|
||||
|
||||
# /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins.
|
||||
present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}
|
||||
assert fallback.exists_fn(own_rep, present_resources) is False
|
||||
assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True
|
||||
|
||||
# The fallback also re-asserts its own field, since it no longer gets
|
||||
# that check for free once it shares LOCK's key (issue #144 review).
|
||||
assert fallback.exists_fn({}, {}) is False
|
||||
|
||||
|
||||
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
|
||||
|
||||
Reference in New Issue
Block a user