Add code comment guidelines; dramatically trim excessive comments

CONTRIBUTING.md gains a "Code comments" section: comment the why not
the what, keep it to a sentence or two with a pointer to the load-bearing
evidence, don't re-derive a sibling's already-documented reasoning, and
move failed-attempt investigation logs out of inline comments.

Applied that policy across the codebase: condensed sprawling module
docstrings, per-entity essays, and multi-paragraph rationale blocks down
to their load-bearing conclusions, while preserving the actual "why"
(issue numbers, calibration evidence, gotchas, don't-guess rationale).
No functional code changed — verified via diff review, ruff, ty, and the
full pytest suite (1211 passed).

One inline investigation log (the AC filter-reset "tried and failed"
notes) moved to docs/investigations/ac-filter-reset.md rather than being
deleted, per the new guideline on where that kind of record belongs.
This commit is contained in:
Marc Billow
2026-08-05 01:24:17 +00:00
parent f8a7a1fa66
commit 7086b134c0
36 changed files with 2127 additions and 3598 deletions
+34
View File
@@ -19,6 +19,40 @@ file covers how changes get committed.
assistant, or tool that helped produce the change. The commit is assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human. attributed entirely to the accountable human.
## Code comments
This codebase reverse-engineers undocumented device APIs, so comments
recording *why* a decision was made (a calibration, a rejected write, an
issue number a quirk was confirmed against) are genuinely valuable — more
valuable than in most codebases. That's exactly why comments here need
discipline: it's easy for "explain the reasoning" to slide into "narrate
the whole investigation," and a file where every line has a paragraph
under it is as hard to read as one with no comments at all. Keep the
conclusion; cut the journey.
- **Comment the "why," never the "what."** If a comment just restates what
the next line already says, delete it. Code should read clearly enough
on its own that comments are only needed for the non-obvious.
- **One or two sentences, not an essay.** State the conclusion and the one
piece of evidence that makes it credible (an issue number, a model name,
a single confirming observation). Don't reproduce the full
investigation — every dump checked, every attempt that failed, every
hypothesis considered and discarded. A future reader needs to trust the
conclusion and know where to look if they need to redo the work, not
relive it.
- **A pointer, not a re-derivation.** Cite the issue/model once; don't
re-explain a sibling function's already-documented reasoning. Reference
it (`same reasoning as X above`) instead of restating it.
- **Module/class docstrings are a short orientation, not a design doc.**
A few lines on purpose and any cross-cutting invariant is enough.
- **Failed-attempt logs don't belong inline.** If an investigation into an
unsolved problem produced real negative results worth preserving (e.g. a
reset mechanism nobody could find), put them in an issue or docs, not a
block comment several times longer than the code it sits above.
- **When in doubt, cut.** If deleting a comment wouldn't lose real
understanding, it's noise. Prefer trimming an existing comment over
adding a new one.
## For AI coding agents ## For AI coding agents
See `AGENTS.md`. See `AGENTS.md`.
+62 -80
View File
@@ -23,25 +23,19 @@ def _serial_from_unique_id(entry: ConfigEntry) -> str:
The config flow has always keyed the entry's unique_id on the serial the The config flow has always keyed the entry's unique_id on the serial the
probe read (`localthings_<serial>`), so that string is the identity the probe read (`localthings_<serial>`), so that string is the identity the
entry's registry entries were minted from -- there is no need to reach the entry's registry entries were minted from -- no need to reach the device
device to recover it. Anything we can't recover one from resolves to the to recover it. Anything unrecoverable resolves to the host, matching
host, which is what the coordinator seeded such an entry with anyway. what the coordinator seeded such an entry with anyway.
The recovered string goes back through resolve_serial rather than being The recovered string goes back through resolve_serial rather than being
taken at face value, because the unique_id records what the flow believed taken at face value: entries created before the placeholder rules
at the time it ran, not what the registry holds now. Entries created (issues #83/#189) were keyed on the placeholder itself, while the
before the placeholder rules landed (issues #83/#189) were keyed on the coordinator has since resolved those same boards to the host.
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` -- Re-keying onto the placeholder would reintroduce the collision those
while the coordinator has since been resolving those same boards to the issues are about -- two units of a family sharing the same placeholder
host. Re-keying the registry onto the placeholder to match the unique_id would share entity unique_ids again. A later wrinkle, same root cause:
would reintroduce the collision those issues are about: two units of that for a stretch the flow wrote `host:port` while the coordinator wrote
family report the *same* placeholder, so they'd share entity unique_ids `host`; collapsed here to the coordinator's form too.
again.
A later wrinkle, same root cause: for a stretch the two sides disagreed on
which fallback to use, the flow writing `host:port` while the coordinator
wrote `host`. Collapse that to the coordinator's form too -- the registry
is what has to keep working.
""" """
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
prefix = f"{DOMAIN}_" prefix = f"{DOMAIN}_"
@@ -59,24 +53,24 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
"""Re-key registry entries this entry minted from the placeholder identity. """Re-key registry entries this entry minted from the placeholder identity.
Before the identity moved onto the config entry, the coordinator seeded Before the identity moved onto the config entry, the coordinator seeded
`device_serial` with the host and only replaced it after the first `device_serial` with the host and only replaced it after the first poll.
successful poll. Anything that registered in between -- the connection-mode Anything that registered in between -- the connection-mode sensor
sensor especially, since it is added unconditionally rather than from especially, added unconditionally rather than from `bound` -- was
`bound` -- was written into the registry keyed on the IP address written into the registry keyed on the IP permanently, orphaned the
permanently, and was orphaned the moment the serial-keyed identity moment the serial-keyed identity appeared (issue #236). Deleting the
appeared (issue #236). Deleting the orphans by hand didn't help: the next orphans by hand didn't help: the next restart that lost the same race
restart that lost the same race recreated them. recreated them.
Rewriting beats deleting where it's possible -- an entity keeps its Rewriting beats deleting where possible -- an entity keeps its
entity_id, name, area and every automation that references it. It's only entity_id, name, area and automations. Only possible when the
possible when the serial-keyed key is still free, though; where both exist serial-keyed key is still free; where both exist the placeholder-keyed
the placeholder-keyed one is the dead duplicate (it has been unavailable one is the dead duplicate (unavailable since the restart that created
since the restart that created it), so it goes. it), so it goes.
""" """
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
if serial == host: if serial == host:
# A board with no usable serial resolves *to* the host, so its keys # A board with no usable serial resolves to the host, so its keys
# were never placeholders -- there is nothing here to re-key. # were never placeholders.
return return
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
@@ -106,11 +100,9 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
existing = dev_reg.async_get_device(identifiers=fresh) existing = dev_reg.async_get_device(identifiers=fresh)
if existing is not None and existing.id != device.id: if existing is not None and existing.id != device.id:
# Removing a device takes its entities with it. Anything still # Removing a device takes its entities with it. Anything still
# attached here came through the pass above re-keyed rather than # attached here was re-keyed rather than removed above -- the
# removed -- i.e. it's the surviving copy, not a duplicate -- so # surviving copy, not a duplicate -- so move it onto the device
# move it onto the device it now belongs to first. Otherwise the # it now belongs to before the removal destroys it too.
# rewrite that was supposed to preserve an entity_id, name and
# area destroys them a few lines later.
for entity in er.async_entries_for_device( for entity in er.async_entries_for_device(
ent_reg, device.id, include_disabled_entities=True ent_reg, device.id, include_disabled_entities=True
): ):
@@ -127,13 +119,13 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an entry to the current version. """Migrate an entry to the current version.
v1 -> v2 stores the device's identity on the entry so the coordinator can v1 -> v2 stores the device's identity on the entry so the coordinator
key its registry entries before the first poll (issue #236), and repairs can key its registry entries before the first poll (issue #236), and
whatever the old placeholder-keyed registration already orphaned. repairs whatever the old placeholder-keyed registration already
orphaned.
""" """
if entry.version > 2: if entry.version > 2:
# Downgrade: this release doesn't know the newer entry's shape. return False # downgrade: this release doesn't know the newer shape
return False
if entry.version == 1: if entry.version == 1:
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
@@ -155,31 +147,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
except Exception as err: except Exception as err:
# `_poll_once` deliberately leaves the session up on a `TimeoutError` # `_poll_once` deliberately leaves the session up on a TimeoutError
# (the transfer may just be slow -- see its docstring), so a refresh # (see its docstring), so a refresh failing that way leaves a live,
# that fails that way ends here with a live, bound UDP socket that # bound UDP socket nothing would ever close. HA retries setup with a
# nothing would ever close. HA retries setup on its own backoff with # new coordinator, and the source port is fixed by design
# a *new* coordinator, and each device's source port is fixed by # (`_local_source_port`), so an abandoned socket would squat the
# design (`_local_source_port`), so an abandoned socket squats the # exact port the next attempt binds.
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
# succeed, leaving two sockets racing for the device's datagrams.
await coordinator.async_close() await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue # Send the DTLS close_notify on Core shutdown, not just on unload (issue
# #254). `async_close` otherwise only runs via `async_unload_entry`, and # #254): HA doesn't unload entries on a plain Core restart, so a restart
# HA does not unload entries on a plain Core restart -- so a restart left # left the previous run's association orphaned, making the next
# the previous run's association orphaned on the appliance, which is what # handshake time out. Complements the fixed source port, which covers
# makes the *next* run's handshake time out. Complements the fixed source # the unclean-exit case this can't.
# port, which covers the unclean-exit case this cannot (see
# `_local_source_port`).
# #
# A coroutine listener, not one that spawns its own task: the event bus # A coroutine listener, not one that spawns its own task: the event bus
# runs it as a hass-tracked job, so the close is awaited by the # runs it as a hass-tracked job, awaited by `async_block_till_done()`
# `async_block_till_done()` inside `hass.async_stop`. A detached task # inside `hass.async_stop`. A detached task would likely be cancelled
# would likely be cancelled mid-shutdown -- the exact no-close_notify # mid-shutdown -- the exact case this exists to prevent.
# case this exists to prevent.
async def _async_close_on_stop(_event: Event) -> None: async def _async_close_on_stop(_event: Event) -> None:
await coordinator.async_close() await coordinator.async_close()
@@ -198,31 +185,26 @@ async def async_remove_config_entry_device(
) -> bool: ) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214). """Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes Home Assistant offer the "Delete Defining this at all is what makes HA offer the "Delete device" action;
device" action for our devices; without it a device registry entry without it, a device belonging to a loaded config entry can never be
belonging to a loaded config entry can never be removed from the UI. That removed from the UI. That matters because a subdevice's HA device
matters because a subdevice's HA device outlives the discovery that outlives the discovery that created it: a candidate materialized under
created it: a candidate that materialized under an older release (issue an older release (issue #214's phantom second air conditioner, born
#214's phantom second air conditioner, born from an unused /device/1 slot from an unused slot reporting the appliance's energy counter -- see
reporting the appliance's energy counter -- see registry/subdevices.py's liveness gate) leaves a device entry nothing
registry/subdevices.py's liveness gate) leaves a device entry behind that recreates or cleans up once the gate stops materializing it. Same for a
nothing recreates and nothing cleans up once the gate stops materializing sibling a firmware update stops exposing.
it. Same for a sibling that a firmware update stops exposing.
Removal is refused for devices this entry *does* currently provide -- Removal is refused for devices this entry does currently provide -- HA
HA would recreate them on the next entity add, so allowing it would look would recreate them on the next entity add. Deliberately no automatic
like the delete silently failed. Deliberately no automatic pruning at pruning at discovery time: a sibling can fail to answer for a single
discovery time: subdevice enumeration is one-shot and a sibling can fail poll (issue #205), so auto-removal would throw away a real subdevice's
to answer for a poll (issue #205 is exactly that on the reference name/area/automations on a transient miss. The user gets the button;
hardware), so auto-removal would throw away a real subdevice's name, the integration doesn't guess.
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
""" """
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id) coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is None: if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this return True # entry not loaded -- nothing claims this device
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get("identifiers") or set()) live = set(coordinator.device_info.get("identifiers") or set())
for subdevice in coordinator.subdevices: for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set()) live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
+100 -152
View File
@@ -1,22 +1,22 @@
"""Climate platform for Local Things. """Climate platform for Local Things.
The first composite entity in this integration: a single HA climate card that The first composite entity in this integration: a single HA climate card
unifies several OCF resources of a Samsung air conditioner. Unlike every other that unifies several OCF resources of a Samsung air conditioner. Unlike
platform here (one descriptor -> one resource field), a climate entity reads every other platform here (one descriptor -> one resource field), a climate
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind entity reads power, HVAC mode, current/target temperature, fan (wind)
direction) and the convenient-mode preset from *different* resources. strength, swing (wind direction) and the convenient-mode preset from
*different* resources.
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the
still tracks it, and reads the sibling resources straight from the coordinator registry still tracks it, and reads the sibling resources straight from the
snapshot via `coordinator.resource(href)` -- the same cross-resource read that coordinator snapshot via `coordinator.resource(href)`.
`number.py` (live range/unit) and `select.py` (options callable) already do.
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the Writes go through `coordinator.async_send_command(bound, (kind, value))`:
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and right `(path_segs, body)`, and `async_send_command` applies the optimistic
applies the optimistic value/settle guard to that same href -- not the bound value/settle guard to that resource's own href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state `/mode/vs/0` href -- so one descriptor drives writes across power, mode,
back for, power, mode, temperature and wind resources alike. temperature and wind resources alike.
""" """
from __future__ import annotations from __future__ import annotations
@@ -95,54 +95,40 @@ _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
_DEVICE_TO_HVAC: dict[str, HVACMode] = { _DEVICE_TO_HVAC: dict[str, HVACMode] = {
"Cool": HVACMode.COOL, "Cool": HVACMode.COOL,
"Dry": HVACMode.DRY, "Dry": HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and # Fan-only is spelled 'Wind' on some boards and 'Fan' on others; both map
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The # to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from
# reverse write can't rely on this map alone (two codes, one HA value) -- # the unit's own supportedModes, so this reverse map is only a fallback
# _device_code_for_hvac() resolves the code from the unit's own # for a unit with no supportedModes at all. 'Fan' listed first so the
# supportedModes, so this is only a fallback for a unit reporting no # {v: k} comprehension below has 'Wind' win that fallback (last-key-wins,
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse # preserving the original single-spelling behavior).
# comprehension below has 'Wind' win that fallback (last-key-wins),
# preserving the original single-spelling behavior rather than silently
# flipping it when 'Fan' was added.
"Fan": HVACMode.FAN_ONLY, "Fan": HVACMode.FAN_ONLY,
"Wind": HVACMode.FAN_ONLY, "Wind": HVACMode.FAN_ONLY,
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA # A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool" # (which implies a two-setpoint heat+cool range these units don't have).
# and implies a two-setpoint heat+cool range these single-setpoint units
# (including cool-only models) don't have.
"Auto": HVACMode.AUTO, "Auto": HVACMode.AUTO,
"Heat": HVACMode.HEAT, "Heat": HVACMode.HEAT,
} }
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat # AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort'
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation # isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto' # overlay on the device's own 'Auto' -- the unit reports both as separate,
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as # mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled # plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction.
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain # Not reachable via async_set_hvac_mode -- entered/left only through the
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the # preset, since there's no HVACMode value for it to write back to.
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
# entered/left only through the preset, since there's no dedicated HVACMode
# value for it to write back to.
_AI_COMFORT_MODE = "AIComfort" _AI_COMFORT_MODE = "AIComfort"
PRESET_AI_COMFORT = "ai_comfort" PRESET_AI_COMFORT = "ai_comfort"
# Codes that appear in /mode/vs/0's supportedModes but are option/capability # Codes in /mode/vs/0's supportedModes that are option/capability flags, not
# flags rather than selectable thermodynamic operations -- dropped silently # selectable thermodynamic operations -- dropped silently rather than
# (no _warn_unmapped call) rather than every owner of an affected unit # tripping the issue #93 unmapped-code warning on every start.
# tripping the issue #93 warning on every start.
# #
# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in # HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in
# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside # /configuration/vs/0's airconOptionList alongside other capability flags,
# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a # and the unit's own current `modes` never reported it active -- consistent
# mode, on that resource. The unit's own `modes` (current mode) never # with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE,
# reported it as active across the reporter's logs, only ever a real # not modeled as a preset either: nothing confirms it's user-selectable.
# thermodynamic mode -- consistent with it being echoed into supportedModes
# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't
# modeled as a preset: there's no confirmation it's user-selectable at all,
# so silently dropping it (rather than guessing a write contract) is the
# 'don't guess' rule applied to a mode code instead of a resource field.
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"}) _NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where # Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
@@ -166,11 +152,9 @@ _DEVICE_TO_SWING: dict[str, str] = {
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} _SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without # Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles # WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING # one combined code. Same HA vocabulary as _DEVICE_TO_SWING above.
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
def _oscillation_swing(rep: dict) -> str | None: def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get("vertical") vertical = rep.get("vertical")
horizontal = rep.get("horizontal") horizontal = rep.get("horizontal")
@@ -189,13 +173,12 @@ def _oscillation_swing(rep: dict) -> str | None:
def _wind_strength_label(code, rep: dict) -> str: def _wind_strength_label(code, rep: dict) -> str:
"""Human label for a /wind/strength/vs/0 code from the device's own """Human label for a /wind/strength/vs/0 code from the device's own
modesName array (parallel-indexed with supportedModes), lowercased for modesName array (parallel-indexed with supportedModes), lowercased --
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155:
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the a board using codes "0"/"31"-"35" instead of the "0"-"4" scale
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving _DEVICE_TO_FAN was built from, with modesName giving the real labels).
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors Falls back to the raw code lowercased when modesName is absent or
preset_mode's dynamic code->str resolution. Falls back to the raw code misaligned."""
lowercased when modesName is absent or misaligned."""
supported = rep.get("x.com.samsung.da.supportedModes") or [] supported = rep.get("x.com.samsung.da.supportedModes") or []
names = rep.get("x.com.samsung.da.modesName") or [] names = rep.get("x.com.samsung.da.modesName") or []
if code in supported and len(names) == len(supported): if code in supported and len(names) == len(supported):
@@ -204,15 +187,10 @@ def _wind_strength_label(code, rep: dict) -> str:
# Preset (convenient mode): resolved dynamically from the device's own # Preset (convenient mode): resolved dynamically from the device's own
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' # /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off'
# code maps to HA's PRESET_NONE ("no preset active"); every other code is # maps to PRESET_NONE; every other code is exposed lowercased and labelled
# exposed as its lowercased self and labelled in translations # in translations, so any board's convenient modes surface without code
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>), # changes, and an unlabelled code renders as its raw value.
# so any board's convenient modes surface without code changes, and an
# unlabelled code just renders as its raw value until a label is added.
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
# translation label, not a hard-coded mode.)
def _preset_to_ha(code) -> str: def _preset_to_ha(code) -> str:
return PRESET_NONE if code == "Off" else str(code).lower() return PRESET_NONE if code == "Off" else str(code).lower()
@@ -248,12 +226,11 @@ def _num(value):
def _temps_vs_item(rep: dict) -> dict: def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array. """First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g. Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard /temperature/current/0 + /temperature/desired/0 pair; it packs current/
/temperature/current/0 + /temperature/desired/0 pair; it reports current desired/minimum/maximum/increment/unit into this one resource's
and target under a single `/temperatures/vs/0` resource whose items[0] instead. Returns {} when absent, so callers fall through
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/ cleanly.
increment/unit. Returns {} when absent, so callers fall through cleanly.
""" """
items = rep.get("x.com.samsung.da.items") items = rep.get("x.com.samsung.da.items")
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
@@ -264,16 +241,12 @@ def _temps_vs_item(rep: dict) -> dict:
class LocalThingsClimate(LocalThingsEntity, ClimateEntity): class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner.""" """Composite climate entity for a Samsung air conditioner."""
# translation_key comes from the ClimateDesc (base __init__ sets # Opts out of the deprecated auto-added TURN_ON/OFF backwards compat.
# _attr_translation_key from bound.desc), resolving the state_attributes
# translations under entity.climate.airconditioner.
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
_enable_turn_on_off_backwards_compatibility = False _enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound) super().__init__(coordinator, bound)
# Primary/main entity for the device: no name suffix, just the device name. self._attr_name = None # primary entity: no name suffix
self._attr_name = None
self._attr_supported_features = ( self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.FAN_MODE
@@ -283,9 +256,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_OFF
) )
# (href, raw device code) pairs already logged by _warn_unmapped -- # (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every coordinator refresh, so an # these properties are read on every refresh, so an un-deduped
# un-deduped warning would spam the log for any device with a # warning would spam the log for a genuinely unrecognized code.
# genuinely unrecognized code.
self._warned_unmapped: set[tuple[str, str]] = set() self._warned_unmapped: set[tuple[str, str]] = set()
# -- resource helpers --------------------------------------------------- # -- resource helpers ---------------------------------------------------
@@ -308,67 +280,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
return {} return {}
def _legacy_airflow(self) -> dict: def _legacy_airflow(self) -> dict:
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to """The /airflow/vs/0 rep, but only when it is the fan/swing channel
use -- i.e. this board has no /wind/strength/vs/0. to use -- i.e. this board has no /wind/strength/vs/0.
Delegates the board-generation test to is_legacy_board (the same Delegates the board-generation test to is_legacy_board (the same
test capabilities/airconditioner.py's token entities are gated on) test the token entities in capabilities/airconditioner.py use)
instead of re-implementing it. Uses self._resources (this unit's own instead of re-implementing it, using self._resources (issue #177)
canonical view, issue #177 -- see LocalThingsEntity._resources) rather than a presence dict built from coordinator.resource()'s
rather than a two-key presence dict built from coordinator.resource()'s
truthiness -- resource() collapses "href absent" and "href present truthiness -- resource() collapses "href absent" and "href present
with an empty {} rep" to the same falsy value, while is_legacy_board but empty" to the same falsy value, while is_legacy_board tests key
(and discover()'s own binding) test key membership, not truthiness. A membership. Reads through self._rep, not coordinator.resource()
presence dict built from truthiness alone would disagree with the directly, so a subdevice's own /airflow/vs/1 gets translated first,
token entities on a board reporting a genuinely empty /airflow/vs/0, like every other sibling read below.
silently reintroducing the drift this delegation exists to prevent.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a subdevice (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own subdevice first, exactly like every other sibling read
below.
""" """
if not is_legacy_board(self._resources): if not is_legacy_board(self._resources):
return {} return {}
return self._rep(AIRFLOW_HREF) return self._rep(AIRFLOW_HREF)
def _legacy_preset(self) -> bool: def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource. """Whether presets come from the Comode_* token rather than a
resource. Gated on the same board test as _legacy_airflow, not on
the convenient rep being empty alone: newer boards carry Comode
tokens too, so a momentarily empty /mode/convenient/vs/0 must not
silently switch the preset path over.
Gated on the same board test as _legacy_airflow, not on the convenient Reads the raw href directly rather than through self._rep's own
rep being empty alone: newer boards carry Comode tokens too, so a CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient()
momentarily empty /mode/convenient/vs/0 there must not silently switch rep this method is deciding whether to use, so routing through it
the preset read (and write) over to the token path. would make the resource never look empty.
Deliberately reads the *raw* href (translated through this bound
entity's own subdevice, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
""" """
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF) convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow()) return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
def _rep(self, href: str) -> dict: def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants -- """`href` is one of this module's canonical HREF_* constants,
translated through this bound entity's own subdevice (issue #177) to translated through this bound entity's own subdevice (issue #177)
the real, on-the-wire href before the single-href cache lookup to the real on-the-wire href -- identity for MAIN."""
(identity for MAIN, so a device with no subdevices reads exactly the
href it always did)."""
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {} rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow(): if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient() return self._legacy_convenient()
return rep return rep
def _is_on(self) -> bool: def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 (present on every observed board and # Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many
# the resource writes target -- see airconditioner._climate_write). # boards and a stale mirror on some, so reading it first showed
# The OCF /power/0 is absent on many boards and a stale mirror on # pre-write state after a power toggle (issue #53).
# some, so reading it first showed pre-write state after a power
# toggle (issue #53: "can turn on but not off").
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power") power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None: if power is not None:
return str(power).lower() == "on" return str(power).lower() == "on"
@@ -379,16 +335,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _warn_unmapped(self, href: str, code: str) -> None: def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no """Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces entry in the relevant device<->HA map, so a real gap surfaces in
in the log instead of silently vanishing (issue #93). the log instead of silently vanishing (issue #93).
Falls back to `unique_id` when `entity_id` is unset (issue #235): Falls back to `unique_id` when `entity_id` is unset (issue #235):
this fires during setup's first discovery pass, before the entity is this can fire during setup's first discovery pass, before the
added to hass, so `entity_id` is always None at that point -- entity is added to hass, when entity_id is still None."""
indistinguishable across multiple same-type devices in the log.
`unique_id` is set eagerly in `__init__` (see entity.py), so it's
always available here even though `entity_id` isn't.
"""
key = (href, code) key = (href, code)
if key in self._warned_unmapped: if key in self._warned_unmapped:
return return
@@ -420,11 +372,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _ocf_temp_authoritative(self) -> bool: def _ocf_temp_authoritative(self) -> bool:
"""True when the OCF /temperature/{current,desired}/0 pair is the """True when the OCF /temperature/{current,desired}/0 pair is the
authoritative temperature channel -- signalled by authoritative channel, signalled by /temperature/current/0 being
/temperature/current/0 being present. Those boards honour reads/ present. Those boards honor reads/writes on /temperature/desired/0
writes on /temperature/desired/0 and ignore the vendor and ignore the vendor /temperatures/vs/0; boards without the pair
/temperatures/vs/0; boards without the pair (only a desired stub, or are the reverse. Confirmed on live units of both kinds."""
nothing) are the reverse. Confirmed on live units of both kinds."""
return bool(self._rep(TEMP_CURRENT_HREF)) return bool(self._rep(TEMP_CURRENT_HREF))
def _temps_vs(self) -> dict: def _temps_vs(self) -> dict:
@@ -597,11 +548,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
return _HVAC_TO_DEVICE.get(hvac_mode) return _HVAC_TO_DEVICE.get(hvac_mode)
async def async_set_temperature(self, **kwargs) -> None: async def async_set_temperature(self, **kwargs) -> None:
# HA's set_temperature service forwards an optional hvac_mode here; honour # HA's set_temperature service can carry an optional hvac_mode;
# it (set the mode first -- that also powers the unit on when it was off), # honor it (setting the mode also powers the unit on) so a dashboard
# matching the climate contract other integrations follow. Without this a # "turn on to Auto 24" button doesn't set the setpoint alone.
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
# Auto 24" button) set the setpoint but never changed mode or powered on.
hvac_mode = kwargs.get("hvac_mode") hvac_mode = kwargs.get("hvac_mode")
if hvac_mode is not None: if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode) await self.async_set_hvac_mode(hvac_mode)
@@ -647,12 +596,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
supported = self._supported(WIND_STRENGTH_HREF) supported = self._supported(WIND_STRENGTH_HREF)
device = _FAN_TO_DEVICE.get(fan_mode) device = _FAN_TO_DEVICE.get(fan_mode)
# A static hit is only trustworthy if this unit's own supportedModes # A static hit is only trustworthy if this unit's own supportedModes
# actually includes that code -- a board can use non-standard codes # includes that code -- a board can use non-standard codes (issue
# (issue #155's "31"-"35") while still spelling a standard label # #155) while still spelling a standard label in modesName, so the
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would # static guess could be a plausible code the device never
# return a plausible-looking code ('1'/'3') the device never # advertised. Fall through to the live scan when it isn't one of
# advertised at all. Fall through to the live scan whenever the # this unit's own codes.
# static guess isn't actually one of this unit's own codes.
if device is None or (supported and device not in supported): if device is None or (supported and device not in supported):
rep = self._rep(WIND_STRENGTH_HREF) rep = self._rep(WIND_STRENGTH_HREF)
for code in supported: for code in supported:
+105 -146
View File
@@ -77,15 +77,10 @@ class CannotConnect(Exception):
"""Base for every probe failure. """Base for every probe failure.
`error_key` selects which message the user sees. The subclasses below `error_key` selects which message the user sees. The subclasses below
exist because "cannot connect" covered wildly different situations -- an exist because "cannot connect" used to cover wildly different situations
IP with nothing on it, an appliance on cloud-only firmware, a device (nothing at that IP, cloud-only firmware, a stale held session, a
that's simply still holding a session from the last attempt, and a device rejected certificate) all under one unhelpful message. Raising this base
that answered and rejected our certificate all told the user the same class directly is still valid for a failure that can't be narrowed down.
thing ("check the IP and the CA credentials"), which is only actionable
advice for one of them.
Raising this base class directly is still valid for a failure we can't
narrow down; it maps to that same generic message.
""" """
error_key = "cannot_connect" error_key = "cannot_connect"
@@ -144,11 +139,9 @@ class InvalidCA(Exception):
def _fetch_samsung_uuid() -> str: def _fetch_samsung_uuid() -> str:
"""Connect to Samsung's cloud gateway and extract the UUID from its TLS cert. """Connect to Samsung's cloud gateway and extract the UUID from its TLS
cert. Verification is disabled: Samsung's chain has a self-signed cert,
Verification is disabled because Samsung's chain contains a self-signed cert. and we only need to read the UUID from the subject, not verify trust."""
We only need to read the UUID from the cert subject, not verify its trust.
"""
from cryptography import x509 as _x509 from cryptography import x509 as _x509
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
@@ -251,13 +244,11 @@ def _order_candidates(ports: list[int]) -> list[int]:
return preferred + rest return preferred + rest
# The kernel's way of saying the datagram never had anywhere to go: no route # The kernel's way of saying the datagram never had anywhere to go: no route,
# to the network, or the host never answered ARP on our own LAN. Distinct from # or the host never answered ARP. Distinct from ECONNREFUSED, which is a
# ECONNREFUSED, which is a *response* -- the host is there and told us the port # response -- the host is there and told us the port is closed. Both leave a
# is closed. Both leave a port "not live", but they mean opposite things about # port "not live", but mean opposite things about whether anything exists at
# whether anything exists at that address, which is the difference between # that address.
# telling a user to check the IP and telling them their appliance is on
# cloud-only firmware.
_UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN}) _UNREACHABLE_ERRNOS = frozenset({errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN})
@@ -273,24 +264,17 @@ class _SweepResult:
def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult: def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResult:
"""Fast UDP liveness sweep -- the sweep's own verdict, nothing added. """Fast UDP liveness sweep -- the sweep's own verdict, nothing added.
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP UDP is connectionless, but a connected UDP socket surfaces the ICMP
port-unreachable that a closed port returns as ECONNREFUSED on its next port-unreachable a closed port returns as ECONNREFUSED on its next recv.
recv. So we send one probe datagram per port and watch for that error: So we send one probe datagram per port and watch for that error:
ECONNREFUSED means closed; silence/data means possibly live. The
in-process equivalent of ``nmap -sU``: takes a nine-port range down to
the one or two worth a full DTLS handshake, bounded to ``timeout``.
* ECONNREFUSED -> port is closed (device actively rejected it) Deliberately the raw verdict, with no preferred-port rescue folded in
* silence / any data -> port may be live (open|filtered); a candidate (that's `_sweep_ports`) -- its shape is evidence about the host, and a
refusal vs. an unreachable are counted apart rather than both "not
This is the in-process equivalent of ``nmap -sU``: it lets us take a live" for that reason (see _SweepResult).
nine-port range down to the one or two ports actually worth a full DTLS
handshake + /device/0 GET, and bounds the total wait to ``timeout``
instead of stalling on every dead port when a firewall swallows the ICMP
replies.
The result is deliberately the raw verdict, with no preferred-port rescue
folded in (that's `_sweep_ports`): its *shape* is evidence about the host,
and mixing a rescue into it would destroy that. Which is also why a
refusal and an unreachable are counted apart rather than both just being
"not live" -- see _SweepResult.
""" """
sockets: dict[int, socket.socket] = {} sockets: dict[int, socket.socket] = {}
sel = selectors.DefaultSelector() sel = selectors.DefaultSelector()
@@ -312,7 +296,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
sock.send(probe) sock.send(probe)
except OSError as exc: except OSError as exc:
# Failing on the way out means the kernel already knows the # Failing on the way out means the kernel already knows the
# datagram can't get there (no route, ARP never resolved). # datagram can't get there.
_rule_out(port, exc) _rule_out(port, exc)
sock.close() sock.close()
continue continue
@@ -329,8 +313,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul
for key, _ in sel.select(timeout=remaining): for key, _ in sel.select(timeout=remaining):
sock = sockets[key.data] sock = sockets[key.data]
try: try:
# Data back means live; an error means the port is # Data back means live; an error rules the port out.
# closed or the host isn't there — either way, rule it out.
sock.recv(1) sock.recv(1)
except OSError as exc: except OSError as exc:
_rule_out(key.data, exc) _rule_out(key.data, exc)
@@ -349,18 +332,17 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
"""`(sweep, candidates)` -- what the host said, and what to actually try. """`(sweep, candidates)` -- what the host said, and what to actually try.
The sweep's ICMP-based verdict isn't reliable on every network path -- The sweep's ICMP-based verdict isn't reliable on every network path --
issue #192 captured a segregated-VLAN device where it called three ports issue #192 captured a segregated-VLAN device where it called live ports
live that a concurrent nmap scan showed as closed, while the port nmap that nmap showed closed, while the port nmap found genuinely open never
found genuinely open|filtered (49154, one of our historically confirmed showed up as live at all. Rather than trust a wrong "not live" verdict
ports) never showed up as live at all. Rather than trust a wrong "not on a port with strong prior evidence, the historically-confirmed ports
live" verdict on a port we already have strong prior evidence for, always always get a real handshake attempt too (bounded cost: at most
give the historically-confirmed ports a real handshake attempt too. len(PREFERRED_PROBE_PORTS) extra handshakes, only when the sweep
Bounded cost: at most len(PREFERRED_PROBE_PORTS) extra handshakes, only disagrees with the prior).
when the sweep disagrees with the prior.
Both halves are returned rather than just the union because they answer Both halves are returned, not just the union, since they answer
different questions: `candidates` is what to hand a handshake, `sweep` is different questions: `candidates` is what to hand a handshake, `sweep`
what the host actually told us about itself. is what the host actually told us about itself.
""" """
sweep = _find_live_ports(host, ports, timeout) sweep = _find_live_ports(host, ports, timeout)
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live] rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in sweep.live]
@@ -371,10 +353,10 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes
class _PortScan: class _PortScan:
"""What port detection learned about a host. """What port detection learned about a host.
`candidates` is what gets a full DTLS handshake. The other two are kept `candidates` is what gets a full DTLS handshake. The other two are the
because they're the evidence behind a failure message: `confirmed` names evidence behind a failure message: `confirmed` names ports a DTLS
ports a DTLS server was *proven* on, and `swept` is the UDP sweep's own server was proven on, `swept` is the UDP sweep's own verdict (None
verdict (None when the sweep never had to run). when the sweep never had to run).
""" """
candidates: list[int] candidates: list[int]
@@ -383,12 +365,10 @@ class _PortScan:
def _clienthello_probe(host: str, port: int): def _clienthello_probe(host: str, port: int):
"""One stateless DTLS ClientHello against `host:port`. """One stateless DTLS ClientHello against `host:port`. Imported lazily
so an install whose smartthings-local predates the probe (< 0.1.2)
Imported lazily so an install whose smartthings-local predates the probe degrades to the UDP sweep at scan time rather than failing to load the
(< 0.1.2) degrades to the UDP sweep at scan time rather than failing to config flow at all."""
load the config flow at all.
"""
from smartthings_local.protocol.dtls_probe import probe from smartthings_local.protocol.dtls_probe import probe
return probe( return probe(
@@ -406,17 +386,14 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
smartthings-local's stateless probe sends one ClientHello and stops the smartthings-local's stateless probe sends one ClientHello and stops the
moment the server proves itself with a HelloVerifyRequest, which per RFC moment the server proves itself with a HelloVerifyRequest, which per RFC
6347 §4.2.1 the server answers *without* allocating association state. So 6347 §4.2.1 the server answers without allocating association state --
this identifies the device's real port in ~1 RTT, leaves nothing behind on identifies the device's real port in ~1 RTT, far cheaper than throwing N
the appliance, and costs it far less than the alternative of throwing N full certificate handshakes at it.
full certificate handshakes at it to find out.
The whole range goes out at once. That's safe in a way racing real The whole range goes out at once, safely: each probe is bounded by
handshakes is not: each probe is bounded by CLIENTHELLO_PROBE_TIMEOUT_S CLIENTHELLO_PROBE_TIMEOUT_S rather than DtlsCoapSession's 12s handshake
rather than DtlsCoapSession's 12s handshake timeout, so the pool's timeout, so the pool's shutdown-and-wait on exit costs one probe's
shutdown-and-wait on exit costs one probe's budget, not the sum of the budget, not the sum of the range.
range -- no `shutdown(wait=False)` and no losing threads left running
behind us.
""" """
with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex: with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex:
results = list(ex.map(lambda port: _clienthello_probe(host, port), ports)) results = list(ex.map(lambda port: _clienthello_probe(host, port), ports))
@@ -432,19 +409,16 @@ def _clienthello_scan(host: str, ports: list[int]) -> list[int]:
def _scan_ports(host: str) -> _PortScan: def _scan_ports(host: str) -> _PortScan:
"""Find the device's DTLS port, preferring proof over absence of evidence. """Find the device's DTLS port, preferring proof over absence of evidence.
The ClientHello probe is authoritative when it finds something: a port The ClientHello probe is authoritative when it finds something: exactly
that answered one is running a DTLS server, so exactly one port gets the one port gets the expensive certificate handshake instead of every port
expensive certificate handshake instead of every port the old UDP sweep the old UDP sweep couldn't rule out (issue #211's 30-40s of 12s handshake
couldn't rule out (each of which cost a full 12s handshake timeout -- timeouts).
issue #211's 30-40s adds).
It stays a *gate*, not a replacement: when it confirms nothing we fall It's a gate, not a replacement: when it confirms nothing, we fall back
back to the ICMP-based sweep, which is wrong in the opposite direction to the ICMP-based sweep, which still surfaces a device the probe
(it reports everything it can't rule out) and so still surfaces a device couldn't reach (a network path dropping the ClientHello, or an install
the probe couldn't reach -- e.g. a network path that drops our on smartthings-local < 0.1.2). Issue #192's segregated-VLAN device is
ClientHello outright, or an install still on smartthings-local < 0.1.2. why that fallback keeps its own preferred-port rescue.
Issue #192's segregated-VLAN device is the reason that fallback keeps its
own preferred-port rescue.
""" """
try: try:
confirmed = _clienthello_scan(host, PROBE_PORT_RANGE) confirmed = _clienthello_scan(host, PROBE_PORT_RANGE)
@@ -472,12 +446,10 @@ def _scan_ports(host: str) -> _PortScan:
return _PortScan(candidates, [], sweep) return _PortScan(candidates, [], sweep)
# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and said # TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and
# no", as opposed to a protocol/cipher disagreement. decrypt_error belongs # said no", as opposed to a protocol/cipher disagreement -- what an
# here: it's what a peer sends when CertificateVerify fails. These are the # appliance sends when the CA behind the leaf isn't one it trusts, the
# alerts an appliance sends when the CA behind the leaf isn't one it trusts -- # single most common real setup mistake.
# the single most common real setup mistake, and the one the old blanket
# "check the IP and the CA credentials" message could never call out.
_CERT_ALERTS = frozenset( _CERT_ALERTS = frozenset(
{ {
"bad_certificate", "bad_certificate",
@@ -493,15 +465,13 @@ _CERT_ALERTS = frozenset(
) )
# OpenSSL renders a received fatal alert into its error text as e.g. # OpenSSL renders a received fatal alert into its error text as e.g.
# "tlsv1 alert unknown ca" / "sslv3 alert bad certificate", which # "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a
# DtlsCoapSession.connect() wraps in a ConnectionError. Reading it back out # ConnectionError. Reading it back tells us what the appliance objected to.
# tells us what the appliance actually objected to.
# #
# Deliberately not the library's diagnostic probe (stateless=False), which # Deliberately not the library's diagnostic probe (stateless=False): that
# would report the alert authoritatively: that mode drives the handshake far # mode commits association state on the device, and an orphaned association
# enough to commit association state on the device, and an orphaned # makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a
# association is exactly what makes the *next* attempt time out (RFC 6347 # path the user is about to retry.
# §4.2.8) -- a bad trade on a path the user is about to retry.
_ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)") _ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)")
@@ -516,16 +486,12 @@ def _classify_handshake_failure(
scan: _PortScan, scan: _PortScan,
failures: list[tuple[int, Exception]], failures: list[tuple[int, Exception]],
) -> CannotConnect: ) -> CannotConnect:
"""Turn "no port worked" into the most specific thing we can honestly say. """Turn "no port worked" into the most specific thing we can honestly
say, in rough order of how much the evidence tells us: an alert means
In rough order of how much the evidence tells us: the appliance refused us on purpose (and says whether it was our
certificate); a confirmed DTLS port that then timed out is likely still
* An alert means the appliance is there, speaks DTLS, and refused us on holding a session from a previous attempt; otherwise the sweep's own
purpose -- and the alert says whether it was about our certificate. shape is the evidence.
* A confirmed DTLS port that then timed out is a device that is present
and healthy but wouldn't finish. Usually it's still holding the session
from a previous attempt, which clears on its own.
* Otherwise the sweep's own shape is the evidence -- see the rules below.
""" """
alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name] alerts = [name for name in (_alert_name(exc) for _, exc in failures) if name]
cert_alerts = [name for name in alerts if name in _CERT_ALERTS] cert_alerts = [name for name in alerts if name in _CERT_ALERTS]
@@ -542,18 +508,18 @@ def _classify_handshake_failure(
if sweep is None: if sweep is None:
return CannotConnect(f"no port on {host} completed a handshake") return CannotConnect(f"no port on {host} completed a handshake")
if sweep.unreachable and not sweep.refused: if sweep.unreachable and not sweep.refused:
# The kernel never got the datagrams off the host, so nothing was # Nothing was ever asked -- the kernel never got the datagrams off
# ever asked. Reporting "ports closed" here would be exactly wrong. # the host, so "ports closed" would be exactly wrong.
return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})") return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})")
if not sweep.live: if not sweep.live:
# Every port answered ICMP port-unreachable: something is at that # Every port answered ICMP port-unreachable: something is there and
# address and it is not exposing the local API. # not exposing the local API.
return PortsClosed( return PortsClosed(
f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
) )
if len(sweep.live) == len(PROBE_PORT_RANGE): if len(sweep.live) == len(PROBE_PORT_RANGE):
# Not one refusal came back across a nine-port ephemeral range. A host # Not one refusal across a nine-port range -- a host that's
# that is actually there answers for at least some of it. # actually there answers for at least some of it.
return NoResponse(f"nothing at {host} responded on any probed port") return NoResponse(f"nothing at {host} responded on any probed port")
return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake") return NoDtlsServer(f"ports on {host} are reachable but none answered a DTLS handshake")
@@ -586,15 +552,15 @@ def _read_device(sess, host: str, port: int) -> dict:
/oic/d before /device/0, deliberately: the device's own OCF device-type /oic/d before /device/0, deliberately: the device's own OCF device-type
declaration is the primary detection signal when a board populates it declaration is the primary detection signal when a board populates it
(see registry/by_type's resolve()), and read_identity's three small GETs (see registry/by_type's resolve()), and read_identity's three small
settle it long before the blockwise /device/0 dump lands. read_identity is GETs settle it long before the blockwise /device/0 dump lands.
defensive on every GET it makes, so a device that answers neither /oic/p read_identity is defensive on every GET, so a device answering neither
nor /oic/d just yields an empty device_types tuple and detection falls /oic/p nor /oic/d falls through to the model-string/resource-signature
through to the model-string/resource-signature path. path.
Everything the entry needs to name and key the device comes from here -- Everything the entry needs to name and key the device comes from here,
resolved serial, model, manufacturer, device type -- so the coordinator so the coordinator never has to mint a registry key from a placeholder
never has to mint a registry key from a placeholder (issue #236). (issue #236).
""" """
import cbor2 import cbor2
@@ -665,17 +631,15 @@ def _probe_and_validate(
) -> dict: ) -> dict:
"""Find the device's port, authenticate to it, and resolve its identity. """Find the device's port, authenticate to it, and resolve its identity.
Port detection runs first and needs no credentials at all, so an Port detection runs first and needs no credentials, so an unreachable
unreachable host fails here rather than after a round trip to Samsung's host fails here rather than after a round trip to Samsung's cloud.
cloud.
`existing_leaf` is another entry's already-minted leaf (issue #211). `existing_leaf` is another entry's already-minted leaf (issue #211).
Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from Every appliance accepts the same leaf, so adding a second device can
Samsung's cloud cert -- so adding a second device can skip the fetch and skip the fetch and mint entirely -- independent of Samsung-cloud
mint entirely, which makes it independent of Samsung-cloud reachability reachability, not merely faster. If that reused leaf turns out to be
rather than merely faster. If that reused leaf turns out to be stale (the stale (the UUID does rotate), a confirmed-live device rejecting it
UUID does rotate), a confirmed-live device rejecting it is unambiguous re-mints and retries once, so the reuse stays self-correcting.
enough to re-mint and try once more, so the reuse stays self-correcting.
""" """
scan = _scan_ports(host) scan = _scan_ports(host)
@@ -718,11 +682,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def _create_entry(self, info: dict) -> ConfigFlowResult: def _create_entry(self, info: dict) -> ConfigFlowResult:
"""Persist everything the probe resolved, identity included. """Persist everything the probe resolved, identity included.
The identity fields are not decoration: the coordinator seeds The identity fields aren't decoration: the coordinator seeds
`device_serial` and its DeviceInfo from them at construction time, so `device_serial` and its DeviceInfo from them at construction time,
entity unique_ids and device identifiers are correct from the very so entity unique_ids are correct from the first entity that
first entity that registers -- even if the first poll is slow, or registers, even if the first poll is slow or fails (issue #236).
fails outright (issue #236).
""" """
from .registry.identity import device_display_name from .registry.identity import device_display_name
@@ -772,8 +735,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) )
except (CannotConnect, InvalidCA) as exc: except (CannotConnect, InvalidCA) as exc:
# Every probe failure carries the message that fits it (see # Every probe failure carries the message that fits it (see
# CannotConnect); the log line is where the specifics live, # CannotConnect); the log line is where the specifics live.
# since the messages point users at it.
_LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc) _LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc)
errors["base"] = exc.error_key errors["base"] = exc.error_key
except Exception: except Exception:
@@ -836,14 +798,11 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
arbitrary resource href, so a user can pin down device-specific write arbitrary resource href, so a user can pin down device-specific write
behavior without waiting on a new release. behavior without waiting on a new release.
The remote-control override exists because most devices reject writes The remote-control override exists because not every model actually
outright while remote control is off and a clear error beats a silent enforces the block most devices do, so a user who's confirmed their
device-side rejection -- but not every model actually enforces that, device accepts writes anyway can turn it off for just that device. The
so this lets a user who's confirmed their device accepts writes anyway debug panel goes further, bypassing that block (and every write_fn/
turn the block off for just that device rather than it being validate_fn) entirely.
hardcoded on for everyone. The debug panel goes further: it bypasses
that block (and every write_fn/validate_fn) entirely, sending exactly
the body the user types to whatever href they pick.
""" """
def __init__(self) -> None: def __init__(self) -> None:
+39 -50
View File
@@ -20,86 +20,75 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem" CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem" CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Device identity, resolved once by the config flow's probe and persisted on # Device identity, resolved once by the config flow's probe and persisted
# the entry (issue #236). These are what the coordinator mints registry keys # on the entry (issue #236) -- what the coordinator mints registry keys
# from at __init__ time, before any poll has happened -- see # from at __init__ time, before any poll has happened. Without them,
# LocalThingsCoordinator.__init__. Without them the coordinator had to seed # anything registering before the first poll (e.g. the connection-mode
# `device_serial` with the host and rebuild its DeviceInfo after the first # sensor) got keyed on the IP address permanently.
# successful poll, so anything that registered in between (the connection-mode
# sensor, which is added unconditionally rather than from `bound`) was written
# into the entity/device registry keyed on the IP address permanently.
# #
# CONF_SERIAL is the *resolved* serial -- registry.identity.resolve_serial's # CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's
# output, i.e. the host itself for a board that reports a placeholder serial # output, the host itself for a placeholder-serial board -- issues
# (issues #83/#189) -- so it matches what _run_discovery computes on the first # #83/#189), so it matches what _run_discovery computes on the first poll.
# poll exactly, and the device identity never changes underneath the registry.
CONF_SERIAL = "serial" CONF_SERIAL = "serial"
CONF_MODEL = "model" CONF_MODEL = "model"
CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER = "manufacturer"
CONF_DEVICE_TYPE = "device_type" CONF_DEVICE_TYPE = "device_type"
# Options-flow key (entry.options, not entry.data): lets a user override the # Options-flow key (entry.options, not entry.data): lets a user override
# device-wide remote-control-off write block for a specific device (issue # the device-wide remote-control-off write block for a specific device
# #54). Some devices report remote control off yet still accept certain # (issue #54). Some devices accept certain writes even while reporting
# writes (e.g. default detergent/softener dosing on a washer, applied even # remote control off (e.g. a washer's default detergent dosing), so the
# to the built-in programs) -- the block exists to give a clear error # blanket-block assumption doesn't hold everywhere. Defaults to False
# instead of a silent device-side rejection, but that assumption doesn't # (block stays on).
# hold for every model. Defaults to False (block stays on) everywhere it's
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock" CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a # Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed # hysteresis-gated timestamp sensor (currently just finish_time) reports a
# to report a new value. Devices commonly revise their own remaining-time # new value. Devices commonly revise their remaining-time estimate by a
# estimate by a minute or two throughout a cycle, and finish_time = now() + # minute or two throughout a cycle, and finish_time = now() + remaining
# remaining drifts by the poll interval between those revisions -- both push # drifts with the poll interval between revisions -- both push a fresh
# a fresh state (and a recorder/logbook entry) far more often than the # state far more often than the estimate is meaningfully different. 0
# estimate is meaningfully different. 0 disables the gate (every computed # disables the gate.
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes" CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3 DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port # The DTLS/CoAP local API binds somewhere in this ephemeral range,
# depends on firmware. Newer builds answer on 49154/49155, but older ones have # depending on firmware (newer builds answer on 49154/49155, older ones as
# been seen as low as 49153, so we sweep the whole range for a live UDP port # low as 49153) -- swept for a live UDP port before the expensive DTLS
# before attempting the (expensive) DTLS handshake. # handshake.
PROBE_PORT_RANGE = list(range(49152, 49161)) PROBE_PORT_RANGE = list(range(49152, 49161))
# Ports we've historically seen complete a DTLS handshake. When more than one # Ports we've historically seen complete a DTLS handshake; tried first when
# port in the range looks live, these are tried first. # more than one port in the range looks live.
PREFERRED_PROBE_PORTS = [49154, 49155] PREFERRED_PROBE_PORTS = [49154, 49155]
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an # Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
# ICMP port-unreachable almost immediately; a live-but-silent port is only # ICMP port-unreachable almost immediately; a live-but-silent port is only
# detected by this timeout elapsing, so keep it short. Only reached now as the # detected by this timeout elapsing, so keep it short. Only reached as the
# fallback for when the ClientHello probe below confirms nothing. # fallback for when the ClientHello probe below confirms nothing.
LIVENESS_PROBE_TIMEOUT_S = 1.5 LIVENESS_PROBE_TIMEOUT_S = 1.5
# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2), # Per-port budget for the DTLS ClientHello probe (smartthings-local >=
# the primary port-detection gate. A real DTLS server answers with a # 0.1.2), the primary port-detection gate. A real server answers with a
# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the # HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent
# budget only bounds how long a *silent* port takes to give up, since the # port takes to give up. 3s covers two retransmits on a slow LAN.
# probe services OpenSSL's retransmit timer rather than reading one dropped
# ClientHello as dead. 3s covers two retransmits on a slow LAN.
CLIENTHELLO_PROBE_TIMEOUT_S = 3.0 CLIENTHELLO_PROBE_TIMEOUT_S = 3.0
CLIENTHELLO_PROBE_RETRIES = 2 CLIENTHELLO_PROBE_RETRIES = 2
# The whole port range is probed at once: each stateless probe is bounded by # The whole port range is probed at once: each stateless probe is bounded
# CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the sweep # by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the
# costs one probe's wall clock rather than the sum of the range. Capped so a # sweep costs one probe's wall clock, not the sum of the range. Capped so a
# widened PROBE_PORT_RANGE can't spawn an unbounded thread pool. # widened PROBE_PORT_RANGE can't spawn an unbounded thread pool.
PROBE_MAX_WORKERS = 12 PROBE_MAX_WORKERS = 12
# Deadline for the blockwise /device/0 GET during the config-flow probe. The # Deadline for the blockwise /device/0 GET during the config-flow probe.
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom # The slowest device observed returns a full dump in ~8s.
# without stalling setup; it matches the per-resource read timeout elsewhere.
PROBE_GET_TIMEOUT_S = 10.0 PROBE_GET_TIMEOUT_S = 10.0
# Base for the local (client-side) DTLS source port, distinct from the # Base for the local (client-side) DTLS source port, distinct from the
# destination probe ports above. See coordinator._local_source_port for why a # destination probe ports above -- see coordinator._local_source_port for
# fixed per-device source port matters and how the per-device offset is # why a fixed per-device source port matters. Mirrors the upstream
# derived. Base mirrors the upstream smartthings-local reference bridge. # smartthings-local reference bridge. Requires smartthings-local >= 0.1.1.
# Requires smartthings-local >= 0.1.1.
DTLS_LOCAL_PORT_BASE = 49700 DTLS_LOCAL_PORT_BASE = 49700
SUMMARY_INTERVAL_S = 30.0 SUMMARY_INTERVAL_S = 30.0
+249 -443
View File
@@ -70,9 +70,8 @@ _SEED_PATH = ["device", "0"]
class _NoOpDescriptor: class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This """No-op: StateCache requires an on_observation hook; this integration
integration doesn't use per-capability observation hooks, so this is a doesn't use per-capability observation hooks."""
deliberate no-op, not a placeholder for missing functionality."""
def on_observation(self, state: dict, href: str, rep: dict) -> None: def on_observation(self, state: dict, href: str, rep: dict) -> None:
return None return None
@@ -84,18 +83,15 @@ _RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
def _local_source_port(host: str) -> int: def _local_source_port(host: str) -> int:
"""Deterministic UDP source port for this device's DTLS socket. """Deterministic UDP source port for this device's DTLS socket.
Binding the same source port on every (re)connect keeps the client on one Binding the same source port across reconnects lets the appliance evict
5-tuple, so the appliance evicts an orphaned session left by a previous run an orphaned session (unclean shutdown, no close_notify) at handshake
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347 time per RFC 6347 §4.2.8, instead of holding it 5-15 min. See
§4.2.8, instead of holding it for 5-15 min while the new session's reads DTLS_LOCAL_PORT_BASE. Requires smartthings-local >= 0.1.1.
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 Must stay unique per device on this host too: the library's socket is
host: the library's socket is unconnected (recvfrom), so two devices unconnected, so two devices sharing a port would mis-demux each other's
sharing a source port would mis-demux each other's datagrams. For the usual datagrams. Last IPv4 octet as offset for the common case; a stable
dotted-IPv4 host we use the last octet as the offset (unique on a /24); CRC32 fold otherwise.
anything else folds a stable CRC32 into the same 256-wide window.
""" """
try: try:
offset = int(ipaddress.IPv4Address(host)) & 0xFF offset = int(ipaddress.IPv4Address(host)) & 0xFF
@@ -111,52 +107,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
device_info: DeviceInfo device_info: DeviceInfo
device_serial: str device_serial: str
# Class-level knobs (not instance attrs) so tests can shrink real-time # Class-level so tests can shrink these via patch.object() without
# delays via `patch.object(LocalThingsCoordinator, ...)` without # touching the production defaults.
# touching the production defaults these are computed from. Production
# code always sees these two values; only tests override them.
_SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s _SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S _OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
_RECONNECT_PAUSE_S: float = 5.0 _RECONNECT_PAUSE_S: float = 5.0
# A single reconnect is normal appliance-side behavior (see the # A single reconnect is normal appliance behavior (README's "Known
# README's "Known device behavior" section) -- Samsung's firmware # device behavior"); only escalate once they pile up in a trailing
# drops the DTLS session briefly every now and then, and the # window (issue #119). Can't be a literal 60s: consecutive attempts are
# coordinator recovering from that on its own isn't something a user # always >= one summary interval + _RECONNECT_PAUSE_S apart, so at most
# needs to see at WARNING. Only escalate once reconnects pile up # ~2 could ever land in 60s regardless of how unhealthy the connection
# within a trailing window (issue #119). # is. 300s/3 is reachable under normal polling and still a reasonable
# # "actually broken" proxy.
# The window can't be a literal 60s: consecutive reconnect attempts are
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
# window regardless of how unhealthy the connection is -- a threshold of
# 5 there could never fire, silently downgrading every reconnect
# (including a persistently broken one) to INFO forever. 300s/3 instead:
# reachable under normal polling, and 3 reconnects inside 5 minutes is
# still a reasonable proxy for the README's "actually broken" case.
_RECONNECT_WARN_WINDOW_S: float = 300.0 _RECONNECT_WARN_WINDOW_S: float = 300.0
_RECONNECT_WARN_THRESHOLD: int = 3 _RECONNECT_WARN_THRESHOLD: int = 3
# A block-level ACK timeout on the summary GET doesn't prove the # A block-level ACK timeout on the summary GET doesn't prove the session
# session is dead (see _poll_once) — require this many in a row # is dead (see _poll_once) -- require this many in a row before treating
# before treating it as one. A single slow transfer on an otherwise # it as one, so one slow transfer doesn't tear down a working OBSERVE
# fine session shouldn't tear down a working OBSERVE subscription. # subscription.
_POLL_TIMEOUT_LIMIT: int = 3 _POLL_TIMEOUT_LIMIT: int = 3
# Timeouts for the two network round trips a write triggers: the PUT # Named (not inline literals) so the write-settle window in
# itself (_do_put), then the confirming full /device/0 summary poll # async_send_command can be sized to outlast both round trips a write
# async_send_command requests right after (_poll_once). Named here # triggers: the PUT itself, then the confirming summary poll.
# (rather than left as inline literals) so the write-settle window
# below can be sized to always outlast both — see async_send_command.
_POST_TIMEOUT_S: float = 8.0 _POST_TIMEOUT_S: float = 8.0
_POLL_TIMEOUT_S: float = 35.0 _POLL_TIMEOUT_S: float = 35.0
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# Per-device logger (module logger scoped to this device's host) so # Per-device logger so every log line (including the base
# every log line — including the base DataUpdateCoordinator's own # coordinator's and ObserveManager's) identifies which device it's
# messages and ObserveManager's — identifies which device it's # about, instead of a shared module-level logger.
# about. A bare module-level logger is shared across every
# configured device, which makes multi-device logs ambiguous.
self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}") self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}")
super().__init__( super().__init__(
hass, hass,
@@ -170,59 +152,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None self._identity: DeviceIdentity | None = None
self._discovered = False self._discovered = False
self.bound = [] self.bound = []
# Sibling indoor subdevices discovered on this connection (issue # Sibling indoor subdevices on this connection (issue #177); set
# #177) -- candidates set once, at first discovery, by # once at first discovery, narrowed to the ones with live state (see
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the # subdevices.discover_partitioned). Never includes MAIN itself.
# ones that actually produced live primary state (see
# subdevices.discover_partitioned). MAIN itself is never in this list
# (see subdevices.canonical_view's docstring for why that's safe):
# it's the *other* subdevices sharing this DTLS session, if any.
self.subdevices: list[Subdevice] = [] self.subdevices: list[Subdevice] = []
# Candidates _run_discovery's gate rejected (an unused SmartThings # Candidates the liveness gate rejected (e.g. an unused SmartThings
# slot that still answers its seed, e.g. the issue #177 reporter's # slot that still answers its seed) -- surfaced in diagnostics.
# /device/2) -- surfaced in diagnostics alongside the materialized
# ones so a report shows what was found and why it didn't become an
# entity.
self._skipped_subdevices: list = [] self._skipped_subdevices: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics # Rejected candidates' raw reps, kept for diagnostics only (see
# only (see _live_subdevice_resources). They are deliberately not in the # _live_subdevice_resources) -- never applied to the state cache, or
# state cache: nothing polls them again, so anything applied there # they'd sit frozen at first-discovery value looking live.
# would sit frozen at its first-discovery value while looking as
# live as every other href in `last_resources`.
self._skipped_subdevice_resources: dict[str, dict] = {} self._skipped_subdevice_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain # /multidevice/vs/0's rep if this board answers it -- corroborates
# subdevice count that corroborates the liveness gate without deciding it. # the liveness gate without deciding it; kept outside `resources`.
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
self._multidevice: dict = {} self._multidevice: dict = {}
# What each subdevice probe found, keyed by the seed href attempted -- # What each subdevice probe found, keyed by seed href -- lets
# surfaced in diagnostics so a report can tell "checked, nothing # diagnostics distinguish "checked, nothing there" from "never
# there" apart from "never checked" (the same posture the # checked".
# speculative-probe code this replaced documented in identity.py).
self._subdevice_probes: dict[str, bool] = {} self._subdevice_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key). # canonical_resources() memo; invalidated in _on_cache_changed so
# Invalidated in _on_cache_changed -- climate.py reads this on every # climate.py's frequent per-property reads don't rebuild it from
# property access (is_legacy_board and friends), so it must not # scratch each time.
# rebuild an O(hrefs) view from scratch on every single property.
self._canonical_cache: dict[tuple[str, str], dict] = {} self._canonical_cache: dict[tuple[str, str], dict] = {}
self._cache = StateCache(_NoOpDescriptor()) self._cache = StateCache(_NoOpDescriptor())
self._cache.set_on_change(self._on_cache_changed) self._cache.set_on_change(self._on_cache_changed)
self._observe = ObserveManager(self._cache, logger=self._log) self._observe = ObserveManager(self._cache, logger=self._log)
self._push_pending = False self._push_pending = False
self._push_pending_lock = threading.Lock() self._push_pending_lock = threading.Lock()
# Identity comes from the config entry, resolved once by the config # Identity is resolved once by the config flow's probe (issue #236).
# flow's probe (issue #236). `device_serial` mints *permanent* # device_serial mints permanent registry keys, so it must be correct
# registry keys -- entity unique_ids (entity.py, sensor.py) and device # before the first entity registers -- a placeholder corrected once
# identifiers (device_info_for) -- so it must be the device's real # the first poll lands orphans the first device/entity pair instead.
# identity before the first entity registers, not a placeholder that # The host fallback covers a pre-migration entry and matches what
# gets corrected once the first poll lands. Anything registered # resolve_serial itself returns for a placeholder-serial board
# against a placeholder is keyed on it in the registry forever; when # (issues #83/#189).
# the real identity showed up moments later, HA created a second
# device and a second entity and orphaned the first pair.
#
# The host fallback covers a config entry created before this was
# stored and whose migration couldn't recover it. It is also what
# resolve_serial itself returns for a board reporting a placeholder
# serial (issues #83/#189), so the two agree by construction.
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST] self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
identifiers={(DOMAIN, self.device_serial)}, identifiers={(DOMAIN, self.device_serial)},
@@ -251,26 +214,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self._cache.snapshot() return self._cache.snapshot()
def resource(self, href: str) -> dict: def resource(self, href: str) -> dict:
"""A single href's current rep. Cheaper than `last_resources.get(href)` """A single href's rep. Cheaper than `last_resources.get(href)`,
for callers that only need one href — `last_resources` copies every which copies every tracked href to build the snapshot dict."""
tracked href's rep to build the snapshot dict, while this is a
direct O(1) cache lookup."""
return self._cache.get(href) or {} return self._cache.get(href) or {}
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]: def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s own view of the live snapshot, rewritten into the """`subdevice`'s view of the live snapshot, rewritten to canonical
canonical hrefs (issue #177) the registry/platforms are written hrefs (issue #177, see subdevices.canonical_view). Any platform
against -- see subdevices.canonical_view. A platform property that property that scans the whole resources dict (exists_fn,
needs the *whole* resources dict (as opposed to one href via is_legacy_board, ...) must use this instead of `last_resources`, or a
`resource()`/`last_resources.get(href)`) must use this instead of sibling subdevice's own `/mode/vs/1` could leak into MAIN's canonical
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak `/mode/vs/0` view. Memoized per cache generation -- see
into MAIN's canonical `/mode/vs/0` view (or vice versa) under _canonical_cache.
exists_fn/is_legacy_board-style checks that scan the whole dict.
Memoized per cache generation: climate.py calls this on every
property read (is_legacy_board and friends), and building it is
O(hrefs) -- _on_cache_changed clears the memo whenever the
snapshot actually changes, not on every property access.
""" """
view_key = (subdevice.kind, subdevice.key) view_key = (subdevice.kind, subdevice.key)
cached = self._canonical_cache.get(view_key) cached = self._canonical_cache.get(view_key)
@@ -281,18 +236,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return view return view
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo: def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice sharing this connection """DeviceInfo for one logical subdevice on this connection (issue
(issue #177) -- the master's own (unchanged) device_info for MAIN, or #177): the master's own device_info for MAIN, or a linked child
a linked child device for a discovered subdevice. device otherwise.
Identifiers derive from the *master's* serial (device_serial) plus Identifiers derive from the master's serial plus this subdevice's
this subdevice's stable key, never from whatever serial the stable key, never the subdevice's own reported serial -- deterministic
subdevice itself reports (or fails to) -- deterministic across across reconnects regardless of whether its identity resource
reconnects whether or not this subdevice's own identity resource answered yet. `serial_number` is set from it when present anyway,
(/information/vs/<n>, or /<id>/information/vs/0) answered on the but is informational only, not an identifier.
poll that first created the HA device. `serial_number` is set from
that resource when present anyway -- it's informational, not an
identifier.
""" """
if subdevice.kind == "main": if subdevice.kind == "main":
return self.device_info return self.device_info
@@ -304,12 +256,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if model: if model:
label = model.replace("_", " ").title() label = model.replace("_", " ").title()
else: else:
# This poll never got (or never will get) the subdevice's own # No identity resource yet (or ever) for this subdevice -- fall
# identity resource -- fall back to a generic per-subdevice label # back to a generic label. 'Subdevice <n>' only applies to an
# rather than leaving the device unnamed. 'Subdevice <n>' only # indexed subdevice; UUID-prefixed ones are never more than one
# makes sense for an indexed subdevice (the key is a small # per connection today.
# ordinal); UUID-prefixed subdevices are never more than one per
# connection today, so there's no ordinal to show.
label = ( label = (
f"Subdevice {subdevice.key}" f"Subdevice {subdevice.key}"
if subdevice.kind == "indexed" if subdevice.kind == "indexed"
@@ -392,25 +342,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def _poll_once(self) -> dict[str, dict]: def _poll_once(self) -> dict[str, dict]:
"""GET /device/0, return parsed resources. Blocking. """GET /device/0, return parsed resources. Blocking.
`sess.get()` raises `TimeoutError` when one block's ACK doesn't A `TimeoutError` here means one block's ACK didn't arrive in time --
arrive in time — the transfer was progressing (earlier blocks not that the session is dead (earlier blocks succeeded). Left open;
succeeded) and just didn't finish before the deadline on a slow `_async_update_data` decides whether repeated timeouts warrant a
device. That does NOT prove the session is dead, so it's left reconnect. Any other exception is unambiguous -- close immediately.
open here; `_async_update_data` decides whether repeated timeouts
(or a lack of them) warrant a reconnect. Anything else (a
`ConnectionError` from an explicitly closed/broken session, a bad
response code) is unambiguous — close immediately.
""" """
if self._session is None: if self._session is None:
self._connect_session() self._connect_session()
sess = self._session sess = self._session
assert sess is not None assert sess is not None
try: try:
# A slow device can still be mid-transfer (block 8, block 11) # 35s gives a slow blockwise transfer room to finish instead of
# when a tighter deadline cuts it off — that's a poll that # raising TimeoutError every cycle on an otherwise-fine device.
# would have succeeded, not a dead session. 35s gives a slow
# blockwise transfer room to actually finish instead of
# generating a TimeoutError every cycle.
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S) code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
except TimeoutError: except TimeoutError:
raise raise
@@ -425,21 +368,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e: except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e raise RuntimeError(f"poll cbor decode: {e}") from e
result = parse_device0_batch(body) if isinstance(body, list) else {} result = parse_device0_batch(body) if isinstance(body, list) else {}
# Refresh every already-enumerated sibling subdevice's seed collection # Refresh every enumerated sibling's seed on this same poll (issue
# on this same summary poll (issue #177) -- without this, a subdevice's # #177) so its state doesn't freeze at enumeration time.
# climate card would show only its enumeration-time snapshot forever.
for subdevice in self.subdevices: for subdevice in self.subdevices:
result.update(self._poll_subdevice_seed(subdevice)) result.update(self._poll_subdevice_seed(subdevice))
return result return result
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]: def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
"""GET one subdevice's seed Collection and return its batch, """GET one subdevice's seed Collection, normalized to real hrefs. A
normalized to real hrefs. A sibling failing to answer is a debug sibling failing to answer is a debug log, never a failed poll -- the
log, never a failed poll -- the master must not go unavailable issue #177 reporter's /device/2 (an unused SmartThings slot) may not
because a sibling timed out or dropped off (e.g. the issue #177 always respond, and the master must not go unavailable for that.
reporter's /device/2, a SmartThings-unused component that may not Blocking -- called from _poll_once, already in executor."""
always respond). Blocking -- called from _poll_once, already in
executor."""
sess = self._session sess = self._session
if sess is None: if sess is None:
return {} return {}
@@ -456,28 +396,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return {} return {}
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]: def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
"""Re-poll a flat-mode prefixed subdevice's hrefs individually """Re-poll a flat-mode subdevice's hrefs individually (issue #205) --
(issue #205) -- it has no Collection endpoint to batch-refresh it has no Collection endpoint to batch-refresh through (see
through (see enumerate_subdevices' fallback), so each canonical enumerate_subdevices' fallback), so each confirmed href gets its own
href confirmed at enumeration time gets its own GET under the GET under the subdevice's prefix. A failing href just drops out of
subdevice's prefix. A href failing to answer this cycle just drops the result, same posture as the Collection path above.
out of the result, same "never let a sibling's flakiness fail the
master's poll" posture as the Collection path above.
Takes `sess` from the caller (already None-checked there) rather Takes `sess` from the caller rather than re-reading self._session --
than re-reading self._session -- async_close() can null that async_close() can null it without holding _session_lock. Skips hrefs
without holding _session_lock, and pace()/get() both need a live already covered by the hot/warm sub-poll tiers, which
session on every iteration, not just the first. _run_subpolls refreshes every 3s/6s, strictly more current than
this once-per-summary-poll pass could offer."""
Skips any href already covered by the hot/warm sub-poll tiers
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
this method builds) -- those are already refreshed every 3s/6s by
_run_subpolls, strictly more current than this once-per-summary-poll
pass could offer, so re-fetching them here would only add GETs
without adding freshness. A subdevice with many confirmed hrefs
(unlike a Collection batch, which is always one GET regardless of
count) is otherwise a summary-poll cost that scales with its href
count."""
skip = set(self._hot_hrefs) | set(self._warm_hrefs) skip = set(self._hot_hrefs) | set(self._warm_hrefs)
result: dict[str, dict] = {} result: dict[str, dict] = {}
first = True first = True
@@ -531,14 +460,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _run_subpolls(self, force: bool = False) -> None: async def _run_subpolls(self, force: bool = False) -> None:
"""Poll hot/warm hrefs in the gaps between summary polls. Only """Poll hot/warm hrefs in the gaps between summary polls. No-op in
runs in poll-only mode — in observe-primary mode those hrefs are observe-primary mode (those hrefs are already covered by push)
already covered by push notifications — unless `force` is set, unless `force` is set -- set when this cycle's sweep found the
which this cycle's sweep found disagreeing with the cache on a cache disagreeing with a still-live observe session (see
still-live observe session (see log_sweep_discrepancies): a log_sweep_discrepancies): a bounded fallback for a channel gone
bounded, self-limiting fallback for a channel that's gone silent silent without a reconnect."""
without a reconnect, without tearing down subscriptions that
would otherwise recover on their own once notifies resume."""
if self._observe.mode == MODE_OBSERVE and not force: if self._observe.mode == MODE_OBSERVE and not force:
return return
hot = self._hot_hrefs hot = self._hot_hrefs
@@ -560,21 +487,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]: def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor subdevices """One-time (first discovery only) probe for sibling indoor
sharing this connection (issue #177) -- see subdevices on this connection (issue #177) -- see
registry.subdevices.enumerate_subdevices for the two detection registry.subdevices.enumerate_subdevices for the two detection
patterns. Blocking -- runs in executor, under the session lock patterns. Runs in executor, under the session lock.
(shares the same DTLS session _poll_once just used this cycle).
Sets self.subdevices to every *candidate* the probes turned up Sets self.subdevices to every candidate found and returns
(self._subdevice_probes as a side effect too) and returns `resources` `resources` merged with each candidate's seed, so this cycle's
merged with whatever each candidate's seed returned, so this cycle's _run_discovery sees every candidate without a second round trip.
_run_discovery sees every candidate's state without a second poll _run_discovery is what narrows this down to the ones actually live
round trip. `_run_discovery` is what narrows self.subdevices down to (see discover_partitioned) -- this method can't tell an unused
the ones that are actually live (see discover_partitioned) -- this SmartThings slot from a real sibling, only that something answered.
method doesn't know how to tell an unused SmartThings slot (the
issue #177 reporter's /device/2) from a real sibling, only that
something answered.
""" """
if self._session is None: if self._session is None:
self._connect_session() self._connect_session()
@@ -592,28 +515,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.subdevices = subdevices self.subdevices = subdevices
self._subdevice_probes = probes self._subdevice_probes = probes
# /multidevice/vs/0 is corroborating metadata, not appliance state, # /multidevice/vs/0 is corroborating metadata, not appliance state,
# and it is probed on *every* device -- so it must not join the # and is probed on every device -- it must not join `resources`, or
# returned resources dict. Two things go wrong if it does. It would # it would bind to nothing on families that don't ignore the href
# reach discovery on families whose registry doesn't ignore that # (raising a spurious coverage-gap repair) and freeze in the cache
# href (only the AC one does), binding to nothing and raising a # since it's never polled again (see _live_subdevice_resources).
# spurious "incomplete capability coverage" repair for every washer # Kept aside for diagnostics and the numofsubdevice cross-check in
# or fridge whose firmware happens to answer it. And it is fetched # _run_discovery instead.
# once here and never polled again, so applying it to the state
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop("/multidevice/vs/0", {}) self._multidevice = extra.pop("/multidevice/vs/0", {})
return {**resources, **extra} return {**resources, **extra}
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]: def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate subdevice the """`resources` minus every href belonging to a rejected subdevice
liveness gate rejected (issue #177). candidate (issue #177). Called once, between _run_discovery and the
first cache apply, so a rejected slot's reps are seen by the gate
Called once, between _run_discovery and the first cache apply, so a and then dropped rather than frozen into the cache forever. Kept in
rejected slot's reps are seen by the gate and then dropped rather _skipped_subdevice_resources for diagnostics.
than frozen into the cache forever -- see the call site. The reps
themselves are kept in _skipped_subdevice_resources for diagnostics,
which is the only thing that still wants them.
""" """
if not self._skipped_subdevices: if not self._skipped_subdevices:
return resources return resources
@@ -638,16 +554,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) -> None: ) -> None:
"""Write this device's resolved identity back onto the config entry. """Write this device's resolved identity back onto the config entry.
For an entry added by the current config flow this is a no-op -- the A no-op for an entry the current config flow already fully stored.
probe already stored all four. It matters for an entry migrated from Matters for an entry migrated from before identity was stored: the
before they were stored: the first poll is where its model and device first poll is where model/type become known, and persisting them
type become known, and persisting them means the *next* restart means the next restart names the device fully instead of renaming it
registers the device fully named before any entity exists, instead of again once a poll lands.
renaming it a second time once the poll lands.
Runs on the event loop (_run_discovery is called directly from Runs on the event loop, which async_update_entry requires.
_async_update_data, not in an executor), which async_update_entry
requires.
""" """
identity = { identity = {
CONF_SERIAL: serial, CONF_SERIAL: serial,
@@ -662,10 +575,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
def _run_discovery(self, resources: dict[str, dict]) -> None: def _run_discovery(self, resources: dict[str, dict]) -> None:
# Reported for diagnostics only -- it names the firmware generation # Diagnostics only -- names the firmware generation (e.g. '7.0 Air
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging # conditioner' is Tizen Lite); doesn't route, since every device
# an issue. It does not route: only a minority of hardware reports it # that reports it is already typed by modelNum.
# at all, and every device that does is already typed by its modelNum.
self.one_ui_version = ( self.one_ui_version = (
resources.get("/otninformation/vs/0", {}) resources.get("/otninformation/vs/0", {})
.get("swVersionInfo", {}) .get("swVersionInfo", {})
@@ -685,17 +597,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
description = info.get("x.com.samsung.da.description", "") description = info.get("x.com.samsung.da.description", "")
# Partitioned discovery (issue #177): the main pass binds every href # Partitioned discovery (issue #177): the main pass binds every href
# owned by no subdevice; one further pass per *candidate* subdevice # owned by no subdevice; one further pass per candidate subdevice
# binds its own canonical view, resolving its own device type from # binds its own canonical view (see subdevices.discover_partitioned),
# its own /information/vs/0 when it reports one and falling back to # gated on whether it actually produced live primary state (an
# the master's registry otherwise. See subdevices.discover_partitioned # unused SmartThings slot answers its seed but never does). A device
# -- it also gates each candidate down to whether it actually # with no candidates behaves exactly like the old single discover()
# produced live primary state (the issue #177 reporter's /device/2, # call.
# an unused SmartThings slot, answers its seed but never does), so
# self.subdevices below is narrowed to the ones that passed, not
# every candidate _enumerate_subdevices_blocking found. For a device
# with no candidates (self.subdevices == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned( bound, device_type_name, materialized, skipped = discover_partitioned(
resources, resources,
self.subdevices, self.subdevices,
@@ -715,13 +622,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
skip.subdevice.kind, skip.subdevice.kind,
list(skip.hrefs), list(skip.hrefs),
) )
# Corroborating signal, not a gate (DESIGN-177.md section 4): # Corroborating signal, not a gate: log, don't raise, on a
# /multidevice/vs/0's numofsubdevice is a plain count the issue # disagreement -- only one known board family exposes
# #177 reporter's board reports independently of the liveness gate # numofsubdevice at all, so a mismatch is a triage signal, not proof
# above. Log, don't raise, on a disagreement -- only this one board # either side is wrong.
# family is known to expose the resource at all, so a mismatch is a
# "look into this" signal for triage, not proof either side is
# wrong.
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice") numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
if numofsubdevice is not None: if numofsubdevice is not None:
try: try:
@@ -739,11 +643,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if device_type_name is not None: if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num) self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else: else:
# All three: detection reads each of them (oic device type, then # modelNum alone doesn't identify every type, and device_types
# board token, then consumer-model code), and this line is what a # is often empty even on hardware we don't map yet -- log all
# user pastes into an issue -- modelNum alone doesn't identify a # three so a user can paste this into an issue.
# washer or dryer, and device_types is often empty even when
# populated hardware exists for a type we don't map yet.
self._log.warning( self._log.warning(
"unknown device type modelNum=%r description=%r device_types=%r; using common caps", "unknown device type modelNum=%r description=%r device_types=%r; using common caps",
model_num, model_num,
@@ -754,20 +656,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.bound = bound self.bound = bound
self._unbound_hrefs = unbound self._unbound_hrefs = unbound
# The identity the entry was registered under wins. This poll's own # The entry's stored identity wins; this poll's answer is only
# answer is only adopted when the entry has nothing stored -- a legacy # adopted when nothing is stored (a legacy migration couldn't
# entry whose migration couldn't recover a serial -- and is then # recover it), then written back. Re-keying an entry with existing
# written back so it stops changing. Re-keying a device that already # registry entries orphans them (issue #236).
# has registry entries is what issue #236 is about: the old keys don't
# follow, they orphan.
polled_serial = resolve_serial( polled_serial = resolve_serial(
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST] info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
) )
serial = self._entry.data.get(CONF_SERIAL) or polled_serial serial = self._entry.data.get(CONF_SERIAL) or polled_serial
if serial != polled_serial: if serial != polled_serial:
# Same IP, different appliance (or a firmware that changed what it # Same IP, different appliance (or firmware that changed what it
# reports). Keeping the stored identity is the safe half of that; # reports) -- keep the registered identity; re-adding is the
# re-adding the device is the user's call. # user's call.
self._log.warning( self._log.warning(
"device at %s reports serial %r but this entry is registered " "device at %s reports serial %r but this entry is registered "
"as %r; keeping the registered identity", "as %r; keeping the registered identity",
@@ -810,12 +710,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
unbound_hrefs: list[str], unbound_hrefs: list[str],
device_name: str, device_name: str,
) -> None: ) -> None:
"""Raise or clear a Repairs issue when capability coverage is incomplete. """Raise or clear a Repairs issue when capability coverage is
incomplete -- unrecognized device type or unbound resources.
Fires once, at discovery time, either because the device type itself Diagnostics (diagnostics.py) is what a user downloads to help; this
wasn't recognized or because some of its resources didn't bind to just tells them there's something to send.
any capability. Diagnostics (diagnostics.py) is what a user actually
downloads to help; this just tells them there's something to send.
""" """
issue_id = f"device_gap_{self._entry.entry_id}" issue_id = f"device_gap_{self._entry.entry_id}"
if unknown_type or unbound_hrefs: if unknown_type or unbound_hrefs:
@@ -839,8 +737,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not hrefs: if not hrefs:
return return
if self._session is None: if self._session is None:
# _poll_once already connects on a real poll; this only fires # _poll_once already connects on a real poll; only fires if the
# if the session was closed out from under us concurrently. # session was closed out from under us concurrently.
await self.hass.async_add_executor_job(self._connect_session) await self.hass.async_add_executor_job(self._connect_session)
sess = self._session sess = self._session
if sess is None: if sess is None:
@@ -863,39 +761,25 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""True if this poll failure should NOT trigger a reconnect this """True if this poll failure should NOT trigger a reconnect this
cycle. cycle.
A `TimeoutError` (see `_poll_once`) means one block's ACK didn't A `TimeoutError` means one block's ACK was late, not that the
arrive in time — not that the session is dead. A recent OBSERVE session is dead (see `_poll_once`). A recent OBSERVE notify is proof
notify is direct proof the channel is still live, so always defer the channel is live, so always defer then. Otherwise defer until
in that case. Otherwise, defer until `_POLL_TIMEOUT_LIMIT` `_POLL_TIMEOUT_LIMIT` consecutive timeouts pile up. Any other
consecutive timeouts have piled up — a single slow transfer is exception reconnects immediately.
normal on a flaky device; a run of them is a real problem. Any
other exception (a `ConnectionError`, an explicitly closed
session) is unambiguous and always reconnects immediately.
Never defers before the first successful discovery (issue #254). Never defers before first discovery (issue #254): deferring returns
Deferring is a *mid-session* judgement call — "keep the entities we an empty dict, which the base coordinator treats as a successful
already have and try again next cycle" — which is only coherent once first refresh -- and since platforms enumerate `bound` once, the
there are entities to keep. Pre-discovery the same exception type entry would load with zero entities and stay that way.
means something else entirely: `_poll_once` calls `_connect_session`,
so `connect()`'s own handshake timeout surfaces here as a
`TimeoutError` too, and that is a dead connection, not a slow
transfer. Deferring it returned an empty dict instead of raising,
which `DataUpdateCoordinator` counts as a successful first refresh —
and since platforms enumerate `bound` exactly once, the entry loaded
with zero entities and stayed that way until a manual reload.
""" """
if not self._discovered: if not self._discovered:
return False return False
if not isinstance(e, TimeoutError): if not isinstance(e, TimeoutError):
return False return False
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified(): if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
# Recent push is proof of life — reset the counter too, so # Recent push is proof of life -- reset the counter too, so
# timeouts from an earlier quiet stretch don't carry over and # timeouts from an earlier quiet stretch don't carry over and
# trigger a reconnect once the device goes quiet again. The # trigger a false reconnect once the device goes quiet again.
# counter should mean "consecutive timeouts with no push
# activity to vouch for the session," not just "consecutive
# timeouts" — otherwise an intermittently-active device could
# still accumulate its way into a false reconnect.
self._consecutive_poll_timeouts = 0 self._consecutive_poll_timeouts = 0
return True return True
self._consecutive_poll_timeouts += 1 self._consecutive_poll_timeouts += 1
@@ -936,11 +820,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) )
return flatten(self.bound, self._cache.snapshot()) return flatten(self.bound, self._cache.snapshot())
self._consecutive_poll_timeouts = 0 self._consecutive_poll_timeouts = 0
# One reconnect attempt — pause briefly so the device can # A lone reconnect is routine (README's "Known device
# clean up its DTLS session state before we knock again. # behavior"); only warn once they pile up. Pause first so
# A lone reconnect is routine (see the README's "Known # the device can clean up its DTLS state before we knock
# device behavior" section); only warn once they're piling # again.
# up within the trailing window.
if self._reconnect_is_frequent(): if self._reconnect_is_frequent():
self._log.warning("poll failed, reconnecting: %s", e) self._log.warning("poll failed, reconnecting: %s", e)
else: else:
@@ -952,29 +835,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e2: except Exception as e2:
self._log.error("poll failed after reconnect: %s", e2) self._log.error("poll failed after reconnect: %s", e2)
snapshot = self._cache.snapshot() snapshot = self._cache.snapshot()
# `self._discovered` is the same precondition # Same precondition as _defer_reconnect_for (issue #254):
# `_defer_reconnect_for` applies (issue #254): returning # degraded-but-successful data only makes sense once
# degraded-but-successful data is only meaningful once # there are bound entities to carry it.
# there are bound entities to carry it. Pre-discovery the
# cache happens to always be empty -- every apply() site
# is gated on post-discovery state -- so this arm is
# unreachable then, but that is a non-local accident
# across four call sites, not something to rely on.
if self._discovered and snapshot: if self._discovered and snapshot:
self._log.debug("Full error:", exc_info=e2) self._log.debug("Full error:", exc_info=e2)
return flatten(self.bound, snapshot) return flatten(self.bound, snapshot)
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2 raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
else: else:
# The reconnect gave us a brand-new session with zero # A fresh session has zero OBSERVE registrations; if we
# OBSERVE registrations. If we were in observe mode, # were in observe mode that state is now stale. Tear it
# that state is now stale — the refresh task is still # down and resubscribe immediately below instead of
# pinned to the old (closed) session and nothing will # waiting for the poll-mode retry timer, which exists to
# ever re-subscribe on the new one. Tear it down and # throttle devices that never had observe working at all
# try to resubscribe immediately below rather than # -- a reconnect just proved this session is healthy.
# waiting for the poll-mode retry timer — that timer
# exists to throttle devices that never had observe
# working at all, but a reconnect just proved this
# session is healthy, so there's no reason to wait.
if self._observe.mode == MODE_OBSERVE: if self._observe.mode == MODE_OBSERVE:
self._log.debug( self._log.debug(
"reconnect while in observe mode; downgrading to " "reconnect while in observe mode; downgrading to "
@@ -984,13 +858,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
just_downgraded_from_observe = True just_downgraded_from_observe = True
if not self._discovered: if not self._discovered:
# One-time (issue #177): find out whether this connection has # One-time (issue #177): find sibling subdevices before the
# sibling indoor subdevices before the first discovery pass, and # first discovery pass, folding their seed resources into this
# fold their seed resources into this cycle's snapshot so # cycle's snapshot so discovery sees every subdevice on the
# discovery sees every subdevice's state on the very first poll # first poll rather than waiting a cycle.
# rather than waiting a cycle. Runs under its own session-lock
# scope (the poll above already released the lock) since it
# shares the same DTLS session.
async with self._session_lock: async with self._session_lock:
resources = await self.hass.async_add_executor_job( resources = await self.hass.async_add_executor_job(
self._enumerate_subdevices_blocking, resources self._enumerate_subdevices_blocking, resources
@@ -999,32 +870,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
source = "sweep" if self._discovered else "poll" source = "sweep" if self._discovered else "poll"
first_cycle = not self._discovered first_cycle = not self._discovered
if first_cycle: if first_cycle:
# Discovery runs *before* the apply loop below, not after it, so # Discovery runs before the apply loop so a rejected candidate's
# a rejected candidate's resources never reach the state cache # resources never reach the state cache (issue #177) --
# at all (issue #177). Enumeration has to fetch every candidate's # StateCache has no eviction, so the only way to keep them out
# seed to evaluate the liveness gate, but only the subdevices that # is to not put them in. Safe to reorder: _run_discovery reads
# pass it are ever polled again -- applying the rest would freeze # the passed dict, never the cache.
# ~14 hrefs per rejected slot into the cache on this one cycle
# and leave them there forever, indistinguishable from live
# state in `last_resources` and in the diagnostics dump built
# from it. StateCache has no eviction, so the only way to keep
# them out is to not put them in. Safe to reorder: _run_discovery
# reads the dict passed to it and never the cache, and
# log_sweep_discrepancies below can't fire on a first cycle
# (observe mode is only ever attempted after discovery).
self._run_discovery(resources) self._run_discovery(resources)
resources = self._live_subdevice_resources(resources) resources = self._live_subdevice_resources(resources)
sweep_mismatch = False sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE: if self._observe.mode == MODE_OBSERVE:
# A sweep/cache mismatch never tears down a still-live OBSERVE # A mismatch never tears down a still-live OBSERVE session (see
# session (see log_sweep_discrepancies) — the sweep below # log_sweep_discrepancies) -- the sweep below re-applies
# re-applies the authoritative state to the cache regardless, # authoritative state regardless. It only triggers extra
# so there's nothing to correct by downgrading. Only a # hot/warm subpolls this cycle so a channel gone silent without
# reconnect (above) proves subscriptions are actually gone. # a reconnect still gets fresher-than-30s data.
# Instead, a mismatch triggers extra hot/warm subpolls this
# cycle below, so a channel gone silent without a reconnect
# (e.g. lost internet on an otherwise-live local session)
# still gets fresher-than-30s data.
sweep_mismatch = self._observe.log_sweep_discrepancies(resources) sweep_mismatch = self._observe.log_sweep_discrepancies(resources)
for href, rep in resources.items(): for href, rep in resources.items():
self._observe.apply(href, rep, source=source) self._observe.apply(href, rep, source=source)
@@ -1034,15 +893,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
elif self._observe.mode == MODE_POLL: elif self._observe.mode == MODE_POLL:
await self._maybe_retry_observe_mode() await self._maybe_retry_observe_mode()
# Schedule sub-polls for hot/warm hrefs between summary polls # Background task, not async_create_task: self-limiting (cancelled
# (no-op in observe-primary mode unless this cycle's sweep found a # and recreated every cycle, see above) and owned entirely by the
# mismatch; _run_subpolls checks the mode/force). A background task, # coordinator, so it shouldn't be tied into HA's startup/shutdown
# not async_create_task: this loop is self-limiting (cancelled and # sequencing -- a subpoll in flight (up to ~27s) would delay both
# recreated every refresh cycle, see the cancel() above) and owned # (issue #207).
# entirely by the coordinator, so it has no business being tracked by
# HA's own startup/shutdown sequencing -- async_create_task ties it
# in regardless, so a subpoll cycle in flight (up to ~27s,
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
if self._hot_hrefs or self._warm_hrefs: if self._hot_hrefs or self._warm_hrefs:
self._subpoll_task = self.hass.async_create_background_task( self._subpoll_task = self.hass.async_create_background_task(
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll" self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
@@ -1055,21 +910,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None: async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style. """Write a value to the device. Fire-and-forget.
A description-level validate_fn (currently SwitchDesc only) runs A description-level validate_fn (SwitchDesc only, currently) rejects
here rather than per-platform, so rejecting a write with a a write with a user-facing message ahead of write_fn's silent
user-facing message -- as opposed to write_fn's silent no-op below no-op. The remote-control check runs first, unconditionally, unless
-- is available to every platform for free. The remote-control the user opted out via CONF_BYPASS_REMOTE_CONTROL (issue #54: some
check runs first and applies to every platform unconditionally, devices accept some writes even while reporting remote control off)
ahead of any description-specific validate_fn -- unless the user has or the laundry firmware declares itself writable without Smart
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue Control."""
#54: some devices accept certain writes, e.g. a washer's default
dosing levels, even while reporting remote control off, so the
block's assumption doesn't hold for every model), or the laundry
firmware flag isModelSettingWithoutSC declares settings writable
without Smart Control (cycle start/pause/stop on /operational/state
still require it)."""
desc = bound_entity.desc desc = bound_entity.desc
write_fn = getattr(desc, "write_fn", None) write_fn = getattr(desc, "write_fn", None)
if write_fn is None: if write_fn is None:
@@ -1104,79 +953,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return return
path_segs, body = result path_segs, body = result
# The write's actual target, not necessarily bound_entity.href. Most # The write's actual target, not necessarily bound_entity.href -- a
# descriptors write to the same resource they're bound to, but a # composite entity (the AC's ClimateDesc) drives writes to sibling
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 -- # resources via path_segs (see airconditioner._climate_write).
# drives writes to several sibling resources via path_segs # Applying the optimistic value to bound_entity.href instead caused
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that # the 20-60s lag in issues #17/#53: the wrong resource got the
# write_fn picks per payload (see airconditioner._climate_write). # optimistic merge while the one HA actually displays from never
# Applying the optimistic value and settle guard below to # did.
# bound_entity.href instead of this target protected the wrong
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
# merge while the resource the climate entity actually displays from
# (e.g. /power/0) never got one, so HA kept showing the pre-write
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
# #
# write_fn's path_segs are canonical (issue #177) -- a subdevice's # path_segs are canonical (issue #177); translate through this
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or # entity's own subdevice so a subdevice's actual href (e.g.
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical # /mode/vs/1) is targeted instead -- identity transform for MAIN.
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
# bound entity's own subdevice so the optimistic apply, the settle
# guard and the POST below all target that subdevice's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# subdevices writes exactly where it always did.
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs)) write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
path_segs = [s for s in write_href.strip("/").split("/") if s] path_segs = [s for s in write_href.strip("/").split("/") if s]
# Apply the write optimistically before starting the settle guard, # Apply optimistically before starting the settle guard -- guard and
# not after -- mark_write_pending gates every source (poll, sweep, # apply share the same gate (mark_write_pending), so reversing the
# observe) through the same apply(), itself included, so flipping # order would drop the very update it exists to protect (issue #27).
# this order would have the guard drop the one update it exists to
# protect. Without an optimistic value in the cache for it to hold
# onto, the settle window was just delaying the real device
# confirmation for a few seconds on every write, which read exactly
# like the write being silently reverted (issue #27).
# #
# settle_s must outlast the PUT and the async_request_refresh() # settle_s must outlast the PUT plus the confirming refresh, not
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds -- # DEFAULT_SETTLE_S's fixed few seconds: the refresh is a full
# that refresh is a full /device/0 summary poll, which # summary poll that can legitimately take tens of seconds (see
# _POLL_TIMEOUT_S itself admits can legitimately take tens of # _poll_once), and some writes (issue #9's washer course/detergent/
# seconds on these devices (see _poll_once), and some writes settle # softener selection) settle on-device well after that. A short
# on the device itself well after that: issue #9's washer packs # fixed window let a stale confirm poll land unprotected and revert
# cycle/detergent/softener selection into the same /course/vs/0 # the optimistic value, read by users as the write "reverting, then
# options[] array, and picking a new value there visibly needs a # re-applying" itself a few seconds later. Releasing the guard early
# few seconds of internal validation/dispenser movement before the # (right after the first confirming refresh) was tried and reverted
# device's own state agrees -- while /washer/vs/0's temperature/ # for the same reason, plus races on overlapping writes to the same
# spin fields (plain flags, no device-side settling) confirm # href.
# instantly on the same device. A short fixed window expired while
# the confirm poll was still in flight (or before the device had
# caught up internally), so that stale read landed unprotected and
# reverted the optimistic value, self-correcting again only once a
# later poll finally saw the real change -- read by the user as the
# write "reverting, then re-applying itself" a few seconds later.
# #
# An earlier attempt at this also released the guard early, right # write_fn bodies touching options/items now carry only the changed
# after the confirming refresh completed, to avoid shutting out # token(s) (issue #54), not the whole array -- but apply()'s
# unrelated real updates (another automation, the physical remote) # field-level merge doesn't know that and would wipe every sibling
# for the rest of settle_s. That was reverted: releasing the guard # option/item for the settle window. Pre-merge here the way the
# the moment one round trip finishes doesn't mean the device has # device does, so the optimistic cache entry stays complete; the
# actually caught up (exactly the slow-settling case above), and it # wire `body` stays minimal.
# introduced its own races around overlapping writes to the same
# href. Simpler and safer to just hold the guard for the full,
# generously-sized window and let it expire on its own.
# write_fn bodies that touch x.com.samsung.da.options or
# x.com.samsung.da.items carry only the changed token(s)/item now
# (issue #54 for options; the AC vendor temperature write for items --
# confirmed sufficient on the wire, the device merges the rest itself),
# not the whole packed array. observe.apply()'s field-level
# {**cached, **rep} merge doesn't know that -- handed the bare
# partial value, it would replace the cached field outright and wipe
# every sibling option/item for the rest of the settle window.
# Pre-merge it here the same way the device does, so the optimistic
# cache entry stays complete; the minimal `body` below is still
# exactly what goes out over the wire.
optimistic_body = body optimistic_body = body
new_options = body.get("x.com.samsung.da.options") new_options = body.get("x.com.samsung.da.options")
if isinstance(new_options, list): if isinstance(new_options, list):
@@ -1185,9 +997,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
**optimistic_body, **optimistic_body,
"x.com.samsung.da.options": merge_options_field(cached_options, new_options), "x.com.samsung.da.options": merge_options_field(cached_options, new_options),
} }
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor # Same fact, items[] shape (see airconditioner._climate_write's
# temperature write, which now carries only {id, desired} -- see that # vendor temperature write).
# module for the write-side half of this).
new_items = body.get("x.com.samsung.da.items") new_items = body.get("x.com.samsung.da.items")
if isinstance(new_items, list): if isinstance(new_items, list):
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items") cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
@@ -1216,11 +1027,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Debug raw write (issue #54): a power-user escape hatch for the # Debug raw write (issue #54): a power-user escape hatch for the
# options-flow debug panel, letting a user POST an arbitrary partial # options-flow debug panel to POST an arbitrary partial body without a
# body to an arbitrary href to pin down device-specific write behavior # new release. Deliberately bypasses the remote-control block and all
# without waiting on a new release. Deliberately bypasses the # write_fn/validate_fn above -- use with care.
# remote-control block and every write_fn/validate_fn above -- that's
# the whole point, so use with care.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]: def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
@@ -1247,13 +1056,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return code, new_rep return code, new_rep
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]: async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
"""Debug-only arbitrary write (issue #54). Bypasses the """Debug-only arbitrary write (issue #54). Bypasses remote-control
remote-control block and all write_fn/validate_fn logic; sends and write_fn/validate_fn; sends `body` verbatim as a partial-rep
`body` verbatim as a partial-rep PATCH to `href`. Returns PATCH to `href`. Returns (coap_code, new_rep) read back right
(coap_code, new_rep) where new_rep is the href's value read back after."""
right after the write. Used by the options-flow debug panel to
help users pin down device-specific write behavior without a new
release."""
if not isinstance(body, dict) or not body: if not isinstance(body, dict) or not body:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@@ -1270,7 +1076,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
code, new_rep = await self.hass.async_add_executor_job( code, new_rep = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, body, norm_href self._raw_write_blocking, path_segs, body, norm_href
) )
# Hasten a full summary poll so entities on other resources catch # Hasten a summary poll so entities on other resources catch up
# up too -- a debug write can affect siblings, not just its href. # too -- a debug write can affect siblings, not just its href.
await self.async_request_refresh() await self.async_request_refresh()
return code, new_rep return code, new_rep
+34 -64
View File
@@ -35,31 +35,24 @@ async def async_get_config_entry_diagnostics(
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured # /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt` # below, so they'd otherwise never reach an issue report. /oic/d's `rt`
# is OCF's standard device-type declaration; /oic/res is OCF's # is OCF's device-type declaration; /oic/res is OCF's discovery
# discovery endpoint, listing every href/Collection the connection # endpoint, relevant to the "Composite Device" model (issue #177). See
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical device exposes more than one logical subdevice. See
# registry/identity.py. # registry/identity.py.
identity = coordinator._identity identity = coordinator._identity
def _seed_diag(su) -> dict: def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0 # A flat-mode subdevice (issue #205: no working /<uuid>/device/0
# Collection, so its state comes from individually-polled hrefs # Collection, state comes from individually-polled hrefs instead)
# instead) has no meaningful seed_path; report the flat_hrefs list # has no meaningful seed_path; report flat_hrefs in its place.
# in its place rather than the misleading bare "/" a joined empty
# tuple would otherwise produce.
return { return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None, "seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs), "flat_hrefs": list(su.flat_hrefs),
} }
def _subdevice_diag(su) -> dict: def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and # `model` reads modelNum off the already-redacted `resources` rather
# the distinct hrefs), and one redaction of this subdevice's canonical # than redacting /information/vs/0 again -- modelNum never matches
# view -- `model` reads modelNum off the already-redacted `resources` # redact.py's substring rules, so the value is the same either way.
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
matching = [b for b in coordinator.bound if b.subdevice == su] matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su)) res = redact_resources(coordinator.canonical_resources(su))
return { return {
@@ -69,13 +62,10 @@ async def async_get_config_entry_diagnostics(
"bound_entity_count": len(matching), "bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}), "hrefs": sorted({b.href for b in matching}),
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""), "model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
# Keyed by this subdevice's *canonical* hrefs, not the real ones # Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or # the real ones it answers on ('/mode/vs/1', '/<uuid>/mode/vs/0')
# '/<uuid>/mode/vs/0'. That's the form the registry and every # -- the form the registry is written against, so a sibling's
# capability are written against, so a sibling's block can be # block reads exactly like the master's `resources` below.
# read (or pasted into the skill's standalone-discovery
# recipe) exactly like the master's `resources` above,
# instead of having to be de-indexed by hand first.
"resources": res, "resources": res,
} }
@@ -91,49 +81,34 @@ async def async_get_config_entry_diagnostics(
if identity is not None if identity is not None
else None, else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs), "unbound_hrefs": sorted(coordinator._unbound_hrefs),
# This subdevice's own resources, and only this subdevice's -- what # This subdevice's own resources, and only this subdevice's. On a
# the module docstring and the adding-device-support skill have # composite device (issue #177) `last_resources` is the union
# always described it as ("the parsed /device/0 snapshot"). On a # across every live subdevice keyed by real hrefs, so reporting it
# composite device (issue #177) `last_resources` is the union across # raw here would mix a sibling's /mode/vs/1 with the master's
# every live subdevice keyed by real hrefs, so reporting it raw here # /mode/vs/0 under no attribution. Each sibling reports its own
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0 # resources in `subdevices` below instead. For a device with no
# under no attribution at all. Each sibling reports its own # subdevices, this is byte-identical to `last_resources`.
# resources in its own `subdevices` entry below instead. For a
# device with no subdevices -- almost every device -- this is
# byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)), "resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue # Sibling indoor subdevices discovered on this connection (issue
# #177) -- per-subdevice kind/key/seed path plus what actually bound # #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# to it, so a report shows whether a composite device's subdevice # from) is deliberately NOT redacted here, unlike elsewhere in
# was found at all and what it resolved to. subdeviceIdList (the # `resources` -- it's an appliance-internal pairing id, not account
# UUID a prefixed subdevice's key comes from) is deliberately NOT # data, and reporting it is what makes this block actionable.
# redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices], "subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
# Candidates that answered their seed but that discover_partitioned's # Candidates that answered their seed but that discover_partitioned's
# entity-level liveness gate rejected -- an unused SmartThings slot # liveness gate rejected -- an unused SmartThings slot, not a real
# (the issue #177 reporter's /device/2) that still answers a # second subdevice. Reported alongside subdevices above so a report
# same-shaped batch, not a real second subdevice. Reported alongside # shows what was found and why it didn't become an entity.
# subdevices above so a report shows what was found *and* why it
# didn't become an entity, not just silence where a third climate
# card might otherwise be expected.
"subdevices_skipped": [ "subdevices_skipped": [
{ {
"kind": skip.subdevice.kind, "kind": skip.subdevice.kind,
"key": skip.subdevice.key, "key": skip.subdevice.key,
**_seed_diag(skip.subdevice), **_seed_diag(skip.subdevice),
"hrefs": list(skip.hrefs), "hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized # The reps the liveness gate actually judged -- the one
# like the materialized subdevices above. These are the one # thing a reader needs to second-guess a skip, and they
# thing a reader needs to second-guess a skip ("is my second # exist nowhere else in this dump: a rejected candidate is
# subdevice really absent, or did the gate get it wrong?"), # never polled again or entered into the state cache.
# and they exist nowhere else in this dump: a rejected
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources( "resources": redact_resources(
{ {
canon: rep canon: rep
@@ -146,17 +121,12 @@ async def async_get_config_entry_diagnostics(
], ],
# What each enumeration probe returned ({} vs a batch), keyed by the # What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing # seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative # there" from "never checked".
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subdevices.py.
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())), "subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it). # /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is # Reported on its own, not inside `resources`, since it's metadata
# metadata about the connection rather than state of any one # about the connection rather than one subdevice's state, and
# subdevice -- and because nothing polls it after discovery, so it # nothing polls it after discovery so it would go stale in there.
# would go stale in there. Its numofsubdevice count is what
# independently corroborates the subdevices/subdevices_skipped split
# above.
"multidevice": redact_resources(coordinator._multidevice), "multidevice": redact_resources(coordinator._multidevice),
"integration_version": integration.version, "integration_version": integration.version,
"smartthings_local_version": stl_version, "smartthings_local_version": stl_version,
+30 -44
View File
@@ -18,28 +18,23 @@ from .registry.discovery import BoundEntity, _snake_to_title
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool: def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
"""Return False if the entity should not be registered for this device. """Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a field, Explicit exists_fn takes priority. Otherwise, if the entity has a
require that field to be present in the resource rep so that optional field, require that field to be present in the resource rep so that
fields on shared resources don't create phantom entities. optional fields on shared resources don't create phantom entities.
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched A stub rep (is_stub_rep) is included anyway so it can be populated by
yet" marker) is included anyway so it can be populated by sub-polls. A sub-polls. A genuinely empty {} rep is included too by this default
genuinely empty {} rep is included too by this default gate -- whether gate: whether empty means "not populated yet" or "permanently
empty means "not populated yet" or "permanently unsupported" needs unsupported" needs per-field domain knowledge this generic gate
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
{} is fridge.py's documented *normal* no-alarm state (see no-alarm state, not an absence signal). Only a capability whose author
_active_alarm_codes), not an absence signal, and it's far from the only has verified a field is genuinely never populated opts into stricter
resource like that. Only a capability whose author has actually verified gating with its own exists_fn (see common.ENERGY_METER, issue #127).
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.
`bound.href` is already the *actual* href (issue #177 -- see `bound.href` is already the actual href (issue #177); `exists_fn` gets
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is; `bound`'s own subdevice's canonical view instead of the raw snapshot,
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the same rule as everywhere else a whole-resources-dict scan happens --
raw snapshot, same rule as everywhere else a whole-resources-dict scan this is a free function, so it can't use self._resources.
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
""" """
rep = coordinator.last_resources.get(bound.href) rep = coordinator.last_resources.get(bound.href)
if rep is None: if rep is None:
@@ -97,12 +92,10 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"instance_name": _instance_display_name(bound, self._state_key) "instance_name": _instance_display_name(bound, self._state_key)
} }
# _attr_name is deliberately left unset: Home Assistant gives an # _attr_name is deliberately left unset: HA gives an explicitly-set
# explicitly-set name precedence over the translation catalog, so # name precedence over the translation catalog, so setting it here
# setting it here would make every entity untranslatable. Every # would make every entity untranslatable. A platform that wants the
# descriptor resolves to a catalog entry (see translation_key below); # bare device name sets _attr_name = None itself (see fan.py).
# a platform that wants the bare device name instead sets
# _attr_name = None itself, as fan.py does for the hood's main entity.
self._attr_icon = bound.desc.icon self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
@@ -112,18 +105,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
def translation_key(self) -> str | None: def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`. """The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key (a property upstream, not a plain Overrides Entity.translation_key so a callable descriptor (e.g.
attribute) so a callable descriptor -- e.g. laundry.cycle_select's laundry.cycle_select's table-id-gated resolver) is re-evaluated
table-id-gated resolver -- is re-evaluated against live coordinator against live coordinator data on every access, not resolved once
data on every access, not resolved once at construction time. at construction time -- a static resolution would risk baking in
a permanent None if the first poll handed a sibling an empty stub
Discovery runs on the first /device/0 poll, which the entity rep (see _is_included's docstring) before it populated.
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
""" """
tk = self._bound.desc.translation_key tk = self._bound.desc.translation_key
if callable(tk): if callable(tk):
@@ -133,12 +120,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@property @property
def _resources(self) -> dict: def _resources(self) -> dict:
"""This entity's own subdevice's canonical resources view (issue """This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform #177) -- see coordinator.canonical_resources. Any platform property
property that needs the *whole* resources dict, as opposed to one needing the whole resources dict, not one href via
href via `coordinator.resource(href)`, must read through this `coordinator.resource(href)`, must read through this instead of
instead of `coordinator.last_resources`, or a sibling subdevice's own `coordinator.last_resources`, or a sibling subdevice's own hrefs
actual hrefs would leak into (or be missing from) this entity's could leak into this entity's view. For MAIN this is exactly
view. For MAIN (every device with no subdevices) this is exactly
`coordinator.last_resources`.""" `coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.subdevice) return self.coordinator.canonical_resources(self._bound.subdevice)
+34 -52
View File
@@ -4,15 +4,14 @@ Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
below since each needs different HA fan semantics: the range hood's fan 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/ 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 Medium/High (issue #56) are both an ordered set of numeric levels
(SET_SPEED) -- the latter confirmed monotonic in capabilities/ (SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module
air_purifier.py's module docstring, with no named-mode list to preserve docstring, with no named-mode list since this board never self-reports one.
since this board never self-reports one. The TP1X air-purifier family's The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21 #130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151)
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors are both named behaviors with no linear order (PRESET_MODE) --
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles LocalThingsAirPurifierFan handles both hrefs, the only difference being
both hrefs, the only difference being whether the label comes straight whether the label comes from supportedModes or a parallel modesName array
from supportedModes or from a parallel modesName array (see (see _label_for_code)."""
_label_for_code)."""
from __future__ import annotations from __future__ import annotations
@@ -76,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
Some boards that reuse this capability (built-in microwave vent fans, Some boards that reuse this capability (built-in microwave vent fans,
issues #137/#142) report no sibling `/power/0` or `/power/vs/0` 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 resource at all -- fan speed 0 is itself the off state there.
separate power toggle to write. `_speed_zero_is_off` detects that `_speed_zero_is_off` detects that shape and switches every method
shape from the hood resource's own settableMinFanSpeed/ below to drive off/on purely through the fanSpeed field.
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`, Deliberately not the same question as `_has_separate_power`, which
which only proves *some* power resource exists on the device -- on a only proves some power resource exists on the device -- on a combi
combi appliance (e.g. an over-the-range microwave) that resource can appliance that resource can belong to the cavity, not the vent fan,
belong to the cavity, not the vent fan, and toggling it from here and toggling it from here would turn off the whole appliance.
would turn off the whole appliance instead of just the fan.
""" """
_enable_turn_on_off_backwards_compatibility = False _enable_turn_on_off_backwards_compatibility = False
@@ -112,10 +106,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _speed_zero_is_off(self) -> bool: def _speed_zero_is_off(self) -> bool:
"""Whether fan speed '0' is itself this hood's off step, with no """Whether fan speed '0' is itself this hood's off step, with no
separate power resource to toggle. The board says so directly: separate power resource to toggle -- settableMinFanSpeed '0', or
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The '0' inside supportedFanSpeed. False for the standalone hood, whose
standalone hood's codes start at 14 and it carries a real /power codes start at 14 and which carries a real /power resource."""
resource instead, so this is False there."""
rep = self._rep(self._bound.href) rep = self._rep(self._bound.href)
return ( return (
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
@@ -140,12 +133,10 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _active_speed_codes(self) -> list[str]: def _active_speed_codes(self) -> list[str]:
codes = self._all_speed_codes() codes = self._all_speed_codes()
if self._speed_zero_is_off(): 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] return [code for code in codes if code != _OFF_SPEED_CODE]
# Power is carried by the separate /power resource. fanSpeed # Power is carried by the separate /power resource; fanSpeed
# retains the selected setting while power is off (as the # retains the selected setting while power is off, so every
# lamp's `current` field does), so every advertised code is an # advertised code is an active ordered speed.
# active ordered speed.
return codes return codes
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
@@ -268,15 +259,12 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
def _label_for_code(self, code) -> str: def _label_for_code(self, code) -> str:
"""Lowercased HA preset label for a device mode code. """Lowercased HA preset label for a device mode code.
The TP1X_DA-AC-AIR board (issue #130) reports its named modes The TP1X_DA-AC-AIR board (issue #130) reports named modes directly
directly as supportedModes ('Smart'/'Max'/...), so the code IS the as supportedModes, so the code IS the label. The A-VTWW-TP2-21
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric board (issue #151) instead reports numeric wind-strength codes with
wind-strength codes ('87'/'89'/...) with a separate modesName array a separate modesName array giving the real names -- same shape as
(parallel-indexed with supportedModes) giving the actual names -- climate.py's _wind_strength_label, and coincidentally the same word
same shape as climate.py's _wind_strength_label, and coincidentally set, so both generations land on identical HA preset values."""
the same word set (Smart/Max/WindFree/Sleep), so both board
generations land on identical HA preset values without needing
their own translation catalog entry."""
rep = self._mode_rep() rep = self._mode_rep()
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ())) supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
names = rep.get(_MODES_NAME_FIELD) names = rep.get(_MODES_NAME_FIELD)
@@ -327,11 +315,8 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
_AIRFLOW_SPEED_FIELD = "speed" _AIRFLOW_SPEED_FIELD = "speed"
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1, # 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 # Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated
# as plain strings, same as _all_speed_codes above, so # as plain ordered strings, same as the range hood's numeric levels.
# 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"] _AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
@@ -354,14 +339,11 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT """Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
LocalThingsAirPurifierFan's vs/0-first order -- that order is only LocalThingsAirPurifierFan's vs/0-first order -- that order is only
harmless for the TP1X board because it never reports /power/0 at harmless for the TP1X board because it never reports /power/0.
all. This family's dumps carry both hrefs, and common.POWER_GENERIC This family's dumps carry both hrefs, and common.POWER_GENERIC is
(the power_switch entity) is unconditionally bound to /power/0 unconditionally bound to /power/0 when present, so writing to
whenever it's present, so writing here to /power/vs/0 first would /power/vs/0 first would leave power_switch and this fan
leave power_switch and this fan reading/writing two different disagreeing until the next poll."""
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
resources = self._resources resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return "power", enabled, target return "power", enabled, target
@@ -63,9 +63,9 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
# Consumer-model prefix (first two letters of the '_'-delimited token in # Consumer-model prefix (first two letters of the '_'-delimited token in
# `description` right before any '/board-info' suffix) -> registry key. # `description` right before any '/board-info' suffix) -> registry key.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_' # NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_'
# internal board-family prefix there, and dishwasher's modelNum contains # board-family prefix there, and dishwasher's modelNum contains the
# the substring 'WW', so a modelNum-only rule misroutes both. # substring 'WW', so a modelNum-only rule misroutes both.
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = { _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
"WW": "washer", "WW": "washer",
"WD": "washer", "WD": "washer",
@@ -79,48 +79,39 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
# Board-family token -> registry key, matched against whole tokens of # Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`). # `modelNum`/`description` (see `_board_tokens`).
# #
# Tokenizing instead of substring-matching is what keeps this a table rather # Tokenizing instead of substring-matching keeps this a table rather than a
# than a ladder of hand-written rules. Samsung spells the same board family # ladder of hand-written rules: Samsung spells the same board family with
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the # either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so
# same RAC family -- so a substring rule has to be written once per spelling # a substring rule would need writing once per spelling, and a token with
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the # no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither.
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
# every one of those as a single entry.
# #
# Entries must name the *specific* device type, never the board family that # Entries must name the specific device type, never the board family that
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry # contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
# would swallow the dehumidifier and the air purifier. Where two families # would swallow the dehumidifier and the air purifier. Where two families
# genuinely share a resource surface they share a registry (all the # genuinely share a resource surface they share a registry (the
# air-conditioner spellings below), which is a statement about the hardware, # air-conditioner spellings below), which is a statement about the
# not a shortcut. # hardware, not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = { _BOARD_TOKEN_TO_KEY: dict[str, str] = {
"REF": "refrigerator", "REF": "refrigerator",
# Air conditioners. Every one of these is a distinct board family with # Air conditioners: distinct board families sharing one resource
# the same resource surface: room (issues #37, #91), package, Korean # surface -- room, package, Korean (#136), window (#87), 2-in-1
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial # floor+wall (#150/#153), system/commercial (#52), cassette (#191), and
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120). # ARA-WW wall-mount (#115-120).
"RAC": "airconditioner", "RAC": "airconditioner",
"PRAC": "airconditioner", "PRAC": "airconditioner",
"KRAC": "airconditioner", "KRAC": "airconditioner",
"WAC": "airconditioner", "WAC": "airconditioner",
"FAC": "airconditioner", "FAC": "airconditioner",
"CAWW": "airconditioner", "CAWW": "airconditioner",
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000 "CAC": "airconditioner", # issue #191
"ARA": "airconditioner", "ARA": "airconditioner",
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate "DHM": "dehumidifier", # issue #88 -- target humidity, no climate
"EHS": "ehs", # Eco Heating System air-to-water heat pump -- "EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water
# zone1 space heating/cooling + dhw domestic
# hot water, its own /mode/*/vs/0 and
# /temperatures/*/vs/0 resource shapes
"TVTL": "air_purifier", # issue #56 (ARTIK051) "TVTL": "air_purifier", # issue #56 (ARTIK051)
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air) "VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
"AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a # issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one
# next-gen BESPOKE Cube Air board; same # letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token.
# lineage as VTWW above but the '-WW-' "AVT": "air_purifier",
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR) "AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
"WATERPURIFIER": "water_purifier", # issue #90 "WATERPURIFIER": "water_purifier", # issue #90
"ADW": "dishwasher", "ADW": "dishwasher",
@@ -129,13 +120,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
"OVEN": "oven", # issue #55 -- wall oven, no burners "OVEN": "oven", # issue #55 -- wall oven, no burners
"MICROWAVE": "microwave", # issues #66, #121 "MICROWAVE": "microwave", # issues #66, #121
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven "COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state # Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter # lives in /mode/vs/0's options array. Deliberately the loosest entry
# token, and so the loosest entry in this table -- it is only ever # here -- reached only when nothing more specific matched, since its
# reached by a device that matched nothing more specific, and its # description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an # induction cooktop via COOKTOP above (see for_device_by_model's field
# induction cooktop via the COOKTOP entry above. See `for_device_by_model` # ordering).
# for the field ordering that makes that resolve correctly.
"CT": "cooktop", "CT": "cooktop",
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station "VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
"DF": "air_dresser", # issue #162 "DF": "air_dresser", # issue #162
@@ -161,20 +151,16 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
def _board_family_key(value: str, cut_at: str) -> str | None: def _board_family_key(value: str, cut_at: str) -> str | None:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None. """First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so No known modelNum or description yields two conflicting board keys, so
which token is found first doesn't matter within one field -- the table is which token is found first doesn't matter within one field -- the
a flat lookup, not a priority list. Adding an entry that could co-occur table is a flat lookup, not a priority list.
with another (a family token, or one short enough to collide by accident)
would break that property; see this table's comment.
One documented exception (issue #196): AILITE water-purifier boards One documented exception (issue #196): AILITE water-purifier boards
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names
shared cooling-subsystem board, not the refrigerator device type -- the shared cooling-subsystem board, not the refrigerator type --
'WATERPURIFIER' is the actual, more specific type here. Rather than drop 'WATERPURIFIER' is the actual, more specific type. This one known
or rename either entry (both are correct on their own for the model co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity
strings that exist today), this one known co-occurrence resolves to carries a matching carve-out for this exact pair.
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
matching carve-out for this exact pair.
""" """
tokens = _board_tokens(value, cut_at) tokens = _board_tokens(value, cut_at)
if "REF" in tokens and "WATERPURIFIER" in tokens: if "REF" in tokens and "WATERPURIFIER" in tokens:
@@ -191,25 +177,19 @@ def _consumer_model_key(description: str) -> str | None:
Usually that token is the last '_'-delimited segment before any Usually that token is the last '_'-delimited segment before any
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4'). '/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
But issue #79's dryer pairs two model numbers in one description -- But issue #79's dryer pairs two model numbers in one description, so
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token the true consumer token sits one segment before the actual last
('DVE50A8800') sits one segment *before* the actual last segment segment -- scan from the end and take the first segment that resolves.
('8600', a bare second model number with no recognizable prefix). Scan
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
Splits on '_' only, unlike `_board_tokens` above: these are two-letter Splits on '_' only, unlike `_board_tokens` above: widening the split to
prefixes matched against the *start* of a segment, so widening the split '-' would start reading board-family segments as consumer models (the
to '-' as well would start reading board-family segments as consumer dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare route to washer).
'WW' segment and route to washer.
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also
board-family token, issue #87) also starts with 'WA' (the top-load-washer matches 'WA' (top-load washer, issue #106) at this granularity --
prefix, issue #106) at this granularity. for_device_by_model() consults for_device_by_model() consults the board-family table first and this
the board-family table first and this function only as a fallback, so only as a fallback, so that ambiguity resolves correctly.
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
""" """
segments = (description or "").split("/", 1)[0].split("_") segments = (description or "").split("/", 1)[0].split("_")
for segment in reversed(segments): for segment in reversed(segments):
@@ -220,36 +200,25 @@ def _consumer_model_key(description: str) -> str | None:
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py) # /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. This is the device naming its own type -- no board-part # -> registry key. The device naming its own type, no board-part guessing --
# guessing involved -- so it's consulted before modelNum/description at all. # consulted before modelNum/description.
# #
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by # Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list # `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately
# stops well short of the full OCF/SmartThings device-type vocabulary: a # stops short of the full OCF/SmartThings vocabulary, since most of it (lights,
# compiled list of `x.com.st.d.*` types will include plenty of device # locks, cameras, TVs, ...) has no registry here to point at, and
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy # 'oic.d.robotcleaner' names an actual robot vacuum, a different product from
# meters, ...) no Samsung DA appliance dump could ever report and this # the clean/auto-empty *station* `vacuum_station` covers.
# integration has no registry for -- and 'oic.d.robotcleaner' names an
# actual robot vacuum, a different product from the clean/auto-empty
# *station* `vacuum_station` covers (see that registry's own module
# docstring); mapping it there would misroute a genuine robot-vacuum dump
# into a registry with no vacuum-body capabilities at all. Add a row only
# once there's a real registry key on the right-hand side to point at.
# #
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF # `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary (used for categories with no `oic.d.*` equivalent), # device-type vocabulary, for categories with no `oic.d.*` equivalent.
# same prefix convention as the `x.com.samsung.da.*` resource fields
# elsewhere in this codebase.
# #
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out. # `oic.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and # reports it, but `cooktop` and `induction_cooktop` are unrelated registries
# `induction_cooktop` are two unrelated registries that happen to share the # sharing the English word (see by_type/cooktop.py's docstring) -- the OCF
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps # type doesn't distinguish them, and as the primary signal it would override
# burner state in /mode/vs/0's options array, a completely different OCF # a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no
# surface). The OCF type does not distinguish them, so mapping it to either key # row.
# would silently misroute the other -- and as the *primary* signal it would
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
_OIC_TYPE_TO_KEY: dict[str, str] = { _OIC_TYPE_TO_KEY: dict[str, str] = {
"oic.d.airconditioner": "airconditioner", "oic.d.airconditioner": "airconditioner",
"oic.d.airpurifier": "air_purifier", "oic.d.airpurifier": "air_purifier",
@@ -268,13 +237,10 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None: def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type """Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration. declaration. The primary path when a dump carries it, since the device
names its own type. Most hardware still doesn't populate `/oic/d`
The primary path when a dump carries it: the device names its own type, usefully, so `for_device_by_model`/`for_device_by_resources` remain
so there's nothing to infer from board part numbers. Most hardware still load-bearing for everything else.
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
this only ever helps a minority of dumps, and `for_device_by_model`/
`for_device_by_resources` remain load-bearing for everything else.
""" """
for device_type in device_types: for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type) key = _OIC_TYPE_TO_KEY.get(device_type)
@@ -323,15 +289,15 @@ def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | No
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None: def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
"""Detect a device family from a distinctive local-resource signature. """Detect a device family from a distinctive local-resource signature.
This runs first as an override path for non-standard devices, not because Runs first as an override path for non-standard devices -- not because
resource signatures are inherently more trustworthy than OIC/model resource signatures are more trustworthy than OIC/model metadata, but
metadata. It also types boards that ship no ``/information/vs/0`` at all, because it also types boards with no ``/information/vs/0`` at all.
leaving `for_device_by_model` nothing to read. Some newer cooktops are the Some newer cooktops were the original case: their mode resource still
original case: their mode resource still identifies them, carrying a identifies them via a DeviceType option and multiple per-burner
DeviceType option and multiple per-burner OperationState options. OperationState options.
Require two independent shapes for every signature here, never one, so Every signature here requires two independent shapes, never one, so
putting this ahead of OIC/model metadata cannot let a common resource running this ahead of OIC/model metadata can't let a common resource
misclassify an unrelated family. misclassify an unrelated family.
""" """
mode = resources.get("/mode/vs/0", {}) mode = resources.get("/mode/vs/0", {})
@@ -347,12 +313,10 @@ def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources: if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
return _REGISTRY_BY_KEY["range_hood"] return _REGISTRY_BY_KEY["range_hood"]
# Oven/range/microwave boards that report no /information/vs/0 at all # Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply # (issues #74, #172) can't be matched via modelNum tokens either. Mode
# absent from the dump, not just empty) can't be matched via # vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside # two-resource signature; it also corrects Qooker's generic oic.d.oven
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature; # metadata (PR #225) since resource detection runs before it.
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
# PR #225) when resource detection runs before metadata.
supported_modes = mode.get("x.com.samsung.da.supportedModes") or () supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
if not isinstance(supported_modes, (list, tuple)): if not isinstance(supported_modes, (list, tuple)):
supported_modes = () supported_modes = ()
@@ -379,20 +343,18 @@ def resolve(
flow's probe and the golden-regression harness all call this, so the flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert. order can't drift between what ships and what the tests assert.
Distinctive resource signatures run first because they describe the live Distinctive resource signatures run first, since they describe the
capability surface a registry must bind. They are deliberately strict in live capability surface a registry must bind; `for_device_by_resources`
`for_device_by_resources`: each requires multiple independent details, so is deliberately strict (multiple independent details required) so this
this can correct misleading metadata (Qooker's generic ``oic.d.oven``) can correct misleading metadata without a common href overriding an
without a common href overriding an unrelated family. When no signature unrelated family. When no signature matches, `/oic/d`'s `rt` wins over
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see model-string parsing.
registry/identity.py) wins over model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It `/otninformation/vs/0`'s oneUiVersion is deliberately not consulted:
reads like the obvious signal -- the device naming its own type, e.g. only a minority of hardware populates it, every device that does is
'7.0 Dishwasher' -- but only a minority of hardware populates it, every already typed by its modelNum board token, and no device-support issue
device that does is already typed by its modelNum board token, and no has ever needed it. Still reported in diagnostics as a firmware
device-support issue has ever been fixed by adding a mapping for it. It marker.
is still reported in diagnostics as a firmware-generation marker.
""" """
info = resources.get("/information/vs/0", {}) info = resources.get("/information/vs/0", {})
return ( return (
@@ -12,24 +12,16 @@ reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this `['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare board confirms what its scale means -- left unbound rather than guessed;
against) confirms what its scale means -- left unbound rather than guessed, index 0 is the only slot any family has ever read.
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class` Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible (pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate
ug/m3 particulate readings in a physically consistent order (coarser values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/ pair, and this board's three-tier naming doesn't confirm where the extra
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's tier or a PM1 reading fits. A wrong guess would silently mislabel every
three-tier naming doesn't confirm where the extra tier or a PM1 reading reading forever, so they're plain `measurement` sensors named after the
actually fits. The adding-device-support skill's read-side rule says device's own field instead, matching air_purifier.AIR_QUALITY's precedent.
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
""" """
from datetime import time as dt_time from datetime import time as dt_time
@@ -134,13 +126,10 @@ def _dnd_time_write(field):
return _write return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one # Issue #210: only one dump exists (DND never toggled in it), so this write
# dump total, DND never toggled in it), so this write contract is an # contract is an educated guess -- symmetric with the read side's own
# educated guess, not a confirmed one -- symmetric with the read side # 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the # confirm it on real hardware.
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability( DND = Capability(
href="/dnd/vs/0", href="/dnd/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -1,61 +1,25 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family """Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56). (model AX60R5080WD/SE, issue #56).
Power, kids-lock, remote-control, alarms, and the energy meter are the shared Power, kids-lock, remote-control, alarms, and the energy meter are the
common.py capabilities (this family exposes the standard /power/0+/power/vs/0 shared common.py capabilities; /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses (identical field/write contract).
dishwasher.DIAGNOSIS -- identical field/write contract
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
/mode/vs/0's x.com.samsung.da.options array packs multiple independent /mode/vs/0's options[] packs several '<Prefix>_<value>' flags, the same
'<Prefix>_<value>' flags into one list -- the same packed-list contract packed-list contract as laundry.py's option_value/option_write. Light_On/
laundry.py's option_value/option_write already model for /course/vs/0's Light_Off is a real on/off switch here -- NOT the same polarity as the AC
options[] (reused directly below, just against this family's own href). Per family's own Light_On/Light_Off token on its own /mode/vs/0, which is
issue #56's follow-up (five diagnostics dumps captured with the physical unit inverted (airconditioner._display_light_on). Comode_Off reads 'Off' on
set to Auto/Sleep/Low/Medium/High): every setting (Auto/Sleep/Low/Medium/High), ruling out the original
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a "fan speed selector" guess; exposed read-only. OptionCode_* and Blooming_*
real switch, RMW-replacing just that one entry. are unmodeled: confirmed opaque / not app-facing.
NOT the same polarity as the AC family's own
Light_On/Light_Off token on its own /mode/vs/0
(airconditioner._display_light_on) -- that one is
confirmed inverted (Light_Off means the panel is
lit) on live hardware. Same token name, same
resource name, different device type and
opposite meaning -- don't unify them.
Comode_Off -- read 'Off' on *every* one of the five dumps,
including High/Low/Medium/Auto -- confirms this
is NOT the fan-speed selector (ruling out the
original guess); exposed read-only since its
actual purpose is still unconfirmed.
OptionCode_60282 -- confirmed opaque/not user-facing in the
SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the same
href).
Blooming_* -- confirmed to have no corresponding SmartThings
app setting; dropped entirely rather than kept
as an unexplained diagnostic (it did track 1:1
with Sleep mode across the five dumps -- 0 in
Sleep, 6 otherwise -- so it's plausibly an
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up). /airflow/0's `speed` is a real fan-speed control: two independent units,
The first round of five dumps above wasn't conclusive -- it read 0 for both sampled 60-90s apart per setting, confirmed a clean monotonic 0-4 mapping
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five across Auto/Sleep/Low/Medium/High. AIRFLOW_GENERIC below builds an
were captured within about a minute of each other, faster than this ordered-speed fan off that range. /airflow/vs/0's vendor `speedLevel` is
integration's own ~30s poll cycle could settle each change. A second round, NOT used for the same purpose -- unreliable on both units in the same
captured 60-90s apart per setting on two independent units, confirmed a round (collided Low/Medium on one, stuck at 0 on the other).
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.
""" """
import datetime import datetime
@@ -73,14 +37,11 @@ from ..entities import (
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, option_write from .laundry import bool_option_exists, bool_option_value, option_value, option_write
# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue # Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/ # on /mode/vs/0's top-level modes/supportedModes instead of packing
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing # everything into options[] like the older ARTIK051_TVTL family. Both
# everything into the options[] array the way the older ARTIK051_TVTL # generations share this href; FAN and MODE below are mutually exclusive
# family above does -- that older family's /mode/vs/0 has no top-level # via presence of supportedModes.
# supportedModes at all (see the module docstring's Comode_Off finding).
# 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_MODE = "/mode/vs/0"
HREF_AIRFLOW = "/airflow/0" HREF_AIRFLOW = "/airflow/0"
HREF_WIND_STRENGTH = "/wind/strength/vs/0" HREF_WIND_STRENGTH = "/wind/strength/vs/0"
@@ -122,12 +83,9 @@ def _consumable_state(items, name):
return None return None
# FilterProgress is a 0-100 percentage counting up as the filter wears -- # FilterProgress counts UP as the filter wears (100 = "needs changing",
# confirmed via issue #56: the SmartThings app shows "Filter needs changing" # confirmed via the SmartThings app) -- named after the raw field rather
# once this reaches 100, so 100 means fully used, not "brand new." Named # than "filter life," which would imply the opposite direction.
# after the raw field (matching the AC/range_hood filterUsage convention,
# which counts the same direction) rather than "filter life," which would
# imply the opposite direction.
FILTER = Capability( FILTER = Capability(
href="/consumable/vs/0", href="/consumable/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -160,11 +118,9 @@ DEVICE_ACTIVE = Capability(
def _power_write(power_href, value): def _power_write(power_href, value):
"""Shared 'power' payload handling for this family's three FanDesc write """Shared 'power' payload handling for this family's FanDescs -- targets
functions -- targets whichever power href fan.py's _power_payload picked whichever power href fan.py picked (the board may only report
(the board may only report /power/0); a hardcoded vendor href here would /power/0)."""
silently no-op on such a board even though the entity's own is_on
already falls back to reading it correctly."""
if power_href == "/power/0": if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)} return ["power", "0"], {"value": bool(value)}
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"}) return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
@@ -179,24 +135,14 @@ def _airflow_fan_write(payload, rep, href=None):
return None return None
# Confirmed via issue #56's second, properly-spaced round of diagnostics # Confirmed monotonic 0-4 speed code (see module docstring) backs a real
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is # ordered-speed fan, same SET_SPEED shape as the range hood's. `direction`
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now # stays a diagnostic: every dump reads 'Off' regardless of fan setting.
# 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 # Keyed 'airflow_fan', not 'fan' -- FAN below shares this registry and also
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built # uses key 'fan'; unique_id is built from key alone, so a shared key would
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are # collide if a board ever reported both (empirically mutually exclusive,
# only *empirically* mutually exclusive (every dump seen has one board # not architecturally enforced the way same-href caps are).
# 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( AIRFLOW_GENERIC = Capability(
href=HREF_AIRFLOW, href=HREF_AIRFLOW,
poll_tier="warm", poll_tier="warm",
@@ -211,10 +157,8 @@ AIRFLOW_GENERIC = Capability(
), ),
) )
# Left exactly as a read-only fallback -- speedLevel is NOT the same # Read-only fallback: speedLevel is unreliable (see module docstring),
# confirmed-reliable field as /airflow/0's speed above (see module # unlike /airflow/0's speed.
# 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( AIRFLOW_VS_FALLBACK = Capability(
href="/airflow/vs/0", href="/airflow/vs/0",
match_fn=lambda rep, resources: "/airflow/0" not in resources, match_fn=lambda rep, resources: "/airflow/0" not in resources,
@@ -239,12 +183,9 @@ AIRFLOW_VS_FALLBACK = Capability(
def _light_write(payload, rep, href=None): def _light_write(payload, rep, href=None):
# option_write's single-token write is confirmed on a washer's # option_write's single-token merge is confirmed on a washer's
# /course/vs/0 (issue #54), NOT independently on this family's # /course/vs/0 (issue #54); extrapolated here on the assumption the
# /mode/vs/0 -- extrapolated on the assumption the same vendor field # same vendor field merges the same way on this family's /mode/vs/0.
# merges the same way everywhere. If some unit replaces the field
# outright instead, this would drop Comode/OptionCode alongside it on
# the next light toggle; revisit if a real device report surfaces that.
return ["mode", "vs", "0"], { return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Light", payload), "x.com.samsung.da.options": option_write("Light", payload),
} }
@@ -286,9 +227,8 @@ def _fan_write(payload, rep, href=None):
def _first_fan_mode(rep): def _first_fan_mode(rep):
"""Representative scalar for the fan entity in the flattened state """Representative scalar for the flattened golden state; the real
(golden/regression), mirroring airconditioner.py's own _first_mode -- entity reads live coordinator state instead."""
the real entity computes its state from live coordinator reads."""
modes = rep.get("x.com.samsung.da.modes") modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)): if isinstance(modes, (list, tuple)):
return modes[0] if modes else None return modes[0] if modes else None
@@ -296,10 +236,8 @@ def _first_fan_mode(rep):
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered # Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
# percentage -- WindFree/Smart/Sleep are named behaviors, not # percentage -- these are named behaviors, not "faster/slower" positions,
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for # so fan.py only exposes PRESET_MODE here.
# this only exposes PRESET_MODE, matching how the AC family's own named
# convenient modes are modeled as a preset rather than a speed number.
FAN = Capability( FAN = Capability(
href=HREF_MODE, href=HREF_MODE,
poll_tier="warm", poll_tier="warm",
@@ -324,19 +262,14 @@ def _wind_strength_fan_write(payload, rep, href=None):
return None return None
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but # A-VTWW-TP2-21-COMMON (issue #151): named presets like FAN above, but on a
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of # distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
# self-describing supportedModes -- x.com.samsung.da.modesName gives the # self-describing supportedModes -- x.com.samsung.da.modesName gives the
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's # real names, read live by fan.py rather than a hardcoded map. `modes` is a
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded # bare string here, not a single-element list like HREF_MODE's.
# per-model map. modes here is a bare string ('87'), not a single-element
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
# #
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry # key is 'wind_strength_fan', not 'fan' -- same unique_id collision hazard
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone # as AIRFLOW_GENERIC above.
# (entity.py's _key), not href, so two same-key FanDescs in one registry
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
# comment on this exact hazard -- missed here in the initial cut).
WIND_STRENGTH_FAN = Capability( WIND_STRENGTH_FAN = Capability(
href=HREF_WIND_STRENGTH, href=HREF_WIND_STRENGTH,
poll_tier="warm", poll_tier="warm",
@@ -350,15 +283,12 @@ WIND_STRENGTH_FAN = Capability(
), ),
) )
# --------------------------------------------------------------------------- # TP1X_DA-AC-AIR-class additions (issue #130): resources the older
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several # ARTIK051_TVTL family never reported.
# resources the older ARTIK051_TVTL family never did.
# ---------------------------------------------------------------------------
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood # Screen/indicator panel on/off, distinct from the display_light switch
# light): both report the same {mode, supportedModes: [On, Off]} shape on # above (ambient mood light) -- two independent controls on separate hrefs
# separate hrefs on this dump, so they're two independent physical controls, # with the same {mode, supportedModes: [On, Off]} shape.
# not a duplicate encoding of one.
DISPLAY = Capability( DISPLAY = Capability(
href="/display/vs/0", href="/display/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -377,10 +307,8 @@ DISPLAY = Capability(
), ),
) )
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own # Same filterUsage/filterCapacity/filterStatus shape as the AC family's
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not # AIR_FILTER; the normal/wash/replace option list is reused as-is.
# seen on this one dump, so the option list there is reused as-is rather
# than re-deriving it from a single sample.
HEPA_FILTER = Capability( HEPA_FILTER = Capability(
href="/filter/hepafilter/vs/0", href="/filter/hepafilter/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -406,10 +334,9 @@ HEPA_FILTER = Capability(
), ),
) )
# Physical panel/cover status -- meaning of the one value seen ('Close') is # Physical panel/cover status ('Close' seen, plausibly the HEPA-filter
# plausible (the HEPA-filter access cover) but unconfirmed, and no # cover) -- unconfirmed, and no supportedStatus list to check against, so a
# supportedStatus list is present to check against -- exposed as a plain # plain diagnostic rather than an asserted binary_sensor.
# diagnostic sensor rather than an asserted binary_sensor polarity.
PANEL_STATUS = Capability( PANEL_STATUS = Capability(
href="/panel/vs/0", href="/panel/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -443,22 +370,18 @@ PET_FILTER_ACTIVATION = Capability(
), ),
) )
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at # Sound mode/volume look like laundry.py's SOUND_MODE/SOUND_VOLUME but this
# a glance, but this board's actual values differ (supportedModes here is # board's actual values differ (supportedModes here is ['mute', 'buzzer'],
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range # not laundry's voice/tone/mute; volume is 0-3, not laundry's fixed 0-15) --
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a # separate descriptors reading live supported values instead of reusing
# valid write ('buzzer') or expose the wrong number range, so these are # laundry's hardcoded table.
# separate descriptors reading the live supported values instead of a
# hardcoded table.
SOUND_MODE = Capability( SOUND_MODE = Capability(
href="/settings/sound/mode/vs/0", href="/settings/sound/mode/vs/0",
poll_tier="cold", poll_tier="cold",
entities=( entities=(
# Distinct translation_key from laundry.SOUND_MODE's shared # Distinct translation_key from laundry.SOUND_MODE's shared
# 'sound_mode' catalog entry -- that one's state table is # 'sound_mode' catalog ({voice, tone, mute}) -- this board's
# {voice, tone, mute}, but this board's supportedModes is # {mute, buzzer} doesn't overlap it.
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
# (falls through to the raw code) since the catalogs don't overlap.
SelectDesc( SelectDesc(
key="sound_mode", key="sound_mode",
translation_key="air_purifier_sound_mode", translation_key="air_purifier_sound_mode",
@@ -510,76 +433,41 @@ SOUND_VOLUME = Capability(
), ),
) )
# --------------------------------------------------------------------------- # AI Purify -- /airlevelcheck/vs/0 (issues #84, #190). Not scheduler
# AI Purify -- /airlevelcheck/vs/0 (issues #84 and #190) # plumbing: it drives the SmartThings app's "AI Purify" feature (the unit
# wakes on a timer, samples air, optionally acts). Reported with the same
# field names by three of this registry's four board families (TP1X_DA-AC-AIR
# #130, A-VTWW-TP2 #151, AVT-WW-TP1 #84/#190); ARTIK051_TVTL has no such
# href. Bound unconditionally since it's safe to no-op where absent.
# #
# Covered as "periodic air-quality sensing scheduler plumbing" until two dumps # Two independent knobs, one entity each rather than folded into one
# of the AVT-WW-TP1-23 board showed it is not plumbing: it drives the feature # select: periodicSensingActivationState (is it running) and autoExeState
# the SmartThings app calls AI Purify, where the unit wakes on a timer, samples # (what it does with a bad reading, Off/Airpurify/Alarm) -- mirrors the
# the air, and optionally acts on the result. Every field is named, none are # appliance's own UI. Folding them lost information: a configured action
# opaque, and two of them are already user-set on the reported units. # became invisible while off, and no option could toggle the feature
# without also overwriting the action. The two "off"s are NOT
# interchangeable: the switch's off stops sampling entirely; the select's
# "Off" keeps sampling but doesn't act on it (the app calls that
# "sensing only").
# #
# Three of this registry's four board families report the resource with the # range_hood.AIR_LEVEL_CHECK models the same href's read-only fields
# same field names -- TP1X_DA-AC-AIR (#130), A-VTWW-TP2 (#151) and AVT-WW-TP1 # (reused verbatim below) but is deliberately not imported: it exposes
# (#84, #190); only ARTIK051_TVTL (#56) has no such href. Bound unconditionally # periodic_air_sensing as a read-only BinarySensorDesc where this board
# rather than behind a match_fn so any board reporting it is covered; the one # needs it writable, and reusing it would migrate every hood user's entity
# field that genuinely varies is gated per-entity below. # to a different platform.
# #
# The resource carries two independent knobs and they get one entity each, # Every write below was exercised on AVT-WW-TP1-23-AXX500 hardware and
# rather than being folded into a single control: # verified by surviving a reconnect (this board 2.04s writes it silently
# discards, so an echo proves nothing). The other two families get the same
# writes on field-shape grounds only.
# #
# periodicSensingActivationState On/Off -- is AI Purify running # Deferred: startSensingOnce looks like a one-shot "sense now" trigger but
# autoExeState Off/Airpurify/Alarm -- what it does with a # stays unbound until its side effect (not just the echo) is confirmed.
# bad reading
#
# The appliance itself presents them that way: its own UI has an on/off for AI
# Purify separately from the three mode choices. Folding them into one select
# was tried first and lost two things -- a configured action became invisible
# while the feature was off, and no option could toggle the feature without
# also overwriting the action.
#
# Note the two 'off's mean opposite things and are not interchangeable. The
# switch's off stops the unit sampling at all; the select's off is the
# advertised autoExeState "Off", where the unit keeps sampling and simply
# doesn't act on what it measures -- the app calls that choice "sensing only".
#
# The select reads its options straight off supportedAutoExeState rather than
# a typed-in tuple, the same shape SOUND_MODE below uses for supportedModes: a
# board advertising a fourth action gets it accepted on both the options list
# and the write path.
#
# range_hood.AIR_LEVEL_CHECK already models this same href, and its read-only
# keys (air_sensing_state / last_air_sensing_time / last_air_sensing_level) are
# reused verbatim so both families share one catalog entry. It is deliberately
# NOT imported: the hood exposes periodic_air_sensing as a read-only
# BinarySensorDesc and this board needs a writable SwitchDesc on that same key,
# so reusing the hood's capability would migrate every hood user's entity to a
# different platform.
#
# Verification: every write below was exercised on AVT-WW-TP1-23-AXX500
# hardware. This board returns 2.04 for writes it silently discards (see
# HEPA_FILTER's filter-reset note), so an echo proves nothing -- each was
# judged by the value surviving a reconnect, which forces a new DTLS session,
# fresh discovery and a fresh observe of this href, leaving no cached state to
# read back. The other two families get the writes on field-shape grounds, the
# same basis on which they already share MODE, HEPA_FILTER and the air-quality
# sensors.
#
# Deferred: startSensingOnce (On/Off on all three dumps) looks like a one-shot
# "sense now" trigger and would be a ButtonDesc, but nothing here writes it yet
# and this board is known to acknowledge writes it discards -- so it stays
# unbound until someone can confirm the side effect rather than the echo.
# ---------------------------------------------------------------------------
def _interval_minutes(seconds): def _interval_minutes(seconds):
"""Device stores the interval in seconds; the entity is in minutes. """Device stores the interval in seconds; the entity is in minutes.
Rounds up (not to nearest) so a sub-minute value can't floor to 0."""
`is None` rather than a falsy check so a reported 0 is distinguishable
from a missing one. Anything else nonzero rounds up rather than to
nearest, so a sub-minute value can't render as 0 and fall below the
entity's own floor.
"""
secs = int_or_none(seconds) secs = int_or_none(seconds)
if secs is None: if secs is None:
return None return None
@@ -587,26 +475,15 @@ def _interval_minutes(seconds):
def _interval_write(payload, rep, href=None): def _interval_write(payload, rep, href=None):
# Minutes in the UI -> seconds on the wire (scalar string). Modelled as a # Minutes in the UI -> seconds on the wire. Modeled as a free Number,
# free Number rather than the app's three fixed choices (10 min / 30 min / # not the app's three fixed choices, since the resource advertises no
# 1 hour): this resource advertises no supported-values or range field for # constraint for this field (unlike supportedAutoExeState beside it)
# the interval -- supportedAutoExeState sits right beside it, so the board # and accepts finer values than the app offers (60s drove an observed
# does advertise constraints where it has them -- and it accepts values the # ~60s sensing cycle on hardware). One-minute floor matches this
# app never offers. Writing 60 s, six times finer than the app's smallest # board's own reporting resolution (lastSensingTime lands on exact
# choice, drove an observed ~60 s sensing cycle on hardware. # minutes). Zero is refused: unlike a real "no timer" 0 elsewhere in
# # this repo, nothing establishes what 0 does here. Silent no-op via
# One minute is the floor because that's the resolution this board reports # None, same shape as range_hood._lamp_level_write.
# results at: lastSensingTime lands on an exact minute on every sample from
# the AVT-WW-TP1 and A-VTWW-TP2 boards (both fixtures, and eleven
# consecutive live readings), where the TP1X/AC/hood boards report arbitrary
# seconds. A sub-minute interval is therefore unobservable here whether or
# not the board honours it. Zero is refused for a separate reason: unlike
# oven.cook_time or operational's delay hours, where 0 is a real setting
# ("no timer", "no delay"), nothing establishes what a 0 interval does to
# this board -- so native_min stops the UI offering it, and this guard
# covers the service-call path. Silent no-op via a None return, the same
# shape range_hood._lamp_level_write uses for a level the device didn't
# advertise.
minutes = round(float(payload)) minutes = round(float(payload))
if minutes < 1: if minutes < 1:
return None return None
@@ -616,10 +493,9 @@ def _interval_write(payload, rep, href=None):
def _periodic_sensing_write(payload, rep, href=None): def _periodic_sensing_write(payload, rep, href=None):
# The master on/off for AI Purify. Leaves autoExeState alone, so the # Master on/off; leaves autoExeState alone so the configured action
# configured action survives the feature being switched off and comes back # survives the feature being toggled off -- the select can't do that,
# with it -- the thing the select cannot do, since every option it writes # since every option write sets an action too.
# sets an action.
return ["airlevelcheck", "vs", "0"], { return ["airlevelcheck", "vs", "0"], {
"x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off") "x.com.samsung.da.periodicSensingActivationState": ("On" if payload == "On" else "Off")
} }
@@ -631,14 +507,11 @@ def _skip_status_write(payload, rep, href=None):
} }
# The daily window during which periodic sensing is skipped, stored as one # Daily skip window, stored as one HHMMHHMM string
# HHMMHHMM string (start+end) on periodicSensingSkipTime. The read side is # (periodicSensingSkipTime). Cross-confirmed on two units (inert
# cross-confirmed on two units: issue #84's sits at the inert '00000000', while # '00000000' vs a real '03002300'). Split into two HA time entities; each
# issue #190's carries a real user-set '03002300' -> 03:00-23:00. Split into # write reads the other half back out of the live rep so the pair
# two HA time entities; each write reads the other half back out of the live # round-trips -- confirmed in both directions on hardware.
# rep so the pair round-trips. Confirmed in both directions on hardware: from
# 13:00-23:00, writing start=07:30 then end=22:00 left the device holding
# '07302200' -- each write kept the half it wasn't given.
def _skip_time_read(part): def _skip_time_read(part):
def _read(value): def _read(value):
raw = str(value or "") raw = str(value or "")
@@ -654,11 +527,9 @@ def _skip_time_read(part):
def _skip_half(raw, part): def _skip_half(raw, part):
"""The half this write isn't setting, normalized. Padding alone would carry """The half this write isn't setting, normalized. An unparseable half
a malformed value straight back to the device -- writing start over a junk becomes '0000' rather than carrying a malformed value back to the
skip time would send '0730' + junk. The read side already refuses a half it device."""
can't parse, so an unparseable one becomes '0000' here and the pair
round-trips honestly in the same cases."""
chunk = (str(raw or "") + "00000000")[:8] chunk = (str(raw or "") + "00000000")[:8]
other = chunk[4:8] if part == "start" else chunk[0:4] other = chunk[4:8] if part == "start" else chunk[0:4]
return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000" return other if _skip_time_read("end" if part == "start" else "start")(chunk) else "0000"
@@ -687,10 +558,8 @@ AIR_LEVEL_CHECK = Capability(
value_fn=lambda v: str(v).lower() == "on", value_fn=lambda v: str(v).lower() == "on",
write_fn=_periodic_sensing_write, write_fn=_periodic_sensing_write,
), ),
# Options come off supportedAutoExeState, not a table here -- the # Options come off supportedAutoExeState rather than a typed table,
# catalog carries the labels for the three values seen so far, and an # so an unrecognized fourth value still reaches the user.
# unrecognized fourth still reaches the user (select.py falls back to
# the device's own token when the catalog doesn't know it).
SelectDesc( SelectDesc(
key="sensing_mode", key="sensing_mode",
field="x.com.samsung.da.autoExeState", field="x.com.samsung.da.autoExeState",
@@ -703,10 +572,9 @@ AIR_LEVEL_CHECK = Capability(
{"x.com.samsung.da.autoExeState": p}, {"x.com.samsung.da.autoExeState": p},
), ),
), ),
# The one field that varies across the three families reporting this # The one field that varies across families: TP1X_DA-AC-AIR (#130)
# resource: the TP1X_DA-AC-AIR dump (#130) omits it while both # omits it, so that board runs sensing on a fixed, unexposed
# AVT-WW-TP1 dumps and the A-VTWW-TP2 dump carry it, so that board runs # interval.
# the sensing engine on a fixed interval it doesn't expose.
NumberDesc( NumberDesc(
key="sensing_interval", key="sensing_interval",
field="x.com.samsung.da.periodicSensingInterval", field="x.com.samsung.da.periodicSensingInterval",
@@ -758,9 +626,8 @@ AIR_LEVEL_CHECK = Capability(
entity_category="diagnostic", entity_category="diagnostic",
value_fn=epoch_to_utc, value_fn=epoch_to_utc,
), ),
# 'Kr1' on both dumps -- a national air-quality grade whose scale is # 'Kr1' on both dumps -- a region-prefixed, undocumented grade;
# region-prefixed and undocumented here, so it stays a raw diagnostic # stays a raw diagnostic rather than an asserted enum.
# rather than being mapped to an asserted enum.
SensorDesc( SensorDesc(
key="last_air_sensing_level", key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel", field="x.com.samsung.da.lastSensingLevel",
@@ -770,18 +637,12 @@ AIR_LEVEL_CHECK = Capability(
), ),
) )
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has # /humidity/0 and /humidity/vs/0 are empty on both dumps -- covered here
# been verified against -- covered here (not globally, per ignored.py's # (not globally) since they collide with fridge/AC schemas elsewhere, same
# module docstring) since those hrefs collide with fridge/AC schemas # reasoning as airconditioner.py's _AC_IGNORED. The next six hrefs (issue
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED. # #130) are the exact same DA-AC- board resources as _AC_IGNORED,
# # duplicated here rather than promoted to the global list (a possible
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same # follow-up DRY cleanup).
# resources, same shapes, same reasoning as airconditioner.py's
# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather
# than promoted to the global ignored.py list, since that would require
# also removing them from _AC_IGNORED in the same change (a global entry
# colliding with a family-local bare Capability on the same href raises in
# _build()); left as a possible follow-up DRY cleanup.
COVERAGE = [ COVERAGE = [
Capability(href="/humidity/0"), Capability(href="/humidity/0"),
Capability(href="/humidity/vs/0"), Capability(href="/humidity/vs/0"),
@@ -790,14 +651,11 @@ COVERAGE = [
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here) Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/ # Do-not-disturb/auto-sleep schedule -- every field reads its inert
# useTimeSetting/functionState) -- every field reads its inert default # default on the only dump seen. Needs a multi-field schedule editor,
# on the only dump seen (times both '00:00:00', useTimeSetting/ # same as fridge.py's /defrost/reservation/vs/0.
# functionState both 'false'). Same "needs a multi-field schedule
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
Capability(href="/dnd/autosleep/vs/0"), Capability(href="/dnd/autosleep/vs/0"),
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's # Empty on the A-VTWW-TP2-21 dump (issue #151) -- this board's
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN # convenient-mode equivalent lives in WIND_STRENGTH_FAN instead.
# above instead.
Capability(href="/mode/convenient/vs/0"), Capability(href="/mode/convenient/vs/0"),
] ]
File diff suppressed because it is too large Load Diff
@@ -49,12 +49,10 @@ def wh_to_kwh(v):
def parse_iso_utc(raw): def parse_iso_utc(raw):
"""ISO datetime defaulting to UTC when the string carries no timezone """ISO datetime defaulting to UTC when the string carries no timezone of
of its own (this integration's convention for other bare ISO datetime its own. A few boards ship a 'Z'/offset suffix already (fromisoformat
fields -- see washer.py's drum-clean-log comment). A few boards do parses that natively since Python 3.11) -- only fill in UTC when parsing
ship a 'Z'/offset suffix (fromisoformat parses that natively since left the result naive."""
Python 3.11) -- only fill in UTC when parsing left the result naive,
rather than unconditionally overwriting whatever offset was parsed."""
if not raw: if not raw:
return None return None
try: try:
@@ -65,11 +63,8 @@ def parse_iso_utc(raw):
def epoch_to_utc(value): def epoch_to_utc(value):
"""Unix epoch seconds -> aware UTC datetime, for the boards that report a """Unix epoch seconds -> aware UTC datetime, for boards that report a
bare epoch rather than the ISO string parse_iso_utc handles. Lived in bare epoch rather than the ISO string parse_iso_utc handles."""
range_hood.py as `_timestamp` until air_purifier.py needed the same reading
for /airlevelcheck/vs/0's lastSensingTime -- promoted here rather than
cross-imported, matching how filter_usage_percent was shared."""
try: try:
return datetime.fromtimestamp(float(value), tz=UTC) return datetime.fromtimestamp(float(value), tz=UTC)
except (TypeError, ValueError, OSError): except (TypeError, ValueError, OSError):
@@ -78,9 +73,8 @@ def epoch_to_utc(value):
def filter_usage_percent(rep): def filter_usage_percent(rep):
"""Filter usage as a percentage of rated capacity. Several families """Filter usage as a percentage of rated capacity. Several families
(AC, air purifier) report `filterUsage` as a raw count in report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain a 500-hour capacity), so a plain value with a '%' unit would be wrong.
value with a '%' unit would be wrong -- normalize to used/capacity.
Returns None when capacity is missing/zero.""" Returns None when capacity is missing/zero."""
used = _num(rep.get("x.com.samsung.da.filterUsage")) used = _num(rep.get("x.com.samsung.da.filterUsage"))
cap = _num(rep.get("x.com.samsung.da.filterCapacity")) cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
@@ -92,8 +86,8 @@ def filter_usage_percent(rep):
def normalize_temp_unit(raw, default="°F"): def normalize_temp_unit(raw, default="°F"):
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to """'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
`default` for any other/missing value. Shared by fridge.py and oven.py, `default` for any other/missing value. Shared by fridge.py and oven.py,
both of which read a per-device unit off a `/temperature*` resource which both read a per-device unit off a `/temperature*` resource
instead of assuming one (see fridge.py's module docstring, issue #7).""" instead of assuming one (issue #7)."""
raw = (raw or "").strip().upper() raw = (raw or "").strip().upper()
if raw.startswith("C"): if raw.startswith("C"):
return "°C" return "°C"
@@ -108,25 +102,17 @@ def _ml_to_l(v):
def _active_alarm_codes(items): def _active_alarm_codes(items):
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted, """Join active alarm codes; skip retained rows Samsung leaves as
and any code ending in '_OFF'. Deleted, and any code ending in '_OFF'.
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
condition clears (see WD7000B diagnostics). Surface only live alarms so condition clears. Samsung also pre-populates this array with one row
HA doesn't stick on a stale ErrorCode. per alarm *type* the board supports, each carrying its own
'<Name>_OFF' placeholder when that alarm isn't firing -- confirmed
Samsung pre-populates this array with one row per alarm *type* the board across independent families. A firing alarm instead reports a plain,
supports, each carrying its own '<Name>_OFF' placeholder code when that unsuffixed code (FilterAlarm, DoorA_Opened, ...); issue #166 shows both
alarm isn't firing -- confirmed across independent device families in one dump. Generalizes what range hood used to special-case as just
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF, the literal 'ErrorCode_OFF' string.
AC_V_0002_OFF all appear in fixtures with no corresponding active
condition). An alarm that's actually firing instead reports a plain,
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
filter alert looks like: code 'FilterAlarm' (no suffix), state
'Created'. Range-hood previously special-cased only the literal
'ErrorCode_OFF' string in its own stricter helper; this generalizes
the same rule to the whole '_OFF' suffix convention.
""" """
if not items or not isinstance(items, list): if not items or not isinstance(items, list):
return "none" return "none"
@@ -145,13 +131,11 @@ def merge_options_field(cached, new_tokens):
x.com.samsung.da.options[]-style array the same way the device itself x.com.samsung.da.options[]-style array the same way the device itself
merges them: match by prefix, replace if present, append if not. merges them: match by prefix, replace if present, append if not.
Confirmed on real hardware (issue #54) that a write only needs to carry Confirmed on hardware (issue #54) that a write only needs to carry the
the changed token(s), not the whole array -- see laundry.option_write / changed token(s), not the whole array -- see laundry.option_write /
oven._option_write for the write side. This is the read side of that oven._option_write for the write side. coordinator.async_send_command
same fact: coordinator.async_send_command uses it to keep the uses this read-side counterpart to keep the optimistic cache entry
optimistic cache entry for the written href complete (every sibling complete during the write-settle window."""
option still present) during the write-settle window, since the wire
body it applies straight to the cache no longer carries them."""
merged = list(cached or []) merged = list(cached or [])
for token in new_tokens or (): for token in new_tokens or ():
if not isinstance(token, str) or "_" not in token: if not isinstance(token, str) or "_" not in token:
@@ -169,18 +153,15 @@ def merge_options_field(cached, new_tokens):
def merge_items_field(cached, new_items): def merge_items_field(cached, new_items):
"""Merge a partial x.com.samsung.da.items[]-style write (matched by """Merge a partial x.com.samsung.da.items[]-style write (matched by
x.com.samsung.da.id) into a cached items array -- the read-side x.com.samsung.da.id) into a cached items array -- the items[]
counterpart of merge_options_field above, for the items[] shape instead counterpart of merge_options_field above.
of the packed options[] shape.
Confirmed on hardware that a write only needs to carry the array item Confirmed on hardware that a write only needs to carry the item with
with the changed id plus the field(s) being changed; the device merges the changed id plus the field(s) being changed (see
the rest itself (same fact as the options[] case, different array -- airconditioner._climate_write's vendor temperature write). Fields
see airconditioner._climate_write's vendor temperature write). Fields
within the matched item are merged, not replaced outright, so a within the matched item are merged, not replaced outright, so a
setpoint-only write doesn't wipe current/minimum/maximum/unit from the setpoint-only write doesn't wipe current/minimum/maximum/unit from the
optimistic cache entry for the settle window. An id with no match in optimistic cache entry. An id with no match in `cached` is appended."""
`cached` is appended."""
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])] merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
for new_item in new_items or (): for new_item in new_items or ():
if not isinstance(new_item, dict): if not isinstance(new_item, dict):
@@ -196,22 +177,19 @@ def merge_items_field(cached, new_items):
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on # /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the # washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for
# life of a given board, so reading them from the /device/0 seed (no dedicated # the life of a board, so reading it from the /device/0 seed is enough.
# poll_tier) is enough.
_SETINFO_HREF = "/wm/setinfo/vs/0" _SETINFO_HREF = "/wm/setinfo/vs/0"
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff" _POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC" _WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
def model_allows_power_on_off(resources: dict) -> bool: def model_allows_power_on_off(resources: dict) -> bool:
"""True unless firmware explicitly declares remote power on/off unsupported. """True unless firmware explicitly declares remote power on/off
unsupported. isModelSettingPowerOnOff is "false" on many laundry
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry boards: /power/0 and /power/vs/0 still report state, but CoAP writes
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state, are ignored. Absent setinfo (non-laundry families) keeps the writable
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps switch."""
the writable switch -- current behavior.
"""
setinfo = resources.get(_SETINFO_HREF) setinfo = resources.get(_SETINFO_HREF)
if setinfo is None: if setinfo is None:
return True return True
@@ -222,13 +200,11 @@ def model_allows_power_on_off(resources: dict) -> bool:
def model_setting_without_sc(resources: dict) -> bool: def model_setting_without_sc(resources: dict) -> bool:
"""True when firmware declares settings writable without Smart Control. """True when firmware declares settings writable without Smart
Control. isModelSettingWithoutSC is "true" on washers/dryers that
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers accept temperature/spin/cycle-option writes while remote control is
that accept temperature/spin/cycle-option writes while remote control is off; cycle start/pause/stop still need Smart Control on those
off. Cycle start/pause/stop still need Smart Control on those boards -- boards."""
the flag name is settings-specific, not a blanket remote-control bypass.
"""
setinfo = resources.get(_SETINFO_HREF) or {} setinfo = resources.get(_SETINFO_HREF) or {}
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true" return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
@@ -243,11 +219,10 @@ def _power_sensor_exists(rep, resources):
def sensor_item_value(items, sensor_type, index=0): def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly item is `{type, value: [...]}`; `index` picks which slot to read
multi-value reading to read (index 0 is the raw measurement on every (index 0 is the raw measurement on every family seen so far). Shared
family seen so far). Shared by range_hood.AIR_QUALITY, by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the air_monitor.SENSORS, which all read the same resource shape."""
same resource shape against the same {type, sensor_type} keys."""
for item in items or (): for item in items or ():
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
@@ -262,26 +237,18 @@ def sensor_item_value(items, sensor_type, index=0):
return None return None
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control. # OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
# # control: each exists as both a standard OCF resource (/power/0,
# These three controls exist as both a standard OCF resource (/power/0, # oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource # resource (/power/vs/0, x.com.samsung.da.power), since Samsung advertises
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its # both while its firmware migrates onto the OCF standard model. Prefer the
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href # OCF-standard href when present; the '-vs' href binds only when it's
# when the device exposes it; the '-vs' href (a string-encoded duplicate for # absent, via match_fn. Older firmware has only the '-vs' resource. See
# these three) binds only when the generic href is absent, via match_fn. Older # the adding-device-support skill's "OCF-standard vs vendor" section.
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a # Every device registry lists both caps of each pair.
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
# choice. Every device registry lists both caps of each pair.
POWER_GENERIC = Capability( POWER_GENERIC = Capability(
href="/power/0", href="/power/0",
# Neither href of this pair carried a poll_tier before (issue #56's
# follow-up), so power state only ever refreshed on the once-per-30s
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
# hrefs get -- the same "signal drives real-time state, but sat in the
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
poll_tier="warm", poll_tier="warm",
entities=( entities=(
# Writable when firmware allows remote power; otherwise a read-only # Writable when firmware allows remote power; otherwise a read-only
@@ -331,16 +298,14 @@ POWER_VS_FALLBACK = Capability(
KIDS_LOCK_GENERIC = Capability( KIDS_LOCK_GENERIC = Capability(
href="/kidslock/0", href="/kidslock/0",
entities=( entities=(
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a # Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183): HA's
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored # switch platform never honored SwitchDesc's device_class='lock'
# by HA (its switch platform only accepts 'outlet'/'switch'), # ('outlet'/'switch' only), leaving a plain switch whose 'On' meant
# leaving a plain switch whose 'On' state meant different things # different things on different boards. As a BinarySensorDesc with
# on different boards. As a BinarySensorDesc with `device_class='lock'`, # device_class='lock', both surfaces read with the same polarity
# both kids-lock surfaces read with the same polarity: 'On' means # ('On' = open/unlocked, per HA convention); value_fn here inverts
# open/unlocked, per HA's lock device_class. The inversion in # the wire value to match (value=False on /kidslock/0 means kids
# value_fn here (and in the fallback below) keeps the on-the-wire # lock is NOT active).
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc( BinarySensorDesc(
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v) key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
), ),
@@ -351,16 +316,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
href="/kidslock/vs/0", href="/kidslock/vs/0",
match_fn=lambda rep, resources: "/kidslock/0" not in resources, match_fn=lambda rep, resources: "/kidslock/0" not in resources,
entities=( entities=(
# Read-only, not a SwitchDesc (issues #181/#183): the write side of # Read-only, not a SwitchDesc (issues #181/#183): the old write
# this capability wrote 'Enable', a value no dump in the fixture # side wrote 'Enable', a value no dump ever reports back (every one
# corpus has ever reported back -- every one reports either 'Ready' # is 'Ready' or 'Run'), and #181's reporter confirmed writing the
# or 'Run', so it was never a confirmed contract. #181's reporter # correct value ('Run') still 4.05s -- genuinely read-only on this
# confirmed this directly: writing the *correct* value ('Run') # hardware. Polarity matches KIDS_LOCK_GENERIC ('On' = unlocked).
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc( BinarySensorDesc(
key="child_lock", key="child_lock",
field="x.com.samsung.da.kidsLock", field="x.com.samsung.da.kidsLock",
@@ -373,15 +333,11 @@ KIDS_LOCK_VS_FALLBACK = Capability(
def remote_control_enabled(resources: dict) -> bool: def remote_control_enabled(resources: dict) -> bool:
"""Single source of truth for the /remotectrl on/off signal, mirroring """Single source of truth for the /remotectrl on/off signal, mirroring
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field precedence. Used both
below. Used both to render the read-only Smart Control binary_sensor to render the read-only Smart Control binary_sensor and, from
(via those two descriptors) and, from coordinator.async_send_command, coordinator.async_send_command, to block writes when remote control is
to block writes outright when remote control is off. Both hrefs are off. True (assume enabled) when neither href is present -- most device
poll_tier='warm' below so that gate reads recent state (subscribed types don't report this capability at all."""
when observe is live, subpolled every ~6s otherwise) rather than a
once-per-30s cold summary poll. True (assume enabled) when neither
href is present -- most device types don't report this capability
at all."""
generic = resources.get("/remotectrl/0") generic = resources.get("/remotectrl/0")
if generic is not None: if generic is not None:
return bool(generic.get("value")) return bool(generic.get("value"))
@@ -446,26 +402,21 @@ ALARMS = Capability(
), ),
) )
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and # instantaneousPower is a dead field on DA_WM_-class laundry dumps and
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500', # dishwashers: the literal sentinel '-500', unchanged across off/idle/
# unchanged across off/idle/running. clamp_power floors it to a misleading # running. clamp_power would floor it to a misleading "0 W". Gate
# "0 W" that reads as a real idle measurement. Gate power_watts out when the # power_watts out when the sentinel is seen, but only then, so a device
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a # reporting a real value (e.g. a fridge's 93 W) still shows it (issue #6).
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
# least one washer model; the exists_fn makes that explicit rather than relying
# on the generic field-presence gate.
_DEAD_INSTANTANEOUS_POWER = "-500" _DEAD_INSTANTANEOUS_POWER = "-500"
ENERGY_METER = Capability( ENERGY_METER = Capability(
href="/energy/consumption/vs/0", href="/energy/consumption/vs/0",
entities=( entities=(
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included): # is_stub_rep(rep) keeps the stub carve-out (see
# an explicit exists_fn otherwise bypasses it, which would drop the # entity._is_included): an explicit exists_fn otherwise bypasses
# entity when /device/0 returns a not-yet-fetched stub. A genuinely # it and would drop the entity when /device/0 returns a
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty) # not-yet-fetched stub. A genuinely empty {} rep is NOT a stub, so
# answer, so it falls through to the normal field/sentinel checks like # it still falls through to the normal field/sentinel checks.
# any populated rep. On a populated rep, hide power only for the dead
# sentinel or an absent field.
SensorDesc( SensorDesc(
key="power_watts", key="power_watts",
field="x.com.samsung.da.instantaneousPower", field="x.com.samsung.da.instantaneousPower",
@@ -493,11 +444,7 @@ ENERGY_METER = Capability(
), ),
), ),
# cumulativeConsumption is a second, independently-varying running # cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report # total some fridges (issue #26) report alongside cumulativePower.
# 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( SensorDesc(
key="power_energy_kwh", key="power_energy_kwh",
field="x.com.samsung.da.cumulativeConsumption", field="x.com.samsung.da.cumulativeConsumption",
@@ -510,8 +457,8 @@ ENERGY_METER = Capability(
), ),
), ),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized # AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ # baseline -- present on some models (issue #21/#27), absent on
# #27) and absent on others (issue #20/#26), unlike cumulativePower. # others (issue #20/#26).
SensorDesc( SensorDesc(
key="energy_saved_kwh", key="energy_saved_kwh",
field="x.com.samsung.da.cumulativeSavedPower", field="x.com.samsung.da.cumulativeSavedPower",
@@ -523,9 +470,8 @@ ENERGY_METER = Capability(
is_stub_rep(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 # Monthly billing-cycle totals -- completed prior month and
# in-progress current month. Not ever-increasing (each resets at # in-progress current month. Not ever-increasing, so no state_class.
# month boundary), so no state_class.
SensorDesc( SensorDesc(
key="energy_last_month_kwh", key="energy_last_month_kwh",
field="x.com.samsung.da.monthlyConsumption", field="x.com.samsung.da.monthlyConsumption",
@@ -586,26 +532,21 @@ WATER_FILTER = Capability(
), ),
) )
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the # AI energy-saving level -- '0' is off, supportedAiLevel lists the
# additional level(s) the device offers ('1' meaning just "on" on most # additional level(s) offered ('1' meaning just "on" on most hardware,
# hardware, but multi-level boards have been reported). Verified cross-family: # multi-level on some). Verified cross-family: fridge (issue #21) and
# fridge (issue #21) and washer (issue #40) both expose this href. # washer (issue #40). Most hardware's supportedAiLevel is a single-entry
# # list, so a select there would offer only one real choice against an
# supportedAiLevel is a single-entry list on most captured hardware, where a # implicit "off" -- shown as a switch instead; '0' is never in
# select would offer only one real choice against an implicit "off" -- shown # supportedAiLevel but is the observed off value, so the select
# as a switch instead. '0' itself is never in supportedAiLevel but has been # synthesizes it back in as an explicit option. No translation_key:
# observed live as the off value of aiLevel, so the select synthesizes it # aiLevel's values are plain digit strings, and select.py already renders
# back in as an explicit option rather than leaving no way to turn off. # an untranslated numeric string as-is.
#
# No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string
# as-is -- there's nothing a catalog entry adds that's worth maintaining
# against an unknown, growing number of future levels.
def _ai_energy_supported_levels(rep): def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not """supportedAiLevel as a list -- a stray scalar must not be
be len()-checked as if it were a list.""" len()-checked as if it were one."""
sl = rep.get("supportedAiLevel") sl = rep.get("supportedAiLevel")
return list(sl) if isinstance(sl, (list, tuple)) else [] return list(sl) if isinstance(sl, (list, tuple)) else []
@@ -630,21 +571,15 @@ AI_ENERGY_LEVEL = Capability(
poll_tier="cold", poll_tier="cold",
entities=( entities=(
# No is_stub_rep 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 # gates in this file: entity creation runs once against whichever
# whichever snapshot happens to be current the moment platforms are # snapshot is current at platform setup, while flatten() re-checks
# set up (see entity._is_included / __init__.py's # exists_fn every poll against live data. Both descriptors share
# async_config_entry_first_refresh-before-forward-entry-setups # key='ai_energy_level' -- a stub carve-out could let one win at
# ordering), while flatten() re-evaluates exists_fn every poll # setup and the other win once real data lands, feeding the
# against live data. Both descriptors share key='ai_energy_level', # instantiated entity a value shaped for the other platform.
# so if a stub carve-out let one of them win at setup time while the # Requiring populated data on both sides keeps the two decisions in
# other wins once real data lands, flatten() would feed the # permanent agreement, at the cost of the entity not appearing
# instantiated entity a value shaped for the other platform (e.g. a # until a reload if the first poll stubs this cold-tier href.
# bool into a Select). Requiring real, populated data on both sides
# keeps the entity-creation decision and the live-value decision in
# permanent agreement -- the cost is this entity doesn't appear
# until a reload if the device's very first poll stubs this
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc( SwitchDesc(
key="ai_energy_level", key="ai_energy_level",
field="aiLevel", field="aiLevel",
@@ -720,37 +655,27 @@ SELF_CHECK = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Cross-family bundles, unpacked into every by_type registry's _build([...]) # Cross-family bundles, unpacked into every by_type registry's _build([...])
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER). # call the same way ignored.IGNORED is. discover() only binds a capability
# discover() only binds a capability whose href is actually present in a # whose href is actually present in a given device's dump, so listing one
# given device's resource dump, so listing one here for a family that # here for a family that doesn't expose the href is a no-op, not a phantom
# doesn't expose the href is a no-op, not a phantom entity -- see the # entity -- see the adding-device-support skill's coverage-discipline
# adding-device-support skill's coverage-discipline section. # section.
# #
# UNIVERSAL holds every capability with no known family that both (a) has # UNIVERSAL holds every capability with no known family that both has the
# the href and (b) needs to model it some other way -- broadening one of # href and needs to model it some other way.
# these to a new family is a safe, harmless guess (issue #40's AI energy
# level: 2 of 6 families confirmed, blanket-added everywhere else).
# #
# POWER is kept separate -- airconditioner is the one family that opts out # POWER is kept separate: airconditioner opts out of it entirely, since
# of it entirely. Canonical reason (see by_type/airconditioner.py and its # its climate entity already owns /power/0 and /power/vs/0 via bare
# test for pointers back here, not restatements): AC's climate entity # no-entity Capability objects (airconditioner.COVERAGE), and a second
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability # real cap on the same href would make _build() raise (see
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ # by_type/airconditioner.py). Kids-lock/remote-control have no such
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href # conflict, so they stay in UNIVERSAL.
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
# in UNIVERSAL.
# #
# Airconditioner also partially opts out of UNIVERSAL itself, not just # Airconditioner also partially opts out of UNIVERSAL itself: issue #193
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by # needs ENERGY_METER's cumulativePower scale to differ by board
# board generation, so by_type/airconditioner.py excludes just that one # generation, so by_type/airconditioner.py excludes just that one member
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`) # and substitutes its own ENERGY_METER_GENERIC/ENERGY_METER_LEGACY.
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
# its place -- every other registry still unpacks UNIVERSAL wholesale.
# ---------------------------------------------------------------------------
UNIVERSAL = ( UNIVERSAL = (
ALARMS, ALARMS,
@@ -15,8 +15,8 @@ from .common import int_or_none
def _first_mode(rep): def _first_mode(rep):
"""Representative scalar for the operating-mode select. `modes` is a """Representative scalar for the operating-mode select. `modes` is a
single-element list on every dump seen so far, mirroring single-element list on every dump seen, mirroring
airconditioner._first_mode's handling of the same field shape.""" airconditioner._first_mode."""
modes = rep.get("x.com.samsung.da.modes") modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)): if isinstance(modes, (list, tuple)):
return modes[0] if modes else None return modes[0] if modes else None
@@ -40,14 +40,11 @@ MODE = Capability(
), ),
) )
# Target humidity is this device's primary control (the issue-#88 dump's # Target humidity is this device's primary control (issue #88's equivalent
# equivalent of a thermostat setpoint). No min/max range field is present in # of a thermostat setpoint). No min/max range field is present in any dump
# any dump seen so far -- native_min/native_max are deliberately left unset # seen, so native_min/native_max are left unset, falling back to HA's own
# so the number entity falls back to HA's own 0-100 default, the natural # 0-100 default rather than a bound guessed from one unit's spec sheet.
# bound for a percentage field, rather than a bound guessed from one unit's # Step comes live from the device's own `increment` field.
# spec sheet (see the adding-device-support skill's "never hard-code the one
# dump's values" section). Step comes live from the device's own `increment`
# field.
HUMIDITY = Capability( HUMIDITY = Capability(
href="/humidity/vs/0", href="/humidity/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -77,15 +74,13 @@ HUMIDITY = Capability(
), ),
) )
# Water-tank ambient light (issues #271/#231, TP1X_DA_AC_DHM_01001_0000): # Water-tank ambient light (issues #271/#231): on/off, color, and
# on/off, color, and brightness are three independent controls on this one # brightness are three independent controls on this one resource.
# resource. `waterfullAlarmStatus` differs between the two dumps that # `waterfullAlarmStatus` differs between the two dumps that reported this
# reported this href (On vs. Off) so it's a real live flag, not a constant -- # href, so it's a real live flag, but its exact meaning (tank full vs. the
# but its exact meaning (tank actually full vs. the chime feature merely # chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already
# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's # surfaces a live WaterTankFull condition -- exposed read-only as a plain
# alarm_code already surfaces a live WaterTankFull condition when one fires # diagnostic rather than guessed at as a binary_sensor.
# (see common._active_alarm_codes), so this is exposed read-only as a plain
# diagnostic value rather than guessed at as a binary_sensor.
WATERTANK_LIGHTING = Capability( WATERTANK_LIGHTING = Capability(
href="/watertank/lighting/vs/0", href="/watertank/lighting/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -131,13 +126,10 @@ WATERTANK_LIGHTING = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Dehumidifier-scoped coverage: vendor plumbing with no user-actionable
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state # state, following the same rule as airconditioner._AC_IGNORED (same
# or no documented write contract, following the same 'don't guess' rule as # DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in # collide with other families' schemas.
# the global ignored.IGNORED since some of these hrefs collide with other
# families' schemas.
# ---------------------------------------------------------------------------
_DHM_IGNORED = [ _DHM_IGNORED = [
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM) "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
"/da/softreset/vs/0", # soft-reset trigger plumbing "/da/softreset/vs/0", # soft-reset trigger plumbing
@@ -41,26 +41,16 @@ DISHWASHER_SETTINGS = Capability(
), ),
) )
# --------------------------------------------------------------------------- # /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the # dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the # options array (shared laundry.bool_option_switch). Course display names
# same options array (shared laundry.bool_option_switch, same options[] # live in translations under entity.select.dishwasher_cycle.
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
# #
# '83'/'86' were transposed in that catalog until issue #226: both the # '83'/'86' were transposed in that catalog until issue #226: the original
# original DW9000F-class fixture this table was built from and the issue # fixture's own live editCourseList puts them back to back, exactly the
# #226 reporter's board report the identical DeviceType_0812 (a real # kind of adjacent pair a manual screenshot transcription slips on. The
# per-board-generation id also seen on unrelated washer/dryer fixtures, so # reporter's live confirmation (selecting 'Normal' ran the physical Express
# this is one shared course table, not a Table_02/Table_03-style generation # 60 program and vice versa) settled it: '86' is Express 60, '83' is Normal.
# split), and the original fixture's own live editCourseList
# ('EditCourseList_0E07908683848D808E8F') puts '86' and '83' back to back at
# positions 4-5 -- exactly the kind of adjacent pair a manual screenshot
# transcription slips on. The reporter's live confirmation (selecting
# 'Normal' ran the physical Express 60 program and vice versa) settles
# which way: '86' is Express 60, '83' is Normal.
# ---------------------------------------------------------------------------
CYCLE_OPTIONS = Capability( CYCLE_OPTIONS = Capability(
href="/course/vs/0", href="/course/vs/0",
@@ -38,27 +38,19 @@ DRYER_SETTINGS = Capability(
) )
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via # /course/vs/0 -- cycle selection, shared with washer/dishwasher via
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as # laundry.cycle_select. Course display names live in translations under
# an RMW on the options array). Course display names live in translations # entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured # '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle
# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a # on the appliance and reading back the raw code (issue #80); '51' Eco
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical # Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5
# appliance and reading back the raw code from the entity's state (issue # (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were # and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
# confirmed the same way on a DV90DG6845LHU5 (issue #244). The
# /st/dryercourse/vs/0 resource re-encodes the same selected course and is
# ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 is ignored
# for washers.
# #
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's # Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same # DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/ # options[] array -- see laundry.drum_clean_cycles_remaining/
# drum_clean_last_cleaned's docstrings for the field contract, including # drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was
# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No # found on either dump #258 supplied, so if the app surfaces that reminder,
# separate heat-exchanger-clean tracking was found on either dump #258
# supplied (DV90BB7445GES7, DV91T6440LE/SA) -- no HeatExchanger*-prefixed
# token, nor any other options[] entry that looks like a second maintenance
# counter -- so if the Samsung app surfaces that reminder for these units,
# it isn't computed from anything this integration can read locally. # it isn't computed from anything this integration can read locally.
DRYER_COURSE = Capability( DRYER_COURSE = Capability(
href="/course/vs/0", href="/course/vs/0",
@@ -9,11 +9,10 @@ vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0 board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs. shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
zone1 has no HA platform with matching semantics (it's a leaving-water- zone1 has no HA platform with matching semantics (a leaving-water-
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's temperature setpoint, not a thermostat with HVAC modes), so it stays
climate.py would fit), so it stays switch/select/number/sensor -- same shape switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/
as dehumidifier.py's power/mode/humidity split. dhw is a real HA humidity split. dhw is a real HA water_heater.py entity (see DHW below),
water_heater.py -- see DHW below and water_heater.py's module docstring --
following the same primary-resource-plus-sibling-reads pattern as following the same primary-resource-plus-sibling-reads pattern as
airconditioner.py's CLIMATE/climate.py. airconditioner.py's CLIMATE/climate.py.
@@ -35,8 +34,7 @@ def _num(v):
def _first_mode(rep): def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element """Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen so far, mirroring airconditioner._first_mode / list on every dump seen, mirroring airconditioner._first_mode."""
dehumidifier._first_mode's handling of the same field shape."""
modes = rep.get("x.com.samsung.da.modes") modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)): if isinstance(modes, (list, tuple)):
return modes[0] if modes else None return modes[0] if modes else None
@@ -48,14 +46,9 @@ def _temp_unit(rep):
def _bounds(rep, default_min, default_max): def _bounds(rep, default_min, default_max):
"""The resource's own (minimum, maximum) pair, or the defaults. """The resource's own (minimum, maximum) pair, or the defaults. Both
ends together or neither -- a board reporting only one would otherwise
Both ends together or neither -- a board reporting only one would pair a real bound with an invented default, silently wrong."""
otherwise pair a real device bound with an invented default, which
looks plausible and is silently wrong. Same rule as
climate._range()/water_heater._range(), and the same reason
oven._setpoint_bounds resolves its pair in one place.
"""
lo = _num(rep.get("x.com.samsung.da.minimum")) lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum")) hi = _num(rep.get("x.com.samsung.da.maximum"))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max) return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
@@ -102,8 +95,8 @@ ZONE_MODE = Capability(
) )
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/ # type=Water/unit=Celsius on this dump names the space-heating loop's flow/
# room setpoint, not a literal water temperature -- Samsung EHS zone control # room setpoint, not a literal water temperature -- EHS zone control is
# is leaving-water-temperature-based, same convention as the dhw loop below. # leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability( ZONE_TEMPERATURE = Capability(
href="/temperatures/indoor/vs/0", href="/temperatures/indoor/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -134,12 +127,10 @@ ZONE_TEMPERATURE = Capability(
), ),
) )
# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE # Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW
# via DHW below and reads the sibling power/temperature hrefs off the # below and reads the sibling power/temperature hrefs off the coordinator
# coordinator snapshot -- same primary-plus-siblings shape as # snapshot -- same primary-plus-siblings shape as airconditioner.py's
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here # HREF_MODE/CLIMATE_CONSUMED_HREFS.
# and imported by water_heater.py, so a new sibling read can't drift out of
# sync with its DHW_CONSUMED_HREFS coverage entry below.
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
@@ -150,8 +141,7 @@ DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
def _dhw_write(payload, rep, href=None): def _dhw_write(payload, rep, href=None):
"""Map a (kind, value) command from the water_heater platform to the """Map a (kind, value) command from the water_heater platform to the
(path_segs, body) for that one sub-write -- same contract as (path_segs, body) for that one sub-write -- same contract as
airconditioner._climate_write, just across the dhw loop's three airconditioner._climate_write, across the dhw loop's three resources."""
resources instead of the AC's power/mode/temperature/wind set."""
kind, value = payload kind, value = payload
if kind == "power": if kind == "power":
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"}) return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
@@ -174,19 +164,15 @@ DHW = Capability(
# Power and temperature are read by the composite DHW entity above, not # Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no # given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern). # gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS).
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS] DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature. # Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings water_heater does wire this same Samsung capability # HA core's smartthings integration wires this same Samsung capability up
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW # to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth
# operation-mode map above is taken from that integration -- so the # stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one # zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped # would present a device-wide setting as hot-water-only.
# sibling href, unlike every other resource in this loop). Hanging it off
# the DHW card would present a device-wide setting as if it only affected
# hot water. It stays a switch until a board turns up with a per-loop away
# resource to bind instead.
AWAY_MODE = Capability( AWAY_MODE = Capability(
href="/option/outgoing/vs/0", href="/option/outgoing/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -205,13 +191,9 @@ AWAY_MODE = Capability(
), ),
) )
# --------------------------------------------------------------------------- # EHS-scoped coverage: opaque vendor plumbing or resources with no
# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/ # confirmed write contract on this dump. Not in the global ignored.IGNORED
# schedule blobs) or resources with no confirmed write contract on this # since these are EHS-only shapes needing their own verification elsewhere.
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
# Not in the global ignored.IGNORED since these are EHS-only shapes that
# would need their own verification on other device families.
# ---------------------------------------------------------------------------
_EHS_IGNORED = [ _EHS_IGNORED = [
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS) "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
"/da/softreset/vs/0", # soft-reset trigger plumbing "/da/softreset/vs/0", # soft-reset trigger plumbing
@@ -3,17 +3,15 @@
Resources verified against the dump at local-tools/dumps/10.0.0.254.json. Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
Temperature unit is read live from each resource, not assumed: the RF9000B Temperature unit is read live from each resource, not assumed: the RF9000B
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"), dump reports Fahrenheit, but a TP1X_REF_21K dump (issue #7) reports the same
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the fields in Celsius for the exact same resources -- the device tells you which
exact same resources — the device tells you which one it is, it's just never one it is. See `_temp_unit`/`_temp_item_unit` below.
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
from href at write time.
Multi-instance note: the two door resources (/door/cooler/0 and Multi-instance note: the two door resources (/door/cooler/0,
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and /door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0,
/icemaker/two/vs/0) use named path segments, so they are modelled via /icemaker/two/vs/0) use named path segments, so they are modeled via
pattern capabilities that auto-derive distinct entity keys from href segments. pattern capabilities that auto-derive distinct entity keys from href
segments.
""" """
import datetime import datetime
@@ -30,10 +28,8 @@ from ..entities import (
from .common import normalize_temp_unit from .common import normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and # Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in translations/en.json, # ice-making-status enums below live in translations/en.json, keyed by the
# keyed by the lowercased raw device value — select.py and SensorDesc.options # lowercased raw device value.
# normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device.
def _int(v): def _int(v):
@@ -44,14 +40,13 @@ def _int(v):
def _temp_unit(rep): def _temp_unit(rep):
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to """'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults
°F (this module's original assumption) if the device omits the field.""" to °F if the device omits the field."""
return normalize_temp_unit(rep.get("units")) return normalize_temp_unit(rep.get("units"))
# --------------------------------------------------------------------------- # Temperature (generic -- covers /temperature/current/* and
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*) # /temperature/desired/*)
# ---------------------------------------------------------------------------
TEMP_CURRENT_GENERIC = Capability( TEMP_CURRENT_GENERIC = Capability(
href=None, href=None,
@@ -74,14 +69,10 @@ TEMP_CURRENT_GENERIC = Capability(
def _temp_setpoint_write(p, rep, href=None, resources=None): def _temp_setpoint_write(p, rep, href=None, resources=None):
"""Write temperature — prefer vendor /temperatures/vs/0 when available, """Prefer vendor /temperatures/vs/0 when present, else the direct OCF
fall back to direct OCF /temperature/desired/ write otherwise. /temperature/desired/ write -- on some models only the vendor path
commits. Item IDs follow the Samsung convention: "0" = Freezer,
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor "1" = Fridge/Cooler."""
/temperatures/vs/0. On some models only the vendor path commits the change;
on others both work. Using the vendor path when present is always correct.
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
"""
if not href: if not href:
return None return None
if resources and "/temperatures/vs/0" in resources: if resources and "/temperatures/vs/0" in resources:
@@ -127,18 +118,12 @@ TEMP_SETPOINT = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Discrete cooler setpoint (issue #186): single-door "cooler only" fridges
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only") # report no /temperature/current|desired/* pair, only this vendor resource
# fridges report no /temperature/current|desired/* pair at all (this href # bundling the live desired value with the specific values the unit
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/' # accepts. supportedList (e.g. ['1','2','3','4','7']) is not a contiguous
# or '/temperature/desired/' prefixes), only this one vendor resource that # range, so this is a select reading its own live options rather than a
# bundles the live desired value together with the *specific* values the # NumberDesc with min/max/step.
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
# user pick an unsupported value; modeled as a select reading its own live
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
# ---------------------------------------------------------------------------
def _definite_cooler_write(p, rep, href=None): def _definite_cooler_write(p, rep, href=None):
@@ -171,11 +156,8 @@ def _definite_freezer_write(p, rep, href=None):
) )
# Freezer half of the same discrete-setpoint pattern (issue #229): a # Freezer half of the same discrete-setpoint pattern (issue #229) -- same
# fridge/freezer combo reporting no /temperature/current|desired/freezer # shape as DEFINITE_TEMPERATURE_COOLER, negative supportedList values.
# pair, only this bundled vendor resource -- identical shape to
# DEFINITE_TEMPERATURE_COOLER above (down to the field names), just negative
# supportedList values (e.g. ['-23','-21','-19','-17','-15']).
DEFINITE_TEMPERATURE_FREEZER = Capability( DEFINITE_TEMPERATURE_FREEZER = Capability(
href="/temperature/definite/freezer/vs/0", href="/temperature/definite/freezer/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -191,10 +173,6 @@ DEFINITE_TEMPERATURE_FREEZER = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Icemaker nighttime quiet mode
# ---------------------------------------------------------------------------
ICEMAKER_NIGHTTIME = Capability( ICEMAKER_NIGHTTIME = Capability(
href="/icemaker/nighttime/vs/0", href="/icemaker/nighttime/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -213,18 +191,13 @@ ICEMAKER_NIGHTTIME = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Icemaker (generic -- covers /icemaker/one/vs/0, /icemaker/two/vs/0).
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0) # /icemaker/status/vs/0 is an exact-href cap and binds first;
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state). # /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
#
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE", # Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") -- read via name_field, reaching the translated name as the # "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two # two makers can both be enabled at once (issue #27), so they stay
# ice makers are independent on/off toggles that can both be enabled at once # separate entities rather than one ice-type select.
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
# ---------------------------------------------------------------------------
def _icemaker_write(field): def _icemaker_write(field):
@@ -273,10 +246,6 @@ ICEMAKER_GENERIC = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Door alert tone
# ---------------------------------------------------------------------------
DOOR_ALERT = Capability( DOOR_ALERT = Capability(
href="/settings/sound/alert/door/vs/0", href="/settings/sound/alert/door/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -296,10 +265,6 @@ DOOR_ALERT = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Status/lock — auto door opener and fridge sound
# ---------------------------------------------------------------------------
def _status_lock_write(field): def _status_lock_write(field):
return lambda p, rep, href=None: ( return lambda p, rep, href=None: (
@@ -331,18 +296,11 @@ STATUS_LOCK = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Defrost delay / active-defrost status
#
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled # /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying # defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming,
# status: despite its "block" naming (originally assumed to mean "defrost # live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the # running right now (seen with defrost_delay off) -- "block" refers to the
# defrost cycle is *actively running* right now, seen with defrost_delay # evaporator/coil block being defrosted, not a prevention state.
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
# not a blocking/prevention state. Exposed as a read-only diagnostic
# binary sensor.
# ---------------------------------------------------------------------------
DEFROST_DELAY = Capability( DEFROST_DELAY = Capability(
href="/defrost/delay/vs/0", href="/defrost/delay/vs/0",
@@ -362,10 +320,9 @@ DEFROST_DELAY = Capability(
), ),
) )
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K # OCF-native boolean mirror of DEFROST_DELAY -- only the vendor resource
# firmware publishes the same state on both hrefs, but only the vendor resource # above has a confirmed write contract, so bind this without another
# above has a confirmed write contract. Bind the native mirror without another # entity to record it as an intentional duplicate.
# entity so discovery records it as an intentional duplicate.
DEFROST_DELAY_NATIVE_DUPLICATE = Capability( DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
href="/defrost/delay/0", href="/defrost/delay/0",
) )
@@ -384,10 +341,6 @@ DEFROST_BLOCK_STATUS = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
def _refrigeration_write(field_name): def _refrigeration_write(field_name):
def _write(p, rep, href=None): def _write(p, rep, href=None):
@@ -421,10 +374,6 @@ REFRIGERATION = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Autofill
# ---------------------------------------------------------------------------
def _autofill_write(p, rep, href=None): def _autofill_write(p, rep, href=None):
if p not in ("On", "Off"): if p not in ("On", "Off"):
@@ -447,10 +396,6 @@ AUTOFILL = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Welcome lighting (proximity-triggered cabinet light)
# ---------------------------------------------------------------------------
WELCOME_LIGHTING = Capability( WELCOME_LIGHTING = Capability(
href="/proximity/vs/0", href="/proximity/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -469,14 +414,11 @@ WELCOME_LIGHTING = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Enhanced cabinet light nighttime schedule: night.starttime is an ISO
# Enhanced cabinet light — nighttime lighting schedule # datetime (only the time portion matters), night.duration.minute is the
# # window length. End time is derived so both time entities write back to
# night.starttime is an ISO datetime; only the time portion is meaningful. # the same resource without stepping on each other: writing start
# night.duration.minute encodes the window length. End time is derived so # preserves duration; writing end recalculates it.
# both time entities write back to the same resource without stepping on each
# other: writing start preserves duration; writing end recalculates duration.
# ---------------------------------------------------------------------------
_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100") _NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100")
@@ -600,10 +542,6 @@ CABINET_LIGHT_ENHANCED = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Cabinet light
# ---------------------------------------------------------------------------
def _cabinet_light_write(p, rep, href=None): def _cabinet_light_write(p, rep, href=None):
if p not in ("On", "Off"): if p not in ("On", "Off"):
@@ -638,10 +576,6 @@ CABINET_LIGHT = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Sabbath mode
# ---------------------------------------------------------------------------
def _sabbath_write(p, rep, href=None): def _sabbath_write(p, rep, href=None):
if p not in ("On", "Off"): if p not in ("On", "Off"):
@@ -664,10 +598,6 @@ SABBATH = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Beverage zone
# ---------------------------------------------------------------------------
def _bzone_write(p, rep, href=None): def _bzone_write(p, rep, href=None):
return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p} return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p}
@@ -689,16 +619,11 @@ BEVERAGE_ZONE = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Pantry / Cool Select Zone -- a convertible compartment toggled between # Pantry / Cool Select Zone -- a convertible compartment toggled between
# wine/deli/drinks temperature presets (issue #20). Same shape as # wine/deli/drinks presets (issue #20). Same shape as BEVERAGE_ZONE but a
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes # distinct field set (x.com.samsung.da.mode/supportedOptions vs
# list) but a distinct resource/field set -- x.com.samsung.da.mode / # roomDesiredMode/roomSupportedModes). Only a "one" instance seen; not
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than # generalized to a pattern cap until a second instance turns up.
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
# instance has been seen; not generalized to a pattern cap until a second
# instance turns up.
# ---------------------------------------------------------------------------
def _pantry_write(p, rep, href=None): def _pantry_write(p, rep, href=None):
@@ -721,18 +646,13 @@ PANTRY_ZONE = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Flex zone (convertible drawer -- /mode/vs/0 on RF9000-class fridges):
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges) # x.com.samsung.da.modes holds several orthogonal flags in one list; the
# # flex-zone entry is whichever item also appears in supportedOptions (the
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the # other flags, WATERFILTER_*/DEFROST_BLOCK_*/CVN_*_ZONE, never do). The
# flex-zone entry is whichever item is also a member of supportedOptions -- # prefix on that item varies by family (CV_TTYPE_RF9000A_ vs CV_FDR_ on
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker) # Bespoke, issues #27/#26), so match by list membership instead of a
# never appear there. The prefix on that item varies by fridge family # hardcoded prefix. Write replaces only that item.
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
# stuck on None), so match by list membership instead of a hardcoded prefix.
# Write replaces only that item; other flags are preserved.
# ---------------------------------------------------------------------------
def _flex_zone_supported(rep): def _flex_zone_supported(rep):
@@ -740,10 +660,9 @@ def _flex_zone_supported(rep):
def _flex_zone_current(rep): def _flex_zone_current(rep):
# Every dump seen has at most one modes/supportedOptions overlap, so # Every dump seen has at most one modes/supportedOptions overlap; a
# "first match" and "strip all matches" (in the write below) agree. If a # future device reporting two would read the first and the write below
# future device ever reports two, this reads the first and the write # would drop both.
# would drop both -- revisit if that turns up.
modes = rep.get("x.com.samsung.da.modes") or [] modes = rep.get("x.com.samsung.da.modes") or []
supported = _flex_zone_supported(rep) supported = _flex_zone_supported(rep)
return next((m for m in modes if m in supported), None) return next((m for m in modes if m in supported), None)
@@ -767,15 +686,11 @@ FLEX_ZONE = Capability(
entity_category="config", entity_category="config",
options_field="x.com.samsung.da.supportedOptions", options_field="x.com.samsung.da.supportedOptions",
# A nonempty supportedOptions alone isn't sufficient: the # A nonempty supportedOptions alone isn't sufficient: the
# kimchi-refrigerator family (issue #26) also populates # kimchi-refrigerator family (issue #26) also populates both
# /mode/vs/0's modes/supportedOptions with real data, but # fields, but its tokens carry a "_[n]:[n]" suffix on
# its tokens carry a "_[n]:[n]" parameter suffix on # supportedOptions that modes never repeats, so nothing ever
# supportedOptions that modes never repeats, so no item # overlaps there. Require an actual resolvable value so this
# ever overlaps -- the RF9000/Bespoke-class overlap this # stays absent on that family instead of stuck on "unknown".
# 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, exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
rep_fn=_flex_zone_current, rep_fn=_flex_zone_current,
write_fn=_flex_zone_write, write_fn=_flex_zone_write,
@@ -783,18 +698,13 @@ FLEX_ZONE = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Generic door pattern capability (href=None -- use as pattern_cap only)
# Generic door pattern capability (href=None — use as pattern_cap only)
# ---------------------------------------------------------------------------
def _door_open_state(rep): def _door_open_state(rep):
"""Most /door/* resources report bare `openState`, but the """Most /door/* resources report bare `openState`, but the
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83) ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77,
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This #83) reports `x.com.samsung.da.openState` instead -- check both."""
capability still binds either way (href_prefix match doesn't care about
field names), but a plain `field=` lookup against the wrong key means
the entity exists and is permanently unavailable -- check both."""
v = rep.get("openState") v = rep.get("openState")
if v is None: if v is None:
v = rep.get("x.com.samsung.da.openState") v = rep.get("x.com.samsung.da.openState")
@@ -816,45 +726,27 @@ DOOR_GENERIC = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi # units, issue #26): top/middle/bottom each report their own storage mode
# units, issue #26) -- top/middle/bottom each report their own storage mode # plus a ripening status/timer on /status/kimchi/<slot>/vs/0, modeled as a
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in # pattern capability the same way DOOR_GENERIC is. Only the top
# an identical shape; modeled as a pattern capability the same way # compartment's door is reported separately (kimchidoors); middle/bottom
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment # apparently have no contact switch, hence the narrower KIMCHI_DOOR_GENERIC
# key and {instance_name} from the href's top/middle/bottom segment. Only # below rather than assuming it's universal.
# 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 # The same state is also packed into single tokens (e.g.
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with # "KIMCHIT_KIMCHI_STORAGE_NORMAL") on /mode/vs/0, the resource FLEX_ZONE
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads # reads for RF9000-class fridges -- this binds to /status/kimchi/<slot>/
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/ # vs/0's plain, self-describing currentMode/supportMode instead.
# 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 path is unconfirmed on real hardware; same "write the field back to
# "write the same field back to the entity's own href" convention as # the entity's own href" convention as PANTRY_ZONE/BEVERAGE_ZONE.
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
# #
# translations/en.json's kimchi_zone_mode state labels were translated # translations/en.json's kimchi_zone_mode labels were translated directly
# directly from the reporter's own (Korean-language) SmartThings app # from the reporter's own Korean SmartThings app screenshots (not guessed),
# screenshots, not guessed from the codes or from their English paraphrase. # and cross-checked against supportMode order to confirm the on-screen
# Cross-checking the screenshots against supportMode confirms the on-screen # option order matches the array order throughout -- so COLD/WARM
# option order matches the array order everywhere it's verifiable: the top # consistently means Strong/Weak everywhere that suffix appears.
# 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): def _kimchi_mode_write(p, rep, href=None):
@@ -896,9 +788,8 @@ KIMCHI_ZONE = Capability(
icon="mdi:timer-sand", icon="mdi:timer-sand",
translation_key="kimchi_ripening_remaining", translation_key="kimchi_ripening_remaining",
entity_category="diagnostic", entity_category="diagnostic",
# No dump has this nonzero (ripeStatus is always "Off" so # No dump has this nonzero (ripeStatus is always "Off" so far)
# far) -- device-reported unit unconfirmed, so this stays # -- unit unconfirmed, so this stays a bare number.
# a bare number rather than asserting minutes or hours.
value_fn=_int, value_fn=_int,
), ),
SensorDesc( SensorDesc(
@@ -921,12 +812,11 @@ KIMCHI_DOOR_GENERIC = Capability(
poll_tier="hot", poll_tier="hot",
entities=( entities=(
# Not deduped against DOORS_FALLBACK below: on the one reporter # Not deduped against DOORS_FALLBACK below: on the one reporter
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the # this binds alongside, /doors/vs/0's aggregate carries a single
# /doors/vs/0 aggregate carries a single generic item (id "4", no # generic item (id "4", no /door/<instance> siblings) that doesn't
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see) # share this compartment's "top" numbering -- a distinct
# that doesn't share this compartment's "top" instance numbering -- # main-cabinet door, not this drawer's contact switch reported
# a distinct main-cabinet door, not this kimchi drawer's own contact # twice.
# switch reported twice.
BinarySensorDesc( BinarySensorDesc(
key="open", key="open",
rep_fn=_door_open_state, rep_fn=_door_open_state,
@@ -937,19 +827,12 @@ KIMCHI_DOOR_GENERIC = Capability(
), ),
) )
# --------------------------------------------------------------------------- # Aggregate-resource fallbacks: /doors/vs/0, /temperatures/vs/0, and
# Aggregate-resource fallbacks # /icemaker/status/vs/0 each duplicate information the per-instance hrefs
# # above expose more precisely, on hardware that has them -- not every
# /doors/vs/0, /temperatures/vs/0, and /icemaker/status/vs/0 each duplicate # fridge does. Each fallback's match_fn checks for the richer sibling
# information exposed more precisely by per-instance hrefs (DOOR_GENERIC, # hrefs and only binds when they're absent, so it's a no-op wherever the
# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware # richer hrefs exist and a real (coarser) source where they don't.
# that has them. Not every fridge does — a simpler model may only ever
# advertise the aggregate resource. Each fallback's match_fn checks the
# full resource set for the richer sibling hrefs and only binds when
# they're absent, so it's a no-op (not a gap — see discovery.py) wherever
# the richer hrefs exist, and a real (if coarser) source of the same data
# where they don't.
# ---------------------------------------------------------------------------
def _any_door_generic(resources): def _any_door_generic(resources):
@@ -1045,23 +928,19 @@ ICEMAKER_STATUS_FALLBACK = Capability(
), ),
) )
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured # OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit # TP1X_REF_21K it duplicates both the vendor aggregate and the richer
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so # per-unit hrefs; its write contract isn't advertised, so bind it as a
# keep the proven per-unit/vendor controls and bind this as a duplicate only. # duplicate only.
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
href="/icemaker/status/0", href="/icemaker/status/0",
) )
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out # OCF-native /refrigeration/0 (issue #7): its three fields duplicate two
# in this section: its three fields duplicate two *different* richer # different richer hrefs (REFRIGERATION's rapidFridge/rapidFreezing,
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and # DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a # gating is per-entity (exists_fn) rather than one capability-level
# single capability-level match_fn can't express it. Gated per-entity # match_fn. No write path confirmed, so these stay read-only.
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
# REFRIGERATION's href is present; defrost_active only when
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
# these are read-only, unlike REFRIGERATION's switches.
REFRIGERATION_FALLBACK = Capability( REFRIGERATION_FALLBACK = Capability(
href="/refrigeration/0", href="/refrigeration/0",
poll_tier="warm", poll_tier="warm",
@@ -19,9 +19,9 @@ here would silently do nothing on that path. Enumerate each known href
instead; it's a short, stable list. instead; it's a short, stable list.
This list is maintainer-curated only; there is no per-installation This list is maintainer-curated only; there is no per-installation
override. Grow it as real /device/0 dumps surface more universal noise — override. Grow it as real /device/0 dumps surface more universal noise --
do not add a href here on a guess. If a href's relevance is unclear, leave never on a guess. If a href's relevance is unclear, leave it unbound so it
it unbound so it surfaces as a gap for a human to look at. surfaces as a gap for a human to look at.
""" """
from ..capability import Capability from ..capability import Capability
@@ -61,12 +61,9 @@ IGNORED: list[Capability] = [
# Redundant with capabilities already declared elsewhere. # Redundant with capabilities already declared elsewhere.
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME). # /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
Capability(href="/speakersound/vs/0"), Capability(href="/speakersound/vs/0"),
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da. # No entities of its own -- editCourseList is read directly out of the
# editCourseList is read directly out of the resource snapshot by # resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select # cycle selects to build the device's supported course list.
# (options=_cycle_options) to build that device's actual supported
# course list, rather than exposing this href's raw byte string
# through its own entity.
Capability(href="/wm/editcourse/vs/0"), Capability(href="/wm/editcourse/vs/0"),
# Bixby audio feedback (chime + volume played when Bixby starts/stops # Bixby audio feedback (chime + volume played when Bixby starts/stops
# listening) — only meaningful with Bixby enabled, which this # listening) — only meaningful with Bixby enabled, which this
@@ -96,17 +93,13 @@ IGNORED: list[Capability] = [
# Temperature-unit display preference, redundant with HA's own units. # Temperature-unit display preference, redundant with HA's own units.
Capability(href="/wm/submode/vs/0"), Capability(href="/wm/submode/vs/0"),
# Read-only re-encoding of the course already exposed by # Read-only re-encoding of the course already exposed by
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode # washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed
# is literally "Table_02_Course_<same hex code>"). # "Table_02_Course_").
Capability(href="/st/washercourse/vs/0"), Capability(href="/st/washercourse/vs/0"),
# Dryer counterpart of the above: re-encoding of the course already # Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0.
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href="/st/dryercourse/vs/0"), Capability(href="/st/dryercourse/vs/0"),
# AirDresser counterpart of the above (issue #157): read only for its # AirDresser counterpart (issue #157): read only for its courseTable id
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no # (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own.
# entity of its own -- same "no entity, just the table id" role as
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
Capability(href="/st/airdressercourse/vs/0"), Capability(href="/st/airdressercourse/vs/0"),
# Empty on every washer dump seen so far. # Empty on every washer dump seen so far.
Capability(href="/wm/welcomemsg/vs/0"), Capability(href="/wm/welcomemsg/vs/0"),
@@ -114,29 +107,21 @@ IGNORED: list[Capability] = [
# state without a multi-slot editor; revisit if that becomes valuable. # state without a multi-slot editor; revisit if that becomes valuable.
Capability(href="/wm/personalcourse/vs/0"), Capability(href="/wm/personalcourse/vs/0"),
# OCF-native energy resource is empty ({}) on washer hardware seen so # OCF-native energy resource is empty ({}) on washer hardware seen so
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real # far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only # real source.
# real source for this control.
Capability(href="/energy/consumption/0"), Capability(href="/energy/consumption/0"),
# Empty ({}) on every washer dump seen so far -- nothing to expose. # Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href="/cycleinterface/vs/0"), Capability(href="/cycleinterface/vs/0"),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program # OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally. # dependency this integration doesn't support locally.
Capability(href="/drlc/0"), Capability(href="/drlc/0"),
# OCF-native duplicate of /operational/state/vs/0, which is already # OCF-native duplicate of /operational/state/vs/0, already modeled by
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable # operational.OPERATIONAL_STATE (richer, write-capable, used by washer,
# capability with start/pause/stop buttons and a delay-start control) # dishwasher, dryer, oven). This generic href is read-only overlapping
# used by washer, dishwasher, dryer, and oven. This generic href only # data with no verified write contract worth building around.
# carries read-only overlapping data (current job state, remaining
# time, progress percentage) with no write path -- not worth building a
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href="/operational/state/0"), Capability(href="/operational/state/0"),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP # Cooktop guided-cooking/recipe status (issue #86): every field
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and # empty/zero on the only dump seen (device idle). Same "don't guess"
# a textData.menu string -- every field empty/zero on the only dump # treatment as the microwave family's /recipe/cook/vs/0.
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href="/cooktop/recipe/status/vs/0"), Capability(href="/cooktop/recipe/status/vs/0"),
] ]
@@ -178,33 +178,24 @@ BUZZER_SOUND = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Cycle selection over /course/vs/0. # Cycle selection over /course/vs/0.
# #
# The selected course and every other user-tunable option ride in the # The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens. # x.com.samsung.da.options array as `<Prefix>_<value>` tokens. Confirmed on
# Confirmed on real hardware (issue #54): a write only needs to carry the one # real hardware (issue #54): a write only needs to carry the one changed
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` -- # token -- the device matches by prefix, evicts the stale token, and merges
# the device matches by prefix, evicts the stale token, and merges the result # the result itself (see option_write). The set of selectable courses is
# into the array itself. No read-modify-write of the whole array needed (see # read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not
# option_write). The set of *selectable* courses is not hardcoded -- it's read # hardcoded. Course codes are uppercase hex; display names live in
# live from # translations under entity.select.<translation_key>.state.<id lowercased>.
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
# never show a course a given model doesn't have or hide one it does. Course
# codes are uppercase hex; display names live in translations under
# entity.select.<translation_key>.state.<id lowercased> so they can be
# localized -- every device-enum select in this integration works this way.
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
# #
# Some boards populate /wm/editcourse/vs/0 without ever filling in # Some boards populate /wm/editcourse/vs/0 without ever filling in
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving # editCourseList itself (issue #1) -- cycle_options() falls back to
# the same list from /course/vs/0's own supportedOptions in that case; see # deriving the list from /course/vs/0's own supportedOptions in that case;
# _course_codes_from_supported_options for the byte-level evidence. # see _course_codes_from_supported_options.
# #
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards # Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family
# expose the same /course/vs/0 options contract. # boards expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
def hex_pairs(codes): def hex_pairs(codes):
@@ -237,13 +228,11 @@ def option_value(options, prefix):
# Drum Clean+ maintenance tracking, from the same options[] array as the # Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue # selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258); both families use identical DrumCleanProposal_/WashingTimes_/ # #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
# DrumCleanLog_ tokens. DrumCleanProposal_<N> is the wash/dry-cycle interval # DrumCleanProposal_<N> is the cycle interval between recommended cleans;
# between recommended cleans; WashingTimes_<N> is the count since the last # WashingTimes_<N> is the count since the last one -- their difference is
# one -- their difference is exactly the "N cycles until due" figure the # the "N cycles until due" figure the app shows (verified: 40 - 3 == 37,
# Samsung app shows (verified on a washer: DrumCleanProposal_40 - # matching a live app screenshot).
# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia
# po 37 cykloch").
def drum_clean_cycles_remaining(rep): def drum_clean_cycles_remaining(rep):
opts = rep.get("x.com.samsung.da.options") or [] opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal") proposal = option_value(opts, "DrumCleanProposal")
@@ -257,14 +246,10 @@ def drum_clean_cycles_remaining(rep):
# DrumCleanLog_ is the clean-history field: a washer reports one bare ISO # DrumCleanLog_ is the clean-history field: a washer reports one bare ISO
# datetime (the last clean, verified against the same app screenshot's "10 # datetime (the last clean); a dryer (issue #258) instead reports a
# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a # '|'-joined history of every past clean in increasing order. Splitting on
# '|'-joined history of every past clean, ten deep on that dump, in # '|' and taking the last element handles both shapes identically. No
# strictly increasing order. Splitting on '|' and taking the last element # timezone accompanies either shape, so it's treated as UTC.
# handles both shapes identically -- a no-'|' value is unaffected. No
# explicit timezone field accompanies either shape, so it's treated as UTC,
# matching this integration's convention for other bare ISO datetime fields
# (see fridge.py's night-light schedule comment).
def drum_clean_last_cleaned(rep): def drum_clean_last_cleaned(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog") raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw: if not raw:
@@ -278,41 +263,27 @@ def drum_clean_last_cleaned(rep):
def _course_codes_from_supported_options(course_rep): def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable """Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions course list from /course/vs/0's own supportedOptions instead (issue #1:
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the some boards populate /wm/editcourse/vs/0 but never fill in
/wm/editcourse/vs/0 href but never fill in editCourseList itself). editCourseList itself).
supportedOptions is a 1-hex-nibble header followed by one fixed-width supportedOptions is a 1-hex-nibble header followed by one fixed-width
record per selectable course, self-indexed rather than positional -- record per selectable course, self-indexed rather than positional --
the first byte of every record is that course's own hex code, just in the first byte of every record is that course's own hex code.
the firmware's own internal order, not editCourseList's. Confirmed Confirmed against six independent real-world dumps: every one divides
against six independent real-world washer/dryer/dishwasher dumps: every evenly into `header + N * K bytes` with fully unique first bytes across
one divides evenly into `header + N * K bytes` with fully unique first all N records, at the record's true byte width.
bytes across all N records, at the record's true byte width. (What the
rest of each record encodes is still unconfirmed -- this only uses the
course-code byte.)
Two guards, deliberately conservative rather than guessing further: the Two conservative guards rather than guessing further: the derived codes
derived codes must (a) all be distinct -- a real course table, not must all be distinct, and must include whatever course is currently
noise -- and (b) include whatever course is currently selected selected. If no split satisfies both, this returns [].
(x.com.samsung.da.options' Course_<code> token), which must always be a
member of its own device's valid list. If no split satisfies both, this
returns [] rather than guess.
Among splits that satisfy both, the *smallest* passing K wins, rather Among splits that satisfy both, the smallest passing K wins -- more
than requiring a single unambiguous one -- more than one K reliably than one K reliably passes on real data, and smallest-K-wins matches
does pass on real data (e.g. the shipped dishwasher fixture: true the confirmed answer on all six dumps checked, though it's a heuristic
K=7 passes, but so do 10, 14, and 35, none of which are multiples of rather than a proof. Not guarded further: course tables are typically
7 -- position 0 always lands on the same real course code regardless large enough that colliding by chance on both checks is unlikely, and
of K, which is enough on its own to satisfy the current-course guard no device seen so far needs it.
for several unrelated splits). Smallest-K-wins is a heuristic, not a
proof: it matches the confirmed answer on every one of six independent
real-world dumps this was checked against, but a coincidentally
unique, current-course-inclusive *smaller* K is not mathematically
impossible on some future device, and would be picked silently. Not
guarded against further here, since course tables are typically large
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
""" """
raw = course_rep.get("x.com.samsung.da.supportedOptions") raw = course_rep.get("x.com.samsung.da.supportedOptions")
hexstr = raw[0] if isinstance(raw, list) and raw else raw hexstr = raw[0] if isinstance(raw, list) and raw else raw
@@ -340,7 +311,7 @@ def _course_codes_from_supported_options(course_rep):
def option_write(prefix, new_value): def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment """A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array.""" above for why this doesn't read/rewrite the whole array."""
return [f"{prefix}_{new_value}"] return [f"{prefix}_{new_value}"]
@@ -360,35 +331,24 @@ def _table_id(resources, table_href):
def cycle_select(*, translation_key, icon, table_href=None): def cycle_select(*, translation_key, icon, table_href=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`. """A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The option list, current value, and write path are all shared across The option list, current value, and write path are shared across
washer/dryer/dishwasher; only the translation is family- (and, for washer/dryer/dishwasher; only the translation is family/board-specific.
washer/dryer, board-) specific.
table_href (washer/dryer only -- see washer.py/dryer.py's call sites) table_href (washer/dryer only) suffixes translation_key with the
suffixes translation_key with the device's own course-table id, read device's own course-table id, read from /st/washercourse/vs/0 or
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's /st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' ->
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' -> 'washer_cycle_table_02'). This matters because course codes are NOT
'washer_cycle_table_02'). An absent or unrecognized table id gets the guaranteed consistent across board generations sharing the same
name-only ``cycle`` translation key while the raw course code remains /course/vs/0 contract: washer_cycle_table_02 was confirmed against
visible and writable. Table_02 devices, but FlexWash's older board reports Table_00, where
the same hex code could mean a different course. An absent or
This matters because course codes are NOT guaranteed consistent across unrecognized table id falls back to the name-only ``cycle`` key
board generations sharing the same /course/vs/0 contract: every code in instead of borrowing a label from another board generation --
washer_cycle_table_02 was confirmed against Table_02-reporting devices translating a new table is a translations-only change.
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
Table_00 instead, so the same hex code could mean a different course
there for all we've verified. So a table-specific key is used only when
the shipped catalog actually has one; any other table (Table_00 today,
whatever ships next) falls back to the name-only ``cycle`` key, which
shows the raw course code rather than a label borrowed from another
board generation. Translating a new table is therefore a
translations-only change -- add the ``<family>_cycle_<table>`` entry and
this resolver picks it up.
Left at its default for dishwasher, which has no equivalent table-id Left at its default for dishwasher, which has no equivalent table-id
resource in any dump seen and no evidence its course codes vary by resource and no evidence its codes vary by table the way washer/
table the way washer/dryer's do -- there's nothing to build a dryer's do.
table-specific key from.
""" """
key = translation_key key = translation_key
if table_href is not None: if table_href is not None:
@@ -411,14 +371,11 @@ def cycle_select(*, translation_key, icon, table_href=None):
) )
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a # Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as # '<prefix>_On'/'<prefix>_Off' token, merged the same way as the 'Course'
# the 'Course' token above. Shared by washer (bubble soak, pre-wash, # token above. Shared by washer (bubble soak, pre-wash, intensive -- issue
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) -- # #22) and dishwasher (storm wash, auto release dry), just with different
# both families ride this exact contract, just with different prefixes and # prefixes and presence/validation needs on top.
# different presence/validation needs on top.
# ---------------------------------------------------------------------------
def bool_option_write(prefix): def bool_option_write(prefix):
@@ -450,12 +407,11 @@ def bool_option_switch(
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token. """A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report gate_on_presence self-gates the entity off on models that never report
the token at all (washer's bubble soak/pre-wash/intensive); leave False the token (washer's bubble soak/pre-wash/intensive); leave False for a
for a toggle every device in the family reports (dishwasher's storm toggle every device in the family reports (dishwasher's storm wash).
wash). validate_fn is passed straight through to SwitchDesc for callers validate_fn passes straight through to SwitchDesc for callers that need
that need to reject a write against live device state (e.g. washer's to reject a write against live state -- this factory has no opinion on
per-course availability check) -- this factory has no opinion on it and it.
building one, if needed, is the caller's job.
""" """
return SwitchDesc( return SwitchDesc(
key=key, key=key,
@@ -468,14 +424,11 @@ def bool_option_switch(
) )
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason # /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus # (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An # that populates it. An earlier dryer descriptor read
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump # x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried,
# ever carried that field, so the dryer sensor was always blank -- fixed by # so the dryer sensor was always blank -- fixed by sharing this one reader.
# sharing this one reader.
# ---------------------------------------------------------------------------
JOB_BEGINNING_STATUS = Capability( JOB_BEGINNING_STATUS = Capability(
href="/wm/jobbeginingstatus/vs/0", href="/wm/jobbeginingstatus/vs/0",
@@ -8,33 +8,25 @@ oven.py in by_type/microwave.py rather than duplicated. What's genuinely
different from an oven, and defined fresh here: different from an oven, and defined fresh here:
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/ * Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
KeepWarm never appear on an oven's /mode/vs/0, and this family spells KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding
some shared-sounding modes differently than oven.py's own constants modes are spelled differently (e.g. 'AirFryer', not oven.py's
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and 'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE.
mode list, not oven.OVEN_MODE.
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec * Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's (issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range.
30-270 C range (verified against a different, bake-oven-class board). * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W)
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W that plain ovens don't report -- exposed as its own sensor.
on the MicroWave mode's powerListData) that plain ovens don't report -- * Lamp: this family's option-array token is bare 'Lamp' (issue #137), not
exposed as its own sensor. oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's #121), so it's exists_fn-gated rather than assumed universal. 'On' has
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on never been observed as a value; the only confirmed non-Off token is
the combi dump (issue #121), so it's gated with exists_fn rather than 'High' (issue #152) -- the switch treats any non-Off/non-None value as
assumed universal like oven.py's lamp switch. Issue #137's dump only "on" for reads and writes back 'High'/'Off'.
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
ME7500D dump is the first to show a real non-Off value, and it's 'High'
(a brightness level, not literally 'On') -- the switch now treats any
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
(the two confirmed tokens) rather than the never-confirmed 'On'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep' * Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), both with On and Off observed live option-array tokens (issue #181), gated with exists_fn like Lamp since
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
the MW7300B combi dump has neither. the MW7300B combi dump has neither.
Note: cooking-mode writes are unproven here, same caveat as oven.py's Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write -- exposed as a SelectDesc for fidelity, first real-world write is the test.
is also the test.
""" """
from ..capability import Capability from ..capability import Capability
@@ -46,13 +38,10 @@ from .laundry import option_value, option_write
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Union of every mode seen across the two known dumps: issue #121's combi # Union of every mode seen across the two known dumps (issues #121, #137).
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/ # No dump has shown every mode below on one device -- the select surfaces
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's # whatever a given board's own supportedModes reports; an entry here a
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown # device never sends just never gets picked.
# 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 = ( _MICROWAVE_MODES = (
"NoOperation", "NoOperation",
"MicroWave", "MicroWave",
@@ -67,22 +56,18 @@ _MICROWAVE_MODES = (
"KeepWarm", "KeepWarm",
) )
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40, # Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C,
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family; # step 5. No Fahrenheit dump exists for this family, unlike oven.py's own
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified # independently-verified F bounds, so this module only exposes the
# against issue #44's range dump), there's nothing to verify a microwave's # setpoint control when the live unit is Celsius (see _microwave_temp_unit).
# 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_MIN_C = 40
SETPOINT_MAX_C = 200 SETPOINT_MAX_C = 200
SETPOINT_STEP_C = 5 SETPOINT_STEP_C = 5
def _microwave_temp_unit(rep): def _microwave_temp_unit(rep):
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[] """Same shape as oven.py's _oven_temp_unit. Both known dumps report
carries a per-item x.com.samsung.da.unit field. Both known dumps for 'Celsius'; kept live rather than hardcoded (issue #7)."""
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 [] items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C") return normalize_temp_unit(unit, default="°C")
@@ -90,8 +75,7 @@ def _microwave_temp_unit(rep):
def _setpoint_write(p, rep, href=None): def _setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array -- unproven for this """RMW write to /temperatures/vs/0 items array -- unproven for this
family (no live write confirmed against a real unit), same "exposed for family, same "exposed for fidelity" caveat as the mode select."""
fidelity" caveat as the mode select."""
try: try:
temp = float(p) temp = float(p)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -118,12 +102,11 @@ def _power_level_watts(v):
def _cooking_mode_options(resources): def _cooking_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when """Live mode list from the device's own supportedModes when reported
it reports one (both known dumps do); the union-of-all-dumps (both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback otherwise. Same live-first, static-fallback pattern as
pattern as oven._oven_mode_options -- a fixed list here would offer oven._oven_mode_options -- a fixed list would offer modes a unit
users modes their own unit doesn't have (issue #152's ME7500D reports doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11)."""
only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get("/mode/vs/0") or {} rep = resources.get("/mode/vs/0") or {}
live = rep.get("x.com.samsung.da.supportedModes") live = rep.get("x.com.samsung.da.supportedModes")
return list(live) if live else list(_MICROWAVE_MODES) return list(live) if live else list(_MICROWAVE_MODES)
@@ -163,9 +146,8 @@ def _lamp_write(p, rep, href=None):
return None return None
if not rep.get("x.com.samsung.da.options"): if not rep.get("x.com.samsung.da.options"):
return None return None
# 'High' and 'Off' are the two tokens actually confirmed on live dumps # 'High'/'Off' are the two confirmed tokens (see module docstring);
# (issues #137/#152) -- 'On' has never been observed and the device # 'On' has never been observed and likely isn't recognized.
# likely doesn't recognize it (see module docstring).
token = "High" if p == "On" else "Off" token = "High" if p == "On" else "Off"
return ["mode", "vs", "0"], { return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Lamp", token), "x.com.samsung.da.options": option_write("Lamp", token),
@@ -271,11 +253,9 @@ MICROWAVE_MODE = Capability(
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"), value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
write_fn=_lamp_write, write_fn=_lamp_write,
), ),
# issue #181: Filter Reminder / End Signal Reminder toggles, # issue #181: Filter Reminder / End Signal Reminder toggles, only on
# confirmed present (both On and Off observed across dumps -- see # boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off
# issue #152's ME7500D fixtures) but only on boards that carry the # elsewhere (the MW7300B combi dump has neither).
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc( SwitchDesc(
key="filter_remind", key="filter_remind",
field="x.com.samsung.da.options", field="x.com.samsung.da.options",
@@ -98,11 +98,9 @@ def _finish_time(rep):
if not total_s: if not total_s:
return None return None
# Round to whole minutes -- remainingTime itself only has minute # Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/ # resolution, but datetime.now()'s fresh seconds/microseconds would
# microseconds, so an unrounded result changes on nearly every poll # otherwise change the result on nearly every poll, flooding the
# even when the device-reported remaining time hasn't. That floods # recorder with values that look identical once the UI rounds them.
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(UTC) + timedelta(seconds=total_s) finish = datetime.now(UTC) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0) return finish.replace(second=0, microsecond=0)
@@ -147,15 +145,11 @@ OPERATIONAL_STATE = Capability(
translation_key="machine_state", translation_key="machine_state",
value_fn=_to_ocf, value_fn=_to_ocf,
), ),
# cycle_active is a bool derived from machine_state; used by the # cycle_active is a bool derived from machine_state, gated on
# adapter to gate oven writes (cycle_active_field='cycle_active'). # progress too since firmware keeps state='Run' after progress
# Harmless for non-oven appliances — just an extra bool in state. # reaches 'Finish' (a stuck 'Running' indication otherwise). Named
# Samsung firmware keeps state='Run' after progress reaches 'Finish', # 'Running' in the catalog, not 'Cycle active' -- this href is
# so we also gate on progress to avoid a stuck 'Running' indication. # shared with oven, and 'cycle' is laundry-specific vocabulary.
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc( BinarySensorDesc(
key="cycle_active", key="cycle_active",
device_class="running", device_class="running",
@@ -183,9 +177,8 @@ OPERATIONAL_STATE = Capability(
else _int(rep.get("x.com.samsung.da.progressPercentage")) else _int(rep.get("x.com.samsung.da.progressPercentage"))
), ),
), ),
# Only show finish time when machine is actively running. Samsung # Only show finish time while actively running -- firmware leaves a
# firmware leaves a stale remainingTime after a cycle ends, and # stale remainingTime after a cycle ends, frozen at '00:01:00'.
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc( SensorDesc(
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
), ),
@@ -1,26 +1,19 @@
"""Capabilities for the oven family (Samsung NV7000BS-class). """Capabilities for the oven family (Samsung NV7000BS-class).
Resources verified against the live device via DTLS-CoAP. Resources verified against the live device via DTLS-CoAP. See
See `local-tools/comparisons/oven-tree.md` for the full field reference. `local-tools/comparisons/oven-tree.md` for the full field reference.
Write surfaces this module exposes: Proven write: lamp, via /mode/vs/0 options RMW, works even with Remote
Control off. Unproven (first HA use is also the test): sound/fastPreheat/
naturalSteam (same RMW pattern), setpoint via /temperatures/vs/0 items RMW,
cook time via /operational/state/vs/0's operationTime/remainingTime, mode
select via /mode/vs/0.modes (mid-cook acceptance unknown), stop via
state='Ready'.
proven: Cycle start is not implemented: local-OCF cycle start isn't reproducible on
* Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py) this firmware. Mode writes are also unreliable -- the oven rolls them back
— works even with Remote Control off. once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only
in practice.
unproven (first HA use is also the test):
* Sound, FastPreheat, NaturalSteam — same RMW pattern as lamp.
* Setpoint via /temperatures/vs/0 items RMW.
* Cook time via /operational/state/vs/0 operationTime/remainingTime.
* Mode select via /mode/vs/0 .modes — mid-cook acceptance unknown.
* Stop via /operational/state/vs/0 state='Ready'.
Note: Cycle start is not implemented. Reverse-engineering shows local-OCF
cycle start is not reproducible on this firmware (see project_oven_remote
_start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@@ -45,26 +38,18 @@ SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270 SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5 SETPOINT_STEP_C = 5
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as # Verified against issue #44's range dump: Bake mode's modeSpec reports
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0 # tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate # converted from the Celsius bounds above, which are themselves unverified.
# constant set rather than converted from the Celsius bounds above, which
# are themselves unverified (no live dump; see module docstring).
SETPOINT_MIN_F = 175 SETPOINT_MIN_F = 175
SETPOINT_MAX_F = 550 SETPOINT_MAX_F = 550
SETPOINT_STEP_F = 5 SETPOINT_STEP_F = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred # Mode options seen on NV7000BS-class. No dump exists so this list is
# from Samsung documentation and firmware observations. The firmware will # inferred from Samsung documentation and firmware observations; the
# reject unknown modes; missing entries here are a coverage gap, not a bug. # firmware rejects unknown modes, so a missing entry is a coverage gap, not
# # a bug. Fallback only, used when a device's own /mode/vs/0 doesn't report
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report # supportedModes at all -- see _oven_mode_options/_oven_mode_write below.
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
# in its own supportedModes; those are read live rather than added here, per
# the adding-device-support skill's preference for device-reported option
# lists over hardcoded ones.
_OVEN_MODES = ( _OVEN_MODES = (
"NoOperation", "NoOperation",
"Bake", "Bake",
@@ -138,22 +123,18 @@ def _option_value(options, prefix):
def _has_option(prefix): def _has_option(prefix):
"""exists_fn for an options-array switch: bind only when the device's own """exists_fn for an options-array switch: bind only when the device's
options[] actually carries a `<prefix>_<value>` token. own options[] actually carries a `<prefix>_<value>` token.
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as fast_preheat/natural_steam were shipped unconditionally (no exists_fn)
an unverified guess (see module docstring) -- issue #183's dump (model as an unverified guess -- issue #183's dump reports neither token in
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its its options[] at all, so both switches were phantom controls that
options[] at all, so both switches were phantom controls: always read as "don't appear to do anything."
off, and toggling them wrote a token the firmware never recognized in
the first place, hence "does not appear to do anything."
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's `is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
identical per-token exists_fn on its own options[]-array href: a stub identical exists_fn: a stub /device/0 seed rep has no options[] at all,
/device/0 seed rep (not yet sub-polled) has no options[] at all, and and without this a genuinely-present token would never get a first
without this an entity whose token is genuinely present would never get chance to bind.
a first chance to bind, since exists_fn runs before that first real
fetch lands.
""" """
return lambda rep, resources: ( return lambda rep, resources: (
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
@@ -163,13 +144,10 @@ def _has_option(prefix):
def _option_write(prefix, new_value): def _option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write, mirroring """A one-token x.com.samsung.da.options write, mirroring
laundry.option_write. NOT independently confirmed on an oven -- issue laundry.option_write. NOT independently confirmed on an oven -- issue
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0. #54 only confirmed prefix-merge-on-write for a washer's /course/vs/0;
This extrapolates that same vendor field/contract to the oven's this extrapolates the same contract here. If some oven replaces the
/mode/vs/0, on the assumption the firmware handles the array the same field outright instead of merging, this would drop every other option
way there. If that assumption is wrong for some oven, a device that on the next write -- revisit if a real device report surfaces that."""
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f"{prefix}_{new_value}"] return [f"{prefix}_{new_value}"]
@@ -318,13 +296,11 @@ OVEN_CAVITY = Capability(
def _oven_temp_unit(rep): def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the """Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate
same aggregate `/temperatures/vs/0` items[] resource type, which on `/temperatures/vs/0` items[] resource carries a per-item `unit` field
fridge hardware carries a per-item `x.com.samsung.da.unit` field that was previously hardcoded away (issue #7). Keeps the verified '°C'
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7). default when the field is absent, but reads it live -- issue #44's
Keeps the verified '°C' default when the field is absent (the original range dump is the first to report 'Fahrenheit' here."""
NV7000BS-class dump this module was written against), but reads it live
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
items = rep.get("x.com.samsung.da.items") or [] items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C") return normalize_temp_unit(unit, default="°C")
@@ -404,17 +380,12 @@ OVEN_CONNECTED = Capability(
), ),
) )
# Static cavity capability metadata (count/type/supported features) -- no # Static cavity capability metadata -- no per-cavity data varies at runtime
# per-cavity data varies at runtime on any dump seen so far (issue #44's # on any dump seen so far. Bound with no entities purely for coverage.
# range: single cavity, no supportedFeatureList entries). Bound with no
# entities purely for coverage; revisit if a multi-cavity dump surfaces
# fields worth exposing.
OVEN_SPEC = Capability(href="/oven/spec/vs/0") OVEN_SPEC = Capability(href="/oven/spec/vs/0")
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded # Quick-recipe display blob (combi microwave, issue #121) -- every field
# string (language/menu/servingSize/option) with every field blank on the # blank on the only dump seen, no documented write contract.
# only dump seen, and no documented write contract. No entity to bind per
# the 'don't guess' rule; a bare Capability still marks the href covered.
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0") OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
OVEN_MODE = Capability( OVEN_MODE = Capability(
@@ -462,9 +433,8 @@ OVEN_MODE = Capability(
write_fn=_option_switch_write("NaturalSteam"), write_fn=_option_switch_write("NaturalSteam"),
), ),
# 120-hour energy-saving standby (issue #183): confirmed present in # 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] (EnergySaving_On) and directly requested -- # this unit's options[] -- unlike fast_preheat/natural_steam above,
# unlike fast_preheat/natural_steam above, this token is real on this # this token is real on this hardware, just previously unbound.
# hardware, just previously unbound entirely.
SwitchDesc( SwitchDesc(
key="energy_saving", key="energy_saving",
field="x.com.samsung.da.options", field="x.com.samsung.da.options",
@@ -1,36 +1,32 @@
"""Capabilities for the cooktop half of range/combo appliances (issue #44, """Capabilities for the cooktop half of range/combo appliances (issue #44,
model TP1X_DA-KS-RANGE-0102X). model TP1X_DA-KS-RANGE-0102X).
Not to be confused with PR #23's registry/capabilities/cooktop.py, which Not to be confused with registry/capabilities/cooktop.py, which covers an
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes unrelated standalone-cooktop product (NA9300K-class) that encodes burner
burner state as strings inside /mode/vs/0's options array instead of the state as strings inside /mode/vs/0's options array instead of the
structured /cooktop/status/vs/0 resource this module reads -- two different structured /cooktop/status/vs/0 resource this module reads -- two
OCF surfaces that happen to share the English word "cooktop". different OCF surfaces that happen to share the English word "cooktop".
Unlike the rest of the OCF surface, these hrefs use plain camelCase field Unlike the rest of the OCF surface, these hrefs use plain camelCase field
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already names (no `x.com.samsung.da.` prefix).
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList` `/cooktop/status/vs/0` carries every burner's live state in one
array (indexed by `burnerNumber`, not by a separate href per burner like `burnerList` array (indexed by `burnerNumber`), so per-burner entities are
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS hardcoded up to MAX_BURNERS and gated by exists_fn against whichever
and gated by exists_fn against whichever indices the device actually indices the device actually reports -- an index absent from burnerList
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since just never binds.
an index absent from burnerList just never binds.
Write surfaces here are unproven (no live device to verify against, same Write surfaces here are unproven (no live device to verify against, same
caveat as oven.py's RMW writes) -- power level uses the same read-modify- caveat as oven.py's RMW writes) -- power level uses the same
write pattern already proven safe elsewhere in this codebase (oven setpoint, read-modify-write pattern already proven safe elsewhere in this codebase.
icemaker toggles).
""" """
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit from .common import normalize_temp_unit
# Observed as high as 4 (this issue's dump); user-reported hardware with 5 # Observed as high as 4; user-reported hardware with 5 burners exists.
# burners exists. Kept a little above both since exists_fn gates unused # Kept a little above both since exists_fn gates unused slots out.
# slots out -- see module docstring.
MAX_BURNERS = 6 MAX_BURNERS = 6
@@ -140,11 +136,10 @@ COOKTOP_STATUS = Capability(
poll_tier="hot", poll_tier="hot",
entities=( entities=(
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"), SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
# The cooktop section's own on/off (issue #86) -- distinct from # The cooktop section's own on/off (issue #86), distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this # common.POWER's whole-appliance switch. Read-only: no live device
# family (the range combo) additionally carry for the whole # to confirm remotely turning it on wouldn't leave a burner active
# appliance. Read-only: no live device to confirm remotely turning # unattended.
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc( BinarySensorDesc(
key="cooktop_power", key="cooktop_power",
field="power", field="power",
@@ -153,8 +148,7 @@ COOKTOP_STATUS = Capability(
value_fn=lambda v: str(v).lower() == "on", value_fn=lambda v: str(v).lower() == "on",
), ),
# Safe to write -- a lock toggle, not a heat control -- via a # Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this # direct single-field PUT, no RMW needed.
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc( SwitchDesc(
key="cooktop_child_lock", key="cooktop_child_lock",
field="childLock", field="childLock",
@@ -169,16 +163,14 @@ COOKTOP_STATUS = Capability(
) )
# Static burner-count/power-level-list metadata, read directly by # Static burner-count/power-level-list metadata, read directly by
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather # COOKTOP_STATUS's power-level select (options=_power_level_options)
# than exposed through its own entity -- same "informs another capability, # rather than exposed through its own entity.
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0") COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured # settingTime (seconds) is the hot-surface auto-shutoff timer's configured
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the # duration; state on/off is whether the feature itself is enabled -- not a
# feature itself is enabled -- not a live "surface is hot right now" alert # live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract # hot_surface). No write contract verified, so read-only for now.
# verified, so read-only for now.
COOKTOP_SAFETY = Capability( COOKTOP_SAFETY = Capability(
href="/cooktop/settings/status/vs/0", href="/cooktop/settings/status/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -194,9 +186,8 @@ COOKTOP_SAFETY = Capability(
# Bluetooth meat probe (issue #86). All-idle sentinel values when # Bluetooth meat probe (issue #86). All-idle sentinel values when
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special # disconnected (operationBurnerNumber -1, temperatures 0) -- no special
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of # gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a
# showing a disconnected accessory's fields plainly rather than hiding the # disconnected accessory's fields plainly rather than hiding the capability.
# whole capability.
PROBE_STATUS = Capability( PROBE_STATUS = Capability(
href="/bluetooth/probe/status/vs/0", href="/bluetooth/probe/status/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -232,14 +223,12 @@ PROBE_STATUS = Capability(
), ),
) )
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 # Some range boards (issue #74) report no /cooktop/status/vs/0 burner
# burner array at all -- their local API only exposes this coarse # array at all -- their local API only exposes this coarse monitoring
# monitoring resource for the cooktop half, with no per-burner detail. # resource, with no per-burner detail. Meaning of `cooktopMonitoring`
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and # (bare "0" on the only dump seen) and `warmingCenterState`'s full value
# `warmingCenterState`'s full value set aren't confirmed, so both are # set aren't confirmed, so both are plain sensors rather than a guessed
# exposed as plain sensors rather than guessed at as a switch/select -- # switch/select.
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability( COOKTOP_MONITORING = Capability(
href="/cooktopmonitoring/vs/0", href="/cooktopmonitoring/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -182,18 +182,13 @@ HOOD_FILTER = Capability(
) )
# After Run (issue #147): the hood keeps the fan running at low speed for a # After Run (issue #147): the hood keeps the fan running at low speed after
# while after it's switched off, to clear residual cooking smoke -- a # it's switched off, to clear residual cooking smoke -- a feature a user
# feature a user actively watches and cancels, not passive diagnostics, so # actively watches and cancels, so none of the three entities below carry
# none of the three entities below carry entity_category. No # entity_category. No supported-values list is advertised for
# supported-values list is advertised for activationState, so it's modeled # activationState, so it's read-only monitoring rather than an invented
# read-only (monitoring, not an invented "enable" write) per the 'don't # "enable" write; runningCancel's only observed value is the command name
# guess' rule; runningCancel's only observed value is the command name # itself ('Cancel'), the same shape as operational.STOP_BUTTON.
# 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( AFTER_RUN = Capability(
href="/afterrun/vs/0", href="/afterrun/vs/0",
poll_tier="warm", poll_tier="warm",
@@ -29,62 +29,33 @@ from .laundry import (
option_write, option_write,
) )
# --------------------------------------------------------------------------- # Course_XX hex code labels (translations/en.json,
# Course_XX hex codes. 23 of the codes named in translations/en.json # washer_cycle_table_02.state.<id>) come from several devices, cross-checked
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured # rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList,
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList # matched positionally against a user's app screenshots and the printed
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched # manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry,
# positionally against a Slovak-UI user's screenshots of their app's course # a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own
# list (same order, same count -- see issue #2) and cross-checked against # editCourseList and screenshots (issue #22, a combo's own course set, not
# the printed user manual's course table (confirming e.g. '8F' as 'Intense # implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels,
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a # Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw
# cycle the manual marks "applicable models only" and that does not appear # code after selecting each cycle on the appliance (issue #80). Two code
# in this device's editCourseList -- nor does 'AI Wash', also "applicable # pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels)
# models only"). FixedCourseList_1C29 (the two courses always pinned in the # legitimately share a label across different course tables -- not typos.
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
# #
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry, # No static fallback list is kept here: other models have different actual
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the # course sets, so hardcoding one device's list would show/hide the wrong
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer # options elsewhere. laundry.cycle_options() reads only the live
# combo's editCourseList and were named from that user's app screenshot # x.com.samsung.da.editCourseList; a device that doesn't populate it gets no
# (issue #22). Combo units carry their own course set, so these codes # cycle select at all (see cycle_select's exists_fn). x.com.samsung.da.
# don't imply anything about '1F' on a plain washer. # options' MostUsed_* entry was considered as a fallback source (its first
# # byte matches the selected Course_XX on both dumps), but the remaining
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a # bytes don't decode to any confirmed course code, so it isn't used.
# WF50A8600AV/US, verified directly rather than by inference: the reporter
# selected each cycle on the physical appliance and read back the resulting
# raw code from the cycle_select entity's state (issue #80). '54' shares a
# display name with the existing '24' Towels -- a different code on a
# different course table legitimately landing on the same label, not a typo
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
# Load' -- both "applicable models only" per the manual -- would have yet
# another set), so hardcoding one device's list would show/hide the wrong
# options on a different model. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; if a device doesn't populate that
# resource, the cycle select isn't created at all (see cycle_select's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
# fallback source (its first byte reliably equals the currently-selected
# Course_XX on both dumps we have), but the bytes after that don't
# correspond to any confirmed course code on either device -- e.g. dump 1's
# MostUsed_1C8410923FA67F00000000000000 decodes to
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
# so it isn't trustworthy as a list of selectable courses and isn't used.
# ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- # /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also # Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF # bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
# path for different device families. Only one of the two ever binds for a # path for different device families. Only one of the two ever binds for a
# given device, since dryer and washer are separate by_type registries. # given device, since dryer and washer are separate by_type registries.
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability( WASHER_SETTINGS = Capability(
href="/washer/vs/0", href="/washer/vs/0",
@@ -123,9 +94,8 @@ WASHER_SETTINGS = Capability(
), ),
), ),
# Washer/dryer combo units carry a dryLevel field on the wash # Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue # resource itself (issue #22). Self-gates off on plain washers,
# #22. Self-gates off on plain washers, which never report # which never report supportedDryLevel.
# supportedDryLevel.
SelectDesc( SelectDesc(
key="dry_level", key="dry_level",
field="x.com.samsung.da.dryLevel", field="x.com.samsung.da.dryLevel",
@@ -142,12 +112,9 @@ WASHER_SETTINGS = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the # /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
# drum-clean and dispenser-dosing entities below are washer-specific reads off # drum-clean and dispenser-dosing entities below are washer-specific reads
# the same options array. # off the same options array.
# ---------------------------------------------------------------------------
# Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/ # Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/
# drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue # drum_clean_last_cleaned live in laundry.py, shared with dryer.py (issue
@@ -157,30 +124,17 @@ WASHER_SETTINGS = Capability(
# Detergent/softener auto-dispense dosing, from the same options[] array # Detergent/softener auto-dispense dosing, from the same options[] array
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity; # (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for # '<Prefix>Level2Ctrl_<code>' is a second dial (water hardness for
# detergent, concentration for softener -- matching the SmartThings app's # detergent, concentration for softener), matching the app's two-field
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté # dispenser screens. 'Supported<Prefix>Ctrl_<hexpairs>' lists the valid raw
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per # codes, same hex-pair shape as EditCourseList. '<Prefix>Alarm_<On/Off>' is
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the # a low-reservoir warning flag.
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
# #
# Label mapping (entity.select.{detergent,softener}_quantity / # Label mapping (translations/en.json's {detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration in translations/en.json) is an # detergent_water_hardness / softener_concentration) is an assumed reading
# assumed, not cross-device-verified, reading of the single issue #9 dump + # of the single issue #9 dump + screenshots, cross-checked against the
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no # selected value on both dispensers, not independently verified per code --
# on-screen equivalent -- the app's Quantité picker only offers # revisit if a second device's dump contradicts it.
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
# 1x/2x/3x for softener concentration does *not* cleanly match
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
# setting the user changed in the app between the dump (issue body) and the
# screenshots (a later comment), not a different code scheme, since it's
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
def _supported_level_options(resources, prefix): def _supported_level_options(resources, prefix):
rep = resources.get("/course/vs/0") or {} rep = resources.get("/course/vs/0") or {}
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}") raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
@@ -192,15 +146,13 @@ def _level_options(prefix):
def _dosing_level(prefix): def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format. """Current dose code, normalized to the `Supported<prefix>` code
format. The device reports the selected level as `<prefix>_<code>`
The device reports the selected level as `<prefix>_<code>` with the code un-padded (e.g. '3'), but the select's own options come from
un-padded (e.g. '3'), but the valid codes -- which are also this select's `Supported<prefix>_<hexpairs>` as zero-padded hex pairs (e.g. '03').
options and its translation keys -- come from `Supported<prefix>_<hexpairs>` Left as '3', the value sits outside the select's own option list and
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits HA renders it 'unknown' (issue #9) -- resolve it to the matching
outside the select's own option list, so HA renders it 'unknown' (issue #9). zero-padded code instead."""
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep): def fn(rep):
opts = rep.get("x.com.samsung.da.options") opts = rep.get("x.com.samsung.da.options")
@@ -227,9 +179,8 @@ def _level_write(prefix):
def write(p, rep, href=None): def write(p, rep, href=None):
if not rep.get("x.com.samsung.da.options"): if not rep.get("x.com.samsung.da.options"):
return None return None
# `p` is the zero-padded supported code the UI selected (e.g. '03'); # `p` is the zero-padded supported code (e.g. '03'); the device
# the device stores the level un-padded (e.g. '3'), matching how it # stores it un-padded (e.g. '3'), matching how it's reported.
# reports it, so write it back in that native shape.
try: try:
native = format(int(p, 16), "X") native = format(int(p, 16), "X")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -248,37 +199,28 @@ def _dosing_low(prefix):
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[] # Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a # array (issue #22 follow-up). Each rides as a plain '<Prefix>_On'/'_Off'
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with # token, confirmed against a dump taken with Bubble Soak switched on in the
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape # app -- the same shape as AiOption/KidsLockBypass in this array.
# already used by AiOption and KidsLockBypass in this same array, so
# PreWashSetting/IntensiveSetting are assumed to follow suit.
# #
# Each also has a differently-named hex-pair availability field that lines up # Each also has a hex-pair availability field positional with
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet, # editCourseList (BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of # IntensiveAvailableSet): on the reporter's dump 'F0' at a course's
# 24), all three read 'F0' at that position and the toggle was writable -- # position matched the app enabling the control there, '00' matched it
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak # grayed out. exists_fn only runs once at setup, so it can't do this
# Off') decodes to '00' for that course, matching the app graying the # per-course check -- validate_fn runs on every write attempt instead,
# control out there. 'F0'/'00' is treated as available/unavailable on that # rejecting an on-write for a course whose byte isn't 'F0' with a
# evidence. exists_fn (device-level presence) still only runs once, against # user-facing error rather than silently no-opping. The read/write/
# the setup-time snapshot, so it isn't a fit for this per-course check -- # presence machinery is laundry.bool_option_switch, shared with
# validate_fn runs on every write attempt instead (dispatched from # dishwasher's storm-wash/auto-release-dry toggles; only this per-course
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write # gating is washer-only.
# for a course whose byte isn't 'F0' with a user-facing error rather than
# silently no-opping against the device. The read/write/presence machinery
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
def _bool_option_switch(key, icon, prefix, availability_field): def _bool_option_switch(key, icon, prefix, availability_field):
def validate(p, rep, resources): def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in """Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls `availability_field` isn't 'F0'. Turning off is never blocked.
back to allowing the write whenever the availability data can't be Falls back to allowing the write whenever the availability data
resolved (unrecognized course, missing/mismatched-length bitmap) can't be resolved (unrecognized course, missing/mismatched-length
rather than guessing -- a false rejection is worse than an bitmap) -- a false rejection is worse than an occasional no-op."""
occasional no-op write."""
if p != "On": if p != "On":
return None return None
opts = rep.get("x.com.samsung.da.options") or [] opts = rep.get("x.com.samsung.da.options") or []
@@ -25,19 +25,13 @@ DISPENSE = Capability(
{"x.com.samsung.da.desiredType": p}, {"x.com.samsung.da.desiredType": p},
), ),
), ),
# Only a handful of discrete temperatures are selectable (not a # Only a handful of discrete temperatures are selectable -- a select
# continuous range) -- a select over the live-reported set, not a # over the live-reported set, not a number with invented bounds.
# number with invented bounds. # Newer boards (issue #196) don't populate supportedHotTemperatures
# # at all, reporting a hotwaterRange/hotwaterLevel pair instead with
# Newer boards (issue #196, RWP70F15ANW) don't populate # no confirmed write contract -- gate the entity off entirely there
# supportedHotTemperatures at all -- they report a hotwaterRange # rather than guess at that pair's meaning (an empty options list
# (min/max) and a hotwaterLevel (step count?) instead, with no # otherwise left current_option rendering "unknown").
# confirmed write contract for values off the old preset list. With
# an empty options_field result, HA's current_option still returns
# the live tempDesiredHotWater, which isn't in the (empty) options
# list and renders as "unknown" -- the exact symptom reported. Gate
# the entity off entirely when the board doesn't report a supported
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
SelectDesc( SelectDesc(
key="hot_water_temperature", key="hot_water_temperature",
field="x.com.samsung.da.tempDesiredHotWater", field="x.com.samsung.da.tempDesiredHotWater",
@@ -53,12 +47,10 @@ DISPENSE = Capability(
), ),
), ),
# Bounds and step come live from the device's own # Bounds and step come live from the device's own
# desiredCapacityRange/capacityResolution fields, not a hardcoded # desiredCapacityRange/capacityResolution, not a hardcoded constant.
# constant -- see the adding-device-support skill's "never hard-code # No unit is set: capacityUnit reads "C" on this dump, which can't
# the one dump's values" section. No unit is set: capacityUnit reads # be right for a volume field, so it's left unset rather than
# "C" on this dump, which can't be right for a volume field, so per # assumed to be mL.
# the 'don't guess' rule the unit is left unset rather than assumed
# to be mL.
NumberDesc( NumberDesc(
key="dispense_capacity", key="dispense_capacity",
field="x.com.samsung.da.desiredCapacity", field="x.com.samsung.da.desiredCapacity",
@@ -159,24 +151,16 @@ FAVORITE_CAPACITY = Capability(
), ),
) )
# 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: def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring """Three-way read of /status/lock/vs/0's hotwaterLock field, favoring
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
still ambiguous: still ambiguous: href absent -> True (fallback may claim the entity);
href present but an unfetched stub ({}) -> False (pending, not
- href entirely absent from this device -> definitely no clash, the confirmed absence -- LOCK's own exists_fn optimistically includes
switchHotwater fallback below may claim the entity. itself through a stub too, so returning True would register both
- href present but an unfetched stub ({}) -> outcome pending, *not* a descriptors under one key until the next poll); href present and
confirmed absence. LOCK's own exists_fn optimistically includes itself fetched -> the real answer."""
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") rep = resources.get("/status/lock/vs/0")
if rep is None: if rep is None:
return True return True
@@ -189,24 +173,15 @@ FAVORITE_HOTWATER = Capability(
href="/favorite/hotwater/vs/0", href="/favorite/hotwater/vs/0",
poll_tier="cold", poll_tier="cold",
entities=( entities=(
# Despite the resource/field naming, switchHotwater's value domain is # Despite the naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same # Locked/Unlocked, not an enable flag (issue #144) -- the same
# hot-water lock as LOCK.hotwater_lock below, just surfaced through # hot-water lock as LOCK.hotwater_lock below, surfaced through this
# this href on boards that don't populate /status/lock/vs/0's # href on boards that don't populate /status/lock/vs/0's
# hotwaterLock field. Shares that descriptor's key so only one "Hot # hotwaterLock. Shares that descriptor's key so only one "Hot water
# water lock" entity ever appears. # lock" entity appears; both halves need an exists_fn since
# # adapter.flatten() only ever honors exists_fn, not entity.py's
# Both halves of this fallback pair need an exists_fn, not just this # implicit field-presence default -- without it, whichever
# one: adapter.flatten() (the coordinator.data source every entity's # same-keyed descriptor is processed last would silently win.
# 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( SwitchDesc(
key="hotwater_lock", key="hotwater_lock",
field="x.com.samsung.da.switchHotwater", field="x.com.samsung.da.switchHotwater",
@@ -222,18 +197,12 @@ FAVORITE_HOTWATER = Capability(
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"}, {"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
), ),
), ),
# Issue #196: `supportedList` is only the four *fixed* presets # Issue #196: `supportedList` is only the four fixed presets -- the
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets # app also lets the user add one custom value to their own display
# the user add one custom value to their own display list via its # list, which shows up in `showList` but never in `supportedList`.
# "temperatures to display" editor (a wheel picker bounded by # Reading from `supportedList` meant a unit whose current default
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource), # was that custom value rendered as "unknown"; `showList` is a
# and that custom value shows up in `showList` # superset that always includes the actual current default.
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc( SelectDesc(
key="favorite_hotwater_temperature", key="favorite_hotwater_temperature",
field="x.com.samsung.da.favorite.defaultTemperature", field="x.com.samsung.da.favorite.defaultTemperature",
@@ -291,11 +260,9 @@ CUP_STATE = Capability(
) )
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/ # Sound mode/output/volume (issue #196). Shapes echo laundry.py/
# air_purifier.py's same-named hrefs, but this board's own values differ # air_purifier.py's same-named hrefs, but this board's own supportedModes
# from both (supportedModes here is voice/fixedTone/mute, not laundry's # (voice/fixedTone/mute) differs from both, so these read the device's own
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would # supported list/range rather than reusing either.
# reject a live-supported value, so these read the device's own supported
# list/range like air_purifier's versions do.
SOUND_MODE = Capability( SOUND_MODE = Capability(
href="/settings/sound/mode/vs/0", href="/settings/sound/mode/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -359,9 +326,8 @@ SOUND_VOLUME = Capability(
) )
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed # Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity # (no sibling unit field on this resource) so it's left unitless rather
# which at least has an -- albeit suspect -- capacityUnit) so it's left # than assumed to be mL.
# unitless rather than assumed to be mL.
STATISTIC_POUR = Capability( STATISTIC_POUR = Capability(
href="/statistic/pour/vs/0", href="/statistic/pour/vs/0",
poll_tier="cold", poll_tier="cold",
@@ -387,12 +353,8 @@ LOCK = Capability(
poll_tier="warm", poll_tier="warm",
entities=( entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also # above (issue #144); see the comment there. A stub rep ({}) still
# needs an explicit exists_fn now that the two share a key in # counts as "present" here, matching entity.py's own default.
# 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( SwitchDesc(
key="hotwater_lock", key="hotwater_lock",
field="x.com.samsung.da.hotwaterLock", field="x.com.samsung.da.hotwaterLock",
@@ -430,29 +392,21 @@ LOCK = Capability(
), ),
) )
# ---------------------------------------------------------------------------
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no # Water-purifier-scoped coverage: hrefs with no user-actionable state or no
# confirmed contract, following the 'don't guess' rule. # confirmed contract, following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_WP_IGNORED = [ _WP_IGNORED = [
# supportedModes carries a single opaque wizard-workflow token # supportedModes carries a single opaque wizard-workflow token and
# ('HOMECARE_WIZARD_V2') and modes reports a completely different, # modes reports an unrelated value not even in supportedModes --
# unrelated value ('WATERFILTER_DISABLE') not even present in # internal plumbing, not a real mode select.
# supportedModes -- internal plumbing, not a real user-facing mode
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
# already covered by the global ignored.IGNORED (fridge's OCF-native
# vacation-mode flag shares that href).
"/mode/vs/0", "/mode/vs/0",
# Static support-flags blob (automation.supported.modes/options) -- no # Static support-flags blob -- no live "current setting" field.
# live "current automation setting" field to expose.
"/automation/waterpurifier/vs/0", "/automation/waterpurifier/vs/0",
# Coffee-capable variant (issue #107). All four are static # Coffee-capable variant (issue #107): static capability-advertisement
# capability-advertisement blobs or empty -- no live "current recipe" / # blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0 # does carry live brew status.
# (COFFEE above), which does carry live brew status.
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata "/brand/recipe/info/vs/0", # revision + max-brand-count metadata
"/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs "/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs
"/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content "/recipe/coffee/vs/0", # same shape, no per-recipe content
"/recipe/coffee/deletion/vs/0", # empty {} on this dump "/recipe/coffee/deletion/vs/0", # empty {} on this dump
] ]
@@ -31,10 +31,9 @@ class BoundEntity:
key_override: str | None = None key_override: str | None = None
instance_name: str | None = None instance_name: str | None = None
# Which logical indoor subdevice (issue #177) this entity belongs to. # Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that # `href` above is always the actual, on-the-wire href for that
# subdevice -- MAIN's # subdevice -- MAIN's to_actual is the identity transform, so a device
# to_actual is the identity transform, so every device with no subdevices # with no subdevices is unaffected.
# behaves exactly as before this field existed.
subdevice: Subdevice = MAIN subdevice: Subdevice = MAIN
@@ -101,24 +100,19 @@ def discover(
tier_log: Callable[[str, str], None] | None = None, tier_log: Callable[[str, str], None] | None = None,
subdevice: Subdevice = MAIN, subdevice: Subdevice = MAIN,
) -> list[BoundEntity]: ) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually """`tier_log(href, poll_tier)` fires for every href a capability
matches, even a no-entity "coverage-only" capability (see COVERAGE lists actually matches, even a no-entity "coverage-only" capability that
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows. `_bind()` turns into zero `BoundEntity` rows. Callers that need a
Callers that need a href's poll cadence (the coordinator's hot/warm href's poll cadence must use this, not `bound` -- a coverage-only
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a capability's `poll_tier` would otherwise never appear in `bound`.
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`.
`resources` is always keyed by *canonical* hrefs -- for a subdevice `resources` is always keyed by canonical hrefs -- for a subdevice
(issue #177) that means its own canonical view (see (issue #177), its own canonical view (see subdevices.canonical_view),
subdevices.canonical_view), the same shape as a plain single-subdevice the same shape as a single-subdevice device's resources dict, so
device's resources dict, so registry lookups/rt_filter/match_fn/ registry lookups behave identically regardless of which subdevice is
instance_suffix all behave identically regardless of which subdevice is being discovered. `subdevice` only affects the href stamped onto each
being discovered. BoundEntity and the href `log`/`tier_log` report -- the real,
`subdevice` only affects the *href* stamped onto each BoundEntity (via subscribable/pollable path, not the canonical one.
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
""" """
out: list[BoundEntity] = [] out: list[BoundEntity] = []
@@ -33,10 +33,10 @@ class SamsungEntityDescription:
# here, so a descriptor only sets this to share one catalog entry across # here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one. # several descriptors, or to point at a differently-named one.
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]] translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource # callable form receives the full href->rep snapshot and returns the key
# snapshot and returns the key to use -- for a descriptor shared across # to use -- for a descriptor shared across board generations whose
# board generations whose state-code meaning isn't guaranteed consistent # state-code meaning isn't consistent between them; see
# between them; see laundry.cycle_select's table-id-gated resolver. # laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Mapping[str, str] | None = None translation_placeholders: Mapping[str, str] | None = None
# Dynamic resources such as fridge compartments and ice makers use a # Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name. # device-provided or href-derived instance label inside a translated name.
@@ -59,10 +59,9 @@ class SensorDesc(SamsungEntityDescription):
unit: str | None = None unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum' options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable # Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for # (see sensor.py). Only for values expected to jitter between
# values that are expected to jitter around their "true" value between # device-side revisions -- not a general-purpose flag.
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False hysteresis: bool = False
@@ -102,10 +101,9 @@ class NumberDesc(SamsungEntityDescription):
native_min: float | None = None native_min: float | None = None
native_max: float | None = None native_max: float | None = None
step: float | None = None step: float | None = None
# Override native_min/native_max/step from the live rep, when set -- # Override native_min/max/step from the live rep, when set -- same
# same "static default, live override" shape as unit_fn, for resources # "static default, live override" shape as unit_fn, for resources whose
# whose sane bounds depend on a per-device value (e.g. a temperature # bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit).
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Callable[[dict], float] | None = None native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None step_fn: Callable[[dict], float] | None = None
@@ -120,27 +118,23 @@ class TimeDesc(SamsungEntityDescription):
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription): class ClimateDesc(SamsungEntityDescription):
# A composite entity: it binds one *primary* resource (its href) but the # Composite entity: binds one primary resource (its href) but the
# climate platform reads sibling resources (power, temperature, wind) from # climate platform reads sibling resources from the coordinator
# the coordinator snapshot and writes to several of them. write_fn takes a # snapshot and writes to several of them. write_fn takes a (kind,
# (kind, value) payload from the platform and returns the (path_segs, body) # value) payload and returns the (path_segs, body) for that sub-write.
# for that one sub-write, so a single desc drives multi-resource writes.
write_fn: WriteFn = None write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class FanDesc(SamsungEntityDescription): class FanDesc(SamsungEntityDescription):
# Composite fan entity: reads power from /power/0 and speed/support data # Composite fan entity: reads power from /power/0 and speed/support data
# from its bound href. Payloads are (kind, value), like ClimateDesc. # from its bound href. Payloads are (kind, value), like ClimateDesc.
write_fn: WriteFn = None write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription): class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity: binds one primary resource (its href, # Composite water_heater entity, same (kind, value) -> (path_segs,
# typically an operation-mode resource) but the water_heater platform
# reads sibling resources (power, temperature) from the coordinator
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc. # body) write_fn shape as ClimateDesc/FanDesc.
write_fn: WriteFn = None write_fn: WriteFn = None
@@ -22,24 +22,19 @@ def is_placeholder_serial(serial: str) -> bool:
The ARTIK051_DONGLE_REF firmware family reports the literal string The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial` 'Nothing(SVC)' for every unit -- non-empty, so a plain `if not serial`
check doesn't catch it, and the resolved serial feeds both the HA check doesn't catch it, and two such units on the same install silently
device-registry identifier and every entity's unique_id (entity.py), so collide, dropping the second one's entities (issue #83).
two such units on the same install silently collide and the second one's
entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a
reports a flash-unset sentinel instead -- every character the same flash-unset sentinel instead -- every character the same repeated hex
repeated hex digit (a washer and a dryer, two different physical units, digit -- which the 'nothing' check doesn't catch either, aborting the
both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which the second unit's config flow as already configured.
'nothing' check above doesn't catch either, so the second unit's config
flow aborted as already configured.
Lives here, rather than being duplicated in config_flow.py and Lives here rather than duplicated in config_flow.py/coordinator.py: the
coordinator.py as it once was, because the config flow now resolves the config flow resolves the serial once and persists it for the
serial once and persists it on the entry for the coordinator to seed its coordinator to seed its registry keys from (issue #236), so two copies
registry keys from (issue #236). Two copies of this rule meant the two of this rule could let the two sides disagree and orphan a registry
sides could disagree about what a device's identity is -- and a entry.
disagreement is exactly what orphans a registry entry.
""" """
s = serial.strip() s = serial.strip()
if s.lower().startswith("nothing"): if s.lower().startswith("nothing"):
@@ -65,15 +60,12 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str: def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
"""The model string to name and register a device under. """The model string to name and register a device under.
`model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many `model_num` is /information/vs/0's modelNum, which many boards report
boards report as `<model>|<board>` -- only the part before the pipe is the as `<model>|<board>` -- only the part before the pipe is recognizable.
model a user would recognize. A board that reports no modelNum at all A board reporting no modelNum falls back to /oic/p's mnmo. Shared with
falls back to /oic/p's mnmo, which read_identity already parsed. resolve_serial's motivation: two copies of this split rule could let
the config flow and the coordinator's post-poll recompute disagree, and
Shared with resolve_serial's motivation: the config flow resolves this a device renaming itself after the first poll is the visible symptom.
once and persists it on the entry, and the coordinator recomputes it after
the first poll. Two copies of the split rule would let those two disagree,
and a device that renames itself on the first poll is the visible symptom.
""" """
if model_num: if model_num:
return model_num.split("|", 1)[0] return model_num.split("|", 1)[0]
@@ -81,13 +73,11 @@ def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
def device_display_name(device_type_name: str | None, model: str) -> str: def device_display_name(device_type_name: str | None, model: str) -> str:
"""The HA device name for a resolved device type + model. """The HA device name for a resolved device type + model. Shared by the
config flow and the coordinator's post-discovery rebuild, so the name a
Shared by the config flow (which builds the entry's stored identity) and device is first registered under matches what discovery produces later
the coordinator's post-discovery rebuild, so the name a device is first -- otherwise every setup would rename the device once the first poll
registered under is the same string discovery would produce later -- landed."""
otherwise every setup would rename the device once the first poll landed.
"""
device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance" device_type = device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}" return f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
@@ -120,15 +110,13 @@ def _get_links(sess, path) -> list:
def _device_types(d: dict) -> tuple[str, ...]: def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration. """/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the The one standardized "what am I" field in OCF: alongside the generic
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner' 'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()` SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
now consults this first, ahead of board-part-number parsing, via consults this first, via `for_device_by_oic_type`, but only a minority
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a of dumps populate it, so the modelNum/description path stays
minority of dumps populate it, so the modelNum/description path stays load-bearing. Kept whole in diagnostics (see `raw` below) so issue
load-bearing for everything else. It's also kept whole in diagnostics reports keep surfacing types the table doesn't know about yet.
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
""" """
rt = d.get("rt") rt = d.get("rt")
if isinstance(rt, str): if isinstance(rt, str):
@@ -142,17 +130,13 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
p = _get(sess, ["oic", "p"]) p = _get(sess, ["oic", "p"])
d = _get(sess, ["oic", "d"]) d = _get(sess, ["oic", "d"])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast # /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint # RETRIEVE returns every Resource/Collection href this endpoint hosts,
# hosts, not just the one /device/0 seed path the coordinator polls. # not just /device/0. Relevant for the "Composite Device" model (issue
# Relevant for the OCF "Composite Device" model (issue #177: a single # #177: one physical device exposing more than one logical subdevice,
# physical device -- one IP, one /oic/p -- exposing more than one logical # each its own Collection). registry.subdevices.enumerate_subdevices
# subdevice, each as its own Collection resource, same rt shape as our own # reads this to find a board's `/device/<n>` siblings -- that probing
# /device/0). This is what registry.subdevices.enumerate_subdevices reads # used to run right here on every _connect_session/reconnect and moved
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's # to that module so it only runs once, at first discovery.
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ["oic", "res"]) res = _get_links(sess, ["oic", "res"])
return DeviceIdentity( return DeviceIdentity(
manufacturer=p.get("mnmn") or "Samsung", manufacturer=p.get("mnmn") or "Samsung",
@@ -160,8 +144,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
name=d.get("n") or "", name=d.get("n") or "",
serial=serial, serial=serial,
device_types=_device_types(d), device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside # Kept whole rather than field-by-field: outside the /device/0 dump
# the /device/0 dump diagnostics already captures, and we don't yet # diagnostics already captures, and we don't yet know which fields
# know which of their fields will turn out to identify a device type. # will turn out to identify a device type.
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res}, raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
) )
@@ -30,19 +30,12 @@ _SENSITIVE_SUBSTRINGS = (
"secret", "secret",
) )
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit # Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
# with bare one- and two-letter keys that the rules above cannot see, being # unit with bare one/two-letter keys too short for the substring rules above
# far too short to match on -- 'di' alone is a substring of 'condition', # ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
# 'display', 'dispenser' and plenty of other ordinary appliance fields: # device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
# # carry a person's name -- the device-type signal we actually want from
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial # that resource is `rt`, which is not redacted.
# number above.
# 'n' -- /oic/d's device name. Free text the owner can set from the
# SmartThings app, so it may well carry a person's name. Nothing
# in the /device/0 dump has ever exposed it; it only became
# reachable when diagnostics started reporting /oic/d, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) _SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
@@ -1,90 +1,36 @@
"""Subdevice ("composite device") support for one physical connection exposing """Subdevice ("composite device") support for one physical connection
more than one logical indoor subdevice -- issue #177. exposing more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different Three discovery patterns, unified by the same shape: a logical subdevice is
mechanisms for exposing a second indoor subdevice over one IP / one DTLS a seed collection path to poll, plus an href transform between the
session (see DESIGN-177.md section 1 for the full evidence trail; the two canonical href the registry knows (e.g. `/mode/vs/0`) and the actual
diagnostics dumps this was built against come from the Pattern A and on-the-wire href.
Pattern B reporters, respectively -- they each filed one of the two
reports this module unifies):
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's - **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res`
board). `/oic/res` lists three complete parallel resource sets whose lists parallel resource sets by trailing index (`/mode/vs/0`,
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`, `/mode/vs/1`, ...); each sibling has its own `/device/<n>` Collection.
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s - **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res`
batch carries only the index-0 hrefs -- the sibling subdevices are hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID.
reachable only via their own `/device/<n>` collection. `GET /<uuid>/device/0` is tried first; when it comes back empty (issue
#205 -- not even the reference board always exposes it), this falls back
to probing every href the master answered this cycle individually under
the UUID prefix (see `Subdevice.flat_hrefs`).
- **Pattern C -- UUID prefix via `/oic/res` only** (`AWM-WW-AID-26-ONEBODY`
washer+dryer combo, issue #241). No `subdeviceIdList`, `/device/<n>`
404s; the sibling's UUID only appears as a link prefix in `/oic/res`
(e.g. the `x.com.samsung.da.multidevice` link) -- treated as Pattern B's
transform with the UUID sourced from there instead.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board). A non-empty seed batch is necessary but not sufficient for a candidate to
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that reporter's own `/device/2`) answers the same shape with constant/echoed
same UUID appears as a literal href prefix in `/oic/res` reps and no live state. Gating on resource shape would need per-family
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on domain knowledge, so `discover_partitioned` instead gates at the *entity*
that reporter's unit is narrower than early issue #177 writeups suggested: a layer: a candidate is only materialized if it produces at least one live,
single individual `GET /<uuid>/information/vs/0` was read by hand through non-`None`, primary (no `entity_category`), non-meter bound entity. The
the debug panel and came back carrying a different model/serial than the meter exclusion (issue #214) covers a second failure mode: an unused slot
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the reporting a populated whole-appliance energy counter, which is the
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a appliance's own bookkeeping, not evidence of a second indoor unit -- see
second subdevice exists at that prefix, but not evidence that `GET
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and falls back, when it's empty, to probing every href the
master itself answered this cycle individually under the UUID prefix --
the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
Pattern C -- UUID prefix advertised only via `/oic/res`
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
subdevice's UUID appears nowhere except as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res`, and
`GET /<uuid>/device/0` answers the washer's own full Collection batch
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
transform with the UUID sourced from the link prefix instead of
`subdeviceIdList`.
All of these are "the same thing wearing different clothes": a logical subdevice is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (the Pattern A
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
no `/device/1`), so no disambiguation logic is needed --
`enumerate_subdevices` checks both and materializes any candidate whose
seed answers with a non-empty batch.
A non-empty seed batch is necessary but not sufficient for the *candidate*
to actually be a live second subdevice, though: the Pattern A reporter's
own board also has a `/device/2` -- a third, unused SmartThings slot --
that answers with the exact same 14-href shape as the real `/device/1`
sibling, populated with three constant/echoed/shape-only reps (a region
code identical to every other subdevice's, an /information rep echoing the
*same* model string as subdevice 1, and a /temperatures items[] entry with
an id/description but no current/desired/minimum/maximum reading) and
nothing resembling live climate state. Gating on *resource* shape/hrefs
turned out to be the wrong layer -- it would need per-family domain
knowledge (which hrefs mean "in use" for a washer's second drum, a
fridge's second compartment, ...) baked into a registry field before any
of those families could use this module at all. `discover_partitioned`
instead gates at the *entity* layer, after discovery+flattening: a
candidate is only kept if it produced at least one *primary* (no
`entity_category`), non-meter bound entity whose flattened value isn't
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
`alarm_code` value, but that entity is diagnostic-category and derived from
an empty /alarms/vs/2, so it doesn't count. This reuses the same
primary/config/diagnostic taxonomy every registry already declares (see the
adding-device-support skill's entity-taxonomy section) instead of adding a
second, parallel domain-knowledge mechanism.
The meter carve-out is issue #214, and it's the same "an unused slot still
answers *something*" problem one layer further in: that reporter's
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
all empty {} -- the /device/2 shape above -- but which also reports a
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
`_has_live_primary_entity`. `_has_live_primary_entity`.
""" """
@@ -102,18 +48,15 @@ from .by_type._base import DeviceRegistry
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$") _INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
# A UUID as the first path segment of an /oic/res link href -- Pattern C's # A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241): a subdevice tree whose UUID is advertised # discovery signal (issue #241).
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/") _UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a # Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from # second subdevice's Collection (moved here from identity.py, issue #177,
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the # since the old read_identity fired these on every _connect_session
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session, # including reconnects, when enumeration only needs to run once). A plain
# including every reconnect, for information enumeration only needs once). # tolerated-404 RETRIEVE, not the kind of guess the write-contract
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of # 'don't guess' rule is about. Widen only if a board needs more siblings.
# guess the write-contract 'don't guess' rule is about. Widen only if a real
# board ever turns out to need more than two siblings.
_SPECULATIVE_DEVICE_INDICES = (1, 2) _SPECULATIVE_DEVICE_INDICES = (1, 2)
@@ -122,22 +65,20 @@ class Subdevice:
"""One logical indoor subdevice reachable over a single physical """One logical indoor subdevice reachable over a single physical
connection. connection.
`kind='main'` is the subdevice this config entry actually connects to and `kind='main'` is the subdevice this config entry actually connects to
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the and always exists (see MAIN below) -- its `to_actual`/`to_canonical`
identity transform, so every existing single-subdevice device keeps are the identity transform, so a single-subdevice device behaves
behaving exactly as it did before this module existed. `'indexed'`/ exactly as before this module existed. `'indexed'`/`'prefixed'` are
`'prefixed'` are Pattern A/B above; `key` is the trailing index string Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the or the full subdevice UUID, and `seed_path` is the Collection href (as
Collection href (as path segments) whose batch response path segments) whose batch enumerates/refreshes that subdevice.
enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't `flat_hrefs` is non-empty only for a 'prefixed' subdevice with no
expose its own Collection at `seed_path` (issue #205 -- not even Collection at `seed_path` (issue #205). When set, `seed_path` is
TP2X_FAC_BORA_21K, the board this pattern was built against, always meaningless (left as `()`) and this subdevice's state comes from
does). When set, `seed_path` is meaningless (left as `()`) and this GETting each of these canonical hrefs individually under its prefix
subdevice's state comes from GETting each of these canonical hrefs instead -- see enumerate_subdevices' fallback and
individually under its prefix instead of one Collection batch -- see coordinator._poll_subdevice_seed.
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
""" """
kind: str # 'main' | 'indexed' | 'prefixed' kind: str # 'main' | 'indexed' | 'prefixed'
@@ -150,13 +91,11 @@ class Subdevice:
on-the-wire href for this subdevice.""" on-the-wire href for this subdevice."""
if self.kind == "indexed": if self.kind == "indexed":
head, sep, tail = canonical.rpartition("/") head, sep, tail = canonical.rpartition("/")
# Only the index-0 trailing segment is ours to rewrite -- # Only the index-0 trailing segment is ours to rewrite -- not a
# deliberately not a "replace any trailing digit" rule, which # "replace any trailing digit" rule, which would misread a
# would misread a genuine multi-instance resource (the fridge's # genuine multi-instance resource (e.g. the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No # '/door/vs/1') as a subdevice's. No registry declares a
# registry declares a non-zero trailing index today and no # non-zero trailing index today.
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == "0": if tail == "0":
return f"{head}{sep}{self.key}" return f"{head}{sep}{self.key}"
return canonical return canonical
@@ -179,25 +118,24 @@ class Subdevice:
return actual return actual
def owns(self, actual: str) -> bool: def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this subdevice's namespace. MAIN never """True if `actual` belongs to this subdevice's namespace. MAIN
"owns" anything by this definition -- it gets whatever's left after never "owns" anything by this definition -- it gets whatever's
every other subdevice's hrefs are excluded (see canonical_view).""" left after every other subdevice's hrefs are excluded (see
canonical_view)."""
if self.kind == "main": if self.kind == "main":
return False return False
return self.to_canonical(actual) is not None return self.to_canonical(actual) is not None
@property @property
def key_prefix(self) -> str: def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see """Prefix guaranteeing a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master's flattened state adapter._key). '' for MAIN, so the master's flattened state keys
keys must stay byte-identical to every device this integration stay byte-identical to every device shipped before issue #177. The
shipped before issue #177, so no golden file changes. The full full subdevice UUID is used verbatim (non-alphanumerics stripped)
subdevice UUID is used verbatim (non-alphanumerics stripped, not rather than an enumeration-order ordinal, since it's device-reported
truncated or replaced with an ordinal) because it's device-reported and stable across reconnects; it never appears in a user-visible
and stable across reconnects/restarts, unlike an ordinal assigned by string, since HA derives entity_id from device+entity name, not
enumeration order -- and it never appears in a user-visible string unique_id.
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
""" """
if self.kind == "indexed": if self.kind == "indexed":
return f"subdevice{self.key}_" return f"subdevice{self.key}_"
@@ -219,16 +157,15 @@ def canonical_view(
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against. and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the For MAIN this is the snapshot minus every href owned by one of the
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1` other subdevices in `subdevices` -- otherwise a sibling's own
would leak into the master's view under the same canonical key `/mode/vs/1` would leak into the master's view under the canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to, ('/mode/vs/0') the master's own resource also maps to. For an
silently mixing two subdevices' state together. For an indexed/prefixed indexed/prefixed subdevice it's the reverse: only the hrefs that
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten subdevice owns, rewritten back through `to_canonical`.
back through `to_canonical`.
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always `subdevices` may or may not include MAIN itself -- MAIN.owns() is
False, so including it is harmless. always False, so including it is harmless.
""" """
if subdevice.kind == "main": if subdevice.kind == "main":
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)} owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
@@ -245,11 +182,9 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
normalized so every href actually carries this subdevice's prefix/index. normalized so every href actually carries this subdevice's prefix/index.
Indexed subdevices need no change -- the device echoes the real `/x/<n>` Indexed subdevices need no change -- the device echoes the real `/x/<n>`
href in its own `/device/<n>` batch (confirmed against the Pattern A href in its own `/device/<n>` batch. A prefixed subdevice's batch
reporter's dump). A prefixed subdevice's batch entries may or may not entries may or may not already carry the `/<id>` prefix (unconfirmed),
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B so it's added when missing.
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
""" """
if subdevice.kind != "prefixed": if subdevice.kind != "prefixed":
return batch return batch
@@ -265,9 +200,8 @@ def _iter_oic_res_hrefs(oic_res):
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]` Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
-- see identity.py's read_identity/_get_links), so that's the shape -- see identity.py's read_identity/_get_links), so that's the shape
handled here. Tolerant of a flat link-list too (nothing in the OCF spec handled here. Tolerant of a flat link-list too, and of anything else by
rules it out, and _get_links' own posture already treats any list-shaped yielding nothing.
body as possible) and of anything else by yielding nothing.
""" """
for entry in oic_res or []: for entry in oic_res or []:
if not isinstance(entry, dict): if not isinstance(entry, dict):
@@ -290,8 +224,7 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
def _get_raw(sess, path_segs: tuple[str, ...]): def _get_raw(sess, path_segs: tuple[str, ...]):
"""GET `path_segs` and CBOR-decode the payload, or None on any """GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) -- missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below, which differ shared tolerated-absence posture for both callers below."""
only in which body shape they accept."""
try: try:
code, pl = sess.get(list(path_segs), timeout=10.0) code, pl = sess.get(list(path_segs), timeout=10.0)
if code == 0x45 and pl: if code == 0x45 and pl:
@@ -311,10 +244,8 @@ def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
def _get_property(sess, path_segs: tuple[str, ...]) -> dict: def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection """GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
`/oic/res` on the Pattern A reporter's board but absent from from `/device/0`'s batch, so it needs its own RETRIEVE."""
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
single Property map, not a [devcol-rep, ...] list."""
body = _get_raw(sess, path_segs) body = _get_raw(sess, path_segs)
return body if isinstance(body, dict) else {} return body if isinstance(body, dict) else {}
@@ -329,34 +260,27 @@ def enumerate_subdevices(
connection. connection.
Runs once, at first discovery, in an executor, under the coordinator's Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract session lock -- every GET here is a plain RETRIEVE. Returns the
'don't guess' rule doesn't apply to reading an extra resource to find *candidate* subdevices and the resources already fetched while probing
out whether it's there). Returns the *candidate* subdevices and the resources them (normalized to real hrefs), so the coordinator's first discovery
already fetched while probing them (already normalized to real hrefs), poll doesn't need to re-poll them.
so the coordinator's first discovery poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or `probe_log(seed_href, found)` fires for every seed attempted, whether
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes) or not it answered, so diagnostics can tell "checked, nothing there"
can tell "checked, nothing there" apart from "never checked", the same apart from "never checked".
posture the speculative-probe code this replaces used to document in
identity.py.
Every candidate whose seed answers with a non-empty batch is returned Every candidate whose seed answers with a non-empty batch is returned
here -- this function has no way to tell a real sibling from an unused here -- this function can't tell a real sibling from an unused
SmartThings slot that merely answers the same shape (the Pattern A SmartThings slot that answers the same shape; that requires
reporter's `/device/2`); that requires discovering+flattening the discovering+flattening the candidate's own entities first, which is
candidate's own entities first, which is `discover_partitioned`'s job, `discover_partitioned`'s job. See this module's docstring.
not this one's.
See this module's docstring.
""" """
subdevices: list[Subdevice] = [] subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {} fetched: dict[str, dict] = {}
# Case-insensitive -- the same UUID can reach here once from # Case-insensitive -- the same UUID can reach here once from
# subdeviceIdList and once from an /oic/res link prefix with different # subdeviceIdList and once from an /oic/res link prefix with different
# casing (Samsung's own fields disagree on this elsewhere too, e.g. the # casing, and probing it twice would materialize the same physical
# redaction-prone subdeviceIdList handling below), and probing it twice # subdevice as two Subdevice candidates.
# would materialize the same physical subdevice as two Subdevice
# candidates under two different keys.
probed_ids: set[str] = set() probed_ids: set[str] = set()
def _probed(seed_href: str, batch: dict) -> None: def _probed(seed_href: str, batch: dict) -> None:
@@ -379,29 +303,22 @@ def enumerate_subdevices(
fetched.update(normalize_seed_batch(subdevice, batch)) fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice) subdevices.append(subdevice)
return return
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this # Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board
# pattern was built against -- turns out not to always expose its own # doesn't always expose its own `/<uuid>/device/0` Collection. With
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice # no Collection to seed from and no per-UUID entry in /oic/res to
# has one" doesn't hold even on the reference hardware. With no
# Collection to seed from and no per-UUID entry in `/oic/res` to
# enumerate hrefs from, the only signal left is that a composite # enumerate hrefs from, the only signal left is that a composite
# device's siblings are the same physical board family as the # device's siblings share the master's own resource surface -- so
# subdevice this config entry already talks to -- so probe every # probe every href the master answered this cycle, individually,
# href the master itself answered this cycle, individually, under # under this UUID's prefix, and keep whichever answer. Each is a
# this UUID's prefix, and keep whichever ones answer. Each is a # plain tolerated-404 RETRIEVE.
# plain tolerated-404 RETRIEVE, same posture as every other probe in
# this function.
# #
# Known gap, not yet guarded against: a firmware that answers *any* # Known gap: a firmware that echoes the master's own state back
# request under an unrecognized prefix (echoing the master's own # under an unrecognized prefix, rather than 4.04ing, would pass
# state back rather than 4.04ing) would pass every one of these # every probe here and could materialize a phantom duplicate. Every
# probes and, if the echoed state also clears discover_partitioned's # board seen so far genuinely 4.04s on paths it doesn't own (issue
# liveness gate, materialize a phantom duplicate of the master # #205's unit answered only 1 of 31 probes), so this hasn't been
# rather than a real sibling. Every board seen so far genuinely # guarded against -- the fix would compare a candidate's confirmed
# 4.04s on paths it doesn't own (issue #205's own unit answered only # reps against the master's own values for the same hrefs.
# 1 of 31 probes), so this hasn't been built -- the one place it
# could hook in later is comparing a candidate's confirmed reps
# against the master's own values for those same canonical hrefs.
flat_hrefs = [] flat_hrefs = []
first = True first = True
for href in sorted(resources): for href in sorted(resources):
@@ -427,35 +344,26 @@ def enumerate_subdevices(
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------ # --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList") raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
# Tolerate anything but a list of strings -- this field is redaction-prone # Tolerate anything but a list of strings -- this field is
# (it matches the 'deviceid' substring rule in redact.py) and the existing # redaction-prone (matches redact.py's 'deviceid' rule) and a shipped
# airconditioner_fac_bora fixture carries the literal string # fixture carries the literal string 'REDACTED' there. That must yield
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a # zero subdevices, not a crash -- issue #177 is additive and must never
# crash -- issue #177 is additive, it must never break an already-working # break an already-working single-climate-entity device.
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else [] ids = raw_ids if isinstance(raw_ids, list) else []
listed = sorted(i for i in ids if isinstance(i, str) and i) listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed: for sub_id in listed:
_probe_prefixed(sub_id) _probe_prefixed(sub_id)
# --- Pattern C: UUID prefix advertised only via /oic/res ---------------- # --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third # (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
# multidevice shape: the board answers numofsubdevice='2' on # /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList # is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). # link). Its own tree answers a full Collection at /<uuid>/device/0,
# The only trace of the sibling is a UUID-prefixed link in /oic/res # exactly Pattern B's transform, so every UUID path prefix seen in
# itself: the x.com.samsung.da.multidevice link, # /oic/res is treated as a candidate. _probe_prefixed's probed_ids
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree # guard (not a set difference against `listed`) is what keeps an id
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's # already named by subdeviceIdList from being probed twice, since the
# transform -- so treat every UUID path prefix seen in /oic/res as a # two sources can disagree on case.
# prefixed-subdevice candidate. Probing is the same tolerated-404
# RETRIEVE as everything else here, and discover_partitioned's
# entity-level liveness gate still decides materialization, so a board
# that advertises a UUID link without a live sibling behind it
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
# difference against `listed` here -- is what keeps an id already named
# by subdeviceIdList from being probed and materialized a second time,
# since the two sources can disagree on that UUID's case.
linked = sorted( linked = sorted(
{ {
m.group(1) m.group(1)
@@ -477,10 +385,9 @@ def enumerate_subdevices(
} }
) )
if not indices: if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's # A board that hides its whole tree from /oic/res gives us nothing
# reporter board does this too, but it has no /device/<n> to find # to enumerate from -- fall back to the bounded speculative probe
# regardless) gives us nothing to enumerate from -- fall back to the # this replaces from identity.py.
# bounded speculative probe this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES) indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices: for n in indices:
seed = ("device", str(n)) seed = ("device", str(n))
@@ -492,19 +399,13 @@ def enumerate_subdevices(
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice) subdevices.append(subdevice)
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's # /multidevice/vs/0: listed in /oic/res on some boards but never in
# board lists it in /oic/res but it never appears in /device/0's batch, # /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
# so it needs its own RETRIEVE. It's a plain corroborating count # count (numofsubdevice), confirmed read-only -- captured for
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write # diagnostics only, folded into the merged resources dict like any
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded # other href (see airconditioner._AC_IGNORED). Not a gate:
# into the merged resources dict like any other href (see # discover_partitioned's entity-level liveness check decides
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as # materialization without it.
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ("multidevice", "vs", "0") multidevice_seed = ("multidevice", "vs", "0")
multidevice = _get_property(sess, multidevice_seed) multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice) _probed(_seed_href(multidevice_seed), multidevice)
@@ -516,23 +417,21 @@ def enumerate_subdevices(
@dataclass(frozen=True) @dataclass(frozen=True)
class SkippedSubdevice: class SkippedSubdevice:
"""A candidate `enumerate_subdevices` found whose seed answered, but that """A candidate `enumerate_subdevices` found whose seed answered, but
`discover_partitioned`'s entity-level liveness gate rejected -- an that `discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a unused SmartThings slot, not a real second subdevice. Kept around so a
real second subdevice. Kept around (rather than silently dropped) so a
caller can log/report what was skipped and why.""" caller can log/report what was skipped and why."""
subdevice: Subdevice subdevice: Subdevice
hrefs: tuple[str, ...] hrefs: tuple[str, ...]
# Sensor kinds whose value is a running total the *appliance* keeps rather # Sensor kinds whose value is a running total the appliance keeps rather
# than a reading of the subdevice's own hardware -- excluded from the # than a reading of the subdevice's own hardware -- excluded from the
# liveness gate below (issue #214). HA's own running-total state classes # liveness gate below (issue #214). HA's running-total state classes cover
# cover most of them; the consumption device classes catch the rest, since a # most of them; the consumption device classes catch the rest (a descriptor
# descriptor may deliberately declare no state_class (common.ENERGY_METER's # may deliberately declare no state_class, e.g. common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't # monthly totals that reset at each billing boundary).
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"}) _METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"}) _METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
@@ -549,35 +448,26 @@ def _is_meter(desc) -> bool:
def _has_live_primary_entity(bound, state: dict) -> bool: def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity """True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a *primary* entity -- list) produced at least one non-`None` value for a primary entity
`entity_category` unset, HA's own "the user acts on or watches this" (`entity_category` unset) that isn't a cumulative meter (`_is_meter`).
tier (see the adding-device-support skill's entity-taxonomy section) --
that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring). This is the materialization gate itself (see this module's docstring).
Two exclusions, both for the same reason -- the question this answers is Two exclusions, both because the question this answers is "is a
"is a physical subdevice installed at this slot?", and neither kind of physical subdevice installed at this slot?", and neither kind of value
value can speak to it: can speak to it:
- **Non-primary entities.** The Pattern A reporter's `/device/2` does - **Non-primary entities.** An unused slot can still flatten to a
flatten to one non-`None` value (`alarm_code`), but that entity is diagnostic-category value derived from an empty resource (e.g. a
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves
config/diagnostic entity reading "something" proves nothing about nothing about whether hardware is there.
whether hardware is there. - **Cumulative meters** (issue #214). An unused slot has been seen
- **Cumulative meters** (issue #214). An unused slot on the issue #214 reporting a populated whole-appliance `cumulativePower` while every
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a operational rep on it is empty `{}`. A single-split AC has one
populated `cumulativePower` while every operational rep on it compressor and one energy meter, so a whole-appliance total showing
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`, up under a second index is the appliance's own bookkeeping, not
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` -- evidence of a second indoor unit. A genuinely installed subdevice
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh reports its own operational state too, and that is what still passes
counter. That counter got the slot materialized as a phantom second this gate.
air conditioner. A single-split AC has one compressor and one energy
meter, so a whole-appliance total showing up under a second index is
the appliance's own bookkeeping, not evidence of a second indoor unit.
A genuinely installed subdevice reports its own operational state too
(the Pattern A reporter's real `/device/1` reports power, mode, both
temperatures and airflow), and that state is what still passes this
gate.
""" """
from .adapter import _key # see discover_partitioned's deferred-import note from .adapter import _key # see discover_partitioned's deferred-import note
@@ -596,64 +486,53 @@ def discover_partitioned(
tier_log: Callable[[str, str], None] | None = None, tier_log: Callable[[str, str], None] | None = None,
oic_device_types: Sequence[str] = (), oic_device_types: Sequence[str] = (),
): ):
"""Bind every href in `resources` (the merged, real-href snapshot -- main """Bind every href in `resources` (the merged, real-href snapshot --
plus every enumerated subdevice's seed) to entities, partitioned by which main plus every enumerated subdevice's seed) to entities, partitioned
subdevice owns it. by which subdevice owns it.
Main pass runs over hrefs owned by no subdevice -- otherwise every Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main `/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious
device's registry claims that literal href) and raise a spurious coverage-gap repair. Then one pass per candidate subdevice over its own
coverage-gap repair. Then one pass per *candidate* subdevice over its own
canonical view, resolving that subdevice's own device type from its own canonical view, resolving that subdevice's own device type from its own
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's `/information/vs/0` when it reports one, falling back to the master's
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token -> registry otherwise -- a sibling that fails to answer its own identity
airconditioner), falling back to the master's registry otherwise -- resource is still treated as the same appliance type as the master.
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the subdevice this config entry was set up against.
Each candidate is discovered and flattened *twice*: once silently to Each candidate is discovered and flattened twice: once silently to
evaluate `_has_live_primary_entity` (this module's materialization evaluate `_has_live_primary_entity`, and, only if that passes, a second
gate -- see its docstring and this module's own), and, only if that time with `log`/`tier_log` wired so its coverage gaps and poll tiers
passes, a second time with `log`/`tier_log` wired so its coverage gaps actually count. A candidate that fails the gate contributes nothing at
and poll tiers actually count. A candidate that fails the gate all, as if it had never answered its seed.
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
`oic_device_types` (from the master's own `/oic/d`, see `oic_device_types` (from the master's own `/oic/d`) is passed only to
registry/identity.py) is passed only to the *master's* resolution -- the master's resolution -- subdevices resolve from their own
subdevices have no `/oic/d` of their own read today (they resolve from `/information/vs/0` or fall back to the master's whole registry, and
their own `/information/vs/0` or fall back to the master's whole blindly applying the master's OCF device type to every subdevice would
registry, as documented above), and blindly applying the master's OCF be wrong the moment a composite appliance pairs two different device
device type to every subdevice's own model-based resolution would be types under one connection.
wrong the moment a composite appliance ever pairs two genuinely
different device types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`: Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized - `bound`: the concatenated BoundEntity list (main + every materialized
subdevice). subdevice).
- `device_type_name`: the *master's* resolved device type (used for - `device_type_name`: the master's resolved device type (used for
logging/device naming; each subdevice's own resolved type only affects logging/device naming; each subdevice's own resolved type only
which capabilities bind its hrefs, not this). affects which capabilities bind its hrefs).
- `materialized`: the subset of `subdevices` that passed the gate, in the - `materialized`: the subset of `subdevices` that passed the gate, in
same order -- what the caller should keep as its live subdevice roster the same order -- what the caller should keep as its live subdevice
going forward (poll seeds, canonical_resources, device_info_for, ...). roster going forward (poll seeds, canonical_resources,
device_info_for, ...).
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't. - `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
""" """
# Deferred import: discovery.py imports Subdevice/MAIN from this module at # Deferred import: discovery.py imports Subdevice/MAIN from this module
# module scope, so importing discover() back here at module scope would # at module scope, so importing discover() back here at module scope
# be a circular import. By the time this function actually runs both # would be circular. By the time this function runs both modules are
# modules are fully loaded. adapter.py imports discovery.py, so the same # fully loaded; adapter.py imports discovery.py, so the same applies to
# applies to flatten()/_key(). # flatten()/_key().
from .adapter import flatten from .adapter import flatten
from .discovery import discover from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every # Same computation canonical_view does for MAIN -- reuse it rather than
# other subdevice's owned hrefs) -- reuse it rather than re-deriving # re-deriving owned_elsewhere here too.
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices) main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view, device_types=oic_device_types) reg = resolve_registry(main_view, device_types=oic_device_types)
+17 -26
View File
@@ -52,34 +52,26 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
def _display(value, translation_key: str | None): def _display(value, translation_key: str | None):
"""Turn a raw device option/state value into what's shown in the UI. """Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (SelectDesc. `translation_key` is the entity's already-resolved key (it can itself
translation_key can itself be a callable -- see entities.py -- so be a callable -- see entities.py -- so callers pass the resolved
callers pass the resolved value, e.g. self.translation_key, not value, not the raw descriptor field).
the raw descriptor field).
An entity with a translation_key looks its state up in the shipped An entity with a translation_key looks its state up in the shipped
translation catalog, whose state keys are lowercase -- so those values translation catalog, whose state keys are lowercase, and the device
must be lowercased exactly to match, and the device still expects still expects that same raw casing back on write (mapped back via
that same raw casing back on write (callers map the displayed value _raw_options()). Everything else has no catalog lookup, so there's no
back to raw via _raw_options()). reason to destroy the device's own casing: only two cosmetic fixups
apply, title-casing a fully lowercase token ("voice") and spacing a
Everything else has no catalog lookup, so there's no reason to PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value
destroy the device's own casing. Only two cosmetic fixups apply: a ("AI Wash") matches neither and passes through unchanged.
fully lowercase device-native token (e.g. "voice") is title-cased,
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
the case boundary ("Extra High"). A value that's already
human-friendly (e.g. "AI Wash") matches neither pattern and passes
through unchanged.
""" """
if not isinstance(value, str): if not isinstance(value, str):
return value return value
if translation_key: if translation_key:
known = translated_states("select", translation_key) known = translated_states("select", translation_key)
if not known: if not known:
# No state table for this key: either the entity isn't translated # No state table for this key: either untranslated, or its
# at all, or its name is translated but its options deliberately # options deliberately aren't (an unrecognized course table).
# aren't (an unrecognized course table, say). Either way the
# opaque device value is the best thing to show.
return value return value
if translated := _translation_state(value, known): if translated := _translation_state(value, known):
return translated return translated
@@ -99,12 +91,11 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
desc = cast(SelectDesc, self._bound.desc) desc = cast(SelectDesc, self._bound.desc)
if callable(desc.options): if callable(desc.options):
# Per-device option list computed from the full resource # Per-device option list computed from the full resource
# snapshot (not just this entity's own href) -- e.g. a course # snapshot -- e.g. a course list decoded from a sibling
# list decoded from a sibling resource. There is no static # resource. No static fallback: when unpopulated, the callable
# fallback: when that resource isn't populated the callable # returns [] and exists_fn suppresses the entity entirely. Uses
# returns [] and the entity's exists_fn suppresses it entirely. # this subdevice's canonical view (issue #177), not the raw
# This entity's own subdevice's canonical view (issue #177), not # snapshot -- see LocalThingsEntity._resources.
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or []) return list(desc.options(self._resources) or [])
if desc.options_field: if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {} rep = self.coordinator.last_resources.get(self._bound.href) or {}
+38 -60
View File
@@ -1,38 +1,29 @@
"""Water heater platform for Local Things. """Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module Second composite entity in this integration (see climate.py's module
docstring for the general pattern this follows): a single HA water_heater docstring for the general pattern): a single HA water_heater card for a
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in
in registry/capabilities/ehs.py) so the registry still tracks it, and reads registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight `/temperatures/dhw/vs/0` resources straight from the coordinator snapshot,
from the coordinator snapshot -- the same cross-resource read climate.py uses the same cross-resource read climate.py uses.
for the AC's power/temperature/wind siblings.
Writes go through `coordinator.async_send_command(bound, (kind, value))`: Writes go through `coordinator.async_send_command`: DHW's `write_fn`
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the (ehs._dhw_write) maps each `(kind, value)` payload to the right
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs `(path_segs, body)`, applying the optimistic value/settle guard to that
and applies the optimistic value/settle guard to that same href -- not the resource's own href rather than the bound `/mode/dhw/vs/0` href.
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
fresh state back for, power, mode and temperature alike.
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/ Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
Power) map onto HA's own standard water_heater states -- the same mapping Power) map onto HA's own standard water_heater states, the same mapping
Home Assistant's core `smartthings` integration uses for this exact Samsung HA core's `smartthings` integration uses for this exact Samsung capability
capability over the cloud API (`samsungce.ehsThermostat` / (`samsungce.ehsThermostat`), just title-cased to match this OCF resource's
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/ spelling. Reusing HA's standard states means no state translation catalog
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF entry is needed for them.
resource's own code spelling. Reusing HA's standard states means no *state*
translation catalog entry is needed for them (see the entity_component
fallback in homeassistant.components.water_heater.strings.json).
Naming is a separate question from that, and the answer here differs from Naming differs from climate.py's: the AC *is* the device, so its card takes
climate.py's: the AC *is* the device, so its climate card takes the bare the bare device name. An EHS unit has two loops, and DHW isn't "the
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target entity is named through the catalog via `translation_key='dhw'`
Temperature", so a card labelled just "EHS" would misrepresent which loop
it drives. This entity is named through the catalog like every other
descriptor here, via the DHW descriptor's `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water"). (entity.water_heater.dhw.name -> "Hot water").
""" """
@@ -73,8 +64,7 @@ _LOGGER = logging.getLogger(__name__)
_MODES_FIELD = "x.com.samsung.da.modes" _MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes" _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Device mode <-> HA water_heater operation state -- see the module # Device mode <-> HA water_heater operation state -- see module docstring.
# docstring above for the SmartThings-cloud precedent this mirrors.
_DEVICE_TO_STATE: dict[str, str] = { _DEVICE_TO_STATE: dict[str, str] = {
"Eco": STATE_ECO, "Eco": STATE_ECO,
"Std": STATE_HEAT_PUMP, "Std": STATE_HEAT_PUMP,
@@ -83,12 +73,10 @@ _DEVICE_TO_STATE: dict[str, str] = {
} }
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()} _STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded. climate.py resolves write codes from the # Read-side lookup, case-folded: this map is bijective (unlike climate.py's
# unit's own supportedModes because two spellings there mean one HA value # 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can # directly; only the read side needs to absorb a board spelling the same
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board # code differently ('eco'/'ECO').
# spelling the same code differently ('eco'/'ECO'), and case is the one
# variation worth absorbing rather than warning about.
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()} _DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
@@ -132,21 +120,18 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound) super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a # No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name ("Hot water") through the # two-loop device and takes a catalog name through translation_key.
# descriptor's translation_key -- see the module docstring.
self._attr_supported_features = ( self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.ON_OFF
) )
# Raw device codes already logged by _warn_unmapped -- these # Raw device codes already logged by _warn_unmapped -- read on every
# properties are read on every coordinator refresh, so an un-deduped # refresh, so un-deduped would spam the log for an unrecognized code.
# warning would spam the log for any unit reporting a genuinely
# unrecognized code.
self._warned_unmapped: set[str] = set() self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict: def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants -- """`href` is one of this module's canonical HREF_* constants,
translated through this bound entity's own subdevice (issue #177), translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper.""" same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {} return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
@@ -188,14 +173,10 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired")) return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
def _range(self) -> list | None: def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None. """The device's own (minimum, maximum) pair, or None. Both ends
together or neither -- same rule as climate._range(); a board
Both ends together or neither, deliberately -- same rule as reporting only minimum would otherwise pair it with HA's own
climate._range(). A board reporting minimum but not maximum would default maximum, silently wrong."""
otherwise pair a device minimum (40) with HA's own default maximum
(140 °F), which looks plausible and is silently wrong on a unit
that really allows 62.
"""
rep = self._rep(TEMPERATURE_HREF) rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get("x.com.samsung.da.minimum")) lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum")) hi = _num(rep.get("x.com.samsung.da.maximum"))
@@ -213,8 +194,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
@property @property
def target_temperature_step(self) -> float: def target_temperature_step(self) -> float:
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0 # `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160).
# into the fallback.
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment")) step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
return 0.5 if step is None else step return 0.5 if step is None else step
@@ -245,13 +225,11 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
# -- writes --------------------------------------------------------------- # -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None: async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service takes an optional # HA's water_heater.set_temperature service can carry an optional
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same # operation_mode; honor it, setting the mode first (which also
# as climate.set_temperature does with hvac_mode. Honour it, and set # powers the loop on) so a dashboard "boost to 55" button that
# it first -- that also powers the loop on when it was off -- so a # carries a mode actually changes mode, not just the setpoint. Same
# dashboard "boost to 55" button that carries a mode actually changes # fix as climate.async_set_temperature.
# mode, instead of only moving the setpoint. Same fix as the AC's
# (see climate.async_set_temperature).
operation_mode = kwargs.get("operation_mode") operation_mode = kwargs.get("operation_mode")
if operation_mode is not None: if operation_mode is not None:
await self.async_set_operation_mode(operation_mode) await self.async_set_operation_mode(operation_mode)
+75
View File
@@ -0,0 +1,75 @@
# AC filter-time counter reset: not solved
`registry/capabilities/airconditioner.py`'s `filter_time` sensor
(`FilterTime_<N>` option token, tenths of an hour) has no reset entity. This
is where the failed attempts to find one are kept, so the next attempt starts
from the evidence instead of from scratch. Not finding a mechanism is not the
same as it not existing.
## What the reset actually is
Samsung models it as a **command**, not a value write: capability
`custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in
several SmartThings HA forks; not in the core integration). That reframes
every attempt below — nothing changes the counter by writing to it, because
the board zeroes it itself on receiving a command.
## Tried, all against a live unit, all failed
- `FilterTime_0` via the single-token options merge that works for every
other setting on this href — accepted with no error, then discarded. Tried
on two units in opposite power states to rule out the obvious confound:
5595 → back to 5595 after 69s (powered off, alarm active), 1925 → back to
1925 after 65s (actively cooling).
- A full `options[]` read-modify-write with `FilterTime_0` substituted,
instead of the single-token merge — zero fields changed anywhere.
- A write to `/consumable/vs/0`, the board's own filter resource
(`items[{name: FilterProgress, state: N}]`) — discarded. `/oic/res`
declares that resource `oic.if.s` (read-only), which fits.
- `/actions/vs/0` (`x.com.samsung.da.actions`, `oic.if.a`) is the obvious
local command channel but publishes no schema: GET returns `{}` on
baseline and on `oic.if.a`, and five POSTs probing the shape (empty map,
empty string, empty array, invalid value, items shape) all returned 4.00
with an empty body — no echo of accepted field names, unlike the laundry
firmware's `"Control fail, <...>"`. Guessed action names were deliberately
not enumerated against a live appliance: an unknown vocabulary on a
channel called "actions" can hold a factory reset next to the one we want.
- `/hass/state/vs/0` and `/hass/command/vs/0` (advertised in `/oic/res`, and
`/opt/data/hass.db` exists in `/file/list`) → 4.04 on every interface, so
unimplemented scaffolding on this firmware.
- `/file/transfer/vs/0` serves only `/mnt/usage.db`; selecting another path
returns 4.05/4.00, so the firmware can't be pulled that way to read the
action vocabulary out of it.
- `/rm/micomdata/vs/0` (channel toward the MICOM board the physical panel
talks to) stays empty even after successfully enabling remote management.
## What the failures are not
Not a transport, permission, or cert problem: a control write of `rmState`
on `/rm/state/vs/0` was accepted (2.04 Changed, value held, restored
afterwards), and `FilterAlarmTime_` is written through the very same options
merge and kept. Writes work; this one value just isn't driven that way.
## Where to look next
- The `/actions/vs/0` action vocabulary from an independent source (a
firmware image, or a capture of what the cloud sends the device).
- The IR path — the physical remote has a filter reset (Options → Filter
Reset → SET), and IRremoteESP8266 decodes this AC family, though issue
#1277's dump doesn't include that button.
## Evidence for the counter's direction and scale
Confirmed counting *up* (running time since last reset, not remaining time):
token 1710 matched the Samsung app's "171 hours 0 minutes" for the same
filter (pins the tenths-of-an-hour scale); seen rising while the unit ran
(171.0 → 171.5); and across two units on one site the `/alarms/vs/0` filter
alarm tracks the counter in the right direction — live (`FilterAlarm`,
`Created`) at `FilterTime_5595`, still the `FilterAlarm_OFF`/`Deleted`
placeholder at `FilterTime_1915`, matching the app's own 500-hour threshold
behavior. `FilterAlarmTime_` in the same options blob is that threshold (500
on every unit on record).
The entity key stays `filter_time` rather than `filter_time_elapsed`:
renaming it would change every existing unit's `entity_id`/`unique_id` for a
wording improvement only.