Compare commits

...
19 Commits
Author SHA1 Message Date
Marc Billow cc570533a0 Merge pull request #149 from mbillow/claude/ticket-127-device-coverage-xsb0m4
Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
2026-07-27 22:25:38 -05:00
Marc Billow 6239706065 Keep entity._is_included's default gate permissive on confirmed-empty reps
Opus review of #149 caught that swapping is_stub_rep(rep) in for the
default field-presence gate (not just the 9 hand-audited exists_fn call
sites) was too broad: it silently excludes entities on ANY resource whose
normal, valid state includes reporting {} -- /alarms/vs/0's {} is
fridge.py's documented no-alarm state, not an absence signal, and it's not
the only one (job_beginning_status, diagnosis_status, sabbath_mode,
defrost_delay, ice_maker_enabled all lost entities on real fixtures under
the broader change). That's the opposite of #127's fix: a real fridge
would have dropped its alarm sensor on first-poll timing, not just its
phantom energy sensors.

Restored the default gate to include on either a stub or a genuinely-empty
rep -- verified byte-identical to the pre-#149 baseline across all 40
fixtures, apart from the 9 deliberately-audited exists_fn sites (energy
meter, self-check error, cooktop burner, range-hood auto-op), which are
unaffected and still fix #127. Added tests/test_entity.py exercising
_is_included directly (previously untested) and fixed two now-stale
"not rep" doc references the review also flagged.
2026-07-28 03:18:23 +00:00
Marc Billow 33786ad584 Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
parse_device0_batch used to collapse /device/0's {"href": "..."} "no data
yet" marker into a plain {}, indistinguishable from a resource the device
had actually polled and confirmed empty. Every exists_fn using the "not
rep or ..." stub carve-out (and entity._is_included's default field-gate)
then treated both the same way, creating phantom always-"unknown" entities
for any resource a model simply doesn't support (e.g. GSzabados's fridge's
/energy/consumption/vs/0).

is_stub_rep() now recognizes only the literal {"href": ...} marker as a
stub; a genuine {} is treated as the device's real (if empty) answer and
gates the entity off like any other missing field. Updated the energy
meter, self-check error, cooktop burner, and range-hood auto-operation
exists_fn call sites, plus three golden fixtures that had baked the
phantom-entity behavior in as "expected".
2026-07-28 02:57:30 +00:00
Marc Billow e60fdf26d2 Merge pull request #148 from mbillow/claude/triage-issues-144-147-yv80ab
Fix water-purifier hot-water lock mislabel, add range-hood after-run support
2026-07-27 21:31:31 -05:00
Marc Billow 9011f84787 Model after_run_progress as a percentage, fix Dutch after-run-progress name
runningProgress's own field name states its domain, so restore unit='%' and
state_class='measurement' rather than leaving it an opaque passthrough --
that hedge made sense for activationState (no supported-values list, no
write contract to invent) but not here, where the name itself is the
evidence.

"Nadraaien voortgang" also wasn't idiomatic Dutch (nouns don't stack that
way); "Voortgang nadraaien" matches how the rest of the catalog compounds
these names.
2026-07-28 02:29:01 +00:00
Marc Billow 0640ac3477 Fix hotwater_lock key-collision hazard, address Opus review of triage fixes
An Opus review of the previous commit found that giving the switchHotwater
fallback and LOCK.hotwater_lock the same key introduced a real bug:
adapter.flatten() (the source of coordinator.data, which every switch's
is_on reads) only ever honours exists_fn, never entity.py's implicit
own-field-presence default that gates plain registration. With only one
side of the pair gated, both descriptors still wrote the same key into the
flattened state dict, and whichever was processed last -- decided by
device-reported href order, not correctness -- silently won. Reproduced
with the existing coffee fixture: reversing resource order flipped
hotwater_lock from correct (False) to a stuck True.

Fixed by gating both sides symmetrically via a shared tri-state helper that
also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather
than "confirmed absent" -- otherwise the stub window let both descriptors
pass exists_fn at once, which would have registered two switch entities
with the same unique_id. The fallback also re-asserts its own field's
presence, a check it used to get for free before it shared LOCK's key.

Also addresses two smaller findings from the same review, both in the
range-hood after-run capability (#147): runningProgress's unit='%' was a
guess from a single "0" sample with no supported-values/range field to
confirm the domain -- inconsistent with treating activationState as
read-only for the same "don't guess" reason -- so it's now a bare
passthrough sensor; and entity_category='diagnostic' was dropped from the
two read entities since after-run is a feature the user actively watches
and cancels via the (correctly uncategorized) button, not passive
diagnostics.
2026-07-28 02:25:42 +00:00
Marc Billow b47dabeb31 Merge pull request #143 from mbillow/claude/microwave-fanspeed-coverage-nmvc86
Microwave vent fan and kimchi-refrigerator compartment coverage (#137, #142, #26)
2026-07-27 21:13:03 -05:00
Marc Billow cc3ce12aa4 Fix range hood fan power targeting and kimchi mode write validation
- _speed_zero_is_off now keys off the hood resource's own
  settableMinFanSpeed/supportedFanSpeed fields instead of asking whether
  the device has any power resource at all, so a combi appliance's cavity
  /power/0 can no longer be toggled off by turning off just the vent fan.
- async_turn_on() no longer resets an already-running fan to its lowest
  speed when called without a percentage.
- _has_separate_power() reads through the O(1) resource cache instead of
  copying the full resource snapshot on every property access.
- _kimchi_mode_write rejects values the compartment didn't advertise in
  supportMode instead of writing them blind.
- kimchi_ripening_status no longer lowercases its value, since it has no
  enum catalog entry to translate the lowercased token back through.
- Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0
  aggregate fallback on the one fixture that reports both.

Adds regression tests for the combi-appliance power targeting, the
already-on turn_on no-op, kimchi mode write validation, and a kimchi
select display/write casing round-trip; a translation-coverage guard for
kimchi_zone_mode codes mirroring the existing AC preset one.
2026-07-28 02:10:01 +00:00
Marc Billow 5055d6bb72 Fix water-purifier hot-water lock mislabel, add range-hood after-run support
Water purifier (#144, #145): /favorite/hotwater/vs/0's switchHotwater field
is a Locked/Unlocked hot-water lock, not a "favorite enabled" flag. It's the
same lock as LOCK.hotwater_lock, just surfaced through this href on boards
that don't populate /status/lock/vs/0's hotwaterLock -- the two now share
the hotwater_lock key/translation, gated so only one is ever active.

Range hood (#147): binds /afterrun/vs/0 (after-run activation state,
progress, and a cancel button), clearing the last unbound href for
AHD-WW-TP1-22-COMMON.
2026-07-28 02:00:57 +00:00
Marc Billow 265d94eded Add kimchi-refrigerator compartment coverage (issue #26)
TP2X_REF_20K-class 3-compartment kimchi refrigerators report each
compartment's storage mode and ripening status/timer on
/status/kimchi/<slot>/vs/0, plus a top-compartment door sensor on
/kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern
capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving
the per-compartment entity key and display name from the href's
top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC
already do.

Storage-mode option labels were translated directly from the reporter's
own SmartThings app screenshots rather than guessed from the raw device
codes or their English paraphrase, confirming the on-screen option order
matches supportMode's array order (including the freezer triplet's
-19/-21/-17°C -> Standard/Strong/Weak mapping).

Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also
populates modes/supportedOptions, but with a token shape that never
overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never
repeats), so the existing "supportedOptions is nonempty" check let the
entity bind anyway and get stuck permanently on "unknown". Requiring an
actual resolvable value keeps it working for the RF9000/Bespoke-class
fridges it was built for while leaving it absent here.
2026-07-28 00:35:21 +00:00
Marc Billow e9281aaead Bind microwave built-in vent fan's /hood/fanspeed/vs/0 (issues #137, #142)
Combi microwave units report their vent fan on the same resource shape a
standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the
microwave registry. Unlike a standalone hood, this board has no sibling
/power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls
back to treating fan speed 0 as the off state when no separate power
resource is present. Also gate HOOD_FAN's automatic_operation sensor on
field presence, since this board doesn't report it.
2026-07-28 00:18:38 +00:00
Marc Billow c19800ac00 Merge pull request #135 from QuiteYellow/fix/stale-dtls-session-fixed-source-port
Bind a fixed DTLS source port per device to evict stale sessions
2026-07-27 18:40:36 -05:00
Marc Billow ed5e8e57c8 Merge pull request #140 from mbillow/claude/v0-13-0-issue-triage-9zfjbj
Split microwaves into their own device type instead of the oven registry
2026-07-27 17:10:10 -05:00
Marc Billow f063666291 Merge pull request #139 from mbillow/claude/air-purifier-fan-controls-ananzq
Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56)
2026-07-27 17:06:59 -05:00
Marc Billow 5af9d951c6 Simplify README microwave row label 2026-07-27 22:05:54 +00:00
Marc Billow a1f14cd633 Split microwaves into their own device type instead of the oven registry
Microwaves (combi and plain) were routed onto the oven registry (issue
#121), which meant entities carried oven-flavored keys (oven_state,
oven_mode, oven_setpoint) and inherited oven-specific behavior that's
wrong for this family: a 30-270C setpoint range instead of this family's
actual 40-200C, a cooking-mode list missing MicroWave/MicroWaveGrill/
MicroWaveConvection/KeepWarm entirely, and a lamp switch that read/wrote
the oven's 'UpperLamp' option token instead of this family's 'Lamp' token.

Adds a microwave device type (by_type/microwave.py,
capabilities/microwave.py) that reuses the oven board family's shared
operational-state/door/connected/recipe-cook capabilities but defines its
own cooking-mode, setpoint, and cavity capabilities with the corrected
bounds/vocabulary, plus a new power_level sensor for the cavity's Watt
setting that was previously unexposed.
2026-07-27 22:05:54 +00:00
Marc Billow 2be742489b Fix airflow fan power-href preference and unique_id collision risk
Opus review of the previous commit caught two real bugs. The airflow fan's
power writes preferred /power/vs/0, copied from the TP1X fan class -- but
that order is only harmless there because TP1X never reports /power/0 at
all. This family's dumps carry both hrefs, and the power_switch entity is
unconditionally bound to /power/0 when present, so the fan was writing to
a different resource than power_switch reads/writes, leaving the two
entities disagreeing until the next poll. Flipped to prefer /power/0,
matching the range hood's fan and common.POWER_GENERIC.

Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity
unique_ids are derived from key alone, not href, so it collided with
air_purifier.FAN's own 'fan' key on the (currently unobserved, but
unenforced) possibility of a board reporting both.

Added a platform-level test file covering the power-href preference and
percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness.
2026-07-27 21:48:23 +00:00
Marc Billow d4583b3240 Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56)
/airflow/0's speed was left read-only because the first round of diagnostics
wasn't conclusive (0 for both Auto and High, 3 for Low/Medium and Sleep) --
likely because all five dumps were captured within about a minute of each
other, faster than this integration's own poll cycle could settle each
change. A second round, captured 60-90s apart per setting on two independent
units, confirmed a clean monotonic 0-4 mapping across Auto/Sleep/Low/Medium/
High instead.

Builds an ordered-speed fan off that confirmed range, the same SET_SPEED
shape as the range hood's fan -- this board never self-reports a
supportedModes-style label list, so there's no named-preset table to
preserve, just percentage steps over the raw code. /airflow/vs/0's vendor
speedLevel stays a read-only fallback since it was unreliable in that same
second round.
2026-07-27 21:32:44 +00:00
Jack Nagy 0ebcfb00ff fix: bind a fixed DTLS source port per device to evict stale sessions
When HA restarts without a clean DTLS close_notify (crash, host reboot),
the appliance keeps an orphaned DTLS association keyed to the client's
(IP, source port). Reconnecting from a fresh ephemeral port looks like a
new peer, so the device holds the orphan until its own timer reaps it,
which is 5 to 15 min on always-on appliances (fridges), during which the
new session's first reads hang. This is the root cause behind the repeated
"DTLS handshake timeout" reconnect storms on always-on devices (#119).

Bind a deterministic source port per device so every reconnect re-handshakes
over the same 5-tuple, which the device must treat as a rebooted peer and
evict the old association for (RFC 6347 §4.2.8). Recovery drops from a
device-timer wait to a single handshake.

The port must be stable across restarts and unique per device on the HA host
(the library socket is unconnected, so a shared source port would cross-
deliver datagrams). _local_source_port() uses the host's last IPv4 octet as
the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback
for non-IPv4 hosts.

Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=).
The fix is backwards compatible upstream: local_port defaults to None
(previous ephemeral-port behaviour).

Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14
(bench-verified on oven + dryer, field-verified on an always-on fridge
across repeated restarts).
2026-07-27 19:17:39 +01:00
46 changed files with 2436 additions and 146 deletions
+2 -1
View File
@@ -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` |
+7
View File
@@ -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 = (
+28 -1
View File
@@ -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
+13 -4
View File
@@ -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
+160 -11
View File
@@ -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)))
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
@@ -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
View File
@@ -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"
]
}
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
}
]
}
+34 -2
View File
@@ -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
+105
View File
@@ -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')
+25 -1
View File
@@ -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'
+56
View File
@@ -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) == {}
+6 -6
View File
@@ -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
+36 -12
View File
@@ -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()
+79
View File
@@ -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
+138
View File
@@ -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
+52 -10
View File
@@ -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"
+177
View File
@@ -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
+18
View File
@@ -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
+136 -4
View File
@@ -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')
+28 -2
View File
@@ -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 == []
+86 -6
View File
@@ -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():