diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 088a8b6..c67510b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,40 @@ file covers how changes get committed. assistant, or tool that helped produce the change. The commit is 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 See `AGENTS.md`. diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 72bbb3b..c893622 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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 probe read (`localthings_`), so that string is the identity the - entry's registry entries were minted from -- there is no need to reach the - device to recover it. Anything we can't recover one from resolves to the - host, which is what the coordinator seeded such an entry with anyway. + entry's registry entries were minted from -- no need to reach the device + to recover it. Anything unrecoverable resolves to the host, matching + what the coordinator seeded such an entry with anyway. The recovered string goes back through resolve_serial rather than being - taken at face value, because the unique_id records what the flow believed - at the time it ran, not what the registry holds now. Entries created - before the placeholder rules landed (issues #83/#189) were keyed on the - placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` -- - while the coordinator has since been resolving those same boards to the - host. Re-keying the registry onto the placeholder to match the unique_id - would reintroduce the collision those issues are about: two units of that - family report the *same* placeholder, so they'd share entity unique_ids - 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. + taken at face value: entries created before the placeholder rules + (issues #83/#189) were keyed on the placeholder itself, while the + coordinator has since resolved those same boards to the host. + Re-keying onto the placeholder would reintroduce the collision those + issues are about -- two units of a family sharing the same placeholder + would share entity unique_ids again. A later wrinkle, same root cause: + for a stretch the flow wrote `host:port` while the coordinator wrote + `host`; collapsed here to the coordinator's form too. """ host = entry.data[CONF_HOST] 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. Before the identity moved onto the config entry, the coordinator seeded - `device_serial` with the host and only replaced it after the first - successful poll. Anything that registered in between -- the connection-mode - sensor especially, since it is added unconditionally rather than from - `bound` -- was written into the registry keyed on the IP address - permanently, and was orphaned the moment the serial-keyed identity - appeared (issue #236). Deleting the orphans by hand didn't help: the next - restart that lost the same race recreated them. + `device_serial` with the host and only replaced it after the first poll. + Anything that registered in between -- the connection-mode sensor + especially, added unconditionally rather than from `bound` -- was + written into the registry keyed on the IP permanently, orphaned the + moment the serial-keyed identity appeared (issue #236). Deleting the + orphans by hand didn't help: the next restart that lost the same race + recreated them. - Rewriting beats deleting where it's possible -- an entity keeps its - entity_id, name, area and every automation that references it. It's only - possible when the serial-keyed key is still free, though; where both exist - the placeholder-keyed one is the dead duplicate (it has been unavailable - since the restart that created it), so it goes. + Rewriting beats deleting where possible -- an entity keeps its + entity_id, name, area and automations. Only possible when the + serial-keyed key is still free; where both exist the placeholder-keyed + one is the dead duplicate (unavailable since the restart that created + it), so it goes. """ host = entry.data[CONF_HOST] if serial == host: - # A board with no usable serial resolves *to* the host, so its keys - # were never placeholders -- there is nothing here to re-key. + # A board with no usable serial resolves to the host, so its keys + # were never placeholders. return 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) if existing is not None and existing.id != device.id: # Removing a device takes its entities with it. Anything still - # attached here came through the pass above re-keyed rather than - # removed -- i.e. it's the surviving copy, not a duplicate -- so - # move it onto the device it now belongs to first. Otherwise the - # rewrite that was supposed to preserve an entity_id, name and - # area destroys them a few lines later. + # attached here was re-keyed rather than removed above -- the + # surviving copy, not a duplicate -- so move it onto the device + # it now belongs to before the removal destroys it too. for entity in er.async_entries_for_device( 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: """Migrate an entry to the current version. - v1 -> v2 stores the device's identity on the entry so the coordinator can - key its registry entries before the first poll (issue #236), and repairs - whatever the old placeholder-keyed registration already orphaned. + v1 -> v2 stores the device's identity on the entry so the coordinator + can key its registry entries before the first poll (issue #236), and + repairs whatever the old placeholder-keyed registration already + orphaned. """ if entry.version > 2: - # Downgrade: this release doesn't know the newer entry's shape. - return False + return False # downgrade: this release doesn't know the newer shape if entry.version == 1: 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: await coordinator.async_config_entry_first_refresh() except Exception as err: - # `_poll_once` deliberately leaves the session up on a `TimeoutError` - # (the transfer may just be slow -- see its docstring), so a refresh - # that fails that way ends here with a live, bound UDP socket that - # nothing would ever close. HA retries setup on its own backoff with - # a *new* coordinator, and each device's source port is fixed by - # design (`_local_source_port`), so an abandoned socket squats the - # exact port the next attempt binds -- SO_REUSEADDR lets that bind - # succeed, leaving two sockets racing for the device's datagrams. + # `_poll_once` deliberately leaves the session up on a TimeoutError + # (see its docstring), so a refresh failing that way leaves a live, + # bound UDP socket nothing would ever close. HA retries setup with a + # new coordinator, and the source port is fixed by design + # (`_local_source_port`), so an abandoned socket would squat the + # exact port the next attempt binds. await coordinator.async_close() raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err hass.data[DOMAIN][entry.entry_id] = coordinator # Send the DTLS close_notify on Core shutdown, not just on unload (issue - # #254). `async_close` otherwise only runs via `async_unload_entry`, and - # HA does not unload entries on a plain Core restart -- so a restart left - # the previous run's association orphaned on the appliance, which is what - # makes the *next* run's handshake time out. Complements the fixed source - # port, which covers the unclean-exit case this cannot (see - # `_local_source_port`). + # #254): HA doesn't unload entries on a plain Core restart, so a restart + # left the previous run's association orphaned, making the next + # handshake time out. Complements the fixed source port, which covers + # the unclean-exit case this can't. # # 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 - # `async_block_till_done()` inside `hass.async_stop`. A detached task - # would likely be cancelled mid-shutdown -- the exact no-close_notify - # case this exists to prevent. + # runs it as a hass-tracked job, awaited by `async_block_till_done()` + # inside `hass.async_stop`. A detached task would likely be cancelled + # mid-shutdown -- the exact case this exists to prevent. async def _async_close_on_stop(_event: Event) -> None: await coordinator.async_close() @@ -198,31 +185,26 @@ async def async_remove_config_entry_device( ) -> bool: """Allow deleting a device this entry no longer provides (issue #214). - Defining this at all is what makes Home Assistant offer the "Delete - device" action for our devices; without it a device registry entry - belonging to a loaded config entry can never be removed from the UI. That - matters because a subdevice's HA device outlives the discovery that - created it: a candidate that materialized under an older release (issue - #214's phantom second air conditioner, born from an unused /device/1 slot - reporting the appliance's energy counter -- see - registry/subdevices.py's liveness gate) leaves a device entry behind that - nothing recreates and nothing cleans up once the gate stops materializing - it. Same for a sibling that a firmware update stops exposing. + Defining this at all is what makes HA offer the "Delete device" action; + without it, a device belonging to a loaded config entry can never be + removed from the UI. That matters because a subdevice's HA device + outlives the discovery that created it: a candidate materialized under + an older release (issue #214's phantom second air conditioner, born + from an unused slot reporting the appliance's energy counter -- see + registry/subdevices.py's liveness gate) leaves a device entry nothing + recreates or cleans up once the gate stops materializing it. Same for a + sibling a firmware update stops exposing. - Removal is refused for devices this entry *does* currently provide -- - HA would recreate them on the next entity add, so allowing it would look - like the delete silently failed. Deliberately no automatic pruning at - discovery time: subdevice enumeration is one-shot and a sibling can fail - to answer for a poll (issue #205 is exactly that on the reference - hardware), so auto-removal would throw away a real subdevice's name, - area and automation references on a transient miss. The user gets the - button; the integration doesn't guess. + Removal is refused for devices this entry does currently provide -- HA + would recreate them on the next entity add. Deliberately no automatic + pruning at discovery time: a sibling can fail to answer for a single + poll (issue #205), so auto-removal would throw away a real subdevice's + name/area/automations 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) if coordinator is None: - # Entry not loaded (or already unloaded) -- nothing is claiming this - # device, so there's nothing to protect it from being removed. - return True + return True # entry not loaded -- nothing claims this device live = set(coordinator.device_info.get("identifiers") or set()) for subdevice in coordinator.subdevices: live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set()) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index f095f4c..1482807 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -1,22 +1,22 @@ """Climate platform for Local Things. -The first composite entity in this integration: a single HA climate card that -unifies several OCF resources of a Samsung air conditioner. Unlike every other -platform here (one descriptor -> one resource field), a climate entity reads -power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind -direction) and the convenient-mode preset from *different* resources. +The first composite entity in this integration: a single HA climate card +that unifies several OCF resources of a Samsung air conditioner. Unlike +every other platform here (one descriptor -> one resource field), a climate +entity reads power, HVAC mode, current/target temperature, fan (wind) +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 -still tracks it, and reads the sibling resources straight from the coordinator -snapshot via `coordinator.resource(href)` -- the same cross-resource read that -`number.py` (live range/unit) and `select.py` (options callable) already do. +It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the +registry still tracks it, and reads the sibling resources straight from the +coordinator snapshot via `coordinator.resource(href)`. -Writes go through `coordinator.async_send_command(bound, (kind, value))`: the -CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right -`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and -applies the optimistic value/settle guard to that same href -- not the bound -`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state -back for, power, mode, temperature and wind resources alike. +Writes go through `coordinator.async_send_command(bound, (kind, value))`: +the CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the +right `(path_segs, body)`, and `async_send_command` applies the optimistic +value/settle guard to that resource's own href -- not the bound +`/mode/vs/0` href -- so one descriptor drives writes across power, mode, +temperature and wind resources alike. """ from __future__ import annotations @@ -95,54 +95,40 @@ _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes" _DEVICE_TO_HVAC: dict[str, HVACMode] = { "Cool": HVACMode.COOL, "Dry": HVACMode.DRY, - # Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and - # 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The - # reverse write can't rely on this map alone (two codes, one HA value) -- - # _device_code_for_hvac() resolves the code from the unit's own - # supportedModes, so this is only a fallback for a unit reporting no - # supportedModes at all. 'Fan' is listed first so the {v: k} reverse - # 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-only is spelled 'Wind' on some boards and 'Fan' on others; both map + # to FAN_ONLY. _device_code_for_hvac() resolves the write-side code from + # the unit's own supportedModes, so this reverse map is only a fallback + # for a unit with no supportedModes at all. 'Fan' listed first so the + # {v: k} comprehension below has 'Wind' win that fallback (last-key-wins, + # preserving the original single-spelling behavior). "Fan": HVACMode.FAN_ONLY, "Wind": HVACMode.FAN_ONLY, - # The device's 'Auto' is a single-setpoint "device decides" mode -> HA - # HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool" - # and implies a two-setpoint heat+cool range these single-setpoint units - # (including cool-only models) don't have. + # A single-setpoint "device decides" mode -> HA AUTO, not HEAT_COOL + # (which implies a two-setpoint heat+cool range these units don't have). "Auto": HVACMode.AUTO, "Heat": HVACMode.HEAT, } _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 -# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation -# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto' -# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as -# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled -# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain -# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the -# 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-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON): 'AIComfort' +# isn't a distinct thermodynamic operation like Cool/Dry/Heat, it's an AI +# overlay on the device's own 'Auto' -- the unit reports both as separate, +# mutually-exclusive supportedModes entries. hvac_mode reports AUTO (same as +# plain 'Auto') and a dedicated 'ai_comfort' preset carries the distinction. +# Not reachable via async_set_hvac_mode -- entered/left only through the +# preset, since there's no HVACMode value for it to write back to. _AI_COMFORT_MODE = "AIComfort" PRESET_AI_COMFORT = "ai_comfort" -# Codes that appear in /mode/vs/0's supportedModes but are option/capability -# flags rather than selectable thermodynamic operations -- dropped silently -# (no _warn_unmapped call) rather than every owner of an affected unit -# tripping the issue #93 warning on every start. +# Codes in /mode/vs/0's supportedModes that are option/capability flags, not +# selectable thermodynamic operations -- dropped silently rather than +# tripping the issue #93 unmapped-code warning on every start. # -# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in -# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside -# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a -# mode, on that resource. The unit's own `modes` (current mode) never -# reported it as active across the reporter's logs, only ever a real -# 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. +# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K) also appears in +# /configuration/vs/0's airconOptionList alongside other capability flags, +# and the unit's own current `modes` never reported it active -- consistent +# with an echoed capability flag, not a genuine mode. Unlike _AI_COMFORT_MODE, +# not modeled as a preset either: nothing confirms it's user-selectable. _NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"}) # 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 fallback via /wind/oscillation/vs/0 (issue #126) -- boards without -# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles -# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING -# above (off/vertical/horizontal/both), just read from/written to a pair -# of fields rather than a single one. +# Swing fallback via /wind/oscillation/vs/0 (issue #126): boards without +# WIND_DIRECTION_HREF report two independent Swing|Fix toggles instead of +# one combined code. Same HA vocabulary as _DEVICE_TO_SWING above. def _oscillation_swing(rep: dict) -> str | None: vertical = rep.get("vertical") horizontal = rep.get("horizontal") @@ -189,13 +173,12 @@ def _oscillation_swing(rep: dict) -> str | None: def _wind_strength_label(code, rep: dict) -> str: """Human label for a /wind/strength/vs/0 code from the device's own - modesName array (parallel-indexed with supportedModes), lowercased for - HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue - #155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the - "0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving - "Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors - preset_mode's dynamic code->str resolution. Falls back to the raw code - lowercased when modesName is absent or misaligned.""" + modesName array (parallel-indexed with supportedModes), lowercased -- + used only for codes _DEVICE_TO_FAN doesn't already cover (issue #155: + a board using codes "0"/"31"-"35" instead of the "0"-"4" scale + _DEVICE_TO_FAN was built from, with modesName giving the real labels). + Falls back to the raw code lowercased when modesName is absent or + misaligned.""" supported = rep.get("x.com.samsung.da.supportedModes") or [] names = rep.get("x.com.samsung.da.modesName") or [] 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 -# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' -# code maps to HA's PRESET_NONE ("no preset active"); every other code is -# exposed as its lowercased self and labelled in translations -# (entity.climate.airconditioner.state_attributes.preset_mode.state.), -# 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.) +# /mode/convenient/vs/0 supportedModes -- no per-model table. Device 'Off' +# maps to PRESET_NONE; every other code is exposed lowercased and labelled +# in translations, so any board's convenient modes surface without code +# changes, and an unlabelled code renders as its raw value. def _preset_to_ha(code) -> str: return PRESET_NONE if code == "Off" else str(code).lower() @@ -248,12 +226,11 @@ def _num(value): def _temps_vs_item(rep: dict) -> dict: """First item of the vendor `/temperatures/vs/0` items[] array. - Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g. - model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard - /temperature/current/0 + /temperature/desired/0 pair; it reports current - and target under a single `/temperatures/vs/0` resource whose - `x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/ - increment/unit. Returns {} when absent, so callers fall through cleanly. + Newer AC firmware (Tizen Lite) doesn't expose the OCF-standard + /temperature/current/0 + /temperature/desired/0 pair; it packs current/ + desired/minimum/maximum/increment/unit into this one resource's + items[0] instead. Returns {} when absent, so callers fall through + cleanly. """ items = rep.get("x.com.samsung.da.items") 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): """Composite climate entity for a Samsung air conditioner.""" - # translation_key comes from the ClimateDesc (base __init__ sets - # _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. + # Opts out of the deprecated auto-added TURN_ON/OFF backwards compat. _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None: super().__init__(coordinator, bound) - # Primary/main entity for the device: no name suffix, just the device name. - self._attr_name = None + self._attr_name = None # primary entity: no name suffix self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE @@ -283,9 +256,8 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF ) # (href, raw device code) pairs already logged by _warn_unmapped -- - # these properties are read on every coordinator refresh, so an - # un-deduped warning would spam the log for any device with a - # genuinely unrecognized code. + # these properties are read on every refresh, so an un-deduped + # warning would spam the log for a genuinely unrecognized code. self._warned_unmapped: set[tuple[str, str]] = set() # -- resource helpers --------------------------------------------------- @@ -308,67 +280,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): return {} def _legacy_airflow(self) -> dict: - """The /airflow/vs/0 rep, but only when it is the fan/swing channel to - use -- i.e. this board has no /wind/strength/vs/0. + """The /airflow/vs/0 rep, but only when it is the fan/swing channel + to use -- i.e. this board has no /wind/strength/vs/0. Delegates the board-generation test to is_legacy_board (the same - test capabilities/airconditioner.py's token entities are gated on) - instead of re-implementing it. Uses self._resources (this unit's own - canonical view, issue #177 -- see LocalThingsEntity._resources) - rather than a two-key presence dict built from coordinator.resource()'s + test the token entities in capabilities/airconditioner.py use) + instead of re-implementing it, using self._resources (issue #177) + rather than a presence dict built from coordinator.resource()'s truthiness -- resource() collapses "href absent" and "href present - with an empty {} rep" to the same falsy value, while is_legacy_board - (and discover()'s own binding) test key membership, not truthiness. A - presence dict built from truthiness alone would disagree with the - token entities on a board reporting a genuinely empty /airflow/vs/0, - 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 //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. + but empty" to the same falsy value, while is_legacy_board tests key + membership. Reads through self._rep, not coordinator.resource() + directly, so a subdevice's own /airflow/vs/1 gets translated first, + like every other sibling read below. """ if not is_legacy_board(self._resources): return {} return self._rep(AIRFLOW_HREF) 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 - rep being empty alone: newer boards carry Comode tokens too, so a - momentarily empty /mode/convenient/vs/0 there must not silently switch - the preset read (and write) over to the token path. - - 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. + Reads the raw href directly rather than through self._rep's own + CONVENIENT_HREF fallback -- that fallback IS the legacy_convenient() + rep this method is deciding whether to use, so routing through it + would make the resource never look empty. """ convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF) return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow()) def _rep(self, href: str) -> dict: - """`href` is one of this module's canonical HREF_* constants -- - translated through this bound entity's own subdevice (issue #177) to - the real, on-the-wire href before the single-href cache lookup - (identity for MAIN, so a device with no subdevices reads exactly the - href it always did).""" + """`href` is one of this module's canonical HREF_* constants, + translated through this bound entity's own subdevice (issue #177) + to the real on-the-wire href -- identity for MAIN.""" rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {} if not rep and href == CONVENIENT_HREF and self._legacy_airflow(): return self._legacy_convenient() return rep def _is_on(self) -> bool: - # Prefer the vendor /power/vs/0 (present on every observed board and - # the resource writes target -- see airconditioner._climate_write). - # The OCF /power/0 is absent on many boards and a stale mirror on - # some, so reading it first showed pre-write state after a power - # toggle (issue #53: "can turn on but not off"). + # Prefer the vendor /power/vs/0 -- the OCF /power/0 is absent on many + # boards and a stale mirror on some, so reading it first showed + # pre-write state after a power toggle (issue #53). power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power") if power is not None: return str(power).lower() == "on" @@ -379,16 +335,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _warn_unmapped(self, href: str, code: str) -> None: """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 - in the log instead of silently vanishing (issue #93). + entry in the relevant device<->HA map, so a real gap surfaces in + the log instead of silently vanishing (issue #93). Falls back to `unique_id` when `entity_id` is unset (issue #235): - this fires during setup's first discovery pass, before the entity is - added to hass, so `entity_id` is always None at that point -- - 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. - """ + this can fire during setup's first discovery pass, before the + entity is added to hass, when entity_id is still None.""" key = (href, code) if key in self._warned_unmapped: return @@ -420,11 +372,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def _ocf_temp_authoritative(self) -> bool: """True when the OCF /temperature/{current,desired}/0 pair is the - authoritative temperature channel -- signalled by - /temperature/current/0 being present. Those boards honour reads/ - writes on /temperature/desired/0 and ignore the vendor - /temperatures/vs/0; boards without the pair (only a desired stub, or - nothing) are the reverse. Confirmed on live units of both kinds.""" + authoritative channel, signalled by /temperature/current/0 being + present. Those boards honor reads/writes on /temperature/desired/0 + and ignore the vendor /temperatures/vs/0; boards without the pair + are the reverse. Confirmed on live units of both kinds.""" return bool(self._rep(TEMP_CURRENT_HREF)) def _temps_vs(self) -> dict: @@ -597,11 +548,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): return _HVAC_TO_DEVICE.get(hvac_mode) async def async_set_temperature(self, **kwargs) -> None: - # HA's set_temperature service forwards an optional hvac_mode here; honour - # it (set the mode first -- that also powers the unit on when it was off), - # matching the climate contract other integrations follow. Without this a - # 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. + # HA's set_temperature service can carry an optional hvac_mode; + # honor it (setting the mode also powers the unit on) so a dashboard + # "turn on to Auto 24" button doesn't set the setpoint alone. hvac_mode = kwargs.get("hvac_mode") if hvac_mode is not None: await self.async_set_hvac_mode(hvac_mode) @@ -647,12 +596,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): supported = self._supported(WIND_STRENGTH_HREF) device = _FAN_TO_DEVICE.get(fan_mode) # A static hit is only trustworthy if this unit's own supportedModes - # actually includes that code -- a board can use non-standard codes - # (issue #155's "31"-"35") while still spelling a standard label - # ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would - # return a plausible-looking code ('1'/'3') the device never - # advertised at all. Fall through to the live scan whenever the - # static guess isn't actually one of this unit's own codes. + # includes that code -- a board can use non-standard codes (issue + # #155) while still spelling a standard label in modesName, so the + # static guess could be a plausible code the device never + # advertised. Fall through to the live scan when it isn't one of + # this unit's own codes. if device is None or (supported and device not in supported): rep = self._rep(WIND_STRENGTH_HREF) for code in supported: diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 62ffe0a..5f06bdb 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -77,15 +77,10 @@ class CannotConnect(Exception): """Base for every probe failure. `error_key` selects which message the user sees. The subclasses below - exist because "cannot connect" covered wildly different situations -- an - IP with nothing on it, an appliance on cloud-only firmware, a device - that's simply still holding a session from the last attempt, and a device - that answered and rejected our certificate all told the user the same - 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. + exist because "cannot connect" used to cover wildly different situations + (nothing at that IP, cloud-only firmware, a stale held session, a + rejected certificate) all under one unhelpful message. Raising this base + class directly is still valid for a failure that can't be narrowed down. """ error_key = "cannot_connect" @@ -144,11 +139,9 @@ class InvalidCA(Exception): def _fetch_samsung_uuid() -> str: - """Connect to Samsung's cloud gateway and extract the UUID from its TLS cert. - - Verification is disabled because Samsung's chain contains a self-signed cert. - We only need to read the UUID from the cert subject, not verify its trust. - """ + """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, + and we only need to read the UUID from the subject, not verify trust.""" from cryptography import x509 as _x509 ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) @@ -251,13 +244,11 @@ def _order_candidates(ports: list[int]) -> list[int]: return preferred + rest -# 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 -# ECONNREFUSED, which is a *response* -- the host is there and told us the port -# is closed. Both leave a port "not live", but they mean opposite things about -# whether anything exists at that address, which is the difference between -# telling a user to check the IP and telling them their appliance is on -# cloud-only firmware. +# The kernel's way of saying the datagram never had anywhere to go: no route, +# or the host never answered ARP. Distinct from ECONNREFUSED, which is a +# response -- the host is there and told us the port is closed. Both leave a +# port "not live", but mean opposite things about whether anything exists at +# that address. _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: """Fast UDP liveness sweep -- the sweep's own verdict, nothing added. - UDP is connectionless, but a *connected* UDP socket surfaces the ICMP - port-unreachable that a closed port returns as ECONNREFUSED on its next - recv. So we send one probe datagram per port and watch for that error: + UDP is connectionless, but a connected UDP socket surfaces the ICMP + port-unreachable a closed port returns as ECONNREFUSED on its next recv. + 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) - * silence / any data -> port may be live (open|filtered); a candidate - - This is the in-process equivalent of ``nmap -sU``: it lets us take a - 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. + Deliberately the raw verdict, with no preferred-port rescue folded in + (that's `_sweep_ports`) -- its shape is evidence about the host, and a + refusal vs. an unreachable are counted apart rather than both "not + live" for that reason (see _SweepResult). """ sockets: dict[int, socket.socket] = {} sel = selectors.DefaultSelector() @@ -312,7 +296,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul sock.send(probe) except OSError as exc: # 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) sock.close() continue @@ -329,8 +313,7 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> _SweepResul for key, _ in sel.select(timeout=remaining): sock = sockets[key.data] try: - # Data back means live; an error means the port is - # closed or the host isn't there — either way, rule it out. + # Data back means live; an error rules the port out. sock.recv(1) except OSError as 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. 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 - live that a concurrent nmap scan showed as closed, while the port nmap - found genuinely open|filtered (49154, one of our historically confirmed - ports) never showed up as live at all. Rather than trust a wrong "not - live" verdict on a port we already have strong prior evidence for, always - give the historically-confirmed ports a real handshake attempt too. - Bounded cost: at most len(PREFERRED_PROBE_PORTS) extra handshakes, only - when the sweep disagrees with the prior. + issue #192 captured a segregated-VLAN device where it called live ports + that nmap showed closed, while the port nmap found genuinely open never + showed up as live at all. Rather than trust a wrong "not live" verdict + on a port with strong prior evidence, the historically-confirmed ports + always get a real handshake attempt too (bounded cost: at most + len(PREFERRED_PROBE_PORTS) extra handshakes, only when the sweep + disagrees with the prior). - Both halves are returned rather than just the union because they answer - different questions: `candidates` is what to hand a handshake, `sweep` is - what the host actually told us about itself. + Both halves are returned, not just the union, since they answer + different questions: `candidates` is what to hand a handshake, `sweep` + is what the host actually told us about itself. """ 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] @@ -371,10 +353,10 @@ def _sweep_ports(host: str, ports: list[int], timeout: float) -> tuple[_SweepRes class _PortScan: """What port detection learned about a host. - `candidates` is what gets a full DTLS handshake. The other two are kept - because they're the evidence behind a failure message: `confirmed` names - ports a DTLS server was *proven* on, and `swept` is the UDP sweep's own - verdict (None when the sweep never had to run). + `candidates` is what gets a full DTLS handshake. The other two are the + evidence behind a failure message: `confirmed` names ports a DTLS + server was proven on, `swept` is the UDP sweep's own verdict (None + when the sweep never had to run). """ candidates: list[int] @@ -383,12 +365,10 @@ class _PortScan: def _clienthello_probe(host: str, port: int): - """One stateless DTLS ClientHello against `host:port`. - - Imported lazily so an install whose smartthings-local predates the probe - (< 0.1.2) degrades to the UDP sweep at scan time rather than failing to - load the config flow at all. - """ + """One stateless DTLS ClientHello against `host:port`. Imported lazily + so an install whose smartthings-local predates the probe (< 0.1.2) + degrades to the UDP sweep at scan time rather than failing to load the + config flow at all.""" from smartthings_local.protocol.dtls_probe import 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 moment the server proves itself with a HelloVerifyRequest, which per RFC - 6347 §4.2.1 the server answers *without* allocating association state. So - this identifies the device's real port in ~1 RTT, leaves nothing behind on - the appliance, and costs it far less than the alternative of throwing N - full certificate handshakes at it to find out. + 6347 §4.2.1 the server answers without allocating association state -- + identifies the device's real port in ~1 RTT, far cheaper than throwing N + full certificate handshakes at it. - The whole range goes out at once. That's safe in a way racing real - handshakes is not: each probe is bounded by CLIENTHELLO_PROBE_TIMEOUT_S - rather than DtlsCoapSession's 12s handshake timeout, so the pool's - shutdown-and-wait on exit costs one probe's budget, not the sum of the - range -- no `shutdown(wait=False)` and no losing threads left running - behind us. + The whole range goes out at once, safely: each probe is bounded by + CLIENTHELLO_PROBE_TIMEOUT_S rather than DtlsCoapSession's 12s handshake + timeout, so the pool's shutdown-and-wait on exit costs one probe's + budget, not the sum of the range. """ with ThreadPoolExecutor(max_workers=min(len(ports), PROBE_MAX_WORKERS)) as ex: 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: """Find the device's DTLS port, preferring proof over absence of evidence. - The ClientHello probe is authoritative when it finds something: a port - that answered one is running a DTLS server, so exactly one port gets the - expensive certificate handshake instead of every port the old UDP sweep - couldn't rule out (each of which cost a full 12s handshake timeout -- - issue #211's 30-40s adds). + The ClientHello probe is authoritative when it finds something: exactly + one port gets the expensive certificate handshake instead of every port + the old UDP sweep couldn't rule out (issue #211's 30-40s of 12s handshake + timeouts). - It stays a *gate*, not a replacement: when it confirms nothing we fall - back to the ICMP-based sweep, which is wrong in the opposite direction - (it reports everything it can't rule out) and so still surfaces a device - the probe couldn't reach -- e.g. a network path that drops our - ClientHello outright, or an install still on smartthings-local < 0.1.2. - Issue #192's segregated-VLAN device is the reason that fallback keeps its - own preferred-port rescue. + It's a gate, not a replacement: when it confirms nothing, we fall back + to the ICMP-based sweep, which still surfaces a device the probe + couldn't reach (a network path dropping the ClientHello, or an install + on smartthings-local < 0.1.2). Issue #192's segregated-VLAN device is + why that fallback keeps its own preferred-port rescue. """ try: confirmed = _clienthello_scan(host, PROBE_PORT_RANGE) @@ -472,12 +446,10 @@ def _scan_ports(host: str) -> _PortScan: return _PortScan(candidates, [], sweep) -# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and said -# no", as opposed to a protocol/cipher disagreement. decrypt_error belongs -# here: it's what a peer sends when CertificateVerify fails. These are the -# alerts an appliance sends when the CA behind the leaf isn't one it trusts -- -# 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. +# TLS alerts (RFC 5246 §7.2) that mean "I looked at your certificate and +# said no", as opposed to a protocol/cipher disagreement -- what an +# appliance sends when the CA behind the leaf isn't one it trusts, the +# single most common real setup mistake. _CERT_ALERTS = frozenset( { "bad_certificate", @@ -493,15 +465,13 @@ _CERT_ALERTS = frozenset( ) # OpenSSL renders a received fatal alert into its error text as e.g. -# "tlsv1 alert unknown ca" / "sslv3 alert bad certificate", which -# DtlsCoapSession.connect() wraps in a ConnectionError. Reading it back out -# tells us what the appliance actually objected to. +# "tlsv1 alert unknown ca", which DtlsCoapSession.connect() wraps in a +# ConnectionError. Reading it back tells us what the appliance objected to. # -# Deliberately not the library's diagnostic probe (stateless=False), which -# would report the alert authoritatively: that mode drives the handshake far -# enough to commit association state on the device, and an orphaned -# association is exactly what makes the *next* attempt time out (RFC 6347 -# §4.2.8) -- a bad trade on a path the user is about to retry. +# Deliberately not the library's diagnostic probe (stateless=False): that +# mode commits association state on the device, and an orphaned association +# makes the next attempt time out (RFC 6347 §4.2.8) -- a bad trade on a +# path the user is about to retry. _ALERT_RE = re.compile(r"alert ([a-z0-9 ]+)") @@ -516,16 +486,12 @@ def _classify_handshake_failure( scan: _PortScan, failures: list[tuple[int, Exception]], ) -> CannotConnect: - """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 the appliance is there, speaks DTLS, and refused us on - purpose -- and the alert says whether it was about our certificate. - * 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. + """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 + the appliance refused us on purpose (and says whether it was our + certificate); a confirmed DTLS port that then timed out is likely still + holding a session from a previous attempt; otherwise the sweep's own + shape is the evidence. """ 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] @@ -542,18 +508,18 @@ def _classify_handshake_failure( if sweep is None: return CannotConnect(f"no port on {host} completed a handshake") if sweep.unreachable and not sweep.refused: - # The kernel never got the datagrams off the host, so nothing was - # ever asked. Reporting "ports closed" here would be exactly wrong. + # Nothing was ever asked -- the kernel never got the datagrams off + # the host, so "ports closed" would be exactly wrong. return NoResponse(f"{host} is unreachable (ports {sweep.unreachable})") if not sweep.live: - # Every port answered ICMP port-unreachable: something is at that - # address and it is not exposing the local API. + # Every port answered ICMP port-unreachable: something is there and + # not exposing the local API. return PortsClosed( f"{host} refused every port in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" ) if len(sweep.live) == len(PROBE_PORT_RANGE): - # Not one refusal came back across a nine-port ephemeral range. A host - # that is actually there answers for at least some of it. + # Not one refusal across a nine-port range -- a host that's + # actually there answers for at least some of it. 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") @@ -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 declaration is the primary detection signal when a board populates it - (see registry/by_type's resolve()), and read_identity's three small GETs - settle it long before the blockwise /device/0 dump lands. read_identity is - defensive on every GET it makes, so a device that answers neither /oic/p - nor /oic/d just yields an empty device_types tuple and detection falls - through to the model-string/resource-signature path. + (see registry/by_type's resolve()), and read_identity's three small + GETs settle it long before the blockwise /device/0 dump lands. + read_identity is defensive on every GET, so a device answering neither + /oic/p nor /oic/d falls through to the model-string/resource-signature + path. - Everything the entry needs to name and key the device comes from here -- - resolved serial, model, manufacturer, device type -- so the coordinator - never has to mint a registry key from a placeholder (issue #236). + Everything the entry needs to name and key the device comes from here, + so the coordinator never has to mint a registry key from a placeholder + (issue #236). """ import cbor2 @@ -665,17 +631,15 @@ def _probe_and_validate( ) -> dict: """Find the device's port, authenticate to it, and resolve its identity. - Port detection runs first and needs no credentials at all, so an - unreachable host fails here rather than after a round trip to Samsung's - cloud. + Port detection runs first and needs no credentials, so an unreachable + host fails here rather than after a round trip to Samsung's cloud. `existing_leaf` is another entry's already-minted leaf (issue #211). - Every appliance accepts the same leaf -- CA `AC14K_M` plus the UUID from - Samsung's cloud cert -- so adding a second device can skip the fetch and - mint entirely, which makes it independent of Samsung-cloud reachability - rather than merely faster. If that reused leaf turns out to be stale (the - UUID does rotate), a confirmed-live device rejecting it is unambiguous - enough to re-mint and try once more, so the reuse stays self-correcting. + Every appliance accepts the same leaf, so adding a second device can + skip the fetch and mint entirely -- independent of Samsung-cloud + reachability, not merely faster. If that reused leaf turns out to be + stale (the UUID does rotate), a confirmed-live device rejecting it + re-mints and retries once, so the reuse stays self-correcting. """ scan = _scan_ports(host) @@ -718,11 +682,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _create_entry(self, info: dict) -> ConfigFlowResult: """Persist everything the probe resolved, identity included. - The identity fields are not decoration: the coordinator seeds - `device_serial` and its DeviceInfo from them at construction time, so - entity unique_ids and device identifiers are correct from the very - first entity that registers -- even if the first poll is slow, or - fails outright (issue #236). + The identity fields aren't decoration: the coordinator seeds + `device_serial` and its DeviceInfo from them at construction time, + so entity unique_ids are correct from the first entity that + registers, even if the first poll is slow or fails (issue #236). """ from .registry.identity import device_display_name @@ -772,8 +735,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) except (CannotConnect, InvalidCA) as exc: # Every probe failure carries the message that fits it (see - # CannotConnect); the log line is where the specifics live, - # since the messages point users at it. + # CannotConnect); the log line is where the specifics live. _LOGGER.warning("Probe of %s failed [%s]: %s", self._host, exc.error_key, exc) errors["base"] = exc.error_key except Exception: @@ -836,14 +798,11 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): arbitrary resource href, so a user can pin down device-specific write behavior without waiting on a new release. - The remote-control override exists because most devices reject writes - outright while remote control is off and a clear error beats a silent - device-side rejection -- but not every model actually enforces that, - so this lets a user who's confirmed their device accepts writes anyway - turn the block off for just that device rather than it being - hardcoded on for everyone. 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. + The remote-control override exists because not every model actually + enforces the block most devices do, so a user who's confirmed their + device accepts writes anyway can turn it off for just that device. The + debug panel goes further, bypassing that block (and every write_fn/ + validate_fn) entirely. """ def __init__(self) -> None: diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 58a57f3..b2b876a 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -20,86 +20,75 @@ CONF_CA_KEY_PEM = "ca_key_pem" CONF_LEAF_CERT_PEM = "leaf_cert_pem" CONF_LEAF_KEY_PEM = "leaf_key_pem" -# Device identity, resolved once by the config flow's probe and persisted on -# the entry (issue #236). These are what the coordinator mints registry keys -# from at __init__ time, before any poll has happened -- see -# LocalThingsCoordinator.__init__. Without them the coordinator had to seed -# `device_serial` with the host and rebuild its DeviceInfo after the first -# 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. +# Device identity, resolved once by the config flow's probe and persisted +# on the entry (issue #236) -- what the coordinator mints registry keys +# from at __init__ time, before any poll has happened. Without them, +# anything registering before the first poll (e.g. the connection-mode +# sensor) got keyed on the IP address permanently. # -# 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 -# (issues #83/#189) -- so it matches what _run_discovery computes on the first -# poll exactly, and the device identity never changes underneath the registry. +# CONF_SERIAL is the resolved serial (registry.identity.resolve_serial's +# output, the host itself for a placeholder-serial board -- issues +# #83/#189), so it matches what _run_discovery computes on the first poll. CONF_SERIAL = "serial" CONF_MODEL = "model" CONF_MANUFACTURER = "manufacturer" CONF_DEVICE_TYPE = "device_type" -# Options-flow key (entry.options, not entry.data): lets a user override the -# device-wide remote-control-off write block for a specific device (issue -# #54). Some devices report remote control off yet still accept certain -# writes (e.g. default detergent/softener dosing on a washer, applied even -# to the built-in programs) -- the block exists to give a clear error -# instead of a silent device-side rejection, but that assumption doesn't -# hold for every model. Defaults to False (block stays on) everywhere it's -# read, so devices this doesn't apply to see no behavior change. +# Options-flow key (entry.options, not entry.data): lets a user override +# the device-wide remote-control-off write block for a specific device +# (issue #54). Some devices accept certain writes even while reporting +# remote control off (e.g. a washer's default detergent dosing), so the +# blanket-block assumption doesn't hold everywhere. Defaults to False +# (block stays on). CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock" # Options-flow key: minimum change (in minutes) required before a -# hysteresis-gated timestamp sensor (currently just finish_time) is allowed -# to report a new value. Devices commonly revise their own remaining-time -# estimate by a minute or two throughout a cycle, and finish_time = now() + -# remaining drifts by the poll interval between those revisions -- both push -# a fresh state (and a recorder/logbook entry) far more often than the -# estimate is meaningfully different. 0 disables the gate (every computed -# change is reported, today's behavior). +# hysteresis-gated timestamp sensor (currently just finish_time) reports a +# new value. Devices commonly revise their remaining-time estimate by a +# minute or two throughout a cycle, and finish_time = now() + remaining +# drifts with the poll interval between revisions -- both push a fresh +# state far more often than the estimate is meaningfully different. 0 +# disables the gate. CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes" DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3 -# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port -# depends on firmware. Newer builds answer on 49154/49155, but older ones have -# been seen as low as 49153, so we sweep the whole range for a live UDP port -# before attempting the (expensive) DTLS handshake. +# The DTLS/CoAP local API binds somewhere in this ephemeral range, +# depending on firmware (newer builds answer on 49154/49155, older ones as +# low as 49153) -- swept for a live UDP port before the expensive DTLS +# handshake. PROBE_PORT_RANGE = list(range(49152, 49161)) -# Ports we've historically seen complete a DTLS handshake. When more than one -# port in the range looks live, these are tried first. +# Ports we've historically seen complete a DTLS handshake; tried first when +# more than one port in the range looks live. PREFERRED_PROBE_PORTS = [49154, 49155] # 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 -# 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. LIVENESS_PROBE_TIMEOUT_S = 1.5 -# Per-port budget for the DTLS ClientHello probe (smartthings-local >= 0.1.2), -# the primary port-detection gate. A real DTLS server answers with a -# HelloVerifyRequest in ~1 RTT, so a live port resolves well inside this; the -# budget only bounds how long a *silent* port takes to give up, since the -# probe services OpenSSL's retransmit timer rather than reading one dropped -# ClientHello as dead. 3s covers two retransmits on a slow LAN. +# Per-port budget for the DTLS ClientHello probe (smartthings-local >= +# 0.1.2), the primary port-detection gate. A real server answers with a +# HelloVerifyRequest in ~1 RTT; the budget only bounds how long a silent +# port takes to give up. 3s covers two retransmits on a slow LAN. CLIENTHELLO_PROBE_TIMEOUT_S = 3.0 CLIENTHELLO_PROBE_RETRIES = 2 -# The whole port range is probed at once: each stateless probe is bounded by -# CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the sweep -# costs one probe's wall clock rather than the sum of the range. Capped so a +# The whole port range is probed at once: each stateless probe is bounded +# by CLIENTHELLO_PROBE_TIMEOUT_S (unlike a full handshake's 12s), so the +# 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. PROBE_MAX_WORKERS = 12 -# Deadline for the blockwise /device/0 GET during the config-flow probe. The -# slowest device observed returns a full dump in ~8s, so 10s leaves headroom -# without stalling setup; it matches the per-resource read timeout elsewhere. +# Deadline for the blockwise /device/0 GET during the config-flow probe. +# The slowest device observed returns a full dump in ~8s. PROBE_GET_TIMEOUT_S = 10.0 # Base for the local (client-side) DTLS source port, distinct from the -# destination probe ports above. See coordinator._local_source_port for why a -# fixed per-device source port matters and how the per-device offset is -# derived. Base mirrors the upstream smartthings-local reference bridge. -# Requires smartthings-local >= 0.1.1. +# destination probe ports above -- see coordinator._local_source_port for +# why a fixed per-device source port matters. Mirrors the upstream +# smartthings-local reference bridge. Requires smartthings-local >= 0.1.1. DTLS_LOCAL_PORT_BASE = 49700 SUMMARY_INTERVAL_S = 30.0 diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index c343232..64445f5 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -70,9 +70,8 @@ _SEED_PATH = ["device", "0"] class _NoOpDescriptor: - """StateCache requires a descriptor with an on_observation hook. This - integration doesn't use per-capability observation hooks, so this is a - deliberate no-op, not a placeholder for missing functionality.""" + """No-op: StateCache requires an on_observation hook; this integration + doesn't use per-capability observation hooks.""" def on_observation(self, state: dict, href: str, rep: dict) -> 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: """Deterministic UDP source port for this device's DTLS socket. - Binding the same source port on every (re)connect keeps the client on one - 5-tuple, so the appliance evicts an orphaned session left by a previous run - (unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347 - §4.2.8, instead of holding it for 5-15 min while the new session's reads - hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local - >= 0.1.1 (the version that added DtlsCoapSession(local_port=...)). + Binding the same source port across reconnects lets the appliance evict + an orphaned session (unclean shutdown, no close_notify) at handshake + time per RFC 6347 §4.2.8, instead of holding it 5-15 min. See + DTLS_LOCAL_PORT_BASE. Requires smartthings-local >= 0.1.1. - The port must be stable across restarts and unique per device on this HA - host: the library's socket is unconnected (recvfrom), so two devices - sharing a source port would mis-demux each other's datagrams. For the usual - dotted-IPv4 host we use the last octet as the offset (unique on a /24); - anything else folds a stable CRC32 into the same 256-wide window. + Must stay unique per device on this host too: the library's socket is + unconnected, so two devices sharing a port would mis-demux each other's + datagrams. Last IPv4 octet as offset for the common case; a stable + CRC32 fold otherwise. """ try: offset = int(ipaddress.IPv4Address(host)) & 0xFF @@ -111,52 +107,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): device_info: DeviceInfo device_serial: str - # Class-level knobs (not instance attrs) so tests can shrink real-time - # delays via `patch.object(LocalThingsCoordinator, ...)` without - # touching the production defaults these are computed from. Production - # code always sees these two values; only tests override them. + # Class-level so tests can shrink these via patch.object() without + # touching the production defaults. _SUBPOLL_STEP_S: float = SUMMARY_INTERVAL_S / 10 # 3.0 s _OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S _RECONNECT_PAUSE_S: float = 5.0 - # A single reconnect is normal appliance-side behavior (see the - # README's "Known device behavior" section) -- Samsung's firmware - # drops the DTLS session briefly every now and then, and the - # coordinator recovering from that on its own isn't something a user - # needs to see at WARNING. Only escalate once reconnects pile up - # within a trailing window (issue #119). - # - # 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. + # A single reconnect is normal appliance behavior (README's "Known + # device behavior"); only escalate once they pile up in a trailing + # window (issue #119). Can't be a literal 60s: consecutive attempts are + # always >= one summary interval + _RECONNECT_PAUSE_S apart, so at most + # ~2 could ever land in 60s regardless of how unhealthy the connection + # is. 300s/3 is reachable under normal polling and still a reasonable + # "actually broken" proxy. _RECONNECT_WARN_WINDOW_S: float = 300.0 _RECONNECT_WARN_THRESHOLD: int = 3 - # A block-level ACK timeout on the summary GET doesn't prove the - # session is dead (see _poll_once) — require this many in a row - # before treating it as one. A single slow transfer on an otherwise - # fine session shouldn't tear down a working OBSERVE subscription. + # A block-level ACK timeout on the summary GET doesn't prove the session + # is dead (see _poll_once) -- require this many in a row before treating + # it as one, so one slow transfer doesn't tear down a working OBSERVE + # subscription. _POLL_TIMEOUT_LIMIT: int = 3 - # Timeouts for the two network round trips a write triggers: the PUT - # itself (_do_put), then the confirming full /device/0 summary poll - # async_send_command requests right after (_poll_once). Named here - # (rather than left as inline literals) so the write-settle window - # below can be sized to always outlast both — see async_send_command. + # Named (not inline literals) so the write-settle window in + # async_send_command can be sized to outlast both round trips a write + # triggers: the PUT itself, then the confirming summary poll. _POST_TIMEOUT_S: float = 8.0 _POLL_TIMEOUT_S: float = 35.0 def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - # Per-device logger (module logger scoped to this device's host) so - # every log line — including the base DataUpdateCoordinator's own - # messages and ObserveManager's — identifies which device it's - # about. A bare module-level logger is shared across every - # configured device, which makes multi-device logs ambiguous. + # Per-device logger so every log line (including the base + # coordinator's and ObserveManager's) identifies which device it's + # about, instead of a shared module-level logger. self._log = logging.getLogger(f"{__name__}.{entry.data[CONF_HOST]}") super().__init__( hass, @@ -170,59 +152,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._identity: DeviceIdentity | None = None self._discovered = False self.bound = [] - # Sibling indoor subdevices discovered on this connection (issue - # #177) -- candidates set once, at first discovery, by - # _enumerate_subdevices_blocking; narrowed by _run_discovery to the - # 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. + # Sibling indoor subdevices on this connection (issue #177); set + # once at first discovery, narrowed to the ones with live state (see + # subdevices.discover_partitioned). Never includes MAIN itself. self.subdevices: list[Subdevice] = [] - # Candidates _run_discovery's gate rejected (an unused SmartThings - # slot that still answers its seed, e.g. the issue #177 reporter's - # /device/2) -- surfaced in diagnostics alongside the materialized - # ones so a report shows what was found and why it didn't become an - # entity. + # Candidates the liveness gate rejected (e.g. an unused SmartThings + # slot that still answers its seed) -- surfaced in diagnostics. self._skipped_subdevices: list = [] - # Those rejected candidates' raw reps, kept aside for diagnostics - # only (see _live_subdevice_resources). They are deliberately not in the - # state cache: nothing polls them again, so anything applied there - # would sit frozen at its first-discovery value while looking as - # live as every other href in `last_resources`. + # Rejected candidates' raw reps, kept for diagnostics only (see + # _live_subdevice_resources) -- never applied to the state cache, or + # they'd sit frozen at first-discovery value looking live. self._skipped_subdevice_resources: dict[str, dict] = {} - # /multidevice/vs/0's rep, if this board answers it -- a plain - # subdevice count that corroborates the liveness gate without deciding it. - # Deliberately outside `resources`; see _enumerate_subdevices_blocking. + # /multidevice/vs/0's rep if this board answers it -- corroborates + # the liveness gate without deciding it; kept outside `resources`. self._multidevice: dict = {} - # What each subdevice probe found, keyed by the seed href attempted -- - # surfaced in diagnostics so a report can tell "checked, nothing - # there" apart from "never checked" (the same posture the - # speculative-probe code this replaced documented in identity.py). + # What each subdevice probe found, keyed by seed href -- lets + # diagnostics distinguish "checked, nothing there" from "never + # checked". self._subdevice_probes: dict[str, bool] = {} - # canonical_resources() memo, keyed by (subdevice.kind, subdevice.key). - # Invalidated in _on_cache_changed -- climate.py reads this on every - # property access (is_legacy_board and friends), so it must not - # rebuild an O(hrefs) view from scratch on every single property. + # canonical_resources() memo; invalidated in _on_cache_changed so + # climate.py's frequent per-property reads don't rebuild it from + # scratch each time. self._canonical_cache: dict[tuple[str, str], dict] = {} self._cache = StateCache(_NoOpDescriptor()) self._cache.set_on_change(self._on_cache_changed) self._observe = ObserveManager(self._cache, logger=self._log) self._push_pending = False self._push_pending_lock = threading.Lock() - # Identity comes from the config entry, resolved once by the config - # flow's probe (issue #236). `device_serial` mints *permanent* - # registry keys -- entity unique_ids (entity.py, sensor.py) and device - # identifiers (device_info_for) -- so it must be the device's real - # identity before the first entity registers, not a placeholder that - # gets corrected once the first poll lands. Anything registered - # against a placeholder is keyed on it in the registry forever; when - # 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. + # Identity is resolved once by the config flow's probe (issue #236). + # device_serial mints permanent registry keys, so it must be correct + # before the first entity registers -- a placeholder corrected once + # the first poll lands orphans the first device/entity pair instead. + # The host fallback covers a pre-migration entry and matches what + # resolve_serial itself returns for a placeholder-serial board + # (issues #83/#189). self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST] self.device_info = DeviceInfo( identifiers={(DOMAIN, self.device_serial)}, @@ -251,26 +214,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): return self._cache.snapshot() def resource(self, href: str) -> dict: - """A single href's current rep. Cheaper than `last_resources.get(href)` - for callers that only need one href — `last_resources` copies every - tracked href's rep to build the snapshot dict, while this is a - direct O(1) cache lookup.""" + """A single href's rep. Cheaper than `last_resources.get(href)`, + which copies every tracked href to build the snapshot dict.""" return self._cache.get(href) or {} def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]: - """`subdevice`'s own view of the live snapshot, rewritten into the - canonical hrefs (issue #177) the registry/platforms are written - against -- see subdevices.canonical_view. A platform property that - needs the *whole* resources dict (as opposed to one href via - `resource()`/`last_resources.get(href)`) must use this instead of - `last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak - into MAIN's canonical `/mode/vs/0` view (or vice versa) under - 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. + """`subdevice`'s view of the live snapshot, rewritten to canonical + hrefs (issue #177, see subdevices.canonical_view). Any platform + property that scans the whole resources dict (exists_fn, + is_legacy_board, ...) must use this instead of `last_resources`, or a + sibling subdevice's own `/mode/vs/1` could leak into MAIN's canonical + `/mode/vs/0` view. Memoized per cache generation -- see + _canonical_cache. """ view_key = (subdevice.kind, subdevice.key) cached = self._canonical_cache.get(view_key) @@ -281,18 +236,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): return view def device_info_for(self, subdevice: Subdevice) -> DeviceInfo: - """DeviceInfo for one logical subdevice sharing this connection - (issue #177) -- the master's own (unchanged) device_info for MAIN, or - a linked child device for a discovered subdevice. + """DeviceInfo for one logical subdevice on this connection (issue + #177): the master's own device_info for MAIN, or a linked child + device otherwise. - Identifiers derive from the *master's* serial (device_serial) plus - this subdevice's stable key, never from whatever serial the - subdevice itself reports (or fails to) -- deterministic across - reconnects whether or not this subdevice's own identity resource - (/information/vs/, or //information/vs/0) answered on the - poll that first created the HA device. `serial_number` is set from - that resource when present anyway -- it's informational, not an - identifier. + Identifiers derive from the master's serial plus this subdevice's + stable key, never the subdevice's own reported serial -- deterministic + across reconnects regardless of whether its identity resource + answered yet. `serial_number` is set from it when present anyway, + but is informational only, not an identifier. """ if subdevice.kind == "main": return self.device_info @@ -304,12 +256,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if model: label = model.replace("_", " ").title() else: - # This poll never got (or never will get) the subdevice's own - # identity resource -- fall back to a generic per-subdevice label - # rather than leaving the device unnamed. 'Subdevice ' only - # makes sense for an indexed subdevice (the key is a small - # ordinal); UUID-prefixed subdevices are never more than one per - # connection today, so there's no ordinal to show. + # No identity resource yet (or ever) for this subdevice -- fall + # back to a generic label. 'Subdevice ' only applies to an + # indexed subdevice; UUID-prefixed ones are never more than one + # per connection today. label = ( f"Subdevice {subdevice.key}" if subdevice.kind == "indexed" @@ -392,25 +342,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _poll_once(self) -> dict[str, dict]: """GET /device/0, return parsed resources. Blocking. - `sess.get()` raises `TimeoutError` when one block's ACK doesn't - arrive in time — the transfer was progressing (earlier blocks - succeeded) and just didn't finish before the deadline on a slow - device. That does NOT prove the session is dead, so it's left - 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. + A `TimeoutError` here means one block's ACK didn't arrive in time -- + not that the session is dead (earlier blocks succeeded). Left open; + `_async_update_data` decides whether repeated timeouts warrant a + reconnect. Any other exception is unambiguous -- close immediately. """ if self._session is None: self._connect_session() sess = self._session assert sess is not None try: - # A slow device can still be mid-transfer (block 8, block 11) - # when a tighter deadline cuts it off — that's a poll that - # would have succeeded, not a dead session. 35s gives a slow - # blockwise transfer room to actually finish instead of - # generating a TimeoutError every cycle. + # 35s gives a slow blockwise transfer room to finish instead of + # raising TimeoutError every cycle on an otherwise-fine device. code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S) except TimeoutError: raise @@ -425,21 +368,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): except Exception as e: raise RuntimeError(f"poll cbor decode: {e}") from e result = parse_device0_batch(body) if isinstance(body, list) else {} - # Refresh every already-enumerated sibling subdevice's seed collection - # on this same summary poll (issue #177) -- without this, a subdevice's - # climate card would show only its enumeration-time snapshot forever. + # Refresh every enumerated sibling's seed on this same poll (issue + # #177) so its state doesn't freeze at enumeration time. for subdevice in self.subdevices: result.update(self._poll_subdevice_seed(subdevice)) return result def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]: - """GET one subdevice's seed Collection and return its batch, - normalized to real hrefs. A sibling failing to answer is a debug - log, never a failed poll -- the master must not go unavailable - because a sibling timed out or dropped off (e.g. the issue #177 - reporter's /device/2, a SmartThings-unused component that may not - always respond). Blocking -- called from _poll_once, already in - executor.""" + """GET one subdevice's seed Collection, normalized to real hrefs. A + sibling failing to answer is a debug log, never a failed poll -- the + issue #177 reporter's /device/2 (an unused SmartThings slot) may not + always respond, and the master must not go unavailable for that. + Blocking -- called from _poll_once, already in executor.""" sess = self._session if sess is None: return {} @@ -456,28 +396,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): return {} def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]: - """Re-poll a flat-mode prefixed subdevice's hrefs individually - (issue #205) -- it has no Collection endpoint to batch-refresh - through (see enumerate_subdevices' fallback), so each canonical - href confirmed at enumeration time gets its own GET under the - subdevice's prefix. A href failing to answer this cycle just drops - out of the result, same "never let a sibling's flakiness fail the - master's poll" posture as the Collection path above. + """Re-poll a flat-mode subdevice's hrefs individually (issue #205) -- + it has no Collection endpoint to batch-refresh through (see + enumerate_subdevices' fallback), so each confirmed href gets its own + GET under the subdevice's prefix. A failing href just drops out of + the result, same posture as the Collection path above. - Takes `sess` from the caller (already None-checked there) rather - than re-reading self._session -- async_close() can null that - without holding _session_lock, and pace()/get() both need a live - session on every iteration, not just the first. - - 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.""" + Takes `sess` from the caller rather than re-reading self._session -- + async_close() can null it without holding _session_lock. Skips hrefs + already covered by the hot/warm sub-poll tiers, which + _run_subpolls refreshes every 3s/6s, strictly more current than + this once-per-summary-poll pass could offer.""" skip = set(self._hot_hrefs) | set(self._warm_hrefs) result: dict[str, dict] = {} first = True @@ -531,14 +460,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): # ------------------------------------------------------------------ async def _run_subpolls(self, force: bool = False) -> None: - """Poll hot/warm hrefs in the gaps between summary polls. Only - runs in poll-only mode — in observe-primary mode those hrefs are - already covered by push notifications — unless `force` is set, - which this cycle's sweep found disagreeing with the cache on a - still-live observe session (see log_sweep_discrepancies): a - bounded, self-limiting fallback for a channel that's gone silent - without a reconnect, without tearing down subscriptions that - would otherwise recover on their own once notifies resume.""" + """Poll hot/warm hrefs in the gaps between summary polls. No-op in + observe-primary mode (those hrefs are already covered by push) + unless `force` is set -- set when this cycle's sweep found the + cache disagreeing with a still-live observe session (see + log_sweep_discrepancies): a bounded fallback for a channel gone + silent without a reconnect.""" if self._observe.mode == MODE_OBSERVE and not force: return 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]: - """One-time (first discovery only) probe for sibling indoor subdevices - sharing this connection (issue #177) -- see + """One-time (first discovery only) probe for sibling indoor + subdevices on this connection (issue #177) -- see registry.subdevices.enumerate_subdevices for the two detection - patterns. Blocking -- runs in executor, under the session lock - (shares the same DTLS session _poll_once just used this cycle). + patterns. Runs in executor, under the session lock. - Sets self.subdevices to every *candidate* the probes turned up - (self._subdevice_probes as a side effect too) and returns `resources` - merged with whatever each candidate's seed returned, so this cycle's - _run_discovery sees every candidate's state without a second poll - round trip. `_run_discovery` is what narrows self.subdevices down to - the ones that are actually live (see discover_partitioned) -- this - 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. + Sets self.subdevices to every candidate found and returns + `resources` merged with each candidate's seed, so this cycle's + _run_discovery sees every candidate without a second round trip. + _run_discovery is what narrows this down to the ones actually live + (see discover_partitioned) -- this method can't tell an unused + SmartThings slot from a real sibling, only that something answered. """ if self._session is None: self._connect_session() @@ -592,28 +515,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.subdevices = subdevices self._subdevice_probes = probes # /multidevice/vs/0 is corroborating metadata, not appliance state, - # and it is probed on *every* device -- so it must not join the - # returned resources dict. Two things go wrong if it does. It would - # reach discovery on families whose registry doesn't ignore that - # href (only the AC one does), binding to nothing and raising a - # spurious "incomplete capability coverage" repair for every washer - # or fridge whose firmware happens to answer it. And it is fetched - # 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. + # and is probed on every device -- it must not join `resources`, or + # it would bind to nothing on families that don't ignore the href + # (raising a spurious coverage-gap repair) and freeze in the cache + # since it's never polled again (see _live_subdevice_resources). + # Kept aside for diagnostics and the numofsubdevice cross-check in + # _run_discovery instead. self._multidevice = extra.pop("/multidevice/vs/0", {}) return {**resources, **extra} def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]: - """`resources` minus every href belonging to a candidate subdevice the - liveness gate rejected (issue #177). - - Called once, between _run_discovery and the first cache apply, so a - rejected slot's reps are seen by the gate and then dropped rather - 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. + """`resources` minus every href belonging to a rejected subdevice + candidate (issue #177). Called once, between _run_discovery and the + first cache apply, so a rejected slot's reps are seen by the gate + and then dropped rather than frozen into the cache forever. Kept in + _skipped_subdevice_resources for diagnostics. """ if not self._skipped_subdevices: return resources @@ -638,16 +554,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) -> None: """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 - probe already stored all four. It matters for an entry migrated from - before they were stored: the first poll is where its model and device - type become known, and persisting them means the *next* restart - registers the device fully named before any entity exists, instead of - renaming it a second time once the poll lands. + A no-op for an entry the current config flow already fully stored. + Matters for an entry migrated from before identity was stored: the + first poll is where model/type become known, and persisting them + means the next restart names the device fully instead of renaming it + again once a poll lands. - Runs on the event loop (_run_discovery is called directly from - _async_update_data, not in an executor), which async_update_entry - requires. + Runs on the event loop, which async_update_entry requires. """ identity = { CONF_SERIAL: serial, @@ -662,10 +575,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) def _run_discovery(self, resources: dict[str, dict]) -> None: - # Reported for diagnostics only -- it names the firmware generation - # ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging - # an issue. It does not route: only a minority of hardware reports it - # at all, and every device that does is already typed by its modelNum. + # Diagnostics only -- names the firmware generation (e.g. '7.0 Air + # conditioner' is Tizen Lite); doesn't route, since every device + # that reports it is already typed by modelNum. self.one_ui_version = ( resources.get("/otninformation/vs/0", {}) .get("swVersionInfo", {}) @@ -685,17 +597,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): description = info.get("x.com.samsung.da.description", "") # Partitioned discovery (issue #177): the main pass binds every href - # owned by no subdevice; one further pass per *candidate* subdevice - # binds its own canonical view, resolving its own device type from - # its own /information/vs/0 when it reports one and falling back to - # the master's registry otherwise. See subdevices.discover_partitioned - # -- it also gates each candidate down to whether it actually - # produced live primary state (the issue #177 reporter's /device/2, - # 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. + # owned by no subdevice; one further pass per candidate subdevice + # binds its own canonical view (see subdevices.discover_partitioned), + # gated on whether it actually produced live primary state (an + # unused SmartThings slot answers its seed but never does). A device + # with no candidates behaves exactly like the old single discover() + # call. bound, device_type_name, materialized, skipped = discover_partitioned( resources, self.subdevices, @@ -715,13 +622,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): skip.subdevice.kind, list(skip.hrefs), ) - # Corroborating signal, not a gate (DESIGN-177.md section 4): - # /multidevice/vs/0's numofsubdevice is a plain count the issue - # #177 reporter's board reports independently of the liveness gate - # above. Log, don't raise, on a disagreement -- only this one board - # 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. + # Corroborating signal, not a gate: log, don't raise, on a + # disagreement -- only one known board family exposes + # numofsubdevice at all, so a mismatch is a triage signal, not proof + # either side is wrong. numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice") if numofsubdevice is not None: try: @@ -739,11 +643,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if device_type_name is not None: self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num) else: - # All three: detection reads each of them (oic device type, then - # board token, then consumer-model code), and this line is what a - # user pastes into an issue -- modelNum alone doesn't identify a - # washer or dryer, and device_types is often empty even when - # populated hardware exists for a type we don't map yet. + # modelNum alone doesn't identify every type, and device_types + # is often empty even on hardware we don't map yet -- log all + # three so a user can paste this into an issue. self._log.warning( "unknown device type modelNum=%r description=%r device_types=%r; using common caps", model_num, @@ -754,20 +656,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.bound = bound self._unbound_hrefs = unbound - # The identity the entry was registered under wins. This poll's own - # answer is only adopted when the entry has nothing stored -- a legacy - # entry whose migration couldn't recover a serial -- and is then - # written back so it stops changing. Re-keying a device that already - # has registry entries is what issue #236 is about: the old keys don't - # follow, they orphan. + # The entry's stored identity wins; this poll's answer is only + # adopted when nothing is stored (a legacy migration couldn't + # recover it), then written back. Re-keying an entry with existing + # registry entries orphans them (issue #236). polled_serial = resolve_serial( info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST] ) serial = self._entry.data.get(CONF_SERIAL) or polled_serial if serial != polled_serial: - # Same IP, different appliance (or a firmware that changed what it - # reports). Keeping the stored identity is the safe half of that; - # re-adding the device is the user's call. + # Same IP, different appliance (or firmware that changed what it + # reports) -- keep the registered identity; re-adding is the + # user's call. self._log.warning( "device at %s reports serial %r but this entry is registered " "as %r; keeping the registered identity", @@ -810,12 +710,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): unbound_hrefs: list[str], device_name: str, ) -> None: - """Raise or clear a Repairs issue when capability coverage is incomplete. - - Fires once, at discovery time, either because the device type itself - wasn't recognized or because some of its resources didn't bind to - any capability. Diagnostics (diagnostics.py) is what a user actually - downloads to help; this just tells them there's something to send. + """Raise or clear a Repairs issue when capability coverage is + incomplete -- unrecognized device type or unbound resources. + Diagnostics (diagnostics.py) is what a user downloads to help; this + just tells them there's something to send. """ issue_id = f"device_gap_{self._entry.entry_id}" if unknown_type or unbound_hrefs: @@ -839,8 +737,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not hrefs: return if self._session is None: - # _poll_once already connects on a real poll; this only fires - # if the session was closed out from under us concurrently. + # _poll_once already connects on a real poll; only fires if the + # session was closed out from under us concurrently. await self.hass.async_add_executor_job(self._connect_session) sess = self._session 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 cycle. - A `TimeoutError` (see `_poll_once`) means one block's ACK didn't - arrive in time — not that the session is dead. A recent OBSERVE - notify is direct proof the channel is still live, so always defer - in that case. Otherwise, defer until `_POLL_TIMEOUT_LIMIT` - consecutive timeouts have piled up — a single slow transfer is - 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. + A `TimeoutError` means one block's ACK was late, not that the + session is dead (see `_poll_once`). A recent OBSERVE notify is proof + the channel is live, so always defer then. Otherwise defer until + `_POLL_TIMEOUT_LIMIT` consecutive timeouts pile up. Any other + exception reconnects immediately. - Never defers before the first successful discovery (issue #254). - Deferring is a *mid-session* judgement call — "keep the entities we - already have and try again next cycle" — which is only coherent once - there are entities to keep. Pre-discovery the same exception type - 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. + Never defers before first discovery (issue #254): deferring returns + an empty dict, which the base coordinator treats as a successful + first refresh -- and since platforms enumerate `bound` once, the + entry would load with zero entities and stay that way. """ if not self._discovered: return False if not isinstance(e, TimeoutError): return False 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 - # trigger a reconnect once the device goes quiet again. The - # 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. + # trigger a false reconnect once the device goes quiet again. self._consecutive_poll_timeouts = 0 return True self._consecutive_poll_timeouts += 1 @@ -936,11 +820,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) return flatten(self.bound, self._cache.snapshot()) self._consecutive_poll_timeouts = 0 - # One reconnect attempt — pause briefly so the device can - # clean up its DTLS session state before we knock again. - # A lone reconnect is routine (see the README's "Known - # device behavior" section); only warn once they're piling - # up within the trailing window. + # A lone reconnect is routine (README's "Known device + # behavior"); only warn once they pile up. Pause first so + # the device can clean up its DTLS state before we knock + # again. if self._reconnect_is_frequent(): self._log.warning("poll failed, reconnecting: %s", e) else: @@ -952,29 +835,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): except Exception as e2: self._log.error("poll failed after reconnect: %s", e2) snapshot = self._cache.snapshot() - # `self._discovered` is the same precondition - # `_defer_reconnect_for` applies (issue #254): returning - # degraded-but-successful data is only meaningful once - # 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. + # Same precondition as _defer_reconnect_for (issue #254): + # degraded-but-successful data only makes sense once + # there are bound entities to carry it. if self._discovered and snapshot: self._log.debug("Full error:", exc_info=e2) return flatten(self.bound, snapshot) raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2 else: - # The reconnect gave us a brand-new session with zero - # OBSERVE registrations. If we were in observe mode, - # that state is now stale — the refresh task is still - # pinned to the old (closed) session and nothing will - # ever re-subscribe on the new one. Tear it down and - # try to resubscribe immediately below rather than - # 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. + # A fresh session has zero OBSERVE registrations; if we + # were in observe mode that state is now stale. Tear it + # down and resubscribe immediately below instead of + # waiting for the poll-mode retry timer, which exists to + # throttle devices that never had observe working at all + # -- a reconnect just proved this session is healthy. if self._observe.mode == MODE_OBSERVE: self._log.debug( "reconnect while in observe mode; downgrading to " @@ -984,13 +858,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): just_downgraded_from_observe = True if not self._discovered: - # One-time (issue #177): find out whether this connection has - # sibling indoor subdevices before the first discovery pass, and - # fold their seed resources into this cycle's snapshot so - # discovery sees every subdevice's state on the very first poll - # 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. + # One-time (issue #177): find sibling subdevices before the + # first discovery pass, folding their seed resources into this + # cycle's snapshot so discovery sees every subdevice on the + # first poll rather than waiting a cycle. async with self._session_lock: resources = await self.hass.async_add_executor_job( self._enumerate_subdevices_blocking, resources @@ -999,32 +870,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): source = "sweep" if self._discovered else "poll" first_cycle = not self._discovered if first_cycle: - # Discovery runs *before* the apply loop below, not after it, so - # a rejected candidate's resources never reach the state cache - # at all (issue #177). Enumeration has to fetch every candidate's - # seed to evaluate the liveness gate, but only the subdevices that - # pass it are ever polled again -- applying the rest would freeze - # ~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). + # Discovery runs before the apply loop so a rejected candidate's + # resources never reach the state cache (issue #177) -- + # 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 passed dict, never the cache. self._run_discovery(resources) resources = self._live_subdevice_resources(resources) sweep_mismatch = False if self._observe.mode == MODE_OBSERVE: - # A sweep/cache mismatch never tears down a still-live OBSERVE - # session (see log_sweep_discrepancies) — the sweep below - # re-applies the authoritative state to the cache regardless, - # so there's nothing to correct by downgrading. Only a - # reconnect (above) proves subscriptions are actually gone. - # 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. + # A mismatch never tears down a still-live OBSERVE session (see + # log_sweep_discrepancies) -- the sweep below re-applies + # authoritative state regardless. It only triggers extra + # hot/warm subpolls this cycle so a channel gone silent without + # a reconnect still gets fresher-than-30s data. sweep_mismatch = self._observe.log_sweep_discrepancies(resources) for href, rep in resources.items(): self._observe.apply(href, rep, source=source) @@ -1034,15 +893,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): elif self._observe.mode == MODE_POLL: await self._maybe_retry_observe_mode() - # Schedule sub-polls for hot/warm hrefs between summary polls - # (no-op in observe-primary mode unless this cycle's sweep found a - # mismatch; _run_subpolls checks the mode/force). A background task, - # not async_create_task: this loop is self-limiting (cancelled and - # recreated every refresh cycle, see the cancel() above) and owned - # 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). + # Background task, not async_create_task: self-limiting (cancelled + # and recreated every cycle, see above) and owned entirely by the + # coordinator, so it shouldn't be tied into HA's startup/shutdown + # sequencing -- a subpoll in flight (up to ~27s) would delay both + # (issue #207). if self._hot_hrefs or self._warm_hrefs: self._subpoll_task = self.hass.async_create_background_task( 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: - """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 - here rather than per-platform, so rejecting a write with a - user-facing message -- as opposed to write_fn's silent no-op below - -- is available to every platform for free. The remote-control - check runs first and applies to every platform unconditionally, - ahead of any description-specific validate_fn -- unless the user has - opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue - #54: some devices accept certain writes, e.g. a washer's default - dosing levels, even while reporting remote control off, so the - block's assumption doesn't hold for every model), or the laundry - firmware flag isModelSettingWithoutSC declares settings writable - without Smart Control (cycle start/pause/stop on /operational/state - still require it).""" + A description-level validate_fn (SwitchDesc only, currently) rejects + a write with a user-facing message ahead of write_fn's silent + no-op. The remote-control check runs first, unconditionally, unless + the user opted out via CONF_BYPASS_REMOTE_CONTROL (issue #54: some + devices accept some writes even while reporting remote control off) + or the laundry firmware declares itself writable without Smart + Control.""" desc = bound_entity.desc write_fn = getattr(desc, "write_fn", None) if write_fn is None: @@ -1104,79 +953,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): return path_segs, body = result - # The write's actual target, not necessarily bound_entity.href. Most - # descriptors write to the same resource they're bound to, but a - # composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 -- - # drives writes to several sibling resources via path_segs - # (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that - # write_fn picks per payload (see airconditioner._climate_write). - # Applying the optimistic value and settle guard below to - # bound_entity.href instead of this target protected the wrong - # resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic - # merge while the resource the climate entity actually displays from - # (e.g. /power/0) never got one, so HA kept showing the pre-write - # state until the next real read of that resource -- the 20-60s lag - # in issues #17/#53, which survived the earlier optimistic-apply fix - # (issue #27) because that fix applied to the wrong href too. + # The write's actual target, not necessarily bound_entity.href -- a + # composite entity (the AC's ClimateDesc) drives writes to sibling + # resources via path_segs (see airconditioner._climate_write). + # Applying the optimistic value to bound_entity.href instead caused + # the 20-60s lag in issues #17/#53: the wrong resource got the + # optimistic merge while the one HA actually displays from never + # did. # - # write_fn's path_segs are canonical (issue #177) -- a subdevice's - # ClimateDesc is bound to its own *actual* /mode/vs/1 (or - # //mode/vs/0) href, but _climate_write only knows the canonical - # 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. + # path_segs are canonical (issue #177); translate through this + # entity's own subdevice so a subdevice's actual href (e.g. + # /mode/vs/1) is targeted instead -- identity transform for MAIN. write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs)) path_segs = [s for s in write_href.strip("/").split("/") if s] - # Apply the write optimistically before starting the settle guard, - # not after -- mark_write_pending gates every source (poll, sweep, - # observe) through the same apply(), itself included, so flipping - # 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). + # Apply optimistically before starting the settle guard -- guard and + # apply share the same gate (mark_write_pending), so reversing the + # order would drop the very update it exists to protect (issue #27). # - # settle_s must outlast the PUT and the async_request_refresh() - # below combined, not just DEFAULT_SETTLE_S's fixed few seconds -- - # that refresh is a full /device/0 summary poll, which - # _POLL_TIMEOUT_S itself admits can legitimately take tens of - # seconds on these devices (see _poll_once), and some writes settle - # on the device itself well after that: issue #9's washer packs - # cycle/detergent/softener selection into the same /course/vs/0 - # options[] array, and picking a new value there visibly needs a - # few seconds of internal validation/dispenser movement before the - # device's own state agrees -- while /washer/vs/0's temperature/ - # spin fields (plain flags, no device-side settling) confirm - # instantly on the same device. A short fixed window expired while - # the confirm poll was still in flight (or before the device had - # caught up internally), so that stale read landed unprotected and - # reverted the optimistic value, self-correcting again only once a - # later poll finally saw the real change -- read by the user as the - # write "reverting, then re-applying itself" a few seconds later. + # settle_s must outlast the PUT plus the confirming refresh, not + # DEFAULT_SETTLE_S's fixed few seconds: the refresh is a full + # summary poll that can legitimately take tens of seconds (see + # _poll_once), and some writes (issue #9's washer course/detergent/ + # softener selection) settle on-device well after that. A short + # fixed window let a stale confirm poll land unprotected and revert + # the optimistic value, read by users as the write "reverting, then + # re-applying" itself a few seconds later. Releasing the guard early + # (right after the first confirming refresh) was tried and reverted + # for the same reason, plus races on overlapping writes to the same + # href. # - # An earlier attempt at this also released the guard early, right - # after the confirming refresh completed, to avoid shutting out - # unrelated real updates (another automation, the physical remote) - # for the rest of settle_s. That was reverted: releasing the guard - # the moment one round trip finishes doesn't mean the device has - # actually caught up (exactly the slow-settling case above), and it - # introduced its own races around overlapping writes to the same - # href. Simpler and safer to just hold the guard for the full, - # generously-sized window and let it expire on its own. - # 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. + # write_fn bodies touching options/items now carry only the changed + # token(s) (issue #54), not the whole array -- but apply()'s + # field-level merge doesn't know that and would wipe every sibling + # option/item for the settle window. Pre-merge here the way the + # device does, so the optimistic cache entry stays complete; the + # wire `body` stays minimal. optimistic_body = body new_options = body.get("x.com.samsung.da.options") if isinstance(new_options, list): @@ -1185,9 +997,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): **optimistic_body, "x.com.samsung.da.options": merge_options_field(cached_options, new_options), } - # Same fact, items[] shape (e.g. airconditioner._climate_write's vendor - # temperature write, which now carries only {id, desired} -- see that - # module for the write-side half of this). + # Same fact, items[] shape (see airconditioner._climate_write's + # vendor temperature write). new_items = body.get("x.com.samsung.da.items") if isinstance(new_items, list): 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 - # options-flow debug panel, letting a user POST an arbitrary partial - # body to an arbitrary href to pin down device-specific write behavior - # without waiting on a new release. Deliberately bypasses the - # remote-control block and every write_fn/validate_fn above -- that's - # the whole point, so use with care. + # options-flow debug panel to POST an arbitrary partial body without a + # new release. Deliberately bypasses the remote-control block and all + # write_fn/validate_fn above -- use with care. # ------------------------------------------------------------------ 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 async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]: - """Debug-only arbitrary write (issue #54). Bypasses the - remote-control block and all write_fn/validate_fn logic; sends - `body` verbatim as a partial-rep PATCH to `href`. Returns - (coap_code, new_rep) where new_rep is the href's value read back - right after the write. Used by the options-flow debug panel to - help users pin down device-specific write behavior without a new - release.""" + """Debug-only arbitrary write (issue #54). Bypasses remote-control + and write_fn/validate_fn; sends `body` verbatim as a partial-rep + PATCH to `href`. Returns (coap_code, new_rep) read back right + after.""" if not isinstance(body, dict) or not body: raise ServiceValidationError( translation_domain=DOMAIN, @@ -1270,7 +1076,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): code, new_rep = await self.hass.async_add_executor_job( self._raw_write_blocking, path_segs, body, norm_href ) - # Hasten a full summary poll so entities on other resources catch - # up too -- a debug write can affect siblings, not just its href. + # Hasten a summary poll so entities on other resources catch up + # too -- a debug write can affect siblings, not just its href. await self.async_request_refresh() return code, new_rep diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index eb13bdd..0c80bff 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -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 # 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 - # discovery endpoint, listing every href/Collection the connection - # hosts -- relevant to the "Composite Device" model (issue #177) where - # a single physical device exposes more than one logical subdevice. See + # is OCF's device-type declaration; /oic/res is OCF's discovery + # endpoint, relevant to the "Composite Device" model (issue #177). See # registry/identity.py. identity = coordinator._identity def _seed_diag(su) -> dict: - # A flat-mode subdevice (issue #205 -- no working //device/0 - # Collection, so its state comes from individually-polled hrefs - # instead) has no meaningful seed_path; report the flat_hrefs list - # in its place rather than the misleading bare "/" a joined empty - # tuple would otherwise produce. + # A flat-mode subdevice (issue #205: no working //device/0 + # Collection, state comes from individually-polled hrefs instead) + # has no meaningful seed_path; report flat_hrefs in its place. return { "seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None, "flat_hrefs": list(su.flat_hrefs), } def _subdevice_diag(su) -> dict: - # One pass over coordinator.bound for both fields below (count and - # the distinct hrefs), and one redaction of this subdevice's canonical - # view -- `model` reads modelNum off the already-redacted `resources` - # 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. + # `model` reads modelNum off the already-redacted `resources` rather + # than redacting /information/vs/0 again -- modelNum never matches + # redact.py's substring rules, so the value is the same either way. matching = [b for b in coordinator.bound if b.subdevice == su] res = redact_resources(coordinator.canonical_resources(su)) return { @@ -69,13 +62,10 @@ async def async_get_config_entry_diagnostics( "bound_entity_count": len(matching), "hrefs": sorted({b.href for b in matching}), "model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""), - # Keyed by this subdevice's *canonical* hrefs, not the real ones - # it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or - # '//mode/vs/0'. That's the form the registry and every - # capability are written against, so a sibling's block can be - # 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. + # Keyed by this subdevice's canonical hrefs ('/mode/vs/0'), not + # the real ones it answers on ('/mode/vs/1', '//mode/vs/0') + # -- the form the registry is written against, so a sibling's + # block reads exactly like the master's `resources` below. "resources": res, } @@ -91,49 +81,34 @@ async def async_get_config_entry_diagnostics( if identity is not None else None, "unbound_hrefs": sorted(coordinator._unbound_hrefs), - # This subdevice's own resources, and only this subdevice's -- what - # the module docstring and the adding-device-support skill have - # always described it as ("the parsed /device/0 snapshot"). On a - # composite device (issue #177) `last_resources` is the union across - # every live subdevice keyed by real hrefs, so reporting it raw here - # would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0 - # under no attribution at all. Each sibling reports its own - # resources in its own `subdevices` entry below instead. For a - # device with no subdevices -- almost every device -- this is - # byte-identical to `last_resources`. + # This subdevice's own resources, and only this subdevice's. On a + # composite device (issue #177) `last_resources` is the union + # across every live subdevice keyed by real hrefs, so reporting it + # raw here would mix a sibling's /mode/vs/1 with the master's + # /mode/vs/0 under no attribution. Each sibling reports its own + # resources in `subdevices` below instead. For a device with no + # subdevices, this is byte-identical to `last_resources`. "resources": redact_resources(coordinator.canonical_resources(MAIN)), # Sibling indoor subdevices discovered on this connection (issue - # #177) -- per-subdevice kind/key/seed path plus what actually bound - # to it, so a report shows whether a composite device's subdevice - # was found at all and what it resolved to. subdeviceIdList (the - # UUID a prefixed subdevice's key comes from) is deliberately NOT - # 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. + # #177). subdeviceIdList (the UUID a prefixed subdevice's key comes + # from) is deliberately NOT redacted here, unlike elsewhere in + # `resources` -- it's an appliance-internal pairing id, not account + # data, and reporting it is what makes this block actionable. "subdevices": [_subdevice_diag(su) for su in coordinator.subdevices], # Candidates that answered their seed but that discover_partitioned's - # entity-level liveness gate rejected -- an unused SmartThings slot - # (the issue #177 reporter's /device/2) that still answers a - # same-shaped batch, not a real second subdevice. Reported alongside - # 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. + # liveness gate rejected -- an unused SmartThings slot, not a real + # second subdevice. Reported alongside subdevices above so a report + # shows what was found and why it didn't become an entity. "subdevices_skipped": [ { "kind": skip.subdevice.kind, "key": skip.subdevice.key, **_seed_diag(skip.subdevice), "hrefs": list(skip.hrefs), - # The reps the liveness gate actually judged, canonicalized - # like the materialized subdevices above. These are the one - # thing a reader needs to second-guess a skip ("is my second - # subdevice really absent, or did the gate get it wrong?"), - # 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. + # The reps the liveness gate actually judged -- the one + # thing a reader needs to second-guess a skip, and they + # exist nowhere else in this dump: a rejected candidate is + # never polled again or entered into the state cache. "resources": redact_resources( { canon: rep @@ -146,17 +121,12 @@ async def async_get_config_entry_diagnostics( ], # What each enumeration probe returned ({} vs a batch), keyed by the # seed href attempted -- lets a report distinguish "checked, nothing - # there" from "never checked", the same posture the speculative - # /device/1 //device/2 probe this replaced used to document directly - # in identity.py before it moved to registry/subdevices.py. + # there" from "never checked". "subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())), # /multidevice/vs/0's rep ({} when the board doesn't answer it). - # Reported on its own rather than inside `resources` because it is - # metadata about the connection rather than state of any one - # subdevice -- and because nothing polls it after discovery, so it - # would go stale in there. Its numofsubdevice count is what - # independently corroborates the subdevices/subdevices_skipped split - # above. + # Reported on its own, not inside `resources`, since it's metadata + # about the connection rather than one subdevice's state, and + # nothing polls it after discovery so it would go stale in there. "multidevice": redact_resources(coordinator._multidevice), "integration_version": integration.version, "smartthings_local_version": stl_version, diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index ce6bab8..770390c 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -18,28 +18,23 @@ from .registry.discovery import BoundEntity, _snake_to_title def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool: """Return False if the entity should not be registered for this device. - Explicit exists_fn takes priority. Otherwise, if the entity has a field, - require that field to be present in the resource rep so that optional - fields on shared resources don't create phantom entities. + Explicit exists_fn takes priority. Otherwise, if the entity has a + field, require that field to be present in the resource rep so that + optional fields on shared resources don't create phantom entities. - A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched - yet" marker) is included anyway so it can be populated by sub-polls. A - genuinely empty {} rep is included too by this default gate -- whether - empty means "not populated yet" or "permanently unsupported" needs - per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's - {} is fridge.py's documented *normal* no-alarm state (see - _active_alarm_codes), not an absence signal, and it's far from the only - resource like that. Only a capability whose author has actually verified - a field is genuinely never populated on unsupported hardware opts into - stricter gating with its own is_stub_rep-based exists_fn (see - common.ENERGY_METER, issue #127) -- this default stays permissive. + A stub rep (is_stub_rep) is included anyway so it can be populated by + sub-polls. A genuinely empty {} rep is included too by this default + gate: whether empty means "not populated yet" or "permanently + unsupported" needs per-field domain knowledge this generic gate + doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal + no-alarm state, not an absence signal). Only a capability whose author + has verified a field is genuinely never populated opts into stricter + gating with its own exists_fn (see common.ENERGY_METER, issue #127). - `bound.href` is already the *actual* href (issue #177 -- see - BoundEntity/Subdevice), so the direct cache lookup below is correct as-is; - `exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the - raw snapshot, same rule as everywhere else a whole-resources-dict scan - happens (coordinator.canonical_resources) -- this is a free function, not - an LocalThingsEntity method, so it can't use self._resources. + `bound.href` is already the actual href (issue #177); `exists_fn` gets + `bound`'s own subdevice's canonical view instead of the raw snapshot, + same rule as everywhere else a whole-resources-dict scan happens -- + this is a free function, so it can't use self._resources. """ rep = coordinator.last_resources.get(bound.href) if rep is None: @@ -97,12 +92,10 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): "instance_name": _instance_display_name(bound, self._state_key) } - # _attr_name is deliberately left unset: Home Assistant gives an - # explicitly-set name precedence over the translation catalog, so - # setting it here would make every entity untranslatable. Every - # descriptor resolves to a catalog entry (see translation_key below); - # a platform that wants the bare device name instead sets - # _attr_name = None itself, as fan.py does for the hood's main entity. + # _attr_name is deliberately left unset: HA gives an explicitly-set + # name precedence over the translation catalog, so setting it here + # would make every entity untranslatable. A platform that wants the + # bare device name sets _attr_name = None itself (see fan.py). self._attr_icon = bound.desc.icon raw_cat = bound.desc.entity_category 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: """The descriptor's catalog key, defaulting to its own `key`. - Overrides Entity.translation_key (a property upstream, not a plain - attribute) so a callable descriptor -- e.g. laundry.cycle_select's - table-id-gated resolver -- is re-evaluated against live coordinator - data on every access, not resolved once at construction time. - - Discovery runs on the first /device/0 poll, which the entity - 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. + Overrides Entity.translation_key so a callable descriptor (e.g. + laundry.cycle_select's table-id-gated resolver) is re-evaluated + against live coordinator data on every access, not resolved once + at construction time -- a static resolution would risk baking in + a permanent None if the first poll handed a sibling an empty stub + rep (see _is_included's docstring) before it populated. """ tk = self._bound.desc.translation_key if callable(tk): @@ -133,12 +120,11 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): @property def _resources(self) -> dict: """This entity's own subdevice's canonical resources view (issue - #177) -- see coordinator.canonical_resources. Every platform - property that needs the *whole* resources dict, as opposed to one - href via `coordinator.resource(href)`, must read through this - instead of `coordinator.last_resources`, or a sibling subdevice's own - actual hrefs would leak into (or be missing from) this entity's - view. For MAIN (every device with no subdevices) this is exactly + #177) -- see coordinator.canonical_resources. Any platform property + needing the whole resources dict, not one href via + `coordinator.resource(href)`, must read through this instead of + `coordinator.last_resources`, or a sibling subdevice's own hrefs + could leak into this entity's view. For MAIN this is exactly `coordinator.last_resources`.""" return self.coordinator.canonical_resources(self._bound.subdevice) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 834ab7f..177771a 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -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 speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/ Medium/High (issue #56) are both an ordered set of numeric levels -(SET_SPEED) -- the latter confirmed monotonic in capabilities/ -air_purifier.py's module docstring, with no named-mode list to preserve -since this board never self-reports one. The TP1X air-purifier family's -modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21 -family's /wind/strength/vs/0 modes (issue #151) are both named behaviors -with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles -both hrefs, the only difference being whether the label comes straight -from supportedModes or from a parallel modesName array (see -_label_for_code).""" +(SET_SPEED), confirmed monotonic in capabilities/air_purifier.py's module +docstring, with no named-mode list since this board never self-reports one. +The TP1X air-purifier family's modes (Smart/Max/Mid/WindFree/Sleep, issue +#130) and the A-VTWW-TP2-21 family's /wind/strength/vs/0 modes (issue #151) +are both named behaviors with no linear order (PRESET_MODE) -- +LocalThingsAirPurifierFan handles both hrefs, the only difference being +whether the label comes from supportedModes or a parallel modesName array +(see _label_for_code).""" from __future__ import annotations @@ -76,19 +75,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): Some boards that reuse this capability (built-in microwave vent fans, issues #137/#142) report no sibling `/power/0` or `/power/vs/0` - resource at all -- fan speed 0 is itself the off state there, with no - separate power toggle to write. `_speed_zero_is_off` detects that - shape from the hood resource's own settableMinFanSpeed/ - supportedFanSpeed fields and switches every method below to drive - off/on purely through the fanSpeed field, including '0' in the - ordered speed codes as the off step instead of assuming every - advertised code is an active speed. + resource at all -- fan speed 0 is itself the off state there. + `_speed_zero_is_off` detects that shape and switches every method + below to drive off/on purely through the fanSpeed field. - This is deliberately not the same question as `_has_separate_power`, - which only proves *some* power resource exists on the device -- on a - combi appliance (e.g. an over-the-range microwave) that resource can - belong to the cavity, not the vent fan, and toggling it from here - would turn off the whole appliance instead of just the fan. + Deliberately not the same question as `_has_separate_power`, which + only proves some power resource exists on the device -- on a combi + appliance that resource can belong to the cavity, not the vent fan, + and toggling it from here would turn off the whole appliance. """ _enable_turn_on_off_backwards_compatibility = False @@ -112,10 +106,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _speed_zero_is_off(self) -> bool: """Whether fan speed '0' is itself this hood's off step, with no - separate power resource to toggle. The board says so directly: - settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The - standalone hood's codes start at 14 and it carries a real /power - resource instead, so this is False there.""" + separate power resource to toggle -- settableMinFanSpeed '0', or + '0' inside supportedFanSpeed. False for the standalone hood, whose + codes start at 14 and which carries a real /power resource.""" rep = self._rep(self._bound.href) return ( 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]: codes = self._all_speed_codes() if self._speed_zero_is_off(): - # No separate power resource: '0' is the off step, not a speed. return [code for code in codes if code != _OFF_SPEED_CODE] - # Power is carried by the separate /power resource. fanSpeed - # retains the selected setting while power is off (as the - # lamp's `current` field does), so every advertised code is an - # active ordered speed. + # Power is carried by the separate /power resource; fanSpeed + # retains the selected setting while power is off, so every + # advertised code is an active ordered speed. return codes 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: """Lowercased HA preset label for a device mode code. - The TP1X_DA-AC-AIR board (issue #130) reports its named modes - directly as supportedModes ('Smart'/'Max'/...), so the code IS the - label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric - wind-strength codes ('87'/'89'/...) with a separate modesName array - (parallel-indexed with supportedModes) giving the actual names -- - same shape as climate.py's _wind_strength_label, and coincidentally - 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.""" + The TP1X_DA-AC-AIR board (issue #130) reports named modes directly + as supportedModes, so the code IS the label. The A-VTWW-TP2-21 + board (issue #151) instead reports numeric wind-strength codes with + a separate modesName array giving the real names -- same shape as + climate.py's _wind_strength_label, and coincidentally the same word + set, so both generations land on identical HA preset values.""" rep = self._mode_rep() supported = list(rep.get(_SUPPORTED_MODES_FIELD, ())) names = rep.get(_MODES_NAME_FIELD) @@ -327,11 +315,8 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity): _AIRFLOW_SPEED_FIELD = "speed" # Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1, -# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered -# as plain strings, same as _all_speed_codes above, so -# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat -# it exactly like the range hood's numeric levels -- no named-preset table -# needed since this board never reports mode names to hang one off of. +# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Treated +# as plain ordered strings, same as the range hood's numeric levels. _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]: """Prefer /power/0 like LocalThingsRangeHoodFan above, NOT LocalThingsAirPurifierFan's vs/0-first order -- that order is only - harmless for the TP1X board because it never reports /power/0 at - all. This family's dumps carry both hrefs, and common.POWER_GENERIC - (the power_switch entity) is unconditionally bound to /power/0 - whenever it's present, so writing here to /power/vs/0 first would - leave power_switch and this fan reading/writing two different - resources -- disagreeing until the next poll refreshes the other - one (the same optimistic-apply lag coordinator.py's own comments - warn about).""" + harmless for the TP1X board because it never reports /power/0. + This family's dumps carry both hrefs, and common.POWER_GENERIC is + unconditionally bound to /power/0 when present, so writing to + /power/vs/0 first would leave power_switch and this fan + disagreeing until the next poll.""" resources = self._resources target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF return "power", enabled, target diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index d1fbce0..5c1b98f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -63,9 +63,9 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { # Consumer-model prefix (first two letters of the '_'-delimited token in # `description` right before any '/board-info' suffix) -> registry key. -# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_' -# internal board-family prefix there, and dishwasher's modelNum contains -# the substring 'WW', so a modelNum-only rule misroutes both. +# NOT derived from `modelNum`: washer and dryer share the same 'DA_WM_' +# board-family prefix there, and dishwasher's modelNum contains the +# substring 'WW', so a modelNum-only rule misroutes both. _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { "WW": "washer", "WD": "washer", @@ -79,48 +79,39 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = { # Board-family token -> registry key, matched against whole tokens of # `modelNum`/`description` (see `_board_tokens`). # -# Tokenizing instead of substring-matching is what keeps this a table rather -# than a ladder of hand-written rules. Samsung spells the same board family -# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the -# same RAC family -- so a substring rule has to be written once per spelling -# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the -# 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. +# Tokenizing instead of substring-matching keeps this a table rather than a +# ladder of hand-written rules: Samsung spells the same board family with +# either delimiter ('TP1X_DA-AC-RAC-01001' vs 'TP2X_RAC_20K', both RAC), so +# a substring rule would need writing once per spelling, and a token with +# no trailing delimiter ('ARTIK051_DONGLE_REF') would match neither. # -# 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 # would swallow the dehumidifier and the air purifier. Where two families -# genuinely share a resource surface they share a registry (all the -# air-conditioner spellings below), which is a statement about the hardware, -# not a shortcut. +# genuinely share a resource surface they share a registry (the +# air-conditioner spellings below), which is a statement about the +# hardware, not a shortcut. _BOARD_TOKEN_TO_KEY: dict[str, str] = { "REF": "refrigerator", - # Air conditioners. Every one of these is a distinct board family with - # the same resource surface: room (issues #37, #91), package, Korean - # (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial - # (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120). + # Air conditioners: distinct board families sharing one resource + # surface -- room, package, Korean (#136), window (#87), 2-in-1 + # floor+wall (#150/#153), system/commercial (#52), cassette (#191), and + # ARA-WW wall-mount (#115-120). "RAC": "airconditioner", "PRAC": "airconditioner", "KRAC": "airconditioner", "WAC": "airconditioner", "FAC": "airconditioner", "CAWW": "airconditioner", - "CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000 + "CAC": "airconditioner", # issue #191 "ARA": "airconditioner", "DHM": "dehumidifier", # issue #88 -- target humidity, no climate - "EHS": "ehs", # Eco Heating System air-to-water heat pump -- - # zone1 space heating/cooling + dhw domestic - # hot water, its own /mode/*/vs/0 and - # /temperatures/*/vs/0 resource shapes + "EHS": "ehs", # heat pump: zone1 heating/cooling + domestic hot water "TVTL": "air_purifier", # issue #56 (ARTIK051) "VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air) - "AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a - # next-gen BESPOKE Cube Air board; same - # lineage as VTWW above but the '-WW-' - # delimiter now falls one letter to the - # left ('A-VTWW-' -> 'AVT-WW-'), splitting - # into a token the existing entry can't see + # issue #190: same lineage as VTWW, but the '-WW-' delimiter falls one + # letter left ('A-VTWW-' -> 'AVT-WW-'), splitting into a different token. + "AVT": "air_purifier", "AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR) "WATERPURIFIER": "water_purifier", # issue #90 "ADW": "dishwasher", @@ -129,13 +120,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = { "OVEN": "oven", # issue #55 -- wall oven, no burners "MICROWAVE": "microwave", # issues #66, #121 "COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven - # Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state - # lives in /mode/vs/0's options array. Deliberately a bare two-letter - # token, and so the loosest entry in this table -- it is only ever - # reached by a device that matched nothing more specific, and its - # `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an - # induction cooktop via the COOKTOP entry above. See `for_device_by_model` - # for the field ordering that makes that resolve correctly. + # Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'): burner state + # lives in /mode/vs/0's options array. Deliberately the loosest entry + # here -- reached only when nothing more specific matched, since its + # description ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an + # induction cooktop via COOKTOP above (see for_device_by_model's field + # ordering). "CT": "cooktop", "VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station "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: """First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None. - 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 - a flat lookup, not a priority list. Adding an entry that could co-occur - with another (a family token, or one short enough to collide by accident) - would break that property; see this table's comment. + 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 a flat lookup, not a priority list. One documented exception (issue #196): AILITE water-purifier boards - spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the - shared cooling-subsystem board, not the refrigerator device type -- - 'WATERPURIFIER' is the actual, more specific type here. Rather than drop - or rename either entry (both are correct on their own for the model - strings that exist today), this one known co-occurrence resolves to - 'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a - matching carve-out for this exact pair. + spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names + the shared cooling-subsystem board, not the refrigerator type -- + 'WATERPURIFIER' is the actual, more specific type. This one known + co-occurrence resolves to 'water_purifier'; TestBoardTokenAmbiguity + carries a matching carve-out for this exact pair. """ tokens = _board_tokens(value, cut_at) 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 '/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4'). - But issue #79's dryer pairs two model numbers in one description -- - '..._DVE50A8800_8600/DC92-...' -- so the true consumer token - ('DVE50A8800') sits one segment *before* the actual last segment - ('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. + But issue #79's dryer pairs two model numbers in one description, so + the true consumer token sits one segment before the actual last + segment -- scan from the end and take the first segment that resolves. - Splits on '_' only, unlike `_board_tokens` above: these are two-letter - prefixes matched against the *start* of a segment, so widening the split - to '-' as well would start reading board-family segments as consumer - models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare - 'WW' segment and route to washer. + Splits on '_' only, unlike `_board_tokens` above: widening the split to + '-' would start reading board-family segments as consumer models (the + dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare 'WW' and + route to washer). - Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner - board-family token, issue #87) also starts with 'WA' (the top-load-washer - prefix, issue #106) at this granularity. for_device_by_model() consults - the board-family table first and this function only as a fallback, so - that ambiguity resolves correctly without this function needing to know - about unrelated device families. + Only a 2-letter prefix match, so e.g. 'WAC' (Window AC, issue #87) also + matches 'WA' (top-load washer, issue #106) at this granularity -- + for_device_by_model() consults the board-family table first and this + only as a fallback, so that ambiguity resolves correctly. """ segments = (description or "").split("/", 1)[0].split("_") 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) -# -> registry key. This is the device naming its own type -- no board-part -# guessing involved -- so it's consulted before modelNum/description at all. +# -> registry key. The device naming its own type, no board-part guessing -- +# consulted before modelNum/description. # # 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 -# stops well short of the full OCF/SmartThings device-type vocabulary: a -# compiled list of `x.com.st.d.*` types will include plenty of device -# categories (lights, switches, sensors, locks, cameras, TVs, generic energy -# meters, ...) no Samsung DA appliance dump could ever report and this -# 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. +# `test_every_oic_type_resolves_to_a_real_registry`) -- this deliberately +# stops short of the full OCF/SmartThings vocabulary, since most of it (lights, +# locks, cameras, TVs, ...) has no registry here to point at, and +# 'oic.d.robotcleaner' names an actual robot vacuum, a different product from +# the clean/auto-empty *station* `vacuum_station` covers. # # `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF -# device-type vocabulary (used for categories with no `oic.d.*` equivalent), -# same prefix convention as the `x.com.samsung.da.*` resource fields -# elsewhere in this codebase. +# device-type vocabulary, for categories with no `oic.d.*` equivalent. # -# `oic.d.cooktop` is deliberately absent, and is the one measured type left out. -# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and -# `induction_cooktop` are two unrelated registries that happen to share the -# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps -# burner state in /mode/vs/0's options array, a completely different OCF -# surface). The OCF type does not distinguish them, so mapping it to either key -# 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.d.cooktop` is deliberately absent: a TP1X_DA-KS-COOKTOP induction +# reports it, but `cooktop` and `induction_cooktop` are unrelated registries +# sharing the English word (see by_type/cooktop.py's docstring) -- the OCF +# type doesn't distinguish them, and as the primary signal it would override +# a correct `COOKTOP`/`CT` board token. No unambiguous key to point at, so no +# row. _OIC_TYPE_TO_KEY: dict[str, str] = { "oic.d.airconditioner": "airconditioner", "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: """Device-type detection from /oic/d's `rt` -- OCF's own device-type - declaration. - - The primary path when a dump carries it: the device names its own type, - so there's nothing to infer from board part numbers. Most hardware still - 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. + declaration. The primary path when a dump carries it, since the device + names its own type. Most hardware still doesn't populate `/oic/d` + usefully, so `for_device_by_model`/`for_device_by_resources` remain + load-bearing for everything else. """ for device_type in device_types: 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: """Detect a device family from a distinctive local-resource signature. - This runs first as an override path for non-standard devices, not because - resource signatures are inherently more trustworthy than OIC/model - metadata. It also types boards that ship no ``/information/vs/0`` at all, - leaving `for_device_by_model` nothing to read. Some newer cooktops are the - original case: their mode resource still identifies them, carrying a - DeviceType option and multiple per-burner OperationState options. + Runs first as an override path for non-standard devices -- not because + resource signatures are more trustworthy than OIC/model metadata, but + because it also types boards with no ``/information/vs/0`` at all. + Some newer cooktops were the original case: their mode resource still + identifies them via a DeviceType option and multiple per-burner + OperationState options. - Require two independent shapes for every signature here, never one, so - putting this ahead of OIC/model metadata cannot let a common resource + Every signature here requires two independent shapes, never one, so + running this ahead of OIC/model metadata can't let a common resource misclassify an unrelated family. """ 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: return _REGISTRY_BY_KEY["range_hood"] # Oven/range/microwave boards that report no /information/vs/0 at all - # (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply - # absent from the dump, not just empty) can't be matched via - # for_device_by_model's modelNum tokens either. Mode vocabulary alongside - # the oven cavity resource (/oven/vs/0) is a safe two-resource signature; - # it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue - # PR #225) when resource detection runs before metadata. + # (issues #74, #172) can't be matched via modelNum tokens either. Mode + # vocabulary alongside the oven cavity resource (/oven/vs/0) is a safe + # two-resource signature; it also corrects Qooker's generic oic.d.oven + # metadata (PR #225) since resource detection runs before it. supported_modes = mode.get("x.com.samsung.da.supportedModes") or () if not isinstance(supported_modes, (list, tuple)): supported_modes = () @@ -379,20 +343,18 @@ def resolve( 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. - Distinctive resource signatures run first because they describe the live - capability surface a registry must bind. They are deliberately strict in - `for_device_by_resources`: each requires multiple independent details, so - this can correct misleading metadata (Qooker's generic ``oic.d.oven``) - without a common href overriding an unrelated family. When no signature - matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see - registry/identity.py) wins over model-string parsing. + Distinctive resource signatures run first, since they describe the + live capability surface a registry must bind; `for_device_by_resources` + is deliberately strict (multiple independent details required) so this + can correct misleading metadata without a common href overriding an + unrelated family. When no signature matches, `/oic/d`'s `rt` wins over + model-string parsing. - `/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It - reads like the obvious signal -- the device naming its own type, e.g. - '7.0 Dishwasher' -- but only a minority of hardware populates it, every - device that does is already typed by its modelNum board token, and no - device-support issue has ever been fixed by adding a mapping for it. It - is still reported in diagnostics as a firmware-generation marker. + `/otninformation/vs/0`'s oneUiVersion is deliberately not consulted: + only a minority of hardware populates it, every device that does is + already typed by its modelNum board token, and no device-support issue + has ever needed it. Still reported in diagnostics as a firmware + marker. """ info = resources.get("/information/vs/0", {}) return ( diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 451ef43..0d7f7ad 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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 `['31', '2']`) reads like a coarse quality-grade code, but nothing on this -board (no `supportedGrades`/similar field, no repeated dump to compare -against) confirms what its scale means -- left unbound rather than guessed, -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. +board confirms what its scale means -- left unbound rather than guessed; +index 0 is the only slot any family has ever read. Dust/FineDust/SuperFineDust aren't assigned an HA `device_class` -(pm10/pm25/pm1) or `unit` despite the values reading like plausible -ug/m3 particulate readings in a physically consistent order (coarser ->= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/ -"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's -three-tier naming doesn't confirm where the extra tier or a PM1 reading -actually fits. The adding-device-support skill's read-side rule says -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). +(pm10/pm25/pm1) or `unit` despite reading like plausible ug/m3 particulate +values: Samsung's own two-tier Korean convention maps only to a PM10/PM2.5 +pair, and this board's three-tier naming doesn't confirm where the extra +tier or a PM1 reading fits. A wrong guess would silently mislabel every +reading forever, so they're plain `measurement` sensors named after the +device's own field instead, matching air_purifier.AIR_QUALITY's precedent. """ from datetime import time as dt_time @@ -134,13 +126,10 @@ def _dnd_time_write(field): return _write -# Issue #210: no idle-vs-active dump pair exists for this href (only one -# dump total, DND never toggled in it), so this write contract is an -# educated guess, not a confirmed one -- symmetric with the read side -# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the -# 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. +# Issue #210: only one dump exists (DND never toggled in it), so this write +# contract is an educated guess -- symmetric with the read side's own +# 'true'/'false' and 'HH:MM:SS' formats, but still needs a reporter to +# confirm it on real hardware. DND = Capability( href="/dnd/vs/0", poll_tier="cold", diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 887b6be..a123baa 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -1,61 +1,25 @@ """Capabilities for the Samsung ARTIK051_TVTL-class air purifier family (model AX60R5080WD/SE, issue #56). -Power, kids-lock, remote-control, alarms, and the energy meter are the shared -common.py capabilities (this family exposes the standard /power/0+/power/vs/0 -pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses -dishwasher.DIAGNOSIS -- identical field/write contract -(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps). +Power, kids-lock, remote-control, alarms, and the energy meter are the +shared common.py capabilities; /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS +(identical field/write contract). -/mode/vs/0's x.com.samsung.da.options array packs multiple independent -'_' flags into one list -- the same packed-list contract -laundry.py's option_value/option_write already model for /course/vs/0's -options[] (reused directly below, just against this family's own href). Per -issue #56's follow-up (five diagnostics dumps captured with the physical unit -set to Auto/Sleep/Low/Medium/High): - Light_On / Light_Off -- a plain on/off flag; MODE below models it as a - real switch, RMW-replacing just that one entry. - 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). +/mode/vs/0's options[] packs several '_' flags, the same +packed-list contract as laundry.py's option_value/option_write. Light_On/ +Light_Off is a real on/off switch here -- NOT the same polarity as the AC +family's own Light_On/Light_Off token on its own /mode/vs/0, which is +inverted (airconditioner._display_light_on). Comode_Off reads 'Off' on +every setting (Auto/Sleep/Low/Medium/High), ruling out the original +"fan speed selector" guess; exposed read-only. OptionCode_* and Blooming_* +are unmodeled: confirmed opaque / not app-facing. -/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up). -The first round of five dumps above wasn't conclusive -- it read 0 for both -Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five -were captured within about a minute of each other, faster than this -integration's own ~30s poll cycle could settle each change. A second round, -captured 60-90s apart per setting on two independent units, confirmed a -clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4. -AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4 -range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's -percentage steps straight onto the raw code, no named-preset table needed -(unlike the TP1X family's FAN, which exposes real named modes because its -board actually reports a supportedModes list to hang names off of). - -/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it -was unreliable on both units in that second round (Low/Medium collided on -one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below -stays a plain read-only diagnostic even after this change. +/airflow/0's `speed` is a real fan-speed control: two independent units, +sampled 60-90s apart per setting, confirmed a clean monotonic 0-4 mapping +across Auto/Sleep/Low/Medium/High. AIRFLOW_GENERIC below builds an +ordered-speed fan off that range. /airflow/vs/0's vendor `speedLevel` is +NOT used for the same purpose -- unreliable on both units in the same +round (collided Low/Medium on one, stuck at 0 on the other). """ 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 .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 -# #130) report fan modes directly on /mode/vs/0's top-level `modes`/ -# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing -# everything into the options[] array the way the older ARTIK051_TVTL -# family above does -- that older family's /mode/vs/0 has no top-level -# 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. +# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly +# on /mode/vs/0's top-level modes/supportedModes instead of packing +# everything into options[] like the older ARTIK051_TVTL family. Both +# generations share this href; FAN and MODE below are mutually exclusive +# via presence of supportedModes. HREF_MODE = "/mode/vs/0" HREF_AIRFLOW = "/airflow/0" HREF_WIND_STRENGTH = "/wind/strength/vs/0" @@ -122,12 +83,9 @@ def _consumable_state(items, name): return None -# FilterProgress is a 0-100 percentage counting up as the filter wears -- -# confirmed via issue #56: the SmartThings app shows "Filter needs changing" -# once this reaches 100, so 100 means fully used, not "brand new." Named -# 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. +# FilterProgress counts UP as the filter wears (100 = "needs changing", +# confirmed via the SmartThings app) -- named after the raw field rather +# than "filter life," which would imply the opposite direction. FILTER = Capability( href="/consumable/vs/0", poll_tier="cold", @@ -160,11 +118,9 @@ DEVICE_ACTIVE = Capability( def _power_write(power_href, value): - """Shared 'power' payload handling for this family's three FanDesc write - functions -- targets whichever power href fan.py's _power_payload picked - (the board may only report /power/0); a hardcoded vendor href here would - silently no-op on such a board even though the entity's own is_on - already falls back to reading it correctly.""" + """Shared 'power' payload handling for this family's FanDescs -- targets + whichever power href fan.py picked (the board may only report + /power/0).""" if power_href == "/power/0": return ["power", "0"], {"value": bool(value)} return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"}) @@ -179,24 +135,14 @@ def _airflow_fan_write(payload, rep, href=None): return None -# Confirmed via issue #56's second, properly-spaced round of diagnostics -# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is -# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now -# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same -# SET_SPEED shape as the range hood's) instead of a read-only sensor -- -# no named-preset table needed, since HA's percentage steps map onto the -# raw 0-4 code directly, the same way the range hood's numeric levels do. -# `direction` stays a plain diagnostic: every dump seen (both rounds, both -# units) reads 'Off' for it regardless of fan setting, so there's nothing -# confirmed to control there yet. +# Confirmed monotonic 0-4 speed code (see module docstring) backs a real +# ordered-speed fan, same SET_SPEED shape as the range hood's. `direction` +# stays a diagnostic: every dump reads 'Off' regardless of fan setting. # -# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared -# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built -# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are -# only *empirically* mutually exclusive (every dump seen has one board -# generation's shape or the other, never both), not architecturally -# enforced the way same-href caps are by _build()'s match_fn check -- a -# same key would collide if a future board ever reported both. +# Keyed 'airflow_fan', not 'fan' -- FAN below shares this registry and also +# uses key 'fan'; unique_id is built from key alone, so a shared key would +# collide if a board ever reported both (empirically mutually exclusive, +# not architecturally enforced the way same-href caps are). AIRFLOW_GENERIC = Capability( href=HREF_AIRFLOW, poll_tier="warm", @@ -211,10 +157,8 @@ AIRFLOW_GENERIC = Capability( ), ) -# Left exactly as a read-only fallback -- speedLevel is NOT the same -# confirmed-reliable field as /airflow/0's speed above (see module -# docstring): it collided Low/Medium on one unit and stuck at 0 throughout -# on the other in the same properly-spaced round. +# Read-only fallback: speedLevel is unreliable (see module docstring), +# unlike /airflow/0's speed. AIRFLOW_VS_FALLBACK = Capability( href="/airflow/vs/0", 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): - # option_write's single-token write is confirmed on a washer's - # /course/vs/0 (issue #54), NOT independently on this family's - # /mode/vs/0 -- extrapolated on the assumption the same vendor field - # 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. + # option_write's single-token merge is confirmed on a washer's + # /course/vs/0 (issue #54); extrapolated here on the assumption the + # same vendor field merges the same way on this family's /mode/vs/0. return ["mode", "vs", "0"], { "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): - """Representative scalar for the fan entity in the flattened state - (golden/regression), mirroring airconditioner.py's own _first_mode -- - the real entity computes its state from live coordinator reads.""" + """Representative scalar for the flattened golden state; the real + entity reads live coordinator state instead.""" modes = rep.get("x.com.samsung.da.modes") if isinstance(modes, (list, tuple)): 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 -# percentage -- WindFree/Smart/Sleep are named behaviors, not -# "faster/slower" positions relative to Max/Mid, so fan.py's entity for -# 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. +# percentage -- these are named behaviors, not "faster/slower" positions, +# so fan.py only exposes PRESET_MODE here. FAN = Capability( href=HREF_MODE, poll_tier="warm", @@ -324,19 +262,14 @@ def _wind_strength_fan_write(payload, rep, href=None): return None -# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but -# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of +# A-VTWW-TP2-21-COMMON (issue #151): named presets like FAN above, but on a +# distinct href with numeric codes ("87"/"89"/"90"/"91") instead of # self-describing supportedModes -- x.com.samsung.da.modesName gives the -# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's -# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded -# 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. +# real names, read live by fan.py rather than a hardcoded map. `modes` is a +# bare string here, not a single-element list like HREF_MODE's. # -# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry -# and also uses a FanDesc; BoundEntity's unique_id is built from key alone -# (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). +# key is 'wind_strength_fan', not 'fan' -- same unique_id collision hazard +# as AIRFLOW_GENERIC above. WIND_STRENGTH_FAN = Capability( href=HREF_WIND_STRENGTH, poll_tier="warm", @@ -350,15 +283,12 @@ WIND_STRENGTH_FAN = Capability( ), ) -# --------------------------------------------------------------------------- -# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several -# resources the older ARTIK051_TVTL family never did. -# --------------------------------------------------------------------------- +# TP1X_DA-AC-AIR-class additions (issue #130): resources the older +# ARTIK051_TVTL family never reported. -# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood -# light): both report the same {mode, supportedModes: [On, Off]} shape on -# separate hrefs on this dump, so they're two independent physical controls, -# not a duplicate encoding of one. +# Screen/indicator panel on/off, distinct from the display_light switch +# above (ambient mood light) -- two independent controls on separate hrefs +# with the same {mode, supportedModes: [On, Off]} shape. DISPLAY = Capability( href="/display/vs/0", poll_tier="cold", @@ -377,10 +307,8 @@ DISPLAY = Capability( ), ) -# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own -# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not -# seen on this one dump, so the option list there is reused as-is rather -# than re-deriving it from a single sample. +# Same filterUsage/filterCapacity/filterStatus shape as the AC family's +# AIR_FILTER; the normal/wash/replace option list is reused as-is. HEPA_FILTER = Capability( href="/filter/hepafilter/vs/0", poll_tier="cold", @@ -406,10 +334,9 @@ HEPA_FILTER = Capability( ), ) -# Physical panel/cover status -- meaning of the one value seen ('Close') is -# plausible (the HEPA-filter access cover) but unconfirmed, and no -# supportedStatus list is present to check against -- exposed as a plain -# diagnostic sensor rather than an asserted binary_sensor polarity. +# Physical panel/cover status ('Close' seen, plausibly the HEPA-filter +# cover) -- unconfirmed, and no supportedStatus list to check against, so a +# plain diagnostic rather than an asserted binary_sensor. PANEL_STATUS = Capability( href="/panel/vs/0", 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 -# a glance, but this board's actual values differ (supportedModes here is -# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range -# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a -# valid write ('buzzer') or expose the wrong number range, so these are -# separate descriptors reading the live supported values instead of a -# hardcoded table. +# Sound mode/volume look like laundry.py's SOUND_MODE/SOUND_VOLUME but this +# board's actual values differ (supportedModes here is ['mute', 'buzzer'], +# not laundry's voice/tone/mute; volume is 0-3, not laundry's fixed 0-15) -- +# separate descriptors reading live supported values instead of reusing +# laundry's hardcoded table. SOUND_MODE = Capability( href="/settings/sound/mode/vs/0", poll_tier="cold", entities=( # Distinct translation_key from laundry.SOUND_MODE's shared - # 'sound_mode' catalog entry -- that one's state table is - # {voice, tone, mute}, but this board's supportedModes is - # {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled - # (falls through to the raw code) since the catalogs don't overlap. + # 'sound_mode' catalog ({voice, tone, mute}) -- this board's + # {mute, buzzer} doesn't overlap it. SelectDesc( key="sound_mode", translation_key="air_purifier_sound_mode", @@ -510,76 +433,41 @@ SOUND_VOLUME = Capability( ), ) -# --------------------------------------------------------------------------- -# AI Purify -- /airlevelcheck/vs/0 (issues #84 and #190) +# AI Purify -- /airlevelcheck/vs/0 (issues #84, #190). Not scheduler +# 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 -# of the AVT-WW-TP1-23 board showed it is not plumbing: it drives the feature -# the SmartThings app calls AI Purify, where the unit wakes on a timer, samples -# the air, and optionally acts on the result. Every field is named, none are -# opaque, and two of them are already user-set on the reported units. +# Two independent knobs, one entity each rather than folded into one +# select: periodicSensingActivationState (is it running) and autoExeState +# (what it does with a bad reading, Off/Airpurify/Alarm) -- mirrors the +# appliance's own UI. Folding them lost information: a configured action +# 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 -# same field names -- TP1X_DA-AC-AIR (#130), A-VTWW-TP2 (#151) and AVT-WW-TP1 -# (#84, #190); only ARTIK051_TVTL (#56) has no such href. Bound unconditionally -# rather than behind a match_fn so any board reporting it is covered; the one -# field that genuinely varies is gated per-entity below. +# range_hood.AIR_LEVEL_CHECK models the same href's read-only fields +# (reused verbatim below) but is deliberately not imported: it exposes +# periodic_air_sensing as a read-only BinarySensorDesc where this board +# needs it writable, and reusing it would migrate every hood user's entity +# to a different platform. # -# The resource carries two independent knobs and they get one entity each, -# rather than being folded into a single control: +# Every write below was exercised on AVT-WW-TP1-23-AXX500 hardware and +# 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 -# autoExeState Off/Airpurify/Alarm -- what it does with a -# 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. -# --------------------------------------------------------------------------- +# Deferred: startSensingOnce looks like a one-shot "sense now" trigger but +# stays unbound until its side effect (not just the echo) is confirmed. def _interval_minutes(seconds): """Device stores the interval in seconds; the entity is in minutes. - - `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. - """ + Rounds up (not to nearest) so a sub-minute value can't floor to 0.""" secs = int_or_none(seconds) if secs is None: return None @@ -587,26 +475,15 @@ def _interval_minutes(seconds): def _interval_write(payload, rep, href=None): - # Minutes in the UI -> seconds on the wire (scalar string). Modelled as a - # free Number rather than the app's three fixed choices (10 min / 30 min / - # 1 hour): this resource advertises no supported-values or range field for - # the interval -- supportedAutoExeState sits right beside it, so the board - # does advertise constraints where it has them -- and it accepts values the - # app never offers. Writing 60 s, six times finer than the app's smallest - # choice, drove an observed ~60 s sensing cycle on hardware. - # - # One minute is the floor because that's the resolution this board reports - # 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 in the UI -> seconds on the wire. Modeled as a free Number, + # not the app's three fixed choices, since the resource advertises no + # constraint for this field (unlike supportedAutoExeState beside it) + # and accepts finer values than the app offers (60s drove an observed + # ~60s sensing cycle on hardware). One-minute floor matches this + # board's own reporting resolution (lastSensingTime lands on exact + # minutes). Zero is refused: unlike a real "no timer" 0 elsewhere in + # this repo, nothing establishes what 0 does here. Silent no-op via + # None, same shape as range_hood._lamp_level_write. minutes = round(float(payload)) if minutes < 1: return None @@ -616,10 +493,9 @@ def _interval_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 - # configured action survives the feature being switched off and comes back - # with it -- the thing the select cannot do, since every option it writes - # sets an action. + # Master on/off; leaves autoExeState alone so the configured action + # survives the feature being toggled off -- the select can't do that, + # since every option write sets an action too. return ["airlevelcheck", "vs", "0"], { "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 -# HHMMHHMM string (start+end) on periodicSensingSkipTime. The read side is -# cross-confirmed on two units: issue #84's sits at the inert '00000000', while -# issue #190's carries a real user-set '03002300' -> 03:00-23:00. Split into -# two HA time entities; each write reads the other half back out of the live -# 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. +# Daily skip window, stored as one HHMMHHMM string +# (periodicSensingSkipTime). Cross-confirmed on two units (inert +# '00000000' vs a real '03002300'). Split into two HA time entities; each +# write reads the other half back out of the live rep so the pair +# round-trips -- confirmed in both directions on hardware. def _skip_time_read(part): def _read(value): raw = str(value or "") @@ -654,11 +527,9 @@ def _skip_time_read(part): def _skip_half(raw, part): - """The half this write isn't setting, normalized. Padding alone would carry - a malformed value straight back to the device -- writing start over a junk - skip time would send '0730' + junk. The read side already refuses a half it - can't parse, so an unparseable one becomes '0000' here and the pair - round-trips honestly in the same cases.""" + """The half this write isn't setting, normalized. An unparseable half + becomes '0000' rather than carrying a malformed value back to the + device.""" chunk = (str(raw or "") + "00000000")[:8] 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" @@ -687,10 +558,8 @@ AIR_LEVEL_CHECK = Capability( value_fn=lambda v: str(v).lower() == "on", write_fn=_periodic_sensing_write, ), - # Options come off supportedAutoExeState, not a table here -- the - # catalog carries the labels for the three values seen so far, and an - # unrecognized fourth still reaches the user (select.py falls back to - # the device's own token when the catalog doesn't know it). + # Options come off supportedAutoExeState rather than a typed table, + # so an unrecognized fourth value still reaches the user. SelectDesc( key="sensing_mode", field="x.com.samsung.da.autoExeState", @@ -703,10 +572,9 @@ AIR_LEVEL_CHECK = Capability( {"x.com.samsung.da.autoExeState": p}, ), ), - # The one field that varies across the three families reporting this - # resource: the TP1X_DA-AC-AIR dump (#130) omits it while both - # AVT-WW-TP1 dumps and the A-VTWW-TP2 dump carry it, so that board runs - # the sensing engine on a fixed interval it doesn't expose. + # The one field that varies across families: TP1X_DA-AC-AIR (#130) + # omits it, so that board runs sensing on a fixed, unexposed + # interval. NumberDesc( key="sensing_interval", field="x.com.samsung.da.periodicSensingInterval", @@ -758,9 +626,8 @@ AIR_LEVEL_CHECK = Capability( entity_category="diagnostic", value_fn=epoch_to_utc, ), - # 'Kr1' on both dumps -- a national air-quality grade whose scale is - # region-prefixed and undocumented here, so it stays a raw diagnostic - # rather than being mapped to an asserted enum. + # 'Kr1' on both dumps -- a region-prefixed, undocumented grade; + # stays a raw diagnostic rather than an asserted enum. SensorDesc( key="last_air_sensing_level", 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 -# been verified against -- covered here (not globally, per ignored.py's -# module docstring) since those hrefs collide with fridge/AC schemas -# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED. -# -# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same -# 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. +# /humidity/0 and /humidity/vs/0 are empty on both dumps -- covered here +# (not globally) since they collide with fridge/AC schemas elsewhere, same +# reasoning as airconditioner.py's _AC_IGNORED. The next six hrefs (issue +# #130) are the exact same DA-AC- board resources as _AC_IGNORED, +# duplicated here rather than promoted to the global list (a possible +# follow-up DRY cleanup). COVERAGE = [ Capability(href="/humidity/0"), Capability(href="/humidity/vs/0"), @@ -790,14 +651,11 @@ COVERAGE = [ Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here) Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob - # Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/ - # useTimeSetting/functionState) -- every field reads its inert default - # on the only dump seen (times both '00:00:00', useTimeSetting/ - # functionState both 'false'). Same "needs a multi-field schedule - # editor" treatment as fridge.py's /defrost/reservation/vs/0. + # Do-not-disturb/auto-sleep schedule -- every field reads its inert + # default on the only dump seen. Needs a multi-field schedule editor, + # same as fridge.py's /defrost/reservation/vs/0. Capability(href="/dnd/autosleep/vs/0"), - # Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's - # convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN - # above instead. + # Empty on the A-VTWW-TP2-21 dump (issue #151) -- this board's + # convenient-mode equivalent lives in WIND_STRENGTH_FAN instead. Capability(href="/mode/convenient/vs/0"), ] diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index d0de28d..edf61cc 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1,18 +1,12 @@ -"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class). +"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class, +issue #17 / ARTIK051_PRAC_20K). -Resources verified against the issue #17 diagnostics dump (model -ARTIK051_PRAC_20K). This is the first family whose core controls surface as a -single composite HA `climate` entity rather than a scatter of switches/selects: -power (on/off), HVAC mode, current/target temperature, fan (wind) strength, -swing (wind direction), and the convenient-mode preset all live on one climate -card. The climate platform (climate.py) reads those sibling resources from the -coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the -`ClimateDesc` and mark the consumed siblings as covered. - -None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`, -`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a -different schema (see capabilities/__init__.py). They live only in the AC -by_type registry. +Core controls (power, mode, temperature, fan, swing, preset) surface as one +composite HA `climate` entity; climate.py reads the sibling resources bound +here off the coordinator snapshot. These caps stay out of the global +`ALL`/`CAPABILITIES`: several hrefs (`/mode/vs/0`, `/temperatures/vs/0`, +`/humidity/*`) collide with other families' schemas (see +capabilities/__init__.py) -- AC-only, by_type registry only. """ from dataclasses import replace @@ -39,14 +33,8 @@ def _int(v): def _beep_on(rep): - """Beep on/off from the `Volume_*` option token: Volume_Mute = off, - Volume_100 (and any non-Mute) = on. None when no Volume_ slot. - - _option_token (defined further below, alongside the legacy-board token - helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the - full 'Volume_100' token -- shared with _option_token_num/_option_token_on, - which this module's other options[] readers already rely on. - """ + """Beep on/off from the `Volume_*` option token (`Volume_Mute` = off, + else on).""" tok = _option_token(rep, "Volume") if tok is None: return None @@ -54,10 +42,9 @@ def _beep_on(rep): def _beep_write(payload, rep, href=None): - """Toggle beep via a single-token /mode/vs/0 options write (option_write's - one-token merge -- a full options RMW reverts on ARTIK051_PRAC). 'On' - restores the last non-Mute level rather than forcing Volume_100, so a - user's intermediate setting (e.g. Volume_50 set via the cloud) survives an + """Toggle beep via a single-token options write (a full options RMW + reverts on ARTIK051_PRAC). 'On' restores the last non-Mute level rather + than forcing Volume_100, so a user's intermediate setting survives an off/on cycle; falls back to 100 when no prior level is known.""" if payload not in ("On", "Off"): return None @@ -72,11 +59,7 @@ def _beep_write(payload, rep, href=None): def _tropical_night_value(rep): - """Tropical night mode level (0-16) from the `Sleep_` option token. - - _option_token returns the token's value half already (e.g. '16' for - 'Sleep_16'), same convention as _beep_on above. - """ + """Tropical night mode level (0-16) from the `Sleep_` option token.""" tok = _option_token(rep, "Sleep") if tok is None: return None @@ -84,8 +67,8 @@ def _tropical_night_value(rep): def _tropical_night_write(value, rep, href=None): - """Set tropical night level via a single-token `Sleep_` options write. - Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16).""" + """Set tropical night level via a single-token `Sleep_` write. + Cloud counterpart: custom.airConditionerTropicalNightMode.""" try: level = round(float(value)) except (TypeError, ValueError): @@ -98,20 +81,16 @@ def _tropical_night_write(value, rep, href=None): def _filter_unit(rep): - """Unit of the filter-usage fields, normalised from filterCapacityUnit - ('Hour' -> 'h'). Wired through unit_fn so a board advertising a different - unit doesn't silently mislabel a duration statistic.""" + """Filter-usage unit, normalized from filterCapacityUnit ('Hour' -> 'h').""" u = rep.get("x.com.samsung.da.filterCapacityUnit") return {"Hour": "h", "Minute": "min", "Second": "s"}.get(u, u or "h") def _threshold_write(payload, rep, href=None): - """filterDesiredUsage is locally writable: a plain scalar POST of the - field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists - (confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The - Select only surfaces where the device advertises - supportedFilterDesiredUsage, so the valid options are known rather than - guessed; boards without that enum leave this writable field unexposed.""" + """filterDesiredUsage is locally writable via a plain scalar POST + (confirmed live on ARTIK051_PRAC). The Select only surfaces where the + device advertises supportedFilterDesiredUsage, so options are known + rather than guessed.""" return ["filter", "airdustfilter", "vs", "0"], { "x.com.samsung.da.filterDesiredUsage": payload, } @@ -119,14 +98,9 @@ def _threshold_write(payload, rep, href=None): def _sensor_item_value(items, type_): """First value of the /sensors/vs/0 item with the given - x.com.samsung.da.type. The resource exposes no unit, so no device_class is - set until a populated reading + unit is observed (the 'don't guess' rule). - - Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while - CleanLevel/Odor report a single element -- the second element's meaning is - unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as - a string rather than coerced numeric because only CleanLevel has - corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar).""" + x.com.samsung.da.type. Dust/FineDust/SuperFineDust report a 2-element + array; only v[0] is used, since the second element's meaning is + unconfirmed. No device_class is set: the resource exposes no unit.""" for it in items or []: if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_: v = it.get("x.com.samsung.da.value") @@ -137,24 +111,15 @@ def _sensor_item_value(items, type_): def _has_sensor_type(type_): - """True when the /sensors/vs/0 items[] array lists an item of this type. + """True when /sensors/vs/0's items[] lists an item of this type. - This proves the type is *listed*, not that the reading is real: issue - #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) lists all five item types - with permanent zero values on both its units, and the reporter confirmed - none of these sensors are physically present. A top-level - x.com.samsung.da.cleanLevel scalar (separate from the CleanLevel item) - looked like a corroborating "this reading is real" signal at first -- - present alongside genuinely populated readings on tp1x_da_ac_rac_01011 - and the tp1x_da_ac_air air purifier fixture, absent on every all-zero - ARTIK051_PRAC_20K dump including both #166 units -- but it doesn't hold - up as a general rule: air_purifier_device.json (ARTIK051_TVTL_18K), - air_purifier_vtww_device.json, and range_hood_device.json all carry - genuinely populated, non-AC-family Dust/FineDust/SuperFineDust readings - with no such scalar. So this stays item-type presence only -- the - entities are disabled by default instead (see AIR_QUALITY below) rather - than existence-gated on a signal that would silently drop real readings - on hardware this repo hasn't seen yet.""" + This only proves the type is *listed*, not that the reading is real: + issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero + values on units the reporter confirmed don't have the hardware. So + entities gated on this stay disabled by default (see AIR_QUALITY) rather + than existence-gated further, to avoid silently dropping real readings + on hardware not yet seen. + """ def fn(rep, resources): return any( @@ -165,36 +130,24 @@ def _has_sensor_type(type_): return fn -# --------------------------------------------------------------------------- -# Canonical AC resource hrefs. The climate entity (climate.py) binds the -# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS -# siblings off the coordinator snapshot; those siblings are marked covered -# (no-entity caps) so discover() reports no gap. Declared once here and -# imported by climate.py, so a new sibling read can't drift out of sync with -# its coverage entry. -# --------------------------------------------------------------------------- -HREF_MODE = "/mode/vs/0" # primary (bound by CLIMATE) -HREF_POWER = "/power/0" # on/off -> HVACMode.OFF / TURN_ON/OFF +# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the +# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once +# here so climate.py and the coverage list below can't drift out of sync. +HREF_MODE = "/mode/vs/0" # primary, bound by CLIMATE +HREF_POWER = "/power/0" # OCF on/off HREF_POWER_VS = "/power/vs/0" # vendor fallback for on/off -HREF_TEMP_CURRENT = "/temperature/current/0" # current_temperature -HREF_TEMP_DESIRED = "/temperature/desired/0" # target_temperature (write target) +HREF_TEMP_CURRENT = "/temperature/current/0" +HREF_TEMP_DESIRED = "/temperature/desired/0" HREF_TEMP_CONTROL = "/temperature/control/vs/0" # target_temperature_step HREF_WIND_STRENGTH = "/wind/strength/vs/0" # fan_mode HREF_WIND_DIRECTION = "/wind/direction/vs/0" # swing_mode -# Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no -# HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource -# (separate vertical/horizontal Swing|Fix toggles, each with its own angle). -# climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same -# duality pattern as the OCF/vendor temperature channel below. +# WindFree boards (issue #126) have no HREF_WIND_DIRECTION and instead carry a +# 2-axis oscillation resource; climate.py falls back to this when absent. HREF_WIND_OSCILLATION = "/wind/oscillation/vs/0" # swing_mode fallback HREF_CONVENIENT = "/mode/convenient/vs/0" # preset_mode HREF_TEMPS_VS = "/temperatures/vs/0" # vendor temp fallback (items[] array) -# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources -# at all: fan speed and vane direction sit together in this one resource, as -# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and -# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their -# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a -# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient. +# Legacy ARTIK051 boards (issue #136) have no /wind/* resources: fan speed and +# vane direction live together here instead. See climate.py's _legacy_airflow. HREF_AIRFLOW = "/airflow/vs/0" # legacy fan_mode + swing_mode CLIMATE_CONSUMED_HREFS = [ @@ -221,9 +174,9 @@ def _num(v): def _temps_vs_item(rep): """First item of the vendor `/temperatures/vs/0` items[] array -- the - Tizen Lite board's only current-temperature source (see climate.py's - identical helper; duplicated rather than imported to avoid a - capabilities<->platform import cycle).""" + Tizen Lite board's only current-temperature source. Duplicated from + climate.py's identical helper to avoid a capabilities<->platform import + cycle.""" items = rep.get("x.com.samsung.da.items") if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): return items[0] @@ -239,8 +192,8 @@ def _temps_vs_unit(rep): def _first_mode(rep): - """Representative scalar for the climate entity in the flattened state - (golden/regression). The real entity computes hvac_mode from power + mode.""" + """Representative scalar for the flattened golden state; the real + climate entity derives hvac_mode from power + mode instead.""" modes = rep.get("x.com.samsung.da.modes") if isinstance(modes, (list, tuple)): return modes[0] if modes else None @@ -253,22 +206,16 @@ def _mode_options(rep): def _has_display_light_option(rep, resources): - """True when the panel light state is carried inside /mode/vs/0's options - blob (a `Light_On`/`Light_Off` token) rather than a dedicated /light/vs/0 - switch. The two encodings are mutually exclusive across observed boards: - models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below) - carry no Light_* option, so this entity only materialises on the boards - that would otherwise have no display-light entity at all.""" + """True when the panel light lives in /mode/vs/0's `Light_*` option + token rather than a dedicated /light/vs/0 switch -- the two encodings + are mutually exclusive across observed boards.""" return any(isinstance(o, str) and o.startswith("Light_") for o in _mode_options(rep)) def _display_light_on(rep): - """Panel display light state from /mode/vs/0's options blob. The token is - INVERTED relative to its name -- confirmed by a live toggle test: with the - panel lit the option reads `Light_Off`, and with it dark it reads - `Light_On` (the flag really encodes "night/display-off mode active"). So - `Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the - write below uses the device's own single-token merge (see option_write).""" + """Panel light state from /mode/vs/0's options. The token is INVERTED: + a live toggle test showed `Light_Off` while lit and `Light_On` while + dark (the flag really means "night/display-off mode active").""" for o in _mode_options(rep): if isinstance(o, str) and o.startswith("Light_"): return o == "Light_Off" @@ -276,22 +223,17 @@ def _display_light_on(rep): def _display_light_write(payload, rep, href=None): - """Toggle the panel light via a single-token /mode/vs/0 options write - ('SingleCommand_1' is advertised in this family's /configuration/vs/0 - airconOptionList; option_write's one-token merge is the same mechanism - air_purifier.py uses for its own Light switch). Polarity is inverted (see - _display_light_on): switching the lamp ON writes 'Light_Off', OFF writes + """Toggle the panel light via a single-token options write. Polarity is + inverted (see _display_light_on): ON writes 'Light_Off', OFF writes 'Light_On'.""" token = "Off" if payload == "On" else "On" return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Light", token)}) -# --------------------------------------------------------------------------- -# Legacy ARTIK051 boards keep several settings that newer boards expose as their -# own resources (/option/*, /electriccurrent/vs/0, ...) as `_` -# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart; -# writes reuse option_write's single-token merge, exactly like the display light. -# --------------------------------------------------------------------------- +# Legacy ARTIK051 boards keep several settings that newer boards expose as +# their own resources (/option/*, /electriccurrent/vs/0, ...) as +# `_` tokens in /mode/vs/0's options instead. Reads pull the +# token apart; writes reuse the same single-token merge as the display light. def _option_token(rep, prefix): @@ -303,37 +245,19 @@ def _option_token(rep, prefix): def is_legacy_board(resources): - """True for the board generation whose airflow lives in /airflow/vs/0. - - Newer families carry several of the same option tokens (Sleep, OutdoorTemp, - Autoclean) *alongside* dedicated resources for those settings, so an - ungated token entity would either duplicate an existing one or apply a - scale calibrated elsewhere. (Volume used to be in this list too, back when - it had its own gated buzzer_volume Number for this board generation -- - issue #136 replaced that with the unified 'beep' switch, which applies - across every generation and isn't gated here at all.) Every AC dump on - record has one shape or the other: /airflow/vs/0 with no /wind/* at all, - or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's - _legacy_airflow(), so the entities below and the climate entity can never - disagree about the generation. - """ + """True for the board generation whose airflow lives in /airflow/vs/0 + rather than /wind/strength/vs/0 -- every AC dump on record has one shape + or the other. Same test as climate.py's _legacy_airflow(), so the + entities below and the climate entity can't disagree about generation.""" return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources -# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU / -# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in -# centiwatt-hours -- raw value 100x the plain Wh every other AC board family -# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the -# reporter's own SmartThings-app reading: raw '117430000' vs the app's -# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of -# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in -# ENERGY_METER's entities is present on this board's dump, so only -# 'energy_kwh' needs a replacement value_fn here; the rest pass through -# unchanged in case a future legacy dump ever reports them. +# Legacy ARTIK051 boards (issue #193, ARTIK051_KRAC_18K) report +# /energy/consumption/vs/0's cumulativePower in centiwatt-hours -- 100x the +# plain Wh every other board family (and common.wh_to_kwh) assumes. Confirmed +# against the reporter's own SmartThings-app reading: raw 117430000 vs the +# app's 1,174.30 kWh is exactly a /100000 factor. def _legacy_cumulative_power_kwh(v): - # float, not _int -- matches common.wh_to_kwh's own numeric parsing - # (float via _num) rather than this module's integer-only _int, so a - # decimal-formatted reading doesn't raise and silently go 'unknown'. try: n = float(v) except (TypeError, ValueError): @@ -350,12 +274,8 @@ ENERGY_METER_LEGACY = replace( ), ) -# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to -# common.ENERGY_METER, just excluding the legacy board generation so the two -# capabilities can share /energy/consumption/vs/0 in this registry without -# the 'multiple caps need a discriminator' build check tripping (common. -# ENERGY_METER itself has no match_fn, since every *other* registry includes -# it unconditionally and alone). +# Non-legacy counterpart, needed so both caps can share this href without +# tripping the "multiple caps need a discriminator" build check. ENERGY_METER_GENERIC = replace( common.ENERGY_METER, match_fn=lambda rep, resources: not is_legacy_board(resources), @@ -402,14 +322,8 @@ def _option_number_write(prefix): def _odor_controller_active(rep): """Odor-controller self-clean on/off, from the `SmartCoolClean_` - /mode/vs/0 option token -- corroborated against the SmartThings cloud - custom.airConditionerOdorController capability's airConditionerOdorController - State field, same on/off vocabulary and a matching name ("Smart Cool - Clean" mirrors "odor controller"). Unconditional across board - generations, same as beep above -- no evidence ties this token to the - legacy-vs-newer split _has_option_token gates on. Read-only: no command - capability is confirmed, so this stays a sensor rather than a guessed - write (the 'don't guess' rule).""" + option token (matches the SmartThings cloud's airConditionerOdorController + State field). Read-only: no confirmed write path.""" tok = _option_token(rep, "SmartCoolClean") if tok is None: return None @@ -417,29 +331,19 @@ def _odor_controller_active(rep): def _odor_controller_progress(rep): - """0-100 progress of the odor-controller self-clean cycle, from the - `ProgressSmartClean_` option token -- mirrors the cloud - airConditionerOdorControllerProgress field.""" + """0-100 progress of the odor-controller cycle, from the + `ProgressSmartClean_` token.""" return _int(_option_token(rep, "ProgressSmartClean")) def _humidity(rep): - """Relative humidity, preferring the 5%-rounded field where it exists. + """Relative humidity, preferring the 5%-rounded field where present. - ARTIK051 boards have no fivepercentHumidity field at all and report the - plain x.com.samsung.da.humidity instead -- and only populate it while the - Air monitoring option is on: the unit measures for roughly half a minute - (51% observed, matching what the same unit's cloud integration reported at - that moment), then zeroes the field and switches Air monitoring back off by - itself. So 0 reads as "not measuring" and is reported as unknown rather than - as 0% humidity, which would poison long-term history. - - That zero-as-"not measuring" carve-out is specific to the ARTIK051 - fallback field's hardware quirk -- every other board's fivepercentHumidity - has never been documented getting stuck at zero, and collapsing a - genuine 0% reading there to unknown is a regression, not a safeguard - (issue #160). So fivepercentHumidity passes 0 through unchanged; only the - humidity fallback applies the zero-collapse. + ARTIK051 boards have no fivepercentHumidity and report plain `humidity` + instead, which only populates for ~30s while "Air monitoring" is on + before zeroing itself -- so 0 there means "not measuring" and is reported + as unknown rather than 0%. fivepercentHumidity has no such quirk (issue + #160), so its own 0 readings pass through unchanged. """ if "x.com.samsung.da.fivepercentHumidity" in rep: return _num(rep["x.com.samsung.da.fivepercentHumidity"]) @@ -450,28 +354,13 @@ def _humidity(rep): def _climate_write(payload, rep, href=None): - """Map a (kind, value) command from the climate platform to the - (path_segs, body) for that one sub-write. `value` is already the raw device - code (the platform maps HA<->device). async_send_command POSTs to path_segs, - so a single desc drives writes across the power/mode/temperature/wind - resources. - - Power goes to the vendor `/power/vs/0` (the OCF `/power/0` is absent on - most boards and a non-authoritative mirror where present -- vendor works - on every board). Temperature is board-dependent, so the platform picks the - channel and sends `temperature_ocf` (-> OCF `/temperature/desired/0`, - boards with the full OCF current+desired pair) or `temperature` (-> vendor - `/temperatures/vs/0`, boards without it). Mode/fan/swing/preset are always - the vendor `/x/vs/0` resources. - - Each write sends only its own field(s), leaving the resource's other - fields (e.g. /mode/vs/0's opaque `options` blob, or the vendor temperature - item's current/minimum/maximum/unit) untouched -- the device merges the - rest itself, same contract as the options[] array (see - common.merge_items_field / merge_options_field, which keep the - coordinator's optimistic cache complete for the settle window instead of - this write echoing those fields back). - """ + """Maps a (kind, value) command from the climate platform to the + (path_segs, body) for that one sub-write; `value` is already the raw + device code. Power always goes to vendor `/power/vs/0` (OCF `/power/0` + is absent on most boards). Temperature channel (OCF vs vendor) is picked + by the platform. Mode/fan/swing/preset are always the vendor `/x/vs/0` + resources. Each write sends only its own field(s); the device merges the + rest itself (see common.merge_items_field / merge_options_field).""" kind, value = payload if kind == "power": return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"}) @@ -480,9 +369,7 @@ def _climate_write(payload, rep, href=None): if kind == "temperature_ocf": return (["temperature", "desired", "0"], {"temperature": round(float(value))}) if kind == "temperature": - # Vendor items[] array; only one item observed on every AC dump, id - # '0'. See the docstring above for why this doesn't echo current/ - # minimum/maximum/unit back at the unit. + # Vendor items[] array; only one item observed on every AC dump, id '0'. return ( ["temperatures", "vs", "0"], { @@ -499,10 +386,8 @@ def _climate_write(payload, rep, href=None): if kind == "swing": return (["wind", "direction", "vs", "0"], {"x.com.samsung.da.modes": value}) if kind == "oscillation": - # value is the HA swing_mode string ('off'/'vertical'/'horizontal'/ - # 'both'); both axes are independent Swing|Fix toggles on this - # resource, so one HA value maps to a pair of fields written - # together (see climate.py's oscillation fallback). + # value is HA's swing_mode string; both axes are independent Swing|Fix + # toggles written together (see climate.py's oscillation fallback). return ( ["wind", "oscillation", "vs", "0"], { @@ -515,7 +400,6 @@ def _climate_write(payload, rep, href=None): if kind == "swing_legacy": return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value}) if kind == "preset_legacy": - # Single-token options merge, same mechanism as _display_light_write. return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)}) if kind == "preset": return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value}) @@ -532,15 +416,10 @@ CLIMATE = Capability( rep_fn=_first_mode, write_fn=_climate_write, ), - # Display (panel) light switch, only on boards that encode it in - # /mode/vs/0's options instead of a /light/vs/0 switch (see - # _has_display_light_option). Shares the /mode/vs/0 href with the - # climate entity above -- same Capability, so no multi-cap - # discriminator is needed. /mode/vs/0 is OBSERVE-subscribed, so state - # updates on push; writes go through the single-token merge in - # _display_light_write. Shares the switch.display_light translation - # with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) -- - # mutually exclusive per href, so only one ever binds for a given unit. + # Panel light switch for boards that encode it in /mode/vs/0's options + # instead of a dedicated /light/vs/0 (see _has_display_light_option). + # Shares the switch.display_light translation key with DISPLAY_LIGHT + # below; mutually exclusive per href. SwitchDesc( key="display_light", rep_fn=_display_light_on, @@ -549,19 +428,11 @@ CLIMATE = Capability( icon="mdi:led-on", entity_category="config", ), - # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). - # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. - # - # Applies uniformly across board generations, including legacy - # ARTIK051 boards -- issue #136: this token was originally modeled as - # a 0-100 buzzer_volume Number for legacy boards on the assumption it - # was a real graduated volume level, but every unit confirmed on - # hardware (three units across two reporters) only ever carries - # Volume_100 or Volume_Mute, never an intermediate value, and the - # Number's write path (a plain integer string) could never produce - # the literal 'Mute' token needed to actually turn it off -- writing - # '0' is simply not a token this firmware recognizes. A switch is - # both the correct model and the actual fix. + # Beep on/off from the Volume_* token. Applies uniformly across board + # generations (issue #136: previously modeled as a graduated Number + # for legacy boards, but no unit ever reported an intermediate value, + # and the Number's write path couldn't produce the literal 'Mute' + # token needed to turn it off). SwitchDesc( key="beep", rep_fn=_beep_on, @@ -570,23 +441,11 @@ CLIMATE = Capability( icon="mdi:volume-high", entity_category="config", ), - # Tropical night mode level (0-16) from the `Sleep_` option token. - # Single-token option_write. Cloud: custom.airConditionerTropicalNightMode. - # Gated off the legacy board for the same reason as beep above -- its - # Sleep_ token is already the good_sleep Number below. - # - # exists_fn only proves the Sleep_ token slot is present, not that - # tropical night mode is a real feature of the unit: issue #166 - # (ARxxTXFCAWKNEU) reports Sleep_0 in every dump -- the exact same - # always-there-at-zero shape as the issue #17 dump #164 was verified - # against -- yet the reporter confirmed their remote/app has no - # tropical night mode control at all. Samsung's OCF options[] blob - # carries this scaffolding token regardless of physical capability, - # so there's no reliable signal here to gate on (same 'don't guess' - # rule as elsewhere in this file, just with no signal to guess from). - # Registered but disabled by default, same precedent as - # fridge.rack_count / cooktop.paired_hood_* -- units that do have the - # feature can enable it themselves. + # Tropical night level (Sleep_ token), gated off the legacy board + # (its Sleep_ token is the good_sleep Number below instead). exists_fn + # only proves the token slot is present, not that the feature is real + # (issue #166 reports Sleep_0 on a unit confirmed to have no such + # mode) -- disabled by default so units that do have it can enable it. NumberDesc( key="tropical_night_mode", rep_fn=_tropical_night_value, @@ -610,10 +469,8 @@ CLIMATE = Capability( icon="mdi:air-purifier", entity_category="config", ), - # Shares AUTO_CLEAN's catalog entry rather than duplicating it: same - # feature, different board generation (that one is a /option/autoclean/ - # vs/0 field, absent here). Distinct key, so nothing collides if some - # future board ever reported both. + # Shares AUTO_CLEAN's catalog entry (same feature, different board + # generation) under a distinct key. SwitchDesc( key="auto_clean_legacy", translation_key="auto_clean", @@ -631,9 +488,8 @@ CLIMATE = Capability( icon="mdi:air-filter", entity_category="config", ), - # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token - # carries no range hint and only 0 has been observed on hardware), so a - # write above 0 is unverified. + # "Good Sleep" timer. 0 = off; the upper bound is a guess (only 0 has + # been observed on hardware), so a write above 0 is unverified. NumberDesc( key="good_sleep", rep_fn=_option_token_num("Sleep"), @@ -646,11 +502,8 @@ CLIMATE = Capability( icon="mdi:sleep", entity_category="config", ), - # Outdoor temperature, offset by 55. Two calibration points on one unit: - # token 75 while an independent outdoor thermometer in the same install - # read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far - # worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer - # climate, which is also plausible. + # Outdoor temperature, offset by 55 -- calibrated against an + # independent thermometer (token 75 while it read 20.3°C). SensorDesc( key="outdoor_temperature", rep_fn=_option_token_num("OutdoorTemp", offset=55), @@ -660,87 +513,11 @@ CLIMATE = Capability( unit="°C", icon="mdi:home-thermometer-outline", ), - # Filter time in tenths of an hour: the token read 1710 while the official - # Samsung app displayed "171 hours 0 minutes" for the filter on the same - # unit, and the .0 matching the app's "0 minutes" pins the scale. - # - # It counts UP -- running time accumulated since the last filter reset, - # not time remaining. An earlier revision of this comment called the - # direction unestablished; three independent things now settle it. It was - # seen rising while the unit ran (171.0 -> 171.5). FilterAlarmTime_ in the - # same options[] blob is the threshold it is measured against (500 on - # every unit on record). And across two units on one site, the alarm - # tracks the counter in the right direction: at FilterTime_5595 the - # /alarms/vs/0 filter entry is live (unsuffixed code 'FilterAlarm', state - # 'Created'), while at FilterTime_1915 it is still the - # 'FilterAlarm_OFF'/'Deleted' placeholder. That also matches what the app - # does at 500 hours -- ask the user to clean the filter and reset it. - # - # The key stays 'filter_time' rather than becoming something like - # filter_time_elapsed: renaming it would change every existing unit's - # entity_id and unique_id for a wording improvement. - # - # (The alarm clearing on its own is the causal proof of the direction - # above: the board recomputes it the moment the counter crosses the - # threshold, rather than the app clearing counter and alarm separately.) - # - # --------------------------------------------------------------------- - # RESETTING THIS COUNTER: NOT SOLVED YET -- no reset entity here, and - # the notes below are so the next attempt starts from the evidence - # rather than from scratch. I could not work out how to drive the reset - # locally; that is not the same as it being impossible, and none of the - # results below rule out a mechanism I simply haven't found. - # - # What the reset actually is: a *command*, not a value write. Samsung - # models it as capability `custom.dustFilter`, command - # `resetDustFilter`, no arguments (implemented in several SmartThings HA - # forks; not in the core integration). That reframes every failed - # 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. Two units, opposite power states, to rule out the - # obvious confound: 5595 -> back to 5595 after 69 s (powered off, - # alarm active) and 1925 -> back to 1925 after 65 s (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, i.e. 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, <...>". I - # deliberately did not enumerate guessed action names 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: 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_ below - # is written through the very same options merge and kept. So writes - # work; this one value just isn't driven that way. - # - # Where I'd look next: the action vocabulary for /actions/vs/0 from an - # independent source (firmware image, or a capture of what the cloud - # sends the device), or 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. - # --------------------------------------------------------------------- + # Filter time in tenths of an hour, counting UP since last filter + # reset; scale and direction confirmed against the Samsung app and + # the /alarms/vs/0 threshold crossing (500h). No reset entity: no + # local write path has been found -- see + # docs/investigations/ac-filter-reset.md for what's been tried. SensorDesc( key="filter_time", rep_fn=_option_token_num("FilterTime", divisor=10), @@ -750,19 +527,10 @@ CLIMATE = Capability( state_class="measurement", icon="mdi:air-filter", ), - # The interval FilterTime_ is measured against -- the app offers it as a - # four-way radio (180/300/500/700 hours, default 500) next to the filter - # reminder. The token value is the hour count verbatim: captured by - # watching all 19 resources while the owner stepped through every radio - # position, one field changing per step and nothing else moving. - # - # Static options here, unlike air_filter_threshold on the newer boards - # which reads supportedFilterDesiredUsage: options[] tokens carry no - # supported-values list anywhere in this board's dump, so there is - # nothing to read them from. The four values are the app's own radio - # rather than a guess extrapolated from one observed value -- but a - # board offering different steps would need this revisited, which is - # why the tuple is documented rather than just written down. + # FilterTime_'s threshold, exposed as a static 4-way radio + # (180/300/500/700h, matching the app) since options[] tokens carry + # no supported-values list to read from, unlike air_filter_threshold + # on newer boards. SelectDesc( key="filter_alarm_time", rep_fn=lambda rep: _option_token(rep, "FilterAlarmTime"), @@ -773,9 +541,7 @@ CLIMATE = Capability( entity_category="config", ), # Odor-controller ("Smart Cool Clean") state + progress -- see - # _odor_controller_active's docstring for the cloud-capability - # correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's - # dump); gating is token presence only, not board generation. + # _odor_controller_active's docstring. BinarySensorDesc( key="odor_controller_active", rep_fn=_odor_controller_active, @@ -828,12 +594,8 @@ AUTO_CLEAN = Capability( {"x.com.samsung.da.settingStatus": "On" if p == "On" else "Off"}, ), ), - # The cycle, as opposed to the switch above -- `settingStatus` says the - # feature is enabled, which it is whether or not the unit is drying right - # now. `status` is the run state, and the resource advertises exactly - # Start/Stop in `supportedStatus`. Observed on a TP1X_DA-AC-CAC-01001: - # 'Start' with progress 98 while a cycle ran, then 'Stop' with progress 0 - # the moment it finished. + # Run state (vs settingStatus's "feature enabled"): status is + # Start/Stop per the resource's own supportedStatus. BinarySensorDesc( key="auto_clean_running", field="x.com.samsung.da.status", @@ -841,8 +603,7 @@ AUTO_CLEAN = Capability( entity_category="diagnostic", value_fn=lambda v: v == "Start", ), - # Percent through that cycle, matching the figure the appliance shows on - # its own display (checked against 55% mid-run). + # Percent through the cycle; matches the appliance's own display. SensorDesc( key="auto_clean_progress", field="x.com.samsung.da.progress", @@ -867,9 +628,7 @@ AIR_FILTER = Capability( icon="mdi:air-filter", entity_category="diagnostic", ), - # filterUsage is a lifetime hour counter that only resets on filter - # replacement -- total_increasing so HA's long-term statistics handle - # the reset rather than treating it as a bounded measurement. + # Lifetime hour counter, resets only on filter replacement. SensorDesc( key="air_filter_usage_hours", field="x.com.samsung.da.filterUsage", @@ -880,10 +639,8 @@ AIR_FILTER = Capability( entity_category="diagnostic", value_fn=_int, ), - # The alarm threshold (filterDesiredUsage) is a locally writable option: - # see _threshold_write. Surfaces as a Select only where the device - # advertises supportedFilterDesiredUsage; boards without that enum - # leave it unexposed rather than guess the valid set. + # Locally writable alarm threshold (see _threshold_write); only + # surfaces where supportedFilterDesiredUsage is advertised. SelectDesc( key="air_filter_threshold", field="x.com.samsung.da.filterDesiredUsage", @@ -912,9 +669,8 @@ AIR_FILTER = Capability( def _pm1_threshold_write(payload, rep, href=None): """Same contract as _threshold_write, against this filter's own href -- - not yet confirmed live (no dump seen advertises - supportedFilterDesiredUsage for this href), so the Select this backs - stays gated behind that field's presence, same as AIR_FILTER's.""" + not yet confirmed live, so the Select this backs stays gated behind + supportedFilterDesiredUsage's presence, same as AIR_FILTER's.""" return ["filter", "airdustPM1filter", "vs", "0"], { "x.com.samsung.da.filterDesiredUsage": payload, } @@ -924,15 +680,10 @@ def _has_filter_field(field): return lambda rep, resources: rep.get(field) is not None -# Second, PM1-rated dust filter some TP1X_FAC-class boards report alongside -# AIR_FILTER's /filter/airdustfilter/vs/0. TP1X_FAC_TIME_23K (issue #270) -# reports only filterCapacity/filterCapacityUnit/filterResetType -# ('notresetable') on this href, with no live filterUsage/filterStatus at -# all -- but the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191) reports -# the identical href with full live fields. So this stays a real capability -# rather than a blanket ignore, with every entity individually gated on its -# own field's presence per the 'don't guess' rule: no entity on a board that -# has nothing behind it, real readings on one that does. +# Second, PM1-rated filter some TP1X_FAC boards report alongside AIR_FILTER's +# href (issue #270). Some units report only the capacity/unit fields with no +# live data at all, so every entity here is individually gated on its own +# field's presence. AIR_FILTER_PM1 = Capability( href="/filter/airdustPM1filter/vs/0", poll_tier="cold", @@ -1001,9 +752,7 @@ DISPLAY_LIGHT = Capability( ), ) -# UV-C sterilization LED (issue #270, TP1X_FAC_TIME_23K). Same On/Off -# convention as everything else on this board, and (unlike VENTILATION_ALARM -# below) a real supportedModes list confirms the value set. +# UV-C sterilization LED (issue #270, TP1X_FAC_TIME_23K). UV_LED = Capability( href="/uvled/vs/0", poll_tier="cold", @@ -1022,12 +771,9 @@ UV_LED = Capability( ), ) -# Ventilation-reminder alarm toggle (issue #270). Only a bare `alarm` field -# is present -- no supportedModes list to confirm the value set against, -# unlike UV_LED above. Modeled as a switch on the strength of the same -# On/Off convention used everywhere else in this API; a rejected write is -# the worst case for a wrong guess (see the adding-device-support skill), -# but this hasn't been round-trip confirmed on real hardware. +# Ventilation-reminder alarm toggle (issue #270). No supportedModes list to +# confirm the value set against, unlike UV_LED above -- not round-trip +# confirmed on real hardware. VENTILATION_ALARM = Capability( href="/ventilation/setting/vs/0", poll_tier="cold", @@ -1046,9 +792,7 @@ VENTILATION_ALARM = Capability( ), ) -# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single -# boolean field, no vendor prefix, mirroring the On/Off convention used -# throughout the rest of this API. +# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000). MUTE_ONCE = Capability( href="/option/muteonce/vs/0", poll_tier="warm", @@ -1067,12 +811,9 @@ MUTE_ONCE = Capability( ), ) -# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation` -# toggles the limiter and `modes` picks a level out of `supportedModes` -# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the -# dump to confirm what the levels mean (amps vs. an abstract tier) -- -# exposed read-only per the 'don't guess' rule rather than risking an -# unverified write to live HVAC hardware. +# Circuit-breaker current-limit setting (issue #38, TP1X board). No unit/label +# in the dump to confirm what the levels mean -- exposed read-only per the +# 'don't guess' rule rather than risking an unverified write to live hardware. CURRENT_LIMIT = Capability( href="/electriccurrent/vs/0", poll_tier="cold", @@ -1093,14 +834,9 @@ CURRENT_LIMIT = Capability( ), ) -# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree -# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs -# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration -# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload -# alarm vs. automatic power throttling), but nothing in the dump confirms -# the exact behavioral difference between the two modes or whether writing -# 'operation' is safe on live HVAC hardware -- exposed read-only per the -# 'don't guess' rule, same precedent as CURRENT_LIMIT above. +# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree). No +# confirmation of the behavioral difference between modes -- read-only, same +# precedent as CURRENT_LIMIT above. ANOMALY_LOAD = Capability( href="/anomalyload/vs/0", poll_tier="cold", @@ -1125,20 +861,11 @@ ANOMALY_LOAD = Capability( ), ) -# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 -- -# Lennox-branded heat pump on the RAC board family): `status` is a bare -# On/Off boolean with no vendor prefix, the same shape already shipped -# writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY, -# DISPLAY_LIGHT) despite none of those having a live-confirmed write either -# -- worst case a wrong token no-ops, same risk profile as that family, so -# it's a switch rather than a sensor. `switchPowerSaveMode` picks the save -# intensity out of its own supportedSwitchPowerSaveMode list, but *what* -# writing it actually does to a running compressor isn't knowable from the -# dump -- same 'don't guess' read-only treatment as CURRENT_LIMIT/ -# ANOMALY_LOAD's mode fields. A third field, `motionState`, also carries a -# supportedMotionState list but its role (a live sensor readout vs. a -# sensitivity setting) isn't distinguishable from the dump, so it's left -# unmodeled entirely. +# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001). `status` +# is a bare On/Off with the same shape already shipped writable elsewhere in +# this file, so it's a switch despite no live-confirmed write. `mode` stays +# read-only: no dump evidence for what writing it does to a running +# compressor. ABSENCE_POWER_SAVING = Capability( href="/mds/absencepowersaving/vs/0", poll_tier="cold", @@ -1167,11 +894,8 @@ ABSENCE_POWER_SAVING = Capability( ), ) -# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING -# above on the same dump: `status` is the same bare On/Off shape, promoted to -# a switch for the same reason. `modes` (Direct/Indirect airflow out of -# `supportedModes`) stays read-only -- same reasoning as -# absence_power_saving_mode above. +# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING on +# the same dump; same shape and reasoning. MOTION_DETECT_WIND = Capability( href="/option/motiondetectwind/stateful/vs/0", poll_tier="cold", @@ -1200,12 +924,9 @@ MOTION_DETECT_WIND = Capability( ), ) -# The climate entity already surfaces current_temperature as a card -# attribute, but that's not enough for history graphs/automations/ -# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- -# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK -# pair: both share key='current_temperature_c' so only one ever binds -# (match_fn gates the vendor one off when the OCF resource is present). +# Standalone temperature sensor for history/automations (issue #75); the +# climate card only exposes current_temperature as an attribute. Shares key +# 'current_temperature_c' with the _VS variant below so only one ever binds. CURRENT_TEMPERATURE = Capability( href=HREF_TEMP_CURRENT, poll_tier="warm", @@ -1235,20 +956,10 @@ CURRENT_TEMPERATURE_VS = Capability( ), ) -# Only the vendor resource's `fivepercentHumidity` (current reading, rounded -# to the nearest 5%) has live data on the issue #75 dump -- its `humidity` -# field, and the OCF-standard /humidity/0 resource entirely, both read a -# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule -# (see _AC_IGNORED below). -# -# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and -# there the plain `humidity` field is not stuck: it carries a real reading -# (51%, matching the same unit's cloud integration at that moment) for as long -# as the Air monitoring option is on, which the unit itself switches back off -# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's -# fallback, and hence 0 reading as "not measuring" rather than 0%: on both -# board generations a zero here means no measurement, never dry air. -# /humidity/0 stayed 0 throughout that same observation too. +# fivepercentHumidity is the only live reading on most dumps; the OCF +# /humidity/0 resource and this vendor resource's own `humidity` field both +# read a stuck 0 where fivepercentHumidity is absent. See _humidity's +# docstring for the ARTIK051 fallback and its zero-as-"not measuring" quirk. HUMIDITY = Capability( href="/humidity/vs/0", poll_tier="warm", @@ -1263,26 +974,11 @@ HUMIDITY = Capability( ), ) -# /sensors/vs/0 items[] carry live air-quality readings. Removed from -# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is -# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar -# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others -# are 1- or 2-element arrays with no corroborating scalar, so they stay string -# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only -# v[0] is taken). No unit is advertised on the resource, so no device_class. -# -# exists_fn (_has_sensor_type) only proves the item *type* is listed, not -# that the unit actually carries that sensor: issue #166 (ARxxTXFCAWKNEU, -# board ARTIK051_PRAC_20K) reports all five item types on both its units, -# values permanently '0'/['0','0'] -- yet the reporter confirmed none apply -# to their model. A tighter existence gate was tried (requiring the -# corroborating cleanLevel scalar above) but doesn't hold up as a general -# rule -- see _has_sensor_type's docstring -- and risks silently dropping -# real readings on hardware that reports them without that scalar. So these -# stay bound whenever the type is listed, same as before #166, and disabled -# by default instead (same precedent as fridge.rack_count / -# cooktop.paired_hood_model / this file's own tropical_night_mode): units -# that do have the sensor can enable it themselves. +# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is +# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar, +# so it's a measurement; the others stay string diagnostics (see +# _sensor_item_value). All disabled by default: _has_sensor_type only proves +# the item type is listed, not that the sensor is real (see its docstring). AIR_QUALITY = Capability( href="/sensors/vs/0", poll_tier="cold", @@ -1318,95 +1014,54 @@ AIR_QUALITY = Capability( ) -# --------------------------------------------------------------------------- -# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate -# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. -# These are NOT in the global ignored.IGNORED because several of them -# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with -# other families' schemas. A no-entity Capability still marks the href as -# bound so discover() reports no coverage gap. -# -# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state -# (power, current/target temp, fan, swing, preset) -- the coordinator only -# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py), -# so leaving these at the Capability default of 'cold' meant every state -# change was invisible until the next full /device/0 summary sweep -# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to -# 'warm' -- same tier as CLIMATE's own primary href -- so they get push -# notifications (or, in poll-only mode, the warm sub-poll cadence) instead -# of waiting on the summary sweep. -# --------------------------------------------------------------------------- +# AC-scoped coverage: CLIMATE_CONSUMED_HREFS (read by the climate entity) +# plus vendor-duplicate / ambiguous / plumbing resources. These stay out of +# the global ignored.IGNORED because several collide with other families' +# schemas. A no-entity Capability still marks the href bound so discover() +# reports no gap. CLIMATE_CONSUMED_HREFS are pinned to 'warm' (rather than the +# Capability default of 'cold') so their state changes push instead of +# waiting on the ~30s full-summary sweep (issue #17). _AC_IGNORED = [ - # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor - # resource's usable fivepercentHumidity field instead; this OCF-standard - # one has no corresponding live value confirmed yet. - "/humidity/0", - # Presence-personalization plumbing (empty item list here). - "/personality/presence/vs/0", - # OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs - # {speedLevel, direction}, identical values). The climate entity reads and - # writes the vendor form, which is the one confirmed on hardware here -- - # note that air_purifier.py found the opposite ordering on its family, so - # neither form is reliable sight-unseen and this one stays unmodeled. - "/airflow/0", - # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no - # user-actionable state or no documented write contract, so per the - # 'don't guess' rule they are ignored rather than modeled. - # /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here -- - # see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in - # the by_type registry, both of which have a confirmed, cleanly - # modelable contract. + "/humidity/0", # OCF mirror, stuck at 0 on every dump seen + "/personality/presence/vs/0", # presence-personalization plumbing (empty) + "/airflow/0", # OCF mirror of /airflow/vs/0; vendor form is the one used + # TP1X/TP2X-class housekeeping / opaque blobs with no user-actionable + # state or documented write contract. /option/muteonce/vs/0 and + # /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and + # common.SELF_CHECK, both of which have a confirmed, modelable contract. "/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing "/aisleep/vs/0", # AI-sleep feedback state (no actionable control) "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap "/da/softreset/vs/0", # soft-reset trigger plumbing "/keepnormalstate/vs/0", # internal keep-normal flag - "/mds/absencemonitoring/vs/0", # motion-detection sensor plumbing (empty here) + "/mds/absencemonitoring/vs/0", # motion-detection sensor plumbing (empty) "/mds/absencestate/vs/0", # motion-detection state (empty here) "/remotedatacontrol/vs/0", # remote data-control session status - "/remotedeviceinfo/vs/0", # remote paired-device id list (empty didList here) - "/remotetemperature/vs/0", # external temp-sensor feed (unset on this unit) - # Manual airflow-step position (supportedModes Off/80/60/40/Power). - # Overlaps the /wind/direction swing control already on the climate card, - # and the meaning of the numeric steps vs. 'Power' isn't documented in the - # dump -- ignored per the 'don't guess' rule rather than modeled as a - # select whose write could confuse live HVAC hardware. + "/remotedeviceinfo/vs/0", # remote paired-device id list (empty here) + "/remotetemperature/vs/0", # external temp-sensor feed (unset here) + # Manual airflow-step position; overlaps the swing control already on the + # climate card, and the numeric-step meaning isn't documented. "/stepcontrol/vs/0", "/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob "/welcome/temperature/vs/0", # welcome-cooling plumbing - # System-AC-only (multi-indoor-subdevice commercial installs, e.g. - # A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation - # topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU - # info. Commissioning-time plumbing, not user-actionable appliance state. + # System-AC-only (multi-indoor-subdevice commercial installs, issue #52): + # opaque hex-encoded installation topology, not user-actionable state. "/sac/installationinfo/vs/0", - # Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing - # *and* a wall-mounted indoor subdevice over one shared local IP, e.g. - # TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id - # list, same "remote device ids, not user-actionable locally" role as - # /remotedeviceinfo/vs/0 above -- true whenever this field is redacted - # (the shipped airconditioner_fac_bora fixture) or absent. When it does - # carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture), - # registry/subdevices.py's enumerate_subdevices reads this same - # subdeviceIdList to reach the second indoor subdevice over this same - # connection instead -- see that module's Pattern B. + # Wind-Free 2-in-1 systems (issues #150/#153): paired-subdevice id list. + # registry/subdevices.py reads this same field to reach the second + # indoor subdevice when it's populated -- see that module's Pattern B. "/subdevices/vs/0", - # Undocumented single int (runningMode: 0 on every dump seen), no - # supported-values list to interpret it against -- 'don't guess'. - "/runn/vs/0", - # 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's - # ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain - # corroborating count of indoor subdevices on this connection. Confirmed - # read-only (a write attempt returned CoAP 4.00). Absent from - # /device/0's batch entirely -- registry.subdevices.enumerate_subdevices - # fetches it with its own RETRIEVE and folds it into the resources dict - # for diagnostics, which is why it needs an entry here rather than - # surfacing as an unbound-href gap on every board that has it. + "/runn/vs/0", # undocumented single int (runningMode: always 0 seen) + # 2-in-1/multi-indoor-subdevice systems (issue #177): confirmed read-only + # subdevice count. Fetched separately by + # registry.subdevices.enumerate_subdevices, hence the entry here rather + # than a coverage gap. "/multidevice/vs/0", ] # Built as bare no-entity caps; folded into the AC registry (not global). -# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE -# / CURRENT_TEMPERATURE_VS above already cover those two with real entities. +# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded -- CURRENT_TEMPERATURE / +# CURRENT_TEMPERATURE_VS above already cover those with real entities. COVERAGE = [ Capability(href=h, poll_tier="warm") for h in CLIMATE_CONSUMED_HREFS diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index fc78a02..56c9166 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -49,12 +49,10 @@ def wh_to_kwh(v): def parse_iso_utc(raw): - """ISO datetime defaulting to UTC when the string carries no timezone - of its own (this integration's convention for other bare ISO datetime - fields -- see washer.py's drum-clean-log comment). A few boards do - ship a 'Z'/offset suffix (fromisoformat parses that natively since - Python 3.11) -- only fill in UTC when parsing left the result naive, - rather than unconditionally overwriting whatever offset was parsed.""" + """ISO datetime defaulting to UTC when the string carries no timezone of + its own. A few boards ship a 'Z'/offset suffix already (fromisoformat + parses that natively since Python 3.11) -- only fill in UTC when parsing + left the result naive.""" if not raw: return None try: @@ -65,11 +63,8 @@ def parse_iso_utc(raw): def epoch_to_utc(value): - """Unix epoch seconds -> aware UTC datetime, for the boards that report a - bare epoch rather than the ISO string parse_iso_utc handles. Lived in - 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.""" + """Unix epoch seconds -> aware UTC datetime, for boards that report a + bare epoch rather than the ISO string parse_iso_utc handles.""" try: return datetime.fromtimestamp(float(value), tz=UTC) except (TypeError, ValueError, OSError): @@ -78,9 +73,8 @@ def epoch_to_utc(value): def filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. Several families - (AC, air purifier) report `filterUsage` as a raw count in - `filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain - value with a '%' unit would be wrong -- normalize to used/capacity. + report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of + a 500-hour capacity), so a plain value with a '%' unit would be wrong. Returns None when capacity is missing/zero.""" used = _num(rep.get("x.com.samsung.da.filterUsage")) 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"): """'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to `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 - instead of assuming one (see fridge.py's module docstring, issue #7).""" + which both read a per-device unit off a `/temperature*` resource + instead of assuming one (issue #7).""" raw = (raw or "").strip().upper() if raw.startswith("C"): return "°C" @@ -108,25 +102,17 @@ def _ml_to_l(v): def _active_alarm_codes(items): - """Join active alarm codes; skip retained rows Samsung leaves as Deleted, - and any code ending in '_OFF'. + """Join active alarm codes; skip retained rows Samsung leaves as + Deleted, and any code ending in '_OFF'. Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the - condition clears (see WD7000B diagnostics). Surface only live alarms so - HA doesn't stick on a stale ErrorCode. - - Samsung pre-populates this array with one row per alarm *type* the board - supports, each carrying its own '_OFF' placeholder code when that - alarm isn't firing -- confirmed across independent device families - (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF, - 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. + condition clears. Samsung also pre-populates this array with one row + per alarm *type* the board supports, each carrying its own + '_OFF' placeholder when that alarm isn't firing -- confirmed + across independent families. A firing alarm instead reports a plain, + unsuffixed code (FilterAlarm, DoorA_Opened, ...); issue #166 shows both + in one dump. Generalizes what range hood used to special-case as just + the literal 'ErrorCode_OFF' string. """ if not items or not isinstance(items, list): 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 merges them: match by prefix, replace if present, append if not. - Confirmed on real hardware (issue #54) that a write only needs to carry - the 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 - same fact: coordinator.async_send_command uses it to keep the - optimistic cache entry for the written href complete (every sibling - option still present) during the write-settle window, since the wire - body it applies straight to the cache no longer carries them.""" + Confirmed on hardware (issue #54) that a write only needs to carry the + changed token(s), not the whole array -- see laundry.option_write / + oven._option_write for the write side. coordinator.async_send_command + uses this read-side counterpart to keep the optimistic cache entry + complete during the write-settle window.""" merged = list(cached or []) for token in new_tokens or (): 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): """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 - counterpart of merge_options_field above, for the items[] shape instead - of the packed options[] shape. + x.com.samsung.da.id) into a cached items array -- the items[] + counterpart of merge_options_field above. - Confirmed on hardware that a write only needs to carry the array item - with the changed id plus the field(s) being changed; the device merges - the rest itself (same fact as the options[] case, different array -- - see airconditioner._climate_write's vendor temperature write). Fields + Confirmed on hardware that a write only needs to carry the item with + the changed id plus the field(s) being changed (see + airconditioner._climate_write's vendor temperature write). Fields within the matched item are merged, not replaced outright, so a 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 - `cached` is appended.""" + optimistic cache entry. An id with no match in `cached` is appended.""" merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])] for new_item in new_items or (): 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 -# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the -# life of a given board, so reading them from the /device/0 seed (no dedicated -# poll_tier) is enough. +# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for +# the life of a board, so reading it from the /device/0 seed is enough. _SETINFO_HREF = "/wm/setinfo/vs/0" _POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff" _WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC" def model_allows_power_on_off(resources: dict) -> bool: - """True unless firmware explicitly declares remote power on/off unsupported. - - `/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry - boards (washers/dryers): `/power/0` and `/power/vs/0` still report state, - but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps - the writable switch -- current behavior. - """ + """True unless firmware explicitly declares remote power on/off + unsupported. isModelSettingPowerOnOff is "false" on many laundry + boards: /power/0 and /power/vs/0 still report state, but CoAP writes + are ignored. Absent setinfo (non-laundry families) keeps the writable + switch.""" setinfo = resources.get(_SETINFO_HREF) if setinfo is None: return True @@ -222,13 +200,11 @@ def model_allows_power_on_off(resources: dict) -> bool: def model_setting_without_sc(resources: dict) -> bool: - """True when firmware declares settings writable without Smart Control. - - `/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers - that accept temperature/spin/cycle-option writes while remote control is - off. Cycle start/pause/stop still need Smart Control on those boards -- - the flag name is settings-specific, not a blanket remote-control bypass. - """ + """True when firmware declares settings writable without Smart + Control. isModelSettingWithoutSC is "true" on washers/dryers that + accept temperature/spin/cycle-option writes while remote control is + off; cycle start/pause/stop still need Smart Control on those + boards.""" setinfo = resources.get(_SETINFO_HREF) or {} 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): """Pull one reading out of a `/sensors/vs/0`-style items[] list -- each - item is `{type, value: [...]}`; `index` picks which slot of a possibly - multi-value reading to read (index 0 is the raw measurement on every - family seen so far). Shared by range_hood.AIR_QUALITY, - air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the - same resource shape against the same {type, sensor_type} keys.""" + item is `{type, value: [...]}`; `index` picks which slot to read + (index 0 is the raw measurement on every family seen so far). Shared + by range_hood.AIR_QUALITY, air_purifier.AIR_QUALITY, and + air_monitor.SENSORS, which all read the same resource shape.""" for item in items or (): if not isinstance(item, dict): continue @@ -262,26 +237,18 @@ def sensor_item_value(items, sensor_type, index=0): return None -# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control. -# -# These three controls exist as both a standard OCF resource (/power/0, -# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource -# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its -# firmware migrates onto the OCF standard model. Prefer the OCF-standard href -# when the device exposes it; the '-vs' href (a string-encoded duplicate for -# these three) binds only when the generic href is absent, via match_fn. Older -# firmware has only the '-vs' resource, so the pair is behaviour-identical to a -# 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. +# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote +# control: each exists as both a standard OCF resource (/power/0, +# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor +# resource (/power/vs/0, x.com.samsung.da.power), since Samsung advertises +# both while its firmware migrates onto the OCF standard model. Prefer the +# OCF-standard href when present; the '-vs' href binds only when it's +# absent, via match_fn. Older firmware has only the '-vs' resource. See +# the adding-device-support skill's "OCF-standard vs vendor" section. +# Every device registry lists both caps of each pair. POWER_GENERIC = Capability( 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", entities=( # Writable when firmware allows remote power; otherwise a read-only @@ -331,16 +298,14 @@ POWER_VS_FALLBACK = Capability( KIDS_LOCK_GENERIC = Capability( href="/kidslock/0", entities=( - # Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a - # SwitchDesc. SwitchDesc's `device_class='lock'` was never honored - # by HA (its switch platform only accepts 'outlet'/'switch'), - # leaving a plain switch whose 'On' state meant different things - # on different boards. As a BinarySensorDesc with `device_class='lock'`, - # both kids-lock surfaces read with the same polarity: 'On' means - # open/unlocked, per HA's lock device_class. The inversion in - # value_fn here (and in the fallback below) keeps the on-the-wire - # truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0 - # both mean kids lock NOT active) consistent with that polarity. + # Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183): HA's + # switch platform never honored SwitchDesc's device_class='lock' + # ('outlet'/'switch' only), leaving a plain switch whose 'On' meant + # different things on different boards. As a BinarySensorDesc with + # device_class='lock', both surfaces read with the same polarity + # ('On' = open/unlocked, per HA convention); value_fn here inverts + # the wire value to match (value=False on /kidslock/0 means kids + # lock is NOT active). BinarySensorDesc( 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", match_fn=lambda rep, resources: "/kidslock/0" not in resources, entities=( - # Read-only, not a SwitchDesc (issues #181/#183): the write side of - # this capability wrote 'Enable', a value no dump in the fixture - # corpus has ever reported back -- every one reports either 'Ready' - # or 'Run', so it was never a confirmed contract. #181's reporter - # confirmed this directly: writing the *correct* value ('Run') - # 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'. + # Read-only, not a SwitchDesc (issues #181/#183): the old write + # side wrote 'Enable', a value no dump ever reports back (every one + # is 'Ready' or 'Run'), and #181's reporter confirmed writing the + # correct value ('Run') still 4.05s -- genuinely read-only on this + # hardware. Polarity matches KIDS_LOCK_GENERIC ('On' = unlocked). BinarySensorDesc( key="child_lock", field="x.com.samsung.da.kidsLock", @@ -373,15 +333,11 @@ KIDS_LOCK_VS_FALLBACK = Capability( def remote_control_enabled(resources: dict) -> bool: """Single source of truth for the /remotectrl on/off signal, mirroring - REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence - below. Used both to render the read-only Smart Control binary_sensor - (via those two descriptors) and, from coordinator.async_send_command, - to block writes outright when remote control is off. Both hrefs are - poll_tier='warm' below so that gate reads recent state (subscribed - 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.""" + REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field precedence. Used both + to render the read-only Smart Control binary_sensor and, from + coordinator.async_send_command, to block writes when remote control is + off. True (assume enabled) when neither href is present -- most device + types don't report this capability at all.""" generic = resources.get("/remotectrl/0") if generic is not None: return bool(generic.get("value")) @@ -446,26 +402,21 @@ ALARMS = Capability( ), ) -# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and -# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500', -# unchanged across off/idle/running. clamp_power floors it to a misleading -# "0 W" that reads as a real idle measurement. Gate power_watts out when the -# sentinel is seen -- but only then, so a device reporting a real value (e.g. a -# 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. +# instantaneousPower is a dead field on DA_WM_-class laundry dumps and +# dishwashers: the literal sentinel '-500', unchanged across off/idle/ +# running. clamp_power would floor it to a misleading "0 W". Gate +# power_watts out when the sentinel is seen, but only then, so a device +# reporting a real value (e.g. a fridge's 93 W) still shows it (issue #6). _DEAD_INSTANTANEOUS_POWER = "-500" ENERGY_METER = Capability( href="/energy/consumption/vs/0", entities=( - # `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included): - # an explicit exists_fn otherwise bypasses it, which would drop the - # entity when /device/0 returns a not-yet-fetched stub. A genuinely - # empty {} rep is NOT a stub -- it's the device's confirmed (if empty) - # answer, so it falls through to the normal field/sentinel checks like - # any populated rep. On a populated rep, hide power only for the dead - # sentinel or an absent field. + # is_stub_rep(rep) keeps the stub carve-out (see + # entity._is_included): an explicit exists_fn otherwise bypasses + # it and would drop the entity when /device/0 returns a + # not-yet-fetched stub. A genuinely empty {} rep is NOT a stub, so + # it still falls through to the normal field/sentinel checks. SensorDesc( key="power_watts", field="x.com.samsung.da.instantaneousPower", @@ -493,11 +444,7 @@ ENERGY_METER = Capability( ), ), # cumulativeConsumption is a second, independently-varying running - # total alongside cumulativePower -- some fridges (issue #26) report - # 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. + # total some fridges (issue #26) report alongside cumulativePower. SensorDesc( key="power_energy_kwh", field="x.com.samsung.da.cumulativeConsumption", @@ -510,8 +457,8 @@ ENERGY_METER = Capability( ), ), # AI Energy Mode's lifetime savings estimate vs. an unoptimized - # baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ - # #27) and absent on others (issue #20/#26), unlike cumulativePower. + # baseline -- present on some models (issue #21/#27), absent on + # others (issue #20/#26). SensorDesc( key="energy_saved_kwh", 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 ), ), - # Monthly billing-cycle totals -- the completed prior month and the - # in-progress current month. Not ever-increasing (each resets at - # month boundary), so no state_class. + # Monthly billing-cycle totals -- completed prior month and + # in-progress current month. Not ever-increasing, so no state_class. SensorDesc( key="energy_last_month_kwh", field="x.com.samsung.da.monthlyConsumption", @@ -586,26 +532,21 @@ WATER_FILTER = Capability( ), ) -# AI energy-saving level -- '0' is off, and supportedAiLevel lists the -# additional level(s) the device offers ('1' meaning just "on" on most -# hardware, but multi-level boards have been reported). Verified cross-family: -# fridge (issue #21) and washer (issue #40) both expose this href. -# -# supportedAiLevel is a single-entry list on most captured hardware, where a -# select would offer only one real choice against an implicit "off" -- shown -# as a switch instead. '0' itself is never in supportedAiLevel but has been -# observed live as the off value of aiLevel, so the select synthesizes it -# back in as an explicit option rather than leaving no way to turn off. -# -# 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. +# AI energy-saving level -- '0' is off, supportedAiLevel lists the +# additional level(s) offered ('1' meaning just "on" on most hardware, +# multi-level on some). Verified cross-family: fridge (issue #21) and +# washer (issue #40). Most hardware's supportedAiLevel is a single-entry +# list, so a select there would offer only one real choice against an +# implicit "off" -- shown as a switch instead; '0' is never in +# supportedAiLevel but is the observed off value, so the select +# synthesizes it back in as an explicit option. No translation_key: +# aiLevel's values are plain digit strings, and select.py already renders +# an untranslated numeric string as-is. def _ai_energy_supported_levels(rep): - """supportedAiLevel as a list -- a stray scalar (e.g. a string) must not - be len()-checked as if it were a list.""" + """supportedAiLevel as a list -- a stray scalar must not be + len()-checked as if it were one.""" sl = rep.get("supportedAiLevel") return list(sl) if isinstance(sl, (list, tuple)) else [] @@ -630,21 +571,15 @@ AI_ENERGY_LEVEL = Capability( poll_tier="cold", entities=( # No is_stub_rep carve-out on either side, unlike most exists_fn - # gates in this file -- entity creation only ever runs once, against - # whichever snapshot happens to be current the moment platforms are - # set up (see entity._is_included / __init__.py's - # async_config_entry_first_refresh-before-forward-entry-setups - # ordering), while flatten() re-evaluates exists_fn every poll - # against live data. Both descriptors share key='ai_energy_level', - # so if a stub carve-out let one of them win at setup time while the - # other wins once real data lands, flatten() would feed the - # instantiated entity a value shaped for the other platform (e.g. a - # 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. + # gates in this file: entity creation runs once against whichever + # snapshot is current at platform setup, while flatten() re-checks + # exists_fn every poll against live data. Both descriptors share + # key='ai_energy_level' -- a stub carve-out could let one win at + # setup and the other win once real data lands, feeding the + # instantiated entity a value shaped for the other platform. + # Requiring populated data on both sides keeps the two decisions in + # permanent agreement, at the cost of the entity not appearing + # until a reload if the first poll stubs this cold-tier href. SwitchDesc( key="ai_energy_level", field="aiLevel", @@ -720,37 +655,27 @@ SELF_CHECK = Capability( ), ) -# --------------------------------------------------------------------------- # Cross-family bundles, unpacked into every by_type registry's _build([...]) -# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER). -# discover() only binds a capability whose href is actually present in a -# given device's resource dump, so listing one here for a family that -# doesn't expose the href is a no-op, not a phantom entity -- see the -# adding-device-support skill's coverage-discipline section. +# call the same way ignored.IGNORED is. discover() only binds a capability +# whose href is actually present in a given device's dump, so listing one +# here for a family that doesn't expose the href is a no-op, not a phantom +# entity -- see the adding-device-support skill's coverage-discipline +# section. # -# UNIVERSAL holds every capability with no known family that both (a) has -# the href and (b) needs to model it some other way -- broadening one of -# 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). +# UNIVERSAL holds every capability with no known family that both has the +# href and needs to model it some other way. # -# POWER is kept separate -- airconditioner is the one family that opts out -# of it entirely. Canonical reason (see by_type/airconditioner.py and its -# test for pointers back here, not restatements): AC's climate entity -# already owns /power/0 and /power/vs/0 via bare, no-entity Capability -# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ -# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href -# 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. +# POWER is kept separate: airconditioner opts out of it entirely, since +# its climate entity already owns /power/0 and /power/vs/0 via bare +# no-entity Capability objects (airconditioner.COVERAGE), and a second +# real cap on the same href would make _build() raise (see +# by_type/airconditioner.py). Kids-lock/remote-control have no such +# conflict, so they stay in UNIVERSAL. # -# Airconditioner also partially opts out of UNIVERSAL itself, not just -# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by -# board generation, so by_type/airconditioner.py excludes just that one -# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`) -# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in -# its place -- every other registry still unpacks UNIVERSAL wholesale. -# --------------------------------------------------------------------------- +# Airconditioner also partially opts out of UNIVERSAL itself: issue #193 +# needs ENERGY_METER's cumulativePower scale to differ by board +# generation, so by_type/airconditioner.py excludes just that one member +# and substitutes its own ENERGY_METER_GENERIC/ENERGY_METER_LEGACY. UNIVERSAL = ( ALARMS, diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py index 944d62b..86c043e 100644 --- a/custom_components/localthings/registry/capabilities/dehumidifier.py +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -15,8 +15,8 @@ from .common import int_or_none def _first_mode(rep): """Representative scalar for the operating-mode select. `modes` is a - single-element list on every dump seen so far, mirroring - airconditioner._first_mode's handling of the same field shape.""" + single-element list on every dump seen, mirroring + airconditioner._first_mode.""" modes = rep.get("x.com.samsung.da.modes") if isinstance(modes, (list, tuple)): 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 -# equivalent of a thermostat setpoint). No min/max range field is present in -# any dump seen so far -- native_min/native_max are deliberately left unset -# so the number entity falls back to HA's own 0-100 default, the natural -# bound for a percentage field, rather than a bound guessed from one unit's -# 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. +# Target humidity is this device's primary control (issue #88's equivalent +# of a thermostat setpoint). No min/max range field is present in any dump +# seen, so native_min/native_max are left unset, falling back to HA's own +# 0-100 default rather than a bound guessed from one unit's spec sheet. +# Step comes live from the device's own `increment` field. HUMIDITY = Capability( href="/humidity/vs/0", poll_tier="warm", @@ -77,15 +74,13 @@ HUMIDITY = Capability( ), ) -# Water-tank ambient light (issues #271/#231, TP1X_DA_AC_DHM_01001_0000): -# on/off, color, and brightness are three independent controls on this one -# resource. `waterfullAlarmStatus` differs between the two dumps that -# reported this href (On vs. Off) so it's a real live flag, not a constant -- -# but its exact meaning (tank actually full vs. the chime feature merely -# enabled) isn't confirmed by either dump alone, and /alarms/vs/0's -# alarm_code already surfaces a live WaterTankFull condition when one fires -# (see common._active_alarm_codes), so this is exposed read-only as a plain -# diagnostic value rather than guessed at as a binary_sensor. +# Water-tank ambient light (issues #271/#231): on/off, color, and +# brightness are three independent controls on this one resource. +# `waterfullAlarmStatus` differs between the two dumps that reported this +# href, so it's a real live flag, but its exact meaning (tank full vs. the +# chime feature merely enabled) isn't confirmed, and /alarms/vs/0 already +# surfaces a live WaterTankFull condition -- exposed read-only as a plain +# diagnostic rather than guessed at as a binary_sensor. WATERTANK_LIGHTING = Capability( href="/watertank/lighting/vs/0", poll_tier="cold", @@ -131,13 +126,10 @@ WATERTANK_LIGHTING = Capability( ), ) -# --------------------------------------------------------------------------- -# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state -# or no documented write contract, following the same 'don't guess' rule as -# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in -# the global ignored.IGNORED since some of these hrefs collide with other -# families' schemas. -# --------------------------------------------------------------------------- +# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable +# state, following the same rule as airconditioner._AC_IGNORED (same +# DA_AC_ board family). Not in the global ignored.IGNORED since some hrefs +# collide with other families' schemas. _DHM_IGNORED = [ "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM) "/da/softreset/vs/0", # soft-reset trigger plumbing diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index dff15b0..e47a3f3 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -41,26 +41,16 @@ DISHWASHER_SETTINGS = Capability( ), ) -# --------------------------------------------------------------------------- -# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the -# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the -# same options array (shared laundry.bool_option_switch, same options[] -# 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). +# /course/vs/0 -- cycle selection (shared laundry.cycle_select) plus the +# dishwasher-only StormWashZone / AutoDoorRelease toggles riding in the same +# options array (shared laundry.bool_option_switch). Course display names +# live in translations under entity.select.dishwasher_cycle. # -# '83'/'86' were transposed in that catalog until issue #226: both the -# original DW9000F-class fixture this table was built from and the issue -# #226 reporter's board report the identical DeviceType_0812 (a real -# per-board-generation id also seen on unrelated washer/dryer fixtures, so -# this is one shared course table, not a Table_02/Table_03-style generation -# 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. -# --------------------------------------------------------------------------- +# '83'/'86' were transposed in that catalog until issue #226: the original +# fixture's own live editCourseList puts them back to back, 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) settled it: '86' is Express 60, '83' is Normal. CYCLE_OPTIONS = Capability( href="/course/vs/0", diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 592ad79..8f2b988 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -38,27 +38,19 @@ DRYER_SETTINGS = Capability( ) # /course/vs/0 -- cycle selection, shared with washer/dishwasher via -# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as -# an RMW on the options array). Course display names live in translations -# under entity.select.dryer_cycle (Table_03, DV5000-class, captured -# 2026-05-29). Codes '01' Normal and '06' Time dry were confirmed on a -# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical -# appliance and reading back the raw code from the entity's state (issue -# #80). Codes '51' Eco Cotton, '53' AI Dry+, and '4e' Self Dry were -# 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. +# laundry.cycle_select. Course display names live in translations under +# entity.select.dryer_cycle (Table_03, DV5000-class). Codes '01' Normal and +# '06' Time dry were confirmed on a DVE50A8600V/A3 by selecting each cycle +# on the appliance and reading back the raw code (issue #80); '51' Eco +# Cotton, '53' AI Dry+, and '4e' Self Dry the same way on a DV90DG6845LHU5 +# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course +# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers. # # Drum Clean+ maintenance tracking (issue #258) reuses washer.py's # DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same # options[] array -- see laundry.drum_clean_cycles_remaining/ -# drum_clean_last_cleaned's docstrings for the field contract, including -# the dryer-specific '|'-joined multi-entry DrumCleanLog_ shape. No -# 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, +# drum_clean_last_cleaned. No separate heat-exchanger-clean tracking was +# found on either dump #258 supplied, so if the app surfaces that reminder, # it isn't computed from anything this integration can read locally. DRYER_COURSE = Capability( href="/course/vs/0", diff --git a/custom_components/localthings/registry/capabilities/ehs.py b/custom_components/localthings/registry/capabilities/ehs.py index 5a284e6..e5c553f 100644 --- a/custom_components/localthings/registry/capabilities/ehs.py +++ b/custom_components/localthings/registry/capabilities/ehs.py @@ -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 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- -temperature setpoint, not a thermostat with HVAC modes airconditioner.py's -climate.py would fit), so it stays switch/select/number/sensor -- same shape -as dehumidifier.py's power/mode/humidity split. dhw is a real HA -water_heater.py -- see DHW below and water_heater.py's module docstring -- +zone1 has no HA platform with matching semantics (a leaving-water- +temperature setpoint, not a thermostat with HVAC modes), so it stays +switch/select/number/sensor -- same shape as dehumidifier.py's power/mode/ +humidity split. dhw is a real HA water_heater.py entity (see DHW below), following the same primary-resource-plus-sibling-reads pattern as airconditioner.py's CLIMATE/climate.py. @@ -35,8 +34,7 @@ def _num(v): def _first_mode(rep): """Representative scalar for a mode select -- `modes` is a single-element - list on every dump seen so far, mirroring airconditioner._first_mode / - dehumidifier._first_mode's handling of the same field shape.""" + list on every dump seen, mirroring airconditioner._first_mode.""" modes = rep.get("x.com.samsung.da.modes") if isinstance(modes, (list, tuple)): return modes[0] if modes else None @@ -48,14 +46,9 @@ def _temp_unit(rep): def _bounds(rep, default_min, default_max): - """The resource's own (minimum, maximum) pair, or the defaults. - - Both ends together or neither -- a board reporting only one would - 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. - """ + """The resource's own (minimum, maximum) pair, or the defaults. Both + ends together or neither -- a board reporting only one would otherwise + pair a real bound with an invented default, silently wrong.""" lo = _num(rep.get("x.com.samsung.da.minimum")) 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) @@ -102,8 +95,8 @@ ZONE_MODE = Capability( ) # 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 -# is leaving-water-temperature-based, same convention as the dhw loop below. +# room setpoint, not a literal water temperature -- EHS zone control is +# leaving-water-temperature-based, same convention as the dhw loop below. ZONE_TEMPERATURE = Capability( href="/temperatures/indoor/vs/0", poll_tier="warm", @@ -134,12 +127,10 @@ ZONE_TEMPERATURE = Capability( ), ) -# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE -# via DHW below and reads the sibling power/temperature hrefs off the -# coordinator snapshot -- same primary-plus-siblings shape as -# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here -# 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. +# Canonical dhw resource hrefs. water_heater.py binds HREF_DHW_MODE via DHW +# below and reads the sibling power/temperature hrefs off the coordinator +# snapshot -- same primary-plus-siblings shape as airconditioner.py's +# HREF_MODE/CLIMATE_CONSUMED_HREFS. HREF_DHW_POWER = "/power/dhw/vs/0" # on/off HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation 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): """Map a (kind, value) command from the water_heater platform to the (path_segs, body) for that one sub-write -- same contract as - airconditioner._climate_write, just across the dhw loop's three - resources instead of the AC's power/mode/temperature/wind set.""" + airconditioner._climate_write, across the dhw loop's three resources.""" kind, value = payload if kind == "power": 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 # 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] # Deliberately a plain config switch, not water_heater's AWAY_MODE feature. -# HA core's smartthings water_heater does wire this same Samsung capability -# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW -# operation-mode map above is taken from that integration -- so the -# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one -# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped -# 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. +# HA core's smartthings integration wires this same Samsung capability up +# to WaterHeaterEntityFeature.AWAY_MODE, so the divergence is worth +# stating: /option/outgoing/vs/0 is device-wide (one `away` flag covering +# zone1 too, with no dhw-scoped sibling href). Hanging it off the DHW card +# would present a device-wide setting as hot-water-only. AWAY_MODE = Capability( href="/option/outgoing/vs/0", poll_tier="cold", @@ -205,13 +191,9 @@ AWAY_MODE = Capability( ), ) -# --------------------------------------------------------------------------- -# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/ -# schedule blobs) or resources with no confirmed write contract on this -# 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-scoped coverage: opaque vendor plumbing or resources with no +# confirmed write contract on this dump. Not in the global ignored.IGNORED +# since these are EHS-only shapes needing their own verification elsewhere. _EHS_IGNORED = [ "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS) "/da/softreset/vs/0", # soft-reset trigger plumbing diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 42a827d..a1caa0d 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -3,17 +3,15 @@ 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 -dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"), -but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the -exact same resources — the device tells you which one it is, it's just never -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. +dump reports Fahrenheit, but a TP1X_REF_21K dump (issue #7) reports the same +fields in Celsius for the exact same resources -- the device tells you which +one it is. See `_temp_unit`/`_temp_item_unit` below. -Multi-instance note: the two door resources (/door/cooler/0 and -/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and -/icemaker/two/vs/0) use named path segments, so they are modelled via -pattern capabilities that auto-derive distinct entity keys from href segments. +Multi-instance note: the two door resources (/door/cooler/0, +/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0, +/icemaker/two/vs/0) use named path segments, so they are modeled via +pattern capabilities that auto-derive distinct entity keys from href +segments. """ import datetime @@ -30,10 +28,8 @@ from ..entities import ( from .common import normalize_temp_unit # Display names for the beverage zone, flex zone, ice type, and -# ice-making-status enums below live in translations/en.json, -# keyed by the lowercased raw device value — select.py and SensorDesc.options -# normalize to lowercase for HA's translation lookup and map back to this -# original casing before writing to the device. +# ice-making-status enums below live in translations/en.json, keyed by the +# lowercased raw device value. def _int(v): @@ -44,14 +40,13 @@ def _int(v): def _temp_unit(rep): - """'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to - °F (this module's original assumption) if the device omits the field.""" + """'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults + to °F if the device omits the field.""" return normalize_temp_unit(rep.get("units")) -# --------------------------------------------------------------------------- -# Temperature (generic — covers /temperature/current/* and /temperature/desired/*) -# --------------------------------------------------------------------------- +# Temperature (generic -- covers /temperature/current/* and +# /temperature/desired/*) TEMP_CURRENT_GENERIC = Capability( href=None, @@ -74,14 +69,10 @@ TEMP_CURRENT_GENERIC = Capability( def _temp_setpoint_write(p, rep, href=None, resources=None): - """Write temperature — prefer vendor /temperatures/vs/0 when available, - fall back to direct OCF /temperature/desired/ write otherwise. - - Samsung fridges expose both OCF-standard /temperature/desired/* and vendor - /temperatures/vs/0. On some models only the vendor path commits the change; - on others both work. Using the vendor path when present is always correct. - Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler. - """ + """Prefer vendor /temperatures/vs/0 when present, else the direct OCF + /temperature/desired/ write -- on some models only the vendor path + commits. Item IDs follow the Samsung convention: "0" = Freezer, + "1" = Fridge/Cooler.""" if not href: return None if resources and "/temperatures/vs/0" in resources: @@ -127,18 +118,12 @@ TEMP_SETPOINT = Capability( ), ) -# --------------------------------------------------------------------------- -# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only") -# fridges report no /temperature/current|desired/* pair at all (this href -# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/' -# or '/temperature/desired/' prefixes), only this one vendor resource that -# bundles the live desired value together with the *specific* values the -# 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. -# --------------------------------------------------------------------------- +# Discrete cooler setpoint (issue #186): single-door "cooler only" fridges +# report no /temperature/current|desired/* pair, only this vendor resource +# bundling the live desired value with the specific values the unit +# accepts. supportedList (e.g. ['1','2','3','4','7']) is not a contiguous +# range, so this is a select reading its own live options rather than a +# NumberDesc with min/max/step. 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 -# fridge/freezer combo reporting no /temperature/current|desired/freezer -# 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']). +# Freezer half of the same discrete-setpoint pattern (issue #229) -- same +# shape as DEFINITE_TEMPERATURE_COOLER, negative supportedList values. DEFINITE_TEMPERATURE_FREEZER = Capability( href="/temperature/definite/freezer/vs/0", poll_tier="warm", @@ -191,10 +173,6 @@ DEFINITE_TEMPERATURE_FREEZER = Capability( ), ) -# --------------------------------------------------------------------------- -# Icemaker nighttime quiet mode -# --------------------------------------------------------------------------- - ICEMAKER_NIGHTTIME = Capability( href="/icemaker/nighttime/vs/0", poll_tier="warm", @@ -213,18 +191,13 @@ ICEMAKER_NIGHTTIME = Capability( ), ) -# --------------------------------------------------------------------------- -# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0) -# /icemaker/status/vs/0 is kept as exact-href cap and binds first. +# Icemaker (generic -- covers /icemaker/one/vs/0, /icemaker/two/vs/0). +# /icemaker/status/vs/0 is an exact-href cap and binds first; # /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state). -# # Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE", -# "ICE_BITES") -- read via name_field, reaching the translated name as the -# {instance_name} placeholder -- not the href's "one"/"two" segment. These two -# ice makers are independent on/off toggles that can both be enabled at once -# (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. -# --------------------------------------------------------------------------- +# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these +# two makers can both be enabled at once (issue #27), so they stay +# separate entities rather than one ice-type select. def _icemaker_write(field): @@ -273,10 +246,6 @@ ICEMAKER_GENERIC = Capability( ), ) -# --------------------------------------------------------------------------- -# Door alert tone -# --------------------------------------------------------------------------- - DOOR_ALERT = Capability( href="/settings/sound/alert/door/vs/0", poll_tier="warm", @@ -296,10 +265,6 @@ DOOR_ALERT = Capability( ), ) -# --------------------------------------------------------------------------- -# Status/lock — auto door opener and fridge sound -# --------------------------------------------------------------------------- - def _status_lock_write(field): 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. /defrost/block/vs/0 is an unrelated, independently-varying -# status: despite its "block" naming (originally assumed to mean "defrost -# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the -# defrost cycle is *actively running* right now, seen with defrost_delay -# 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. /defrost/block/vs/0 is unrelated: despite the "block" naming, +# live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively +# running right now (seen with defrost_delay off) -- "block" refers to the +# evaporator/coil block being defrosted, not a prevention state. DEFROST_DELAY = Capability( href="/defrost/delay/vs/0", @@ -362,10 +320,9 @@ DEFROST_DELAY = Capability( ), ) -# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K -# firmware publishes the same state on both hrefs, but only the vendor resource -# above has a confirmed write contract. Bind the native mirror without another -# entity so discovery records it as an intentional duplicate. +# OCF-native boolean mirror of DEFROST_DELAY -- only the vendor resource +# above has a confirmed write contract, so bind this without another +# entity to record it as an intentional duplicate. DEFROST_DELAY_NATIVE_DUPLICATE = Capability( href="/defrost/delay/0", ) @@ -384,10 +341,6 @@ DEFROST_BLOCK_STATUS = Capability( ), ) -# --------------------------------------------------------------------------- -# Refrigeration modes (rapid cooling) -# --------------------------------------------------------------------------- - def _refrigeration_write(field_name): def _write(p, rep, href=None): @@ -421,10 +374,6 @@ REFRIGERATION = Capability( ), ) -# --------------------------------------------------------------------------- -# Autofill -# --------------------------------------------------------------------------- - def _autofill_write(p, rep, href=None): if p not in ("On", "Off"): @@ -447,10 +396,6 @@ AUTOFILL = Capability( ), ) -# --------------------------------------------------------------------------- -# Welcome lighting (proximity-triggered cabinet light) -# --------------------------------------------------------------------------- - WELCOME_LIGHTING = Capability( href="/proximity/vs/0", poll_tier="warm", @@ -469,14 +414,11 @@ WELCOME_LIGHTING = Capability( ), ) -# --------------------------------------------------------------------------- -# Enhanced cabinet light — nighttime lighting schedule -# -# night.starttime is an ISO datetime; only the time portion is meaningful. -# night.duration.minute encodes the window length. End time is derived so -# both time entities write back to the same resource without stepping on each -# other: writing start preserves duration; writing end recalculates duration. -# --------------------------------------------------------------------------- +# Enhanced cabinet light nighttime schedule: night.starttime is an ISO +# datetime (only the time portion matters), night.duration.minute is the +# window length. End time is derived so both time entities write back to +# the same resource without stepping on each other: writing start +# preserves duration; writing end recalculates it. _NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100") @@ -600,10 +542,6 @@ CABINET_LIGHT_ENHANCED = Capability( ), ) -# --------------------------------------------------------------------------- -# Cabinet light -# --------------------------------------------------------------------------- - def _cabinet_light_write(p, rep, href=None): if p not in ("On", "Off"): @@ -638,10 +576,6 @@ CABINET_LIGHT = Capability( ), ) -# --------------------------------------------------------------------------- -# Sabbath mode -# --------------------------------------------------------------------------- - def _sabbath_write(p, rep, href=None): if p not in ("On", "Off"): @@ -664,10 +598,6 @@ SABBATH = Capability( ), ) -# --------------------------------------------------------------------------- -# Beverage zone -# --------------------------------------------------------------------------- - def _bzone_write(p, rep, href=None): return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p} @@ -689,16 +619,11 @@ BEVERAGE_ZONE = Capability( ), ) -# --------------------------------------------------------------------------- # Pantry / Cool Select Zone -- a convertible compartment toggled between -# wine/deli/drinks temperature presets (issue #20). Same shape as -# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes -# list) but a distinct resource/field set -- x.com.samsung.da.mode / -# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than -# 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. -# --------------------------------------------------------------------------- +# wine/deli/drinks presets (issue #20). Same shape as BEVERAGE_ZONE but a +# distinct field set (x.com.samsung.da.mode/supportedOptions vs +# roomDesiredMode/roomSupportedModes). Only a "one" instance seen; not +# generalized to a pattern cap until a second instance turns up. 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) -# -# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the -# flex-zone entry is whichever item is also a member of supportedOptions -- -# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker) -# never appear there. The prefix on that item varies by fridge family -# (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. -# --------------------------------------------------------------------------- +# 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 +# other flags, WATERFILTER_*/DEFROST_BLOCK_*/CVN_*_ZONE, never do). The +# prefix on that item varies by family (CV_TTYPE_RF9000A_ vs CV_FDR_ on +# Bespoke, issues #27/#26), so match by list membership instead of a +# hardcoded prefix. Write replaces only that item. def _flex_zone_supported(rep): @@ -740,10 +660,9 @@ def _flex_zone_supported(rep): def _flex_zone_current(rep): - # Every dump seen has at most one modes/supportedOptions overlap, so - # "first match" and "strip all matches" (in the write below) agree. If a - # future device ever reports two, this reads the first and the write - # would drop both -- revisit if that turns up. + # Every dump seen has at most one modes/supportedOptions overlap; a + # future device reporting two would read the first and the write below + # would drop both. modes = rep.get("x.com.samsung.da.modes") or [] supported = _flex_zone_supported(rep) return next((m for m in modes if m in supported), None) @@ -767,15 +686,11 @@ FLEX_ZONE = Capability( entity_category="config", options_field="x.com.samsung.da.supportedOptions", # A nonempty supportedOptions alone isn't sufficient: the - # kimchi-refrigerator family (issue #26) also populates - # /mode/vs/0's modes/supportedOptions with real data, but - # its tokens carry a "_[n]:[n]" parameter suffix on - # supportedOptions that modes never repeats, so no item - # ever overlaps -- the RF9000/Bespoke-class overlap this - # capability was built for never happens there. Require an - # actual resolvable value instead of just a populated - # list, so this stays absent on that family rather than - # showing a select permanently stuck on "unknown". + # kimchi-refrigerator family (issue #26) also populates both + # fields, but its tokens carry a "_[n]:[n]" suffix on + # supportedOptions that modes never repeats, so nothing ever + # overlaps there. Require an actual resolvable value so this + # stays absent on that family instead of stuck on "unknown". exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None, rep_fn=_flex_zone_current, write_fn=_flex_zone_write, @@ -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): """Most /door/* resources report bare `openState`, but the - ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83) - reports the vendor-prefixed `x.com.samsung.da.openState` instead. This - 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.""" + ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, + #83) reports `x.com.samsung.da.openState` instead -- check both.""" v = rep.get("openState") if v is None: 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 -# units, issue #26) -- top/middle/bottom each report their own storage mode -# plus a ripening status/timer on /status/kimchi//vs/0, all three in -# an identical shape; modeled as a pattern capability the same way -# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment -# key and {instance_name} from the href's top/middle/bottom segment. Only -# the top compartment's door has been seen reported separately (kimchidoors); -# middle/bottom apparently have no contact switch of their own, so that's -# its own narrower pattern cap rather than assumed universal. +# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment +# units, issue #26): top/middle/bottom each report their own storage mode +# plus a ripening status/timer on /status/kimchi//vs/0, modeled as a +# pattern capability the same way DOOR_GENERIC is. Only the top +# compartment's door is reported separately (kimchidoors); middle/bottom +# apparently have no contact switch, hence the narrower KIMCHI_DOOR_GENERIC +# below rather than assuming it's universal. # -# The same state is also mirrored -- packed into single tokens like -# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with -# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads -# for RF9000-class fridges. /status/kimchi//vs/0's plain currentMode/ -# supportMode fields are unpacked and self-describing, so that's what this -# binds to instead. +# The same state is also packed into single tokens (e.g. +# "KIMCHIT_KIMCHI_STORAGE_NORMAL") on /mode/vs/0, the resource FLEX_ZONE +# reads for RF9000-class fridges -- this binds to /status/kimchi// +# vs/0's plain, self-describing currentMode/supportMode instead. # -# Write path is unconfirmed (no live write against a real unit) -- same -# "write the same field back to the entity's own href" convention as -# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test. +# Write path is unconfirmed on real hardware; same "write the field back to +# the entity's own href" convention as PANTRY_ZONE/BEVERAGE_ZONE. # -# translations/en.json's kimchi_zone_mode state labels were translated -# directly from the reporter's own (Korean-language) SmartThings app -# screenshots, not guessed from the codes or from their English paraphrase. -# Cross-checking the screenshots against supportMode confirms the on-screen -# option order matches the array order everywhere it's verifiable: the top -# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at -# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the -# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry -# ripening list, and 4-entry custom-storage list each line up 1:1 with their -# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a -# colder or warmer preset around the NORMAL setpoint) everywhere that suffix -# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants, -# which weren't directly screenshotted but share the same NORMAL/COLD/WARM -# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy") -# and BUY (구입, "purchased") are also confirmed exact matches, not -# abbreviation guesses. -# --------------------------------------------------------------------------- +# translations/en.json's kimchi_zone_mode labels were translated directly +# from the reporter's own Korean SmartThings app screenshots (not guessed), +# and cross-checked against supportMode order to confirm the on-screen +# option order matches the array order throughout -- so COLD/WARM +# consistently means Strong/Weak everywhere that suffix appears. def _kimchi_mode_write(p, rep, href=None): @@ -896,9 +788,8 @@ KIMCHI_ZONE = Capability( icon="mdi:timer-sand", translation_key="kimchi_ripening_remaining", entity_category="diagnostic", - # No dump has this nonzero (ripeStatus is always "Off" so - # far) -- device-reported unit unconfirmed, so this stays - # a bare number rather than asserting minutes or hours. + # No dump has this nonzero (ripeStatus is always "Off" so far) + # -- unit unconfirmed, so this stays a bare number. value_fn=_int, ), SensorDesc( @@ -921,12 +812,11 @@ KIMCHI_DOOR_GENERIC = Capability( poll_tier="hot", entities=( # Not deduped against DOORS_FALLBACK below: on the one reporter - # (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the - # /doors/vs/0 aggregate carries a single generic item (id "4", no - # /door/ siblings for DOORS_FALLBACK's match_fn to see) - # that doesn't share this compartment's "top" instance numbering -- - # a distinct main-cabinet door, not this kimchi drawer's own contact - # switch reported twice. + # this binds alongside, /doors/vs/0's aggregate carries a single + # generic item (id "4", no /door/ siblings) that doesn't + # share this compartment's "top" numbering -- a distinct + # main-cabinet door, not this drawer's contact switch reported + # twice. BinarySensorDesc( key="open", rep_fn=_door_open_state, @@ -937,19 +827,12 @@ KIMCHI_DOOR_GENERIC = Capability( ), ) -# --------------------------------------------------------------------------- -# Aggregate-resource fallbacks -# -# /doors/vs/0, /temperatures/vs/0, and /icemaker/status/vs/0 each duplicate -# information exposed more precisely by per-instance hrefs (DOOR_GENERIC, -# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware -# 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. -# --------------------------------------------------------------------------- +# Aggregate-resource fallbacks: /doors/vs/0, /temperatures/vs/0, and +# /icemaker/status/vs/0 each duplicate information the per-instance hrefs +# above expose more precisely, on hardware that has them -- not every +# fridge does. Each fallback's match_fn checks for the richer sibling +# hrefs and only binds when they're absent, so it's a no-op wherever the +# richer hrefs exist and a real (coarser) source where they don't. def _any_door_generic(resources): @@ -1045,23 +928,19 @@ ICEMAKER_STATUS_FALLBACK = Capability( ), ) -# 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 -# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so -# keep the proven per-unit/vendor controls and bind this as a duplicate only. +# 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 hrefs; its write contract isn't advertised, so bind it as a +# duplicate only. ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( href="/icemaker/status/0", ) -# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out -# in this section: its three fields duplicate two *different* richer -# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and -# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a -# single capability-level match_fn can't express it. Gated per-entity -# (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. +# OCF-native /refrigeration/0 (issue #7): its three fields duplicate two +# different richer hrefs (REFRIGERATION's rapidFridge/rapidFreezing, +# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so +# gating is per-entity (exists_fn) rather than one capability-level +# match_fn. No write path confirmed, so these stay read-only. REFRIGERATION_FALLBACK = Capability( href="/refrigeration/0", poll_tier="warm", diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 8683cfb..af2266b 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -19,9 +19,9 @@ here would silently do nothing on that path. Enumerate each known href instead; it's a short, stable list. This list is maintainer-curated only; there is no per-installation -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 -it unbound so it surfaces as a gap for a human to look at. +override. Grow it as real /device/0 dumps surface more universal noise -- +never on a guess. If a href's relevance is unclear, leave it unbound so it +surfaces as a gap for a human to look at. """ from ..capability import Capability @@ -61,12 +61,9 @@ IGNORED: list[Capability] = [ # Redundant with capabilities already declared elsewhere. # /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME). Capability(href="/speakersound/vs/0"), - # /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da. - # editCourseList is read directly out of the resource snapshot by - # dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select - # (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. + # No entities of its own -- editCourseList is read directly out of the + # resource snapshot by dishwasher.CYCLE_OPTIONS/washer.WASHER_COURSE's + # cycle selects to build the device's supported course list. Capability(href="/wm/editcourse/vs/0"), # Bixby audio feedback (chime + volume played when Bixby starts/stops # 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. Capability(href="/wm/submode/vs/0"), # Read-only re-encoding of the course already exposed by - # washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode - # is literally "Table_02_Course_"). + # washer.WASHER_COURSE at /course/vs/0 (same hex code, just prefixed + # "Table_02_Course_"). Capability(href="/st/washercourse/vs/0"), - # Dryer counterpart of the above: re-encoding of the course already - # exposed by dryer.DRYER_COURSE at /course/vs/0 - # (x.com.samsung.da.st.dryerMode is "Table_03_Course_"). + # Dryer counterpart: re-encodes dryer.DRYER_COURSE's /course/vs/0. Capability(href="/st/dryercourse/vs/0"), - # AirDresser counterpart of the above (issue #157): read only for its - # courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no - # entity of its own -- same "no entity, just the table id" role as - # /st/washercourse/vs/0 and /st/dryercourse/vs/0. + # AirDresser counterpart (issue #157): read only for its courseTable id + # (air_dresser.AIR_DRESSER_COURSE's table_href), no entity of its own. Capability(href="/st/airdressercourse/vs/0"), # Empty on every washer dump seen so far. Capability(href="/wm/welcomemsg/vs/0"), @@ -114,29 +107,21 @@ IGNORED: list[Capability] = [ # state without a multi-slot editor; revisit if that becomes valuable. Capability(href="/wm/personalcourse/vs/0"), # OCF-native energy resource is empty ({}) on washer hardware seen so - # far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real - # data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only - # real source for this control. + # far -- common.ENERGY_METER on /energy/consumption/vs/0 is the only + # real source. Capability(href="/energy/consumption/0"), # Empty ({}) on every washer dump seen so far -- nothing to expose. Capability(href="/cycleinterface/vs/0"), # OCF-native duplicate of /drlc/vs/0 above -- same utility-program # dependency this integration doesn't support locally. Capability(href="/drlc/0"), - # OCF-native duplicate of /operational/state/vs/0, which is already - # modeled by operational.OPERATIONAL_STATE (a richer, write-capable - # capability with start/pause/stop buttons and a delay-start control) - # used by washer, dishwasher, dryer, and oven. This generic href only - # 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. + # OCF-native duplicate of /operational/state/vs/0, already modeled by + # operational.OPERATIONAL_STATE (richer, write-capable, used by washer, + # dishwasher, dryer, oven). This generic href is read-only overlapping + # data with no verified write contract worth building around. Capability(href="/operational/state/0"), - # Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP - # family): sequenceNumber, operationBurnerNumber, a stageInfo block, and - # a textData.menu string -- every field empty/zero on the only dump - # 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. + # Cooktop guided-cooking/recipe status (issue #86): every field + # empty/zero on the only dump seen (device idle). Same "don't guess" + # treatment as the microwave family's /recipe/cook/vs/0. Capability(href="/cooktop/recipe/status/vs/0"), ] diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 512d4b8..a117439 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -178,33 +178,24 @@ BUZZER_SOUND = Capability( ), ) -# --------------------------------------------------------------------------- # Cycle selection over /course/vs/0. # # The selected course and every other user-tunable option ride in the -# x.com.samsung.da.options array on /course/vs/0 as `_` tokens. -# Confirmed on real hardware (issue #54): a write only needs to carry the one -# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` -- -# the device matches by prefix, evicts the stale token, and merges the result -# into the array itself. No read-modify-write of the whole array needed (see -# option_write). The set of *selectable* courses is not hardcoded -- it's read -# live from -# 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..state. 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. +# x.com.samsung.da.options array as `_` tokens. Confirmed on +# real hardware (issue #54): a write only needs to carry the one changed +# token -- the device matches by prefix, evicts the stale token, and merges +# the result itself (see option_write). The set of selectable courses is +# read live from editCourseList on /wm/editcourse/vs/0 (cycle_options), not +# hardcoded. Course codes are uppercase hex; display names live in +# translations under entity.select..state.. # # Some boards populate /wm/editcourse/vs/0 without ever filling in -# editCourseList itself (issue #1) -- cycle_options() falls back to deriving -# the same list from /course/vs/0's own supportedOptions in that case; see -# _course_codes_from_supported_options for the byte-level evidence. +# editCourseList itself (issue #1) -- cycle_options() falls back to +# deriving the list from /course/vs/0's own supportedOptions in that case; +# see _course_codes_from_supported_options. # -# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards -# expose the same /course/vs/0 options contract. -# --------------------------------------------------------------------------- +# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family +# boards expose the same /course/vs/0 options contract. def hex_pairs(codes): @@ -237,13 +228,11 @@ def option_value(options, prefix): # Drum Clean+ maintenance tracking, from the same options[] array as the # selected course -- shared by washer.py (issue #9) and dryer.py (issue -# #258); both families use identical DrumCleanProposal_/WashingTimes_/ -# DrumCleanLog_ tokens. DrumCleanProposal_ is the wash/dry-cycle interval -# between recommended cleans; WashingTimes_ is the count since the last -# one -- their difference is exactly the "N cycles until due" figure the -# Samsung app shows (verified on a washer: DrumCleanProposal_40 - -# WashingTimes_3 == 37, matching a live app screenshot's "Potreba cistenia -# po 37 cykloch"). +# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens. +# DrumCleanProposal_ is the cycle interval between recommended cleans; +# WashingTimes_ is the count since the last one -- their difference is +# the "N cycles until due" figure the app shows (verified: 40 - 3 == 37, +# matching a live app screenshot). def drum_clean_cycles_remaining(rep): opts = rep.get("x.com.samsung.da.options") or [] 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 -# datetime (the last clean, verified against the same app screenshot's "10 -# days ago"); a dryer (issue #258's Dillton-reported dump) instead reports a -# '|'-joined history of every past clean, ten deep on that dump, in -# strictly increasing order. Splitting on '|' and taking the last element -# 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). +# datetime (the last clean); a dryer (issue #258) instead reports a +# '|'-joined history of every past clean in increasing order. Splitting on +# '|' and taking the last element handles both shapes identically. No +# timezone accompanies either shape, so it's treated as UTC. def drum_clean_last_cleaned(rep): raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog") if not raw: @@ -278,41 +263,27 @@ def drum_clean_last_cleaned(rep): def _course_codes_from_supported_options(course_rep): """Fallback for an empty/missing editCourseList: derive the selectable - course list from /course/vs/0's own x.com.samsung.da.supportedOptions - instead (issue #1: some DA_WM_TP1/TP2-class boards populate the - /wm/editcourse/vs/0 href but never fill in editCourseList itself). + course list from /course/vs/0's own supportedOptions instead (issue #1: + some boards populate /wm/editcourse/vs/0 but never fill in + editCourseList itself). supportedOptions is a 1-hex-nibble header followed by one fixed-width 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 firmware's own internal order, not editCourseList's. Confirmed - against six independent real-world washer/dryer/dishwasher dumps: every - one divides evenly into `header + N * K bytes` with fully unique first - 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.) + the first byte of every record is that course's own hex code. + Confirmed against six independent real-world dumps: every one divides + evenly into `header + N * K bytes` with fully unique first bytes across + all N records, at the record's true byte width. - Two guards, deliberately conservative rather than guessing further: the - derived codes must (a) all be distinct -- a real course table, not - noise -- and (b) include whatever course is currently selected - (x.com.samsung.da.options' Course_ token), which must always be a - member of its own device's valid list. If no split satisfies both, this - returns [] rather than guess. + Two conservative guards rather than guessing further: the derived codes + must all be distinct, and must include whatever course is currently + selected. If no split satisfies both, this returns []. - Among splits that satisfy both, the *smallest* passing K wins, rather - than requiring a single unambiguous one -- more than one K reliably - does pass on real data (e.g. the shipped dishwasher fixture: true - K=7 passes, but so do 10, 14, and 35, none of which are multiples of - 7 -- position 0 always lands on the same real course code regardless - of K, which is enough on its own to satisfy the current-course guard - 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. + Among splits that satisfy both, the smallest passing K wins -- more + than one K reliably passes on real data, and smallest-K-wins matches + the confirmed answer on all six dumps checked, though it's a heuristic + rather than a proof. Not guarded further: course tables are typically + large enough that colliding by chance on both checks is unlikely, and + no device seen so far needs it. """ raw = course_rep.get("x.com.samsung.da.supportedOptions") 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): """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}"] @@ -360,35 +331,24 @@ def _table_id(resources, table_href): def cycle_select(*, translation_key, icon, table_href=None): """A 'Cycle' select over /course/vs/0, labelled from `translation_key`. - The option list, current value, and write path are all shared across - washer/dryer/dishwasher; only the translation is family- (and, for - washer/dryer, board-) specific. + The option list, current value, and write path are shared across + washer/dryer/dishwasher; only the translation is family/board-specific. - table_href (washer/dryer only -- see washer.py/dryer.py's call sites) - suffixes translation_key with the device's own course-table id, read - from /st/washercourse/vs/0 or /st/dryercourse/vs/0's - x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' -> - 'washer_cycle_table_02'). An absent or unrecognized table id gets the - name-only ``cycle`` translation key while the raw course code remains - visible and writable. - - This matters because course codes are NOT guaranteed consistent across - board generations sharing the same /course/vs/0 contract: every code in - washer_cycle_table_02 was confirmed against Table_02-reporting devices - (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 ``_cycle_`` entry and - this resolver picks it up. + table_href (washer/dryer only) suffixes translation_key with the + device's own course-table id, read from /st/washercourse/vs/0 or + /st/dryercourse/vs/0's courseTable (e.g. 'washer_cycle' + 'Table_02' -> + 'washer_cycle_table_02'). This matters because course codes are NOT + guaranteed consistent across board generations sharing the same + /course/vs/0 contract: washer_cycle_table_02 was confirmed against + Table_02 devices, but FlexWash's older board reports Table_00, where + the same hex code could mean a different course. An absent or + unrecognized table id falls back to the name-only ``cycle`` key + instead of borrowing a label from another board generation -- + translating a new table is a translations-only change. 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 - table the way washer/dryer's do -- there's nothing to build a - table-specific key from. + resource and no evidence its codes vary by table the way washer/ + dryer's do. """ key = translation_key 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 -# '_On'/'_Off' token, read-modify-written the same way as -# the 'Course' token above. Shared by washer (bubble soak, pre-wash, -# intensive -- issue #22) and dishwasher (storm wash, auto release dry) -- -# both families ride this exact contract, just with different prefixes and -# different presence/validation needs on top. -# --------------------------------------------------------------------------- +# '_On'/'_Off' token, merged the same way as the 'Course' +# token above. Shared by washer (bubble soak, pre-wash, intensive -- issue +# #22) and dishwasher (storm wash, auto release dry), just with different +# prefixes and presence/validation needs on top. def bool_option_write(prefix): @@ -450,12 +407,11 @@ def bool_option_switch( """A SwitchDesc over a '_On'/'_Off' options[] token. 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 - for a toggle every device in the family reports (dishwasher's storm - wash). validate_fn is passed straight through to SwitchDesc for callers - that need to reject a write against live device state (e.g. washer's - per-course availability check) -- this factory has no opinion on it and - building one, if needed, is the caller's job. + the token (washer's bubble soak/pre-wash/intensive); leave False for a + toggle every device in the family reports (dishwasher's storm wash). + validate_fn passes straight through to SwitchDesc for callers that need + to reject a write against live state -- this factory has no opinion on + it. """ return SwitchDesc( key=key, @@ -468,14 +424,11 @@ def bool_option_switch( ) -# --------------------------------------------------------------------------- # /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 -# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An -# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump -# ever carried that field, so the dryer sensor was always blank -- fixed by -# sharing this one reader. -# --------------------------------------------------------------------------- +# (e.g. door open, no water), x.com.samsung.da.currentStatus on every dump +# that populates it. An earlier dryer descriptor read +# x.com.samsung.da.jobBeginingStatus instead, which no dump ever carried, +# so the dryer sensor was always blank -- fixed by sharing this one reader. JOB_BEGINNING_STATUS = Capability( href="/wm/jobbeginingstatus/vs/0", diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index ebac9db..f240174 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -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: * Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/ - KeepWarm never appear on an oven's /mode/vs/0, and this family spells - some shared-sounding modes differently than oven.py's own constants - (e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and - mode list, not oven.OVEN_MODE. + KeepWarm never appear on an oven's /mode/vs/0, and some shared-sounding + modes are spelled differently (e.g. 'AirFryer', not oven.py's + 'AirFry') -- a distinct SelectDesc and mode list, not oven.OVEN_MODE. * Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec - (issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's - 30-270 C range (verified against a different, bake-oven-class board). - * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W - on the MicroWave mode's powerListData) that plain ovens don't report -- - exposed as its own sensor. - * Lamp: this family's option-array token is bare 'Lamp' (issue #137's - 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on - the combi dump (issue #121), so it's gated with exists_fn rather than - assumed universal like oven.py's lamp switch. Issue #137's dump only - 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'. + (issue #121) reports 40-200°C / step 5, not oven.py's 30-270°C range. + * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W) + that plain ovens don't report -- exposed as its own sensor. + * Lamp: this family's option-array token is bare 'Lamp' (issue #137), not + oven.py's 'UpperLamp', and genuinely absent on the combi dump (issue + #121), so it's exists_fn-gated rather than assumed universal. 'On' has + never been observed as a value; the only confirmed non-Off token is + 'High' (issue #152) -- the switch treats any non-Off/non-None value as + "on" for reads and writes back 'High'/'Off'. * Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep' - option-array tokens (issue #181), both with On and Off observed live - (issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since + option-array tokens (issue #181), gated with exists_fn like Lamp since the MW7300B combi dump has neither. -Note: cooking-mode writes are unproven here, same caveat as oven.py's -OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write -is also the test. +Cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE +-- exposed as a SelectDesc for fidelity, first real-world write is the test. """ from ..capability import Capability @@ -46,13 +38,10 @@ from .laundry import option_value, option_write # Constants # --------------------------------------------------------------------------- -# Union of every mode seen across the two known dumps: issue #121's combi -# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/ -# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's -# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown -# every mode below on one device -- the select surfaces whatever a given -# board's own /mode/vs/0 supportedModes reports; an entry here that a device -# never sends just never gets picked. +# Union of every mode seen across the two known dumps (issues #121, #137). +# No dump has shown every mode below on one device -- the select surfaces +# whatever a given board's own supportedModes reports; an entry here a +# device never sends just never gets picked. _MICROWAVE_MODES = ( "NoOperation", "MicroWave", @@ -67,22 +56,18 @@ _MICROWAVE_MODES = ( "KeepWarm", ) -# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40, -# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family; -# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified -# against issue #44's range dump), there's nothing to verify a microwave's -# Fahrenheit bounds against, so this module only exposes the setpoint -# control when the live unit is Celsius (see _microwave_temp_unit below). +# Convection/MicroWaveConvection modeSpec on issue #121's dump: 40-200°C, +# step 5. No Fahrenheit dump exists for this family, unlike oven.py's own +# independently-verified F bounds, so this module only exposes the +# setpoint control when the live unit is Celsius (see _microwave_temp_unit). SETPOINT_MIN_C = 40 SETPOINT_MAX_C = 200 SETPOINT_STEP_C = 5 def _microwave_temp_unit(rep): - """Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[] - carries a per-item x.com.samsung.da.unit field. Both known dumps for - this family report 'Celsius'; kept live rather than hardcoded per the - fridge/oven convention (issue #7).""" + """Same shape as oven.py's _oven_temp_unit. Both known dumps report + 'Celsius'; kept live rather than hardcoded (issue #7).""" items = rep.get("x.com.samsung.da.items") or [] unit = items[0].get("x.com.samsung.da.unit") if items else None return normalize_temp_unit(unit, default="°C") @@ -90,8 +75,7 @@ def _microwave_temp_unit(rep): def _setpoint_write(p, rep, href=None): """RMW write to /temperatures/vs/0 items array -- unproven for this - family (no live write confirmed against a real unit), same "exposed for - fidelity" caveat as the mode select.""" + family, same "exposed for fidelity" caveat as the mode select.""" try: temp = float(p) except (TypeError, ValueError): @@ -118,12 +102,11 @@ def _power_level_watts(v): def _cooking_mode_options(resources): - """Live mode list from the device's own /mode/vs/0 supportedModes when - it reports one (both known dumps do); the union-of-all-dumps - _MICROWAVE_MODES guess otherwise. Same live-first, static-fallback - pattern as oven._oven_mode_options -- a fixed list here would offer - users modes their own unit doesn't have (issue #152's ME7500D reports - only 4 of _MICROWAVE_MODES' 11).""" + """Live mode list from the device's own supportedModes when reported + (both known dumps do); the union-of-all-dumps _MICROWAVE_MODES guess + otherwise. Same live-first, static-fallback pattern as + oven._oven_mode_options -- a fixed list would offer modes a unit + doesn't have (issue #152 reports only 4 of _MICROWAVE_MODES' 11).""" rep = resources.get("/mode/vs/0") or {} live = rep.get("x.com.samsung.da.supportedModes") return list(live) if live else list(_MICROWAVE_MODES) @@ -163,9 +146,8 @@ def _lamp_write(p, rep, href=None): return None if not rep.get("x.com.samsung.da.options"): return None - # 'High' and 'Off' are the two tokens actually confirmed on live dumps - # (issues #137/#152) -- 'On' has never been observed and the device - # likely doesn't recognize it (see module docstring). + # 'High'/'Off' are the two confirmed tokens (see module docstring); + # 'On' has never been observed and likely isn't recognized. token = "High" if p == "On" else "Off" return ["mode", "vs", "0"], { "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"), write_fn=_lamp_write, ), - # issue #181: Filter Reminder / End Signal Reminder toggles, - # confirmed present (both On and Off observed across dumps -- see - # issue #152's ME7500D fixtures) but only on boards that carry the - # FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the - # MW7300B combi dump has neither) rather than assumed universal. + # issue #181: Filter Reminder / End Signal Reminder toggles, only on + # boards carrying the FilterRemind_*/RemindBeep_* tokens; gated off + # elsewhere (the MW7300B combi dump has neither). SwitchDesc( key="filter_remind", field="x.com.samsung.da.options", diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index 7859887..27bf209 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -98,11 +98,9 @@ def _finish_time(rep): if not total_s: return None # Round to whole minutes -- remainingTime itself only has minute - # resolution, but datetime.now() always carries fresh seconds/ - # microseconds, so an unrounded result changes on nearly every poll - # even when the device-reported remaining time hasn't. That floods - # the recorder history/logbook with values that look identical once - # the UI rounds them down for display. + # resolution, but datetime.now()'s fresh seconds/microseconds would + # otherwise change the result on nearly every poll, flooding the + # recorder with values that look identical once the UI rounds them. finish = datetime.now(UTC) + timedelta(seconds=total_s) return finish.replace(second=0, microsecond=0) @@ -147,15 +145,11 @@ OPERATIONAL_STATE = Capability( translation_key="machine_state", value_fn=_to_ocf, ), - # cycle_active is a bool derived from machine_state; used by the - # adapter to gate oven writes (cycle_active_field='cycle_active'). - # Harmless for non-oven appliances — just an extra bool in state. - # Samsung firmware keeps state='Run' after progress reaches 'Finish', - # so we also gate on progress to avoid a stuck 'Running' indication. - # 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. + # cycle_active is a bool derived from machine_state, gated on + # progress too since firmware keeps state='Run' after progress + # reaches 'Finish' (a stuck 'Running' indication otherwise). Named + # 'Running' in the catalog, not 'Cycle active' -- this href is + # shared with oven, and 'cycle' is laundry-specific vocabulary. BinarySensorDesc( key="cycle_active", device_class="running", @@ -183,9 +177,8 @@ OPERATIONAL_STATE = Capability( else _int(rep.get("x.com.samsung.da.progressPercentage")) ), ), - # Only show finish time when machine is actively running. Samsung - # firmware leaves a stale remainingTime after a cycle ends, and - # freezes it at '00:01:00' when progress reaches 'Finish'. + # Only show finish time while actively running -- firmware leaves a + # stale remainingTime after a cycle ends, frozen at '00:01:00'. SensorDesc( key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time ), diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 77da512..50d3847 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -1,26 +1,19 @@ """Capabilities for the oven family (Samsung NV7000BS-class). -Resources verified against the live device via DTLS-CoAP. -See `local-tools/comparisons/oven-tree.md` for the full field reference. +Resources verified against the live device via DTLS-CoAP. See +`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: - * Lamp via /mode/vs/0 options RMW (probe_oven_lamp_toggle.py) - — works even with Remote Control off. - - 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. +Cycle start is not implemented: local-OCF cycle start isn't reproducible on +this firmware. Mode writes are also unreliable -- the oven rolls them back +once a cycle is active, so OVEN_MODE's SelectDesc is effectively read-only +in practice. """ from datetime import UTC, datetime, timedelta @@ -45,26 +38,18 @@ SETPOINT_MIN_C = 30 SETPOINT_MAX_C = 270 SETPOINT_STEP_C = 5 -# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as -# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0 -# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate -# constant set rather than converted from the Celsius bounds above, which -# are themselves unverified (no live dump; see module docstring). +# Verified against issue #44's range dump: Bake mode's modeSpec reports +# tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept separate rather than +# converted from the Celsius bounds above, which are themselves unverified. SETPOINT_MIN_F = 175 SETPOINT_MAX_F = 550 SETPOINT_STEP_F = 5 -# Mode options seen on NV7000BS-class. No dump exists so this list is inferred -# from Samsung documentation and firmware observations. The firmware will -# reject unknown modes; missing entries here are a coverage gap, not a bug. -# -# This is a fallback only, used when a device's own /mode/vs/0 doesn't report -# 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. +# Mode options seen on NV7000BS-class. No dump exists so this list is +# inferred from Samsung documentation and firmware observations; the +# 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 +# supportedModes at all -- see _oven_mode_options/_oven_mode_write below. _OVEN_MODES = ( "NoOperation", "Bake", @@ -138,22 +123,18 @@ def _option_value(options, prefix): def _has_option(prefix): - """exists_fn for an options-array switch: bind only when the device's own - options[] actually carries a `_` token. + """exists_fn for an options-array switch: bind only when the device's + own options[] actually carries a `_` token. - fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as - an unverified guess (see module docstring) -- issue #183's dump (model - NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its - options[] at all, so both switches were phantom controls: always read as - off, and toggling them wrote a token the firmware never recognized in - the first place, hence "does not appear to do anything." + fast_preheat/natural_steam were shipped unconditionally (no exists_fn) + as an unverified guess -- issue #183's dump reports neither token in + its options[] at all, so both switches were phantom controls that + "don't appear to do anything." `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 - /device/0 seed rep (not yet sub-polled) has no options[] at all, and - without this an entity whose token is genuinely present would never get - a first chance to bind, since exists_fn runs before that first real - fetch lands. + identical exists_fn: a stub /device/0 seed rep has no options[] at all, + and without this a genuinely-present token would never get a first + chance to bind. """ return lambda rep, resources: ( 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): """A one-token x.com.samsung.da.options write, mirroring laundry.option_write. NOT independently confirmed on an oven -- issue - #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 - /mode/vs/0, on the assumption the firmware handles the array the same - way there. If that assumption is wrong for some oven, a device 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.""" + #54 only confirmed prefix-merge-on-write for a washer's /course/vs/0; + this extrapolates the same contract here. If some oven replaces the + field outright instead of merging, this would drop every other option + on the next write -- revisit if a real device report surfaces that.""" return [f"{prefix}_{new_value}"] @@ -318,13 +296,11 @@ OVEN_CAVITY = Capability( def _oven_temp_unit(rep): - """Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the - same aggregate `/temperatures/vs/0` items[] resource type, which on - fridge hardware carries a per-item `x.com.samsung.da.unit` field - ('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7). - Keeps the verified '°C' default when the field is absent (the original - NV7000BS-class dump this module was written against), but reads it live - -- issue #44's range dump is the first to report 'Fahrenheit' here.""" + """Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this aggregate + `/temperatures/vs/0` items[] resource carries a per-item `unit` field + that was previously hardcoded away (issue #7). Keeps the verified '°C' + default when the field is absent, 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 [] unit = items[0].get("x.com.samsung.da.unit") if items else None return normalize_temp_unit(unit, default="°C") @@ -404,17 +380,12 @@ OVEN_CONNECTED = Capability( ), ) -# Static cavity capability metadata (count/type/supported features) -- no -# per-cavity data varies at runtime on any dump seen so far (issue #44's -# range: single cavity, no supportedFeatureList entries). Bound with no -# entities purely for coverage; revisit if a multi-cavity dump surfaces -# fields worth exposing. +# Static cavity capability metadata -- no per-cavity data varies at runtime +# on any dump seen so far. Bound with no entities purely for coverage. OVEN_SPEC = Capability(href="/oven/spec/vs/0") -# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded -# string (language/menu/servingSize/option) with every field blank on the -# 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. +# Quick-recipe display blob (combi microwave, issue #121) -- every field +# blank on the only dump seen, no documented write contract. OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0") OVEN_MODE = Capability( @@ -462,9 +433,8 @@ OVEN_MODE = Capability( write_fn=_option_switch_write("NaturalSteam"), ), # 120-hour energy-saving standby (issue #183): confirmed present in - # this unit's options[] (EnergySaving_On) and directly requested -- - # unlike fast_preheat/natural_steam above, this token is real on this - # hardware, just previously unbound entirely. + # this unit's options[] -- unlike fast_preheat/natural_steam above, + # this token is real on this hardware, just previously unbound. SwitchDesc( key="energy_saving", field="x.com.samsung.da.options", diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index e8f11bd..ab49122 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -1,36 +1,32 @@ """Capabilities for the cooktop half of range/combo appliances (issue #44, model TP1X_DA-KS-RANGE-0102X). -Not to be confused with PR #23's registry/capabilities/cooktop.py, which -covers an unrelated standalone-cooktop product (NA9300K-class) that encodes -burner state as strings inside /mode/vs/0's options array instead of the -structured /cooktop/status/vs/0 resource this module reads -- two different -OCF surfaces that happen to share the English word "cooktop". +Not to be confused with registry/capabilities/cooktop.py, which covers an +unrelated standalone-cooktop product (NA9300K-class) that encodes burner +state as strings inside /mode/vs/0's options array instead of the +structured /cooktop/status/vs/0 resource this module reads -- two +different OCF surfaces that happen to share the English word "cooktop". 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 -looks like a vendor resource migrated onto OCF-standard-shaped field naming. +names (no `x.com.samsung.da.` prefix). -`/cooktop/status/vs/0` carries every burner's live state in one `burnerList` -array (indexed by `burnerNumber`, not by a separate href per burner like -fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS -and gated by exists_fn against whichever indices the device actually -reports -- harmless over-declaration, per common.py's UNIVERSAL note, since -an index absent from burnerList just never binds. +`/cooktop/status/vs/0` carries every burner's live state in one +`burnerList` array (indexed by `burnerNumber`), so per-burner entities are +hardcoded up to MAX_BURNERS and gated by exists_fn against whichever +indices the device actually reports -- an index absent from burnerList +just never binds. 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- -write pattern already proven safe elsewhere in this codebase (oven setpoint, -icemaker toggles). +caveat as oven.py's RMW writes) -- power level uses the same +read-modify-write pattern already proven safe elsewhere in this codebase. """ from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc from .common import normalize_temp_unit -# Observed as high as 4 (this issue's dump); user-reported hardware with 5 -# burners exists. Kept a little above both since exists_fn gates unused -# slots out -- see module docstring. +# Observed as high as 4; user-reported hardware with 5 burners exists. +# Kept a little above both since exists_fn gates unused slots out. MAX_BURNERS = 6 @@ -140,11 +136,10 @@ COOKTOP_STATUS = Capability( poll_tier="hot", entities=( SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"), - # 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 - # family (the range combo) additionally carry for the whole - # appliance. Read-only: no live device to confirm remotely turning - # a cooktop on wouldn't leave a burner active unattended. + # The cooktop section's own on/off (issue #86), distinct from + # common.POWER's whole-appliance switch. Read-only: no live device + # to confirm remotely turning it on wouldn't leave a burner active + # unattended. BinarySensorDesc( key="cooktop_power", field="power", @@ -153,8 +148,7 @@ COOKTOP_STATUS = Capability( value_fn=lambda v: str(v).lower() == "on", ), # Safe to write -- a lock toggle, not a heat control -- via a - # direct single-field PUT (no RMW needed; unlike burnerList this - # is a lone scalar, not an array of siblings to preserve). + # direct single-field PUT, no RMW needed. SwitchDesc( key="cooktop_child_lock", field="childLock", @@ -169,16 +163,14 @@ COOKTOP_STATUS = Capability( ) # Static burner-count/power-level-list metadata, read directly by -# COOKTOP_STATUS's power-level select (options=_power_level_options) rather -# than exposed through its own entity -- same "informs another capability, -# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py). +# COOKTOP_STATUS's power-level select (options=_power_level_options) +# rather than exposed through its own entity. COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0") # 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 -# feature itself is enabled -- not a live "surface is hot right now" alert -# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract -# verified, so read-only for now. +# duration; state on/off is whether the feature itself is enabled -- not a +# live "surface is hot right now" alert (that's COOKTOP_STATUS's per-burner +# hot_surface). No write contract verified, so read-only for now. COOKTOP_SAFETY = Capability( href="/cooktop/settings/status/vs/0", poll_tier="warm", @@ -194,9 +186,8 @@ COOKTOP_SAFETY = Capability( # Bluetooth meat probe (issue #86). All-idle sentinel values when # disconnected (operationBurnerNumber -1, temperatures 0) -- no special -# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of -# showing a disconnected accessory's fields plainly rather than hiding the -# whole capability. +# gating, matching cooktop.PAIRED_HOOD_STATUS's precedent of showing a +# disconnected accessory's fields plainly rather than hiding the capability. PROBE_STATUS = Capability( href="/bluetooth/probe/status/vs/0", poll_tier="warm", @@ -232,14 +223,12 @@ PROBE_STATUS = Capability( ), ) -# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0 -# burner array at all -- their local API only exposes this coarse -# monitoring resource for the cooktop half, with no per-burner detail. -# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and -# `warmingCenterState`'s full value set aren't confirmed, so both are -# exposed as plain sensors rather than guessed at as a switch/select -- -# `supportedHoodLampStateList` has no corresponding live-state field on -# this resource, so nothing to bind it to yet. +# Some range boards (issue #74) report no /cooktop/status/vs/0 burner +# array at all -- their local API only exposes this coarse monitoring +# resource, with no per-burner detail. Meaning of `cooktopMonitoring` +# (bare "0" on the only dump seen) and `warmingCenterState`'s full value +# set aren't confirmed, so both are plain sensors rather than a guessed +# switch/select. COOKTOP_MONITORING = Capability( href="/cooktopmonitoring/vs/0", poll_tier="warm", diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index fb2e215..71ed41b 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -182,18 +182,13 @@ HOOD_FILTER = Capability( ) -# After Run (issue #147): the hood keeps the fan running at low speed for a -# while after it's switched off, to clear residual cooking smoke -- a -# feature a user actively watches and cancels, not passive diagnostics, so -# none of the three entities below carry entity_category. No -# supported-values list is advertised for activationState, so it's modeled -# read-only (monitoring, not an invented "enable" write) per the 'don't -# guess' rule; runningCancel's only observed value is the command name -# itself ('Cancel'), the same self-describing command-field shape as -# operational.STOP_BUTTON. runningProgress's own name states its domain -# (a percentage of the cycle completed), so it's modeled as one rather than -# left an opaque passthrough -- unlike activationState/runningCancel, there's -# no ambiguous field name or missing-write-contract question here to hedge on. +# After Run (issue #147): the hood keeps the fan running at low speed after +# it's switched off, to clear residual cooking smoke -- a feature a user +# actively watches and cancels, so none of the three entities below carry +# entity_category. No supported-values list is advertised for +# activationState, so it's read-only monitoring rather than an invented +# "enable" write; runningCancel's only observed value is the command name +# itself ('Cancel'), the same shape as operational.STOP_BUTTON. AFTER_RUN = Capability( href="/afterrun/vs/0", poll_tier="warm", diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 2a78d4b..68e464d 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -29,62 +29,33 @@ from .laundry import ( option_write, ) -# --------------------------------------------------------------------------- -# Course_XX hex codes. 23 of the codes named in translations/en.json -# under entity.select.washer_cycle_table_02.state. were captured -# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList -# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched -# positionally against a Slovak-UI user's screenshots of their app's course -# list (same order, same count -- see issue #2) and cross-checked against -# the printed user manual's course table (confirming e.g. '8F' as 'Intense -# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a -# cycle the manual marks "applicable models only" and that does not appear -# in this device's editCourseList -- nor does 'AI Wash', also "applicable -# models only"). FixedCourseList_1C29 (the two courses always pinned in the -# 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. +# Course_XX hex code labels (translations/en.json, +# washer_cycle_table_02.state.) come from several devices, cross-checked +# rather than guessed: 23 codes from a live WW90DG6U25LEU4's editCourseList, +# matched positionally against a user's app screenshots and the printed +# manual (issue #2); 5 more (Wash+Dry, Air Wash, Cotton Dry, Synthetics Dry, +# a second distinct '1F' Intense Cold) from a WD90T654DBN/S1 combo's own +# editCourseList and screenshots (issue #22, a combo's own course set, not +# implying anything about a plain washer's '1F'); 3 more (Eco Cold, Towels, +# Self Clean+) verified directly on a WF50A8600AV/US by reading back the raw +# code after selecting each cycle on the appliance (issue #80). Two code +# pairs ('21'/'65' Colors, '27'/'5E' Rinse+Spin, and '24'/'54' Towels) +# legitimately share a label across different course tables -- not typos. # -# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry, -# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the -# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer -# combo's editCourseList and were named from that user's app screenshot -# (issue #22). Combo units carry their own course set, so these codes -# don't imply anything about '1F' on a plain washer. -# -# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a -# 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. -# --------------------------------------------------------------------------- +# No static fallback list is kept here: other models have different actual +# course sets, so hardcoding one device's list would show/hide the wrong +# options elsewhere. laundry.cycle_options() reads only the live +# x.com.samsung.da.editCourseList; a device that doesn't populate it gets no +# cycle select at all (see cycle_select's exists_fn). x.com.samsung.da. +# options' MostUsed_* entry was considered as a fallback source (its first +# byte matches the selected Course_XX on both dumps), but the remaining +# bytes don't decode to any confirmed course code, so it 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 # 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 # given device, since dryer and washer are separate by_type registries. -# --------------------------------------------------------------------------- WASHER_SETTINGS = Capability( href="/washer/vs/0", @@ -123,9 +94,8 @@ WASHER_SETTINGS = Capability( ), ), # Washer/dryer combo units carry a dryLevel field on the wash - # resource itself (no separate dryer device/course) -- see issue - # #22. Self-gates off on plain washers, which never report - # supportedDryLevel. + # resource itself (issue #22). Self-gates off on plain washers, + # which never report supportedDryLevel. SelectDesc( key="dry_level", 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 -# drum-clean and dispenser-dosing entities below are washer-specific reads off -# the same options array. -# --------------------------------------------------------------------------- - +# drum-clean and dispenser-dosing entities below are washer-specific reads +# off the same options array. # Drum Clean+ maintenance tracking (issue #9): drum_clean_cycles_remaining/ # 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 # (issue #9). 'LevelCtrl_' is the selected dose quantity; -# 'Level2Ctrl_' is a second dial -- water hardness for -# detergent, concentration for softener -- matching the SmartThings app's -# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté -# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per -# issue #9's screenshots). 'SupportedCtrl_' lists the -# valid raw codes for its field, same hex-pair shape as EditCourseList. -# 'Alarm_' is a low-reservoir warning flag. +# 'Level2Ctrl_' is a second dial (water hardness for +# detergent, concentration for softener), matching the app's two-field +# dispenser screens. 'SupportedCtrl_' lists the valid raw +# codes, same hex-pair shape as EditCourseList. 'Alarm_' is +# a low-reservoir warning flag. # -# Label mapping (entity.select.{detergent,softener}_quantity / -# detergent_water_hardness / softener_concentration in translations/en.json) is an -# assumed, not cross-device-verified, reading of the single issue #9 dump + -# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no -# on-screen equivalent -- the app's Quantité picker only offers -# 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. +# Label mapping (translations/en.json's {detergent,softener}_quantity / +# detergent_water_hardness / softener_concentration) is an assumed reading +# of the single issue #9 dump + screenshots, cross-checked against the +# selected value on both dispensers, not independently verified per code -- +# revisit if a second device's dump contradicts it. def _supported_level_options(resources, prefix): rep = resources.get("/course/vs/0") or {} 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): - """Current dose code, normalized to the `Supported` code format. - - The device reports the selected level as `_` with the code - un-padded (e.g. '3'), but the valid codes -- which are also this select's - options and its translation keys -- come from `Supported_` - as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits - outside the select's own option list, so HA renders it 'unknown' (issue #9). - Resolve it to the supported code with the same integer value so - current_option matches an option (and its translation).""" + """Current dose code, normalized to the `Supported` code + format. The device reports the selected level as `_` + un-padded (e.g. '3'), but the select's own options come from + `Supported_` as zero-padded hex pairs (e.g. '03'). + Left as '3', the value sits outside the select's own option list and + HA renders it 'unknown' (issue #9) -- resolve it to the matching + zero-padded code instead.""" def fn(rep): opts = rep.get("x.com.samsung.da.options") @@ -227,9 +179,8 @@ def _level_write(prefix): def write(p, rep, href=None): if not rep.get("x.com.samsung.da.options"): return None - # `p` is the zero-padded supported code the UI selected (e.g. '03'); - # the device stores the level un-padded (e.g. '3'), matching how it - # reports it, so write it back in that native shape. + # `p` is the zero-padded supported code (e.g. '03'); the device + # stores it un-padded (e.g. '3'), matching how it's reported. try: native = format(int(p, 16), "X") except (TypeError, ValueError): @@ -248,37 +199,28 @@ def _dosing_low(prefix): # 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 -# plain '_On'/'_Off' token, confirmed by a dump taken with -# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape -# already used by AiOption and KidsLockBypass in this same array, so -# PreWashSetting/IntensiveSetting are assumed to follow suit. +# array (issue #22 follow-up). Each rides as a plain '_On'/'_Off' +# token, confirmed against a dump taken with Bubble Soak switched on in the +# app -- the same shape as AiOption/KidsLockBypass in this array. # -# Each also has a differently-named hex-pair availability field that lines up -# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet, -# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of -# 24), all three read 'F0' at that position and the toggle was writable -- -# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak -# Off') decodes to '00' for that course, matching the app graying the -# control out there. 'F0'/'00' is treated as available/unavailable on that -# evidence. exists_fn (device-level presence) still only runs once, against -# the setup-time snapshot, so it isn't a fit for this per-course check -- -# validate_fn runs on every write attempt instead (dispatched from -# coordinator.async_send_command, ahead of write_fn), rejecting an on-write -# 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). +# Each also has a hex-pair availability field positional with +# editCourseList (BubbleSoakSet, PreWashAvailableSet, +# IntensiveAvailableSet): on the reporter's dump 'F0' at a course's +# position matched the app enabling the control there, '00' matched it +# grayed out. exists_fn only runs once at setup, so it can't do this +# per-course check -- validate_fn runs on every write attempt instead, +# rejecting an on-write for a course whose byte isn't 'F0' with a +# user-facing error rather than silently no-opping. The read/write/ +# presence machinery is laundry.bool_option_switch, shared with +# dishwasher's storm-wash/auto-release-dry toggles; only this per-course +# gating is washer-only. def _bool_option_switch(key, icon, prefix, availability_field): def validate(p, rep, resources): """Reject turning on when the selected course's byte in - `availability_field` isn't 'F0'. Turning off is never blocked. Falls - back to allowing the write whenever the availability data can't be - resolved (unrecognized course, missing/mismatched-length bitmap) - rather than guessing -- a false rejection is worse than an - occasional no-op write.""" + `availability_field` isn't 'F0'. Turning off is never blocked. + Falls back to allowing the write whenever the availability data + can't be resolved (unrecognized course, missing/mismatched-length + bitmap) -- a false rejection is worse than an occasional no-op.""" if p != "On": return None opts = rep.get("x.com.samsung.da.options") or [] diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 9b42581..671a375 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -25,19 +25,13 @@ DISPENSE = Capability( {"x.com.samsung.da.desiredType": p}, ), ), - # Only a handful of discrete temperatures are selectable (not a - # continuous range) -- a select over the live-reported set, not a - # number with invented bounds. - # - # Newer boards (issue #196, RWP70F15ANW) don't populate - # supportedHotTemperatures at all -- they report a hotwaterRange - # (min/max) and a hotwaterLevel (step count?) instead, with no - # 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. + # Only a handful of discrete temperatures are selectable -- a select + # over the live-reported set, not a number with invented bounds. + # Newer boards (issue #196) don't populate supportedHotTemperatures + # at all, reporting a hotwaterRange/hotwaterLevel pair instead with + # no confirmed write contract -- gate the entity off entirely there + # rather than guess at that pair's meaning (an empty options list + # otherwise left current_option rendering "unknown"). SelectDesc( key="hot_water_temperature", field="x.com.samsung.da.tempDesiredHotWater", @@ -53,12 +47,10 @@ DISPENSE = Capability( ), ), # Bounds and step come live from the device's own - # desiredCapacityRange/capacityResolution fields, not a hardcoded - # constant -- see the adding-device-support skill's "never hard-code - # the one dump's values" section. No unit is set: capacityUnit reads - # "C" on this dump, which can't be right for a volume field, so per - # the 'don't guess' rule the unit is left unset rather than assumed - # to be mL. + # desiredCapacityRange/capacityResolution, not a hardcoded constant. + # No unit is set: capacityUnit reads "C" on this dump, which can't + # be right for a volume field, so it's left unset rather than + # assumed to be mL. NumberDesc( key="dispense_capacity", 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: - """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 - still ambiguous: - - - href entirely absent from this device -> definitely no clash, the - switchHotwater fallback below may claim the entity. - - href present but an unfetched stub ({}) -> outcome pending, *not* a - confirmed absence. LOCK's own exists_fn optimistically includes itself - through a stub (matching entity.py's default), so returning True here - too would register both descriptors -- as SwitchDescs sharing one key, - with identical unique_ids -- until the next poll resolves it. - - href present and fetched -> the real answer.""" + still ambiguous: href absent -> True (fallback may claim the entity); + href present but an unfetched stub ({}) -> False (pending, not + confirmed absence -- LOCK's own exists_fn optimistically includes + itself through a stub too, so returning True would register both + descriptors under one key until the next poll); href present and + fetched -> the real answer.""" rep = resources.get("/status/lock/vs/0") if rep is None: return True @@ -189,24 +173,15 @@ FAVORITE_HOTWATER = Capability( href="/favorite/hotwater/vs/0", poll_tier="cold", entities=( - # Despite the resource/field naming, switchHotwater's value domain is - # Locked/Unlocked, not an enable flag (issue #144) -- it's the same - # hot-water lock as LOCK.hotwater_lock below, just surfaced through - # this href on boards that don't populate /status/lock/vs/0's - # hotwaterLock field. Shares that descriptor's key so only one "Hot - # water lock" entity ever appears. - # - # Both halves of this fallback pair need an exists_fn, not just this - # one: adapter.flatten() (the coordinator.data source every entity's - # is_on reads) only ever honours exists_fn, never entity.py's - # implicit "require own field present" default that gates plain - # registration. Two same-keyed descriptors with only one of them - # gated still both land in flatten()'s output dict -- whichever is - # processed last silently wins, decided by device-reported href - # order, not by which one is actually correct. So this exists_fn - # also re-asserts its own field's presence (switchHotwater), the - # gate a bare `field=` used to get for free before it had to share a - # key with LOCK's descriptor. + # Despite the naming, switchHotwater's value domain is + # Locked/Unlocked, not an enable flag (issue #144) -- the same + # hot-water lock as LOCK.hotwater_lock below, surfaced through this + # href on boards that don't populate /status/lock/vs/0's + # hotwaterLock. Shares that descriptor's key so only one "Hot water + # lock" entity appears; both halves need an exists_fn since + # adapter.flatten() only ever honors exists_fn, not entity.py's + # implicit field-presence default -- without it, whichever + # same-keyed descriptor is processed last would silently win. SwitchDesc( key="hotwater_lock", field="x.com.samsung.da.switchHotwater", @@ -222,18 +197,12 @@ FAVORITE_HOTWATER = Capability( {"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"}, ), ), - # Issue #196: `supportedList` is only the four *fixed* presets - # (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets - # the user add one custom value to their own display list via its - # "temperatures to display" editor (a wheel picker bounded by - # /setting/waterpurifier/vs/0's hotwaterRange, separate resource), - # and that custom value shows up in `showList` - # (['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. + # Issue #196: `supportedList` is only the four fixed presets -- the + # app also lets the user add one custom value to their own display + # list, which shows up in `showList` but never in `supportedList`. + # Reading from `supportedList` meant a unit whose current default + # was that custom value rendered as "unknown"; `showList` is a + # superset that always includes the actual current default. SelectDesc( key="favorite_hotwater_temperature", field="x.com.samsung.da.favorite.defaultTemperature", @@ -291,11 +260,9 @@ CUP_STATE = Capability( ) # Sound mode/output/volume (issue #196). Shapes echo laundry.py/ -# air_purifier.py's same-named hrefs, but this board's own values differ -# from both (supportedModes here is voice/fixedTone/mute, not laundry's -# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would -# reject a live-supported value, so these read the device's own supported -# list/range like air_purifier's versions do. +# air_purifier.py's same-named hrefs, but this board's own supportedModes +# (voice/fixedTone/mute) differs from both, so these read the device's own +# supported list/range rather than reusing either. SOUND_MODE = Capability( href="/settings/sound/mode/vs/0", poll_tier="cold", @@ -359,9 +326,8 @@ SOUND_VOLUME = Capability( ) # Last-pour statistics (issue #196). last.capacity's unit isn't confirmed -# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity -# which at least has an -- albeit suspect -- capacityUnit) so it's left -# unitless rather than assumed to be mL. +# (no sibling unit field on this resource) so it's left unitless rather +# than assumed to be mL. STATISTIC_POUR = Capability( href="/statistic/pour/vs/0", poll_tier="cold", @@ -387,12 +353,8 @@ LOCK = Capability( poll_tier="warm", entities=( # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback - # above (issue #144); see the comment there for why this half also - # needs an explicit exists_fn now that the two share a key in - # adapter.flatten()'s output. A stub rep ({}) still counts as - # "present" here (matches entity.py's own default for a field-less - # gate) since the alternative -- treating an unfetched resource as - # confirmed-absent -- is what let both descriptors register at once. + # above (issue #144); see the comment there. A stub rep ({}) still + # counts as "present" here, matching entity.py's own default. SwitchDesc( key="hotwater_lock", field="x.com.samsung.da.hotwaterLock", @@ -430,29 +392,21 @@ LOCK = Capability( ), ) -# --------------------------------------------------------------------------- # Water-purifier-scoped coverage: hrefs with no user-actionable state or no # confirmed contract, following the 'don't guess' rule. -# --------------------------------------------------------------------------- _WP_IGNORED = [ - # supportedModes carries a single opaque wizard-workflow token - # ('HOMECARE_WIZARD_V2') and modes reports a completely different, - # unrelated value ('WATERFILTER_DISABLE') not even present in - # 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). + # supportedModes carries a single opaque wizard-workflow token and + # modes reports an unrelated value not even in supportedModes -- + # internal plumbing, not a real mode select. "/mode/vs/0", - # Static support-flags blob (automation.supported.modes/options) -- no - # live "current automation setting" field to expose. + # Static support-flags blob -- no live "current setting" field. "/automation/waterpurifier/vs/0", - # Coffee-capable variant (issue #107). All four are static - # capability-advertisement blobs or empty -- no live "current recipe" / - # "current custom slot" field to expose, unlike /favorite/coffee/vs/0 - # (COFFEE above), which does carry live brew status. + # Coffee-capable variant (issue #107): static capability-advertisement + # blobs or empty, unlike /favorite/coffee/vs/0 (COFFEE above) which + # does carry live brew status. "/brand/recipe/info/vs/0", # revision + max-brand-count metadata - "/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs - "/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content + "/coffee/custom/recipe/vs/0", # allowed custom-recipe slot IDs + "/recipe/coffee/vs/0", # same shape, no per-recipe content "/recipe/coffee/deletion/vs/0", # empty {} on this dump ] diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 90e96d0..3dfa9d7 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -31,10 +31,9 @@ class BoundEntity: key_override: str | None = None instance_name: str | None = None # Which logical indoor subdevice (issue #177) this entity belongs to. - # `href` above is always the *actual*, on-the-wire href for that - # subdevice -- MAIN's - # to_actual is the identity transform, so every device with no subdevices - # behaves exactly as before this field existed. + # `href` above is always the actual, on-the-wire href for that + # subdevice -- MAIN's to_actual is the identity transform, so a device + # with no subdevices is unaffected. subdevice: Subdevice = MAIN @@ -101,24 +100,19 @@ def discover( tier_log: Callable[[str, str], None] | None = None, subdevice: Subdevice = MAIN, ) -> list[BoundEntity]: - """`tier_log(href, poll_tier)` fires for every href a capability actually - matches, even a no-entity "coverage-only" capability (see COVERAGE lists - in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows. - Callers that need a href's poll cadence (the coordinator's hot/warm - sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a - coverage-only capability's `poll_tier` would otherwise be silently - dropped since it never appears in `bound`. + """`tier_log(href, poll_tier)` fires for every href a capability + actually matches, even a no-entity "coverage-only" capability that + `_bind()` turns into zero `BoundEntity` rows. Callers that need a + href's poll cadence must use this, not `bound` -- a coverage-only + capability's `poll_tier` would otherwise never appear in `bound`. - `resources` is always keyed by *canonical* hrefs -- for a subdevice - (issue #177) that means its own canonical view (see - subdevices.canonical_view), the same shape as a plain single-subdevice - device's resources dict, so registry lookups/rt_filter/match_fn/ - instance_suffix all behave identically regardless of which subdevice is - being discovered. - `subdevice` only affects the *href* stamped onto each BoundEntity (via - `_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. + `resources` is always keyed by canonical hrefs -- for a subdevice + (issue #177), its own canonical view (see subdevices.canonical_view), + the same shape as a single-subdevice device's resources dict, so + registry lookups behave identically regardless of which subdevice is + being discovered. `subdevice` only affects the href stamped onto each + BoundEntity and the href `log`/`tier_log` report -- the real, + subscribable/pollable path, not the canonical one. """ out: list[BoundEntity] = [] diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index cc6e983..8dd06eb 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -33,10 +33,10 @@ class SamsungEntityDescription: # here, so a descriptor only sets this to share one catalog entry across # several descriptors, or to point at a differently-named one. translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]] - # callable form receives the coordinator's full href->rep resource - # snapshot and returns the key to use -- for a descriptor shared across - # board generations whose state-code meaning isn't guaranteed consistent - # between them; see laundry.cycle_select's table-id-gated resolver. + # callable form receives the full href->rep snapshot and returns the key + # to use -- for a descriptor shared across board generations whose + # state-code meaning isn't consistent between them; see + # laundry.cycle_select's table-id-gated resolver. translation_placeholders: Mapping[str, str] | None = None # Dynamic resources such as fridge compartments and ice makers use a # device-provided or href-derived instance label inside a translated name. @@ -59,10 +59,9 @@ class SensorDesc(SamsungEntityDescription): unit: str | None = None 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' - # Opt-in: gate this sensor's reported value behind the user-configurable - # CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for - # values that are expected to jitter around their "true" value between - # device-side revisions -- not a general-purpose flag every sensor should set. + # Opt-in: gate this value behind CONF_FINISH_TIME_HYSTERESIS_MINUTES + # (see sensor.py). Only for values expected to jitter between + # device-side revisions -- not a general-purpose flag. hysteresis: bool = False @@ -102,10 +101,9 @@ class NumberDesc(SamsungEntityDescription): native_min: float | None = None native_max: float | None = None step: float | None = None - # Override native_min/native_max/step from the live rep, when set -- - # same "static default, live override" shape as unit_fn, for resources - # whose sane bounds depend on a per-device value (e.g. a temperature - # setpoint reported in Celsius on one device, Fahrenheit on another). + # Override native_min/max/step from the live rep, when set -- same + # "static default, live override" shape as unit_fn, for resources whose + # bounds depend on a per-device value (e.g. Celsius vs. Fahrenheit). native_min_fn: Callable[[dict], float] | None = None native_max_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) class ClimateDesc(SamsungEntityDescription): - # A composite entity: it binds one *primary* resource (its href) but the - # climate platform reads sibling resources (power, temperature, wind) from - # the coordinator snapshot and writes to several of them. write_fn takes a - # (kind, value) payload from the platform and returns the (path_segs, body) - # for that one sub-write, so a single desc drives multi-resource writes. + # Composite entity: binds one primary resource (its href) but the + # climate platform reads sibling resources from the coordinator + # snapshot and writes to several of them. write_fn takes a (kind, + # value) payload and returns the (path_segs, body) for that sub-write. write_fn: WriteFn = None @dataclass(frozen=True, kw_only=True) class FanDesc(SamsungEntityDescription): # 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 @dataclass(frozen=True, kw_only=True) class WaterHeaterDesc(SamsungEntityDescription): - # Composite water_heater entity: binds one primary resource (its href, - # 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, + # Composite water_heater entity, same (kind, value) -> (path_segs, # body) write_fn shape as ClimateDesc/FanDesc. write_fn: WriteFn = None diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 68478bc..c53bcdb 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -22,24 +22,19 @@ def is_placeholder_serial(serial: str) -> bool: The ARTIK051_DONGLE_REF firmware family reports the literal string '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 - device-registry identifier and every entity's unique_id (entity.py), so - two such units on the same install silently collide and the second one's - entities get dropped (issue #83). + check doesn't catch it, and two such units on the same install silently + collide, dropping the second one's entities (issue #83). - Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family - reports a flash-unset sentinel instead -- every character the same - repeated hex digit (a washer and a dryer, two different physical units, - both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which the - 'nothing' check above doesn't catch either, so the second unit's config - flow aborted as already configured. + Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry family reports a + flash-unset sentinel instead -- every character the same repeated hex + digit -- which the 'nothing' check doesn't catch either, aborting the + second unit's config flow as already configured. - Lives here, rather than being duplicated in config_flow.py and - coordinator.py as it once was, because the config flow now resolves the - serial once and persists it on the entry for the coordinator to seed its - registry keys from (issue #236). Two copies of this rule meant the two - sides could disagree about what a device's identity is -- and a - disagreement is exactly what orphans a registry entry. + Lives here rather than duplicated in config_flow.py/coordinator.py: the + config flow resolves the serial once and persists it for the + coordinator to seed its registry keys from (issue #236), so two copies + of this rule could let the two sides disagree and orphan a registry + entry. """ s = serial.strip() 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: """The model string to name and register a device under. - `model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many - boards report as `|` -- only the part before the pipe is the - model a user would recognize. A board that reports no modelNum at all - falls back to /oic/p's mnmo, which read_identity already parsed. - - Shared with resolve_serial's motivation: the config flow resolves this - 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. + `model_num` is /information/vs/0's modelNum, which many boards report + as `|` -- only the part before the pipe is recognizable. + A board reporting no modelNum falls back to /oic/p's mnmo. Shared with + resolve_serial's motivation: two copies of this split rule could let + the config flow and the coordinator's post-poll recompute disagree, and + a device renaming itself after the first poll is the visible symptom. """ if model_num: 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: - """The HA device name for a resolved device type + model. - - Shared by the config flow (which builds the entry's stored identity) and - the coordinator's post-discovery rebuild, so the name a device is first - registered under is the same string discovery would produce later -- - otherwise every setup would rename the device once the first poll landed. - """ + """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 + device is first registered under matches what discovery produces later + -- 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" 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, ...]: """/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 - generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner' - or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()` - now consults this first, ahead of board-part-number parsing, via - `for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a - minority of dumps populate it, so the modelNum/description path stays - load-bearing for everything else. It's also kept whole in diagnostics - (see `raw` below) so incoming issue reports keep surfacing types that - table doesn't know about yet. + The one standardized "what am I" field in OCF: alongside the generic + 'oic.wk.d' it carries a concrete type like 'oic.d.airconditioner' or a + SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()` + consults this first, via `for_device_by_oic_type`, but only a minority + of dumps populate it, so the modelNum/description path stays + load-bearing. Kept whole in diagnostics (see `raw` below) so issue + reports keep surfacing types the table doesn't know about yet. """ rt = d.get("rt") if isinstance(rt, str): @@ -142,17 +130,13 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity: p = _get(sess, ["oic", "p"]) d = _get(sess, ["oic", "d"]) # /oic/res is OCF's baseline resource-discovery endpoint: a unicast - # RETRIEVE on it returns every Resource/Collection href this endpoint - # hosts, not just the one /device/0 seed path the coordinator polls. - # Relevant for the OCF "Composite Device" model (issue #177: a single - # physical device -- one IP, one /oic/p -- exposing more than one logical - # subdevice, each as its own Collection resource, same rt shape as our own - # /device/0). This is what registry.subdevices.enumerate_subdevices reads - # to find a board's `/device/` siblings (Pattern A -- the reporter's - # 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. + # RETRIEVE returns every Resource/Collection href this endpoint hosts, + # not just /device/0. Relevant for the "Composite Device" model (issue + # #177: one physical device exposing more than one logical subdevice, + # each its own Collection). registry.subdevices.enumerate_subdevices + # reads this to find a board's `/device/` siblings -- that probing + # used to run right here on every _connect_session/reconnect and moved + # to that module so it only runs once, at first discovery. res = _get_links(sess, ["oic", "res"]) return DeviceIdentity( manufacturer=p.get("mnmn") or "Samsung", @@ -160,8 +144,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity: name=d.get("n") or "", serial=serial, device_types=_device_types(d), - # Kept whole rather than field-by-field: these resources are outside - # the /device/0 dump diagnostics already captures, and we don't yet - # know which of their fields will turn out to identify a device type. + # Kept whole rather than field-by-field: outside the /device/0 dump + # diagnostics already captures, and we don't yet know which fields + # will turn out to identify a device type. raw={"/oic/p": p, "/oic/d": d, "/oic/res": res}, ) diff --git a/custom_components/localthings/registry/redact.py b/custom_components/localthings/registry/redact.py index 9b09a8a..1ed8e47 100644 --- a/custom_components/localthings/registry/redact.py +++ b/custom_components/localthings/registry/redact.py @@ -30,19 +30,12 @@ _SENSITIVE_SUBSTRINGS = ( "secret", ) -# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit -# with bare one- and two-letter keys that the rules above cannot see, being -# far too short to match on -- 'di' alone is a substring of 'condition', -# 'display', 'dispenser' and plenty of other ordinary appliance fields: -# -# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial -# 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. +# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the +# unit with bare one/two-letter keys too short for the substring rules above +# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the +# 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 +# that resource is `rt`, which is not redacted. _SENSITIVE_EXACT = frozenset({"di", "pi", "n"}) diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 02fec48..9c4ea06 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -1,90 +1,36 @@ -"""Subdevice ("composite device") support for one physical connection exposing -more than one logical indoor subdevice -- issue #177. +"""Subdevice ("composite device") support for one physical connection +exposing more than one logical indoor subdevice -- issue #177. -Two reporters, two different board families, two genuinely different -mechanisms for exposing a second indoor subdevice over one IP / one DTLS -session (see DESIGN-177.md section 1 for the full evidence trail; the two -diagnostics dumps this was built against come from the Pattern A and -Pattern B reporters, respectively -- they each filed one of the two -reports this module unifies): +Three discovery patterns, unified by the same shape: a logical subdevice is +a seed collection path to poll, plus an href transform between the +canonical href the registry knows (e.g. `/mode/vs/0`) and the actual +on-the-wire href. -Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's -board). `/oic/res` lists three complete parallel resource sets whose -trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`, -`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s -batch carries only the index-0 hrefs -- the sibling subdevices are -reachable only via their own `/device/` collection. +- **Pattern A -- indexed siblings** (`ARTIK051_DONGLE_FAC_18K`). `/oic/res` + lists parallel resource sets by trailing index (`/mode/vs/0`, + `/mode/vs/1`, ...); each sibling has its own `/device/` Collection. +- **Pattern B -- UUID-prefixed tree** (`TP2X_FAC_BORA_21K`). `/oic/res` + hides the tree; `/subdevices/vs/0`'s `subdeviceIdList` gives the UUID. + `GET //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/` + 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). -`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead -carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that -same UUID appears as a literal href prefix in `/oic/res` -(`//file/list/vs/0`, ...). What's actually been confirmed live on -that reporter's unit is narrower than early issue #177 writeups suggested: a -single individual `GET //information/vs/0` was read by hand through -the debug panel and came back carrying a different model/serial than the -master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the -master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a -second subdevice exists at that prefix, but not evidence that `GET -//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: `//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 //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 +A non-empty seed batch is necessary but not sufficient for a candidate to +be a real second subdevice: an unused SmartThings slot (e.g. the Pattern A +reporter's own `/device/2`) answers the same shape with constant/echoed +reps and no live state. Gating on resource shape would need per-family +domain knowledge, so `discover_partitioned` instead gates at the *entity* +layer: a candidate is only materialized if it produces at least one live, +non-`None`, primary (no `entity_category`), non-meter bound entity. The +meter exclusion (issue #214) covers a second failure mode: an unused slot +reporting a populated whole-appliance energy counter, which is the +appliance's own bookkeeping, not evidence of a second indoor unit -- see `_has_live_primary_entity`. """ @@ -102,18 +48,15 @@ from .by_type._base import DeviceRegistry _INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$") # 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 -# nowhere except as this prefix (no subdeviceIdList, no /device/). +# discovery signal (issue #241). _UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/") # Speculative /device/ siblings probed when /oic/res doesn't reveal a -# second logical subdevice's Collection on this board (moved here from -# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the -# old read_identity fired these two extra RETRIEVEs on *every* _connect_session, -# including every reconnect, for information enumeration only needs once). -# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of -# 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. +# second subdevice's Collection (moved here from identity.py, issue #177, +# since the old read_identity fired these on every _connect_session +# including reconnects, when enumeration only needs to run once). A plain +# tolerated-404 RETRIEVE, not the kind of guess the write-contract +# 'don't guess' rule is about. Widen only if a board needs more siblings. _SPECULATIVE_DEVICE_INDICES = (1, 2) @@ -122,22 +65,20 @@ class Subdevice: """One logical indoor subdevice reachable over a single physical connection. - `kind='main'` is the subdevice this config entry actually connects to and - always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the - identity transform, so every existing single-subdevice device keeps - behaving exactly as it did before this module existed. `'indexed'`/ - `'prefixed'` are Pattern A/B above; `key` is the trailing index string - ('1', '2', ...) or the full subdevice UUID, and `seed_path` is the - Collection href (as path segments) whose batch response - enumerates/refreshes that subdevice. + `kind='main'` is the subdevice this config entry actually connects to + and always exists (see MAIN below) -- its `to_actual`/`to_canonical` + are the identity transform, so a single-subdevice device behaves + exactly as before this module existed. `'indexed'`/`'prefixed'` are + Pattern A/B above; `key` is the trailing index string ('1', '2', ...) + or the full subdevice UUID, and `seed_path` is the Collection href (as + path segments) whose batch enumerates/refreshes that subdevice. - `flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't - expose its own Collection at `seed_path` (issue #205 -- not even - TP2X_FAC_BORA_21K, the board this pattern was built against, always - does). When set, `seed_path` is meaningless (left as `()`) and this - subdevice's state comes from GETting each of these canonical hrefs - individually under its prefix instead of one Collection batch -- see - enumerate_subdevices' fallback and coordinator._poll_subdevice_seed. + `flat_hrefs` is non-empty only for a 'prefixed' subdevice with no + Collection at `seed_path` (issue #205). When set, `seed_path` is + meaningless (left as `()`) and this subdevice's state comes from + GETting each of these canonical hrefs individually under its prefix + instead -- see enumerate_subdevices' fallback and + coordinator._poll_subdevice_seed. """ kind: str # 'main' | 'indexed' | 'prefixed' @@ -150,13 +91,11 @@ class Subdevice: on-the-wire href for this subdevice.""" if self.kind == "indexed": head, sep, tail = canonical.rpartition("/") - # Only the index-0 trailing segment is ours to rewrite -- - # deliberately not a "replace any trailing digit" rule, which - # would misread a genuine multi-instance resource (the fridge's - # pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No - # registry declares a non-zero trailing index today and no - # fixture in the corpus contains one (verified across the whole - # corpus), so the strict rule costs nothing. + # Only the index-0 trailing segment is ours to rewrite -- not a + # "replace any trailing digit" rule, which would misread a + # genuine multi-instance resource (e.g. the fridge's + # '/door/vs/1') as a subdevice's. No registry declares a + # non-zero trailing index today. if tail == "0": return f"{head}{sep}{self.key}" return canonical @@ -179,25 +118,24 @@ class Subdevice: return actual def owns(self, actual: str) -> bool: - """True if `actual` belongs to this subdevice's namespace. MAIN never - "owns" anything by this definition -- it gets whatever's left after - every other subdevice's hrefs are excluded (see canonical_view).""" + """True if `actual` belongs to this subdevice's namespace. MAIN + never "owns" anything by this definition -- it gets whatever's + left after every other subdevice's hrefs are excluded (see + canonical_view).""" if self.kind == "main": return False return self.to_canonical(actual) is not None @property def key_prefix(self) -> str: - """Prefix that guarantees a unique entity key/unique_id (see - adapter._key). '' for MAIN -- the master's flattened state - keys must stay byte-identical to every device this integration - shipped before issue #177, so no golden file changes. The full - subdevice UUID is used verbatim (non-alphanumerics stripped, not - truncated or replaced with an ordinal) because it's device-reported - and stable across reconnects/restarts, unlike an ordinal assigned by - enumeration order -- and it never appears in a user-visible string - (see DESIGN-177.md section 6): HA derives the visible entity_id from - the device name + entity name, not from unique_id. + """Prefix guaranteeing a unique entity key/unique_id (see + adapter._key). '' for MAIN, so the master's flattened state keys + stay byte-identical to every device shipped before issue #177. The + full subdevice UUID is used verbatim (non-alphanumerics stripped) + rather than an enumeration-order ordinal, since it's device-reported + and stable across reconnects; it never appears in a user-visible + string, since HA derives entity_id from device+entity name, not + unique_id. """ if self.kind == "indexed": return f"subdevice{self.key}_" @@ -219,16 +157,15 @@ def canonical_view( canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board and friends are written against. - 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` - would leak into the master's view under the same canonical key - ('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to, - silently mixing two subdevices' state together. For an indexed/prefixed - subdevice it's the reverse: only the hrefs that subdevice owns, rewritten - back through `to_canonical`. + 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` would leak into the master's view under the canonical key + ('/mode/vs/0') the master's own resource also maps to. For an + indexed/prefixed subdevice it's the reverse: only the hrefs that + subdevice owns, rewritten back through `to_canonical`. - `subdevices` may or may not include MAIN itself -- MAIN.owns() is always - False, so including it is harmless. + `subdevices` may or may not include MAIN itself -- MAIN.owns() is + always False, so including it is harmless. """ if subdevice.kind == "main": 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. Indexed subdevices need no change -- the device echoes the real `/x/` - href in its own `/device/` batch (confirmed against the Pattern A - reporter's dump). A prefixed subdevice's batch entries may or may not - already carry the `/` prefix (unconfirmed which -- the Pattern B - reporter's board was never probed live before the subdevice id was - known), so it's added when missing. + href in its own `/device/` batch. A prefixed subdevice's batch + entries may or may not already carry the `/` prefix (unconfirmed), + so it's added when missing. """ if subdevice.kind != "prefixed": return batch @@ -265,9 +200,8 @@ def _iter_oic_res_hrefs(oic_res): Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]` -- 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 - rules it out, and _get_links' own posture already treats any list-shaped - body as possible) and of anything else by yielding nothing. + handled here. Tolerant of a flat link-list too, and of anything else by + yielding nothing. """ for entry in oic_res or []: 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, ...]): """GET `path_segs` and CBOR-decode the payload, or None on any missing/malformed response (a 4.04, a timeout, an empty payload) -- - shared tolerated-absence posture for both callers below, which differ - only in which body shape they accept.""" + shared tolerated-absence posture for both callers below.""" try: code, pl = sess.get(list(path_segs), timeout=10.0) 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: """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 - `/oic/res` on the Pattern A reporter's board but absent from - `/device/0`'s batch, so it needs its own RETRIEVE, and it answers a - single Property map, not a [devcol-rep, ...] list.""" + batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent + from `/device/0`'s batch, so it needs its own RETRIEVE.""" body = _get_raw(sess, path_segs) return body if isinstance(body, dict) else {} @@ -329,34 +260,27 @@ def enumerate_subdevices( connection. Runs once, at first discovery, in an executor, under the coordinator's - session lock -- every GET here is a plain RETRIEVE (the write-contract - 'don't guess' rule doesn't apply to reading an extra resource to find - out whether it's there). Returns the *candidate* subdevices and the resources - already fetched while probing them (already normalized to real hrefs), - so the coordinator's first discovery poll doesn't need to re-poll them. + session lock -- every GET here is a plain RETRIEVE. Returns the + *candidate* subdevices and the resources already fetched while probing + them (normalized to real hrefs), 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 - not it answered -- so diagnostics (see diagnostics.py's subdevice_probes) - can tell "checked, nothing there" apart from "never checked", the same - posture the speculative-probe code this replaces used to document in - identity.py. + `probe_log(seed_href, found)` fires for every seed attempted, whether + or not it answered, so diagnostics can tell "checked, nothing there" + apart from "never checked". 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 - SmartThings slot that merely answers the same shape (the Pattern A - reporter's `/device/2`); that requires discovering+flattening the - candidate's own entities first, which is `discover_partitioned`'s job, - not this one's. - See this module's docstring. + here -- this function can't tell a real sibling from an unused + SmartThings slot that answers the same shape; that requires + discovering+flattening the candidate's own entities first, which is + `discover_partitioned`'s job. See this module's docstring. """ subdevices: list[Subdevice] = [] fetched: dict[str, dict] = {} # Case-insensitive -- the same UUID can reach here once from # subdeviceIdList and once from an /oic/res link prefix with different - # casing (Samsung's own fields disagree on this elsewhere too, e.g. the - # redaction-prone subdeviceIdList handling below), and probing it twice - # would materialize the same physical subdevice as two Subdevice - # candidates under two different keys. + # casing, and probing it twice would materialize the same physical + # subdevice as two Subdevice candidates. probed_ids: set[str] = set() def _probed(seed_href: str, batch: dict) -> None: @@ -379,29 +303,22 @@ def enumerate_subdevices( fetched.update(normalize_seed_batch(subdevice, batch)) subdevices.append(subdevice) return - # Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this - # pattern was built against -- turns out not to always expose its own - # `//device/0` Collection either, so "every prefixed subdevice - # 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 + # Fallback (issue #205): even the reference TP2X_FAC_BORA_21K board + # doesn't always expose its own `//device/0` Collection. 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 - # device's siblings are the same physical board family as the - # subdevice this config entry already talks to -- so probe every - # href the master itself answered this cycle, individually, under - # this UUID's prefix, and keep whichever ones answer. Each is a - # plain tolerated-404 RETRIEVE, same posture as every other probe in - # this function. + # device's siblings share the master's own resource surface -- so + # probe every href the master answered this cycle, individually, + # under this UUID's prefix, and keep whichever answer. Each is a + # plain tolerated-404 RETRIEVE. # - # Known gap, not yet guarded against: a firmware that answers *any* - # request under an unrecognized prefix (echoing the master's own - # state back rather than 4.04ing) would pass every one of these - # probes and, if the echoed state also clears discover_partitioned's - # liveness gate, materialize a phantom duplicate of the master - # rather than a real sibling. Every board seen so far genuinely - # 4.04s on paths it doesn't own (issue #205's own unit answered only - # 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. + # Known gap: a firmware that echoes the master's own state back + # under an unrecognized prefix, rather than 4.04ing, would pass + # every probe here and could materialize a phantom duplicate. Every + # board seen so far genuinely 4.04s on paths it doesn't own (issue + # #205's unit answered only 1 of 31 probes), so this hasn't been + # guarded against -- the fix would compare a candidate's confirmed + # reps against the master's own values for the same hrefs. flat_hrefs = [] first = True for href in sorted(resources): @@ -427,35 +344,26 @@ def enumerate_subdevices( # --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------ 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 - # (it matches the 'deviceid' substring rule in redact.py) and the existing - # airconditioner_fac_bora fixture carries the literal string - # '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a - # crash -- issue #177 is additive, it must never break an already-working - # single-climate-entity device. + # Tolerate anything but a list of strings -- this field is + # redaction-prone (matches redact.py's 'deviceid' rule) and a shipped + # fixture carries the literal string 'REDACTED' there. That must yield + # zero subdevices, not a crash -- issue #177 is additive and must never + # break an already-working single-climate-entity device. ids = raw_ids if isinstance(raw_ids, list) else [] listed = sorted(i for i in ids if isinstance(i, str) and i) for sub_id in listed: _probe_prefixed(sub_id) # --- Pattern C: UUID prefix advertised only via /oic/res ---------------- - # (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third - # multidevice shape: the board answers numofsubdevice='2' on - # /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList - # -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). - # The only trace of the sibling is a UUID-prefixed link in /oic/res - # itself: the x.com.samsung.da.multidevice link, - # '//multidevice/vs/0' on the reporting board. Its washer tree - # answers a full Collection at //device/0, exactly Pattern B's - # transform -- so treat every UUID path prefix seen in /oic/res as a - # 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. + # (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No + # /subdevices/vs/0 and /device/ 404s; the only trace of the sibling + # is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice + # link). Its own tree answers a full Collection at //device/0, + # exactly Pattern B's transform, so every UUID path prefix seen in + # /oic/res is treated as a candidate. _probe_prefixed's probed_ids + # guard (not a set difference against `listed`) is what keeps an id + # already named by subdeviceIdList from being probed twice, since the + # two sources can disagree on case. linked = sorted( { m.group(1) @@ -477,10 +385,9 @@ def enumerate_subdevices( } ) if not indices: - # A board that hides its whole tree from /oic/res (Pattern B's - # reporter board does this too, but it has no /device/ to find - # regardless) gives us nothing to enumerate from -- fall back to the - # bounded speculative probe this replaces from identity.py. + # A board that hides its whole tree from /oic/res gives us nothing + # to enumerate from -- fall back to the bounded speculative probe + # this replaces from identity.py. indices = list(_SPECULATIVE_DEVICE_INDICES) for n in indices: seed = ("device", str(n)) @@ -492,19 +399,13 @@ def enumerate_subdevices( fetched.update(batch) # already real /x/ hrefs, no normalization needed subdevices.append(subdevice) - # /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's - # board lists it in /oic/res but it never appears in /device/0's batch, - # so it needs its own RETRIEVE. It's a plain corroborating count - # (x.com.samsung.da.numofsubdevice), confirmed read-only (a write - # attempt returned CoAP 4.00) -- captured for diagnostics only, folded - # into the merged resources dict like any other href (see - # airconditioner._AC_IGNORED, which is what keeps it from surfacing as - # 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/vs/0: listed in /oic/res on some boards but never in + # /device/0's batch, so it needs its own RETRIEVE. A plain corroborating + # count (numofsubdevice), confirmed read-only -- captured for + # diagnostics only, folded into the merged resources dict like any + # other href (see airconditioner._AC_IGNORED). Not a gate: + # discover_partitioned's entity-level liveness check decides + # materialization without it. multidevice_seed = ("multidevice", "vs", "0") multidevice = _get_property(sess, multidevice_seed) _probed(_seed_href(multidevice_seed), multidevice) @@ -516,23 +417,21 @@ def enumerate_subdevices( @dataclass(frozen=True) class SkippedSubdevice: - """A candidate `enumerate_subdevices` found whose seed answered, but that - `discover_partitioned`'s entity-level liveness gate rejected -- an - unused SmartThings slot (the Pattern A reporter's `/device/2`), not a - real second subdevice. Kept around (rather than silently dropped) so a + """A candidate `enumerate_subdevices` found whose seed answered, but + that `discover_partitioned`'s entity-level liveness gate rejected -- an + unused SmartThings slot, not a real second subdevice. Kept around so a caller can log/report what was skipped and why.""" subdevice: Subdevice 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 -# liveness gate below (issue #214). HA's own running-total state classes -# cover most of them; the consumption device classes catch the rest, since a -# descriptor may deliberately declare no state_class (common.ENERGY_METER's -# monthly totals reset at each billing boundary, so they aren't -# `total_increasing`). +# liveness gate below (issue #214). HA's running-total state classes cover +# most of them; the consumption device classes catch the rest (a descriptor +# may deliberately declare no state_class, e.g. common.ENERGY_METER's +# monthly totals that reset at each billing boundary). _METER_STATE_CLASSES = frozenset({"total", "total_increasing"}) _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: """True if flattening `bound` (one candidate subdevice's BoundEntity - 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" - tier (see the adding-device-support skill's entity-taxonomy section) -- - that isn't a cumulative meter (`_is_meter`). + list) produced at least one non-`None` value for a primary entity + (`entity_category` unset) that isn't a cumulative meter (`_is_meter`). This is the materialization gate itself (see this module's docstring). - Two exclusions, both for the same reason -- the question this answers is - "is a physical subdevice installed at this slot?", and neither kind of - value can speak to it: + Two exclusions, both because the question this answers is "is a + physical subdevice installed at this slot?", and neither kind of value + can speak to it: - - **Non-primary entities.** The Pattern A reporter's `/device/2` does - flatten to one non-`None` value (`alarm_code`), but that entity is - `diagnostic`-category and derived from an empty `/alarms/vs/2` -- a - config/diagnostic entity reading "something" proves nothing about - whether hardware is there. - - **Cumulative meters** (issue #214). An unused slot on the issue #214 - reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a - populated `cumulativePower` while every operational rep on it - (`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`, - `/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` -- - i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh - counter. That counter got the slot materialized as a phantom second - 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. + - **Non-primary entities.** An unused slot can still flatten to a + diagnostic-category value derived from an empty resource (e.g. a + formatted `alarm_code` off an empty `/alarms/vs/2`) -- that proves + nothing about whether hardware is there. + - **Cumulative meters** (issue #214). An unused slot has been seen + reporting a populated whole-appliance `cumulativePower` while every + operational rep on it is empty `{}`. 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, and that is what still passes + this gate. """ 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, oic_device_types: Sequence[str] = (), ): - """Bind every href in `resources` (the merged, real-href snapshot -- main - plus every enumerated subdevice's seed) to entities, partitioned by which - subdevice owns it. + """Bind every href in `resources` (the merged, real-href snapshot -- + main plus every enumerated subdevice's seed) to entities, partitioned + by which subdevice owns it. Main pass runs over hrefs owned by no subdevice -- otherwise every - `/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main - device's registry claims that literal href) and raise a spurious - coverage-gap repair. Then one pass per *candidate* subdevice over its own + `/mode/vs/1` would land in `unbound_hrefs` too and raise a spurious + coverage-gap repair. Then one pass per candidate subdevice over 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 - wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token -> - airconditioner), falling back to the master's registry otherwise -- - 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. + `/information/vs/0` when it reports one, falling back to the master's + registry otherwise -- a sibling that fails to answer its own identity + resource is still treated as the same appliance type as the master. - Each candidate is discovered and flattened *twice*: once silently to - evaluate `_has_live_primary_entity` (this module's materialization - gate -- see its docstring and this module's own), and, only if that - passes, a second time with `log`/`tier_log` wired so its coverage gaps - and poll tiers actually count. A candidate that fails the gate - 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. + Each candidate is discovered and flattened twice: once silently to + evaluate `_has_live_primary_entity`, and, only if that passes, a second + time with `log`/`tier_log` wired so its coverage gaps and poll tiers + actually count. A candidate that fails the gate contributes nothing at + all, as if it had never answered its seed. - `oic_device_types` (from the master's own `/oic/d`, see - registry/identity.py) is passed only to the *master's* resolution -- - subdevices have no `/oic/d` of their own read today (they resolve from - their own `/information/vs/0` or fall back to the master's whole - registry, as documented above), and blindly applying the master's OCF - device type to every subdevice's own model-based resolution would be - wrong the moment a composite appliance ever pairs two genuinely - different device types under one connection. + `oic_device_types` (from the master's own `/oic/d`) is passed only to + the master's resolution -- subdevices resolve from their own + `/information/vs/0` or fall back to the master's whole registry, and + blindly applying the master's OCF device type to every subdevice would + be wrong the moment a composite appliance pairs two different device + types under one connection. Returns `(bound, device_type_name, materialized, skipped)`: - `bound`: the concatenated BoundEntity list (main + every materialized subdevice). - - `device_type_name`: the *master's* resolved device type (used for - logging/device naming; each subdevice's own resolved type only affects - which capabilities bind its hrefs, not this). - - `materialized`: the subset of `subdevices` that passed the gate, in the - same order -- what the caller should keep as its live subdevice roster - going forward (poll seeds, canonical_resources, device_info_for, ...). + - `device_type_name`: the master's resolved device type (used for + logging/device naming; each subdevice's own resolved type only + affects which capabilities bind its hrefs). + - `materialized`: the subset of `subdevices` that passed the gate, in + the same order -- what the caller should keep as its live subdevice + roster going forward (poll seeds, canonical_resources, + device_info_for, ...). - `skipped`: `SkippedSubdevice` entries for every candidate that didn't. """ - # Deferred import: discovery.py imports Subdevice/MAIN from this module at - # module scope, so importing discover() back here at module scope would - # be a circular import. By the time this function actually runs both - # modules are fully loaded. adapter.py imports discovery.py, so the same - # applies to flatten()/_key(). + # Deferred import: discovery.py imports Subdevice/MAIN from this module + # at module scope, so importing discover() back here at module scope + # would be circular. By the time this function runs both modules are + # fully loaded; adapter.py imports discovery.py, so the same applies to + # flatten()/_key(). from .adapter import flatten from .discovery import discover - # Same computation canonical_view does for MAIN (snapshot minus every - # other subdevice's owned hrefs) -- reuse it rather than re-deriving - # owned_elsewhere here too. + # Same computation canonical_view does for MAIN -- reuse it rather than + # re-deriving owned_elsewhere here too. main_view = canonical_view(MAIN, resources, subdevices) reg = resolve_registry(main_view, device_types=oic_device_types) diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index 37338e2..a58abcb 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -52,34 +52,26 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None: def _display(value, translation_key: str | None): """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 can itself be a callable -- see entities.py -- so - callers pass the resolved value, e.g. self.translation_key, not - the raw descriptor field). + `translation_key` is the entity's already-resolved key (it can itself + be a callable -- see entities.py -- so callers pass the resolved + value, not the raw descriptor field). An entity with a translation_key looks its state up in the shipped - translation catalog, whose state keys are lowercase -- so those values - must be lowercased exactly to match, and the device still expects - that same raw casing back on write (callers map the displayed value - back to raw via _raw_options()). - - Everything else has no catalog lookup, so there's no reason to - destroy the device's own casing. Only two cosmetic fixups apply: a - 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. + translation catalog, whose state keys are lowercase, and the device + still expects that same raw casing back on write (mapped back via + _raw_options()). Everything else has no catalog lookup, so there's no + reason to destroy the device's own casing: only two cosmetic fixups + apply, title-casing a fully lowercase token ("voice") and spacing a + PascalCase one ("ExtraHigh" -> "Extra High"); an already-friendly value + ("AI Wash") matches neither and passes through unchanged. """ if not isinstance(value, str): return value if translation_key: known = translated_states("select", translation_key) if not known: - # No state table for this key: either the entity isn't translated - # at all, or its name is translated but its options deliberately - # aren't (an unrecognized course table, say). Either way the - # opaque device value is the best thing to show. + # No state table for this key: either untranslated, or its + # options deliberately aren't (an unrecognized course table). return value if translated := _translation_state(value, known): return translated @@ -99,12 +91,11 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity): desc = cast(SelectDesc, self._bound.desc) if callable(desc.options): # Per-device option list computed from the full resource - # snapshot (not just this entity's own href) -- e.g. a course - # list decoded from a sibling resource. There is no static - # fallback: when that resource isn't populated the callable - # returns [] and the entity's exists_fn suppresses it entirely. - # This entity's own subdevice's canonical view (issue #177), not - # the raw actual-href snapshot -- see LocalThingsEntity._resources. + # snapshot -- e.g. a course list decoded from a sibling + # resource. No static fallback: when unpopulated, the callable + # returns [] and exists_fn suppresses the entity entirely. Uses + # this subdevice's canonical view (issue #177), not the raw + # snapshot -- see LocalThingsEntity._resources. return list(desc.options(self._resources) or []) if desc.options_field: rep = self.coordinator.last_resources.get(self._bound.href) or {} diff --git a/custom_components/localthings/water_heater.py b/custom_components/localthings/water_heater.py index 4880d73..ef8c69f 100644 --- a/custom_components/localthings/water_heater.py +++ b/custom_components/localthings/water_heater.py @@ -1,38 +1,29 @@ """Water heater platform for Local Things. Second composite entity in this integration (see climate.py's module -docstring for the general pattern this follows): a single HA water_heater -card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds -the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities -in registry/capabilities/ehs.py) so the registry still tracks it, and reads -the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight -from the coordinator snapshot -- the same cross-resource read climate.py uses -for the AC's power/temperature/wind siblings. +docstring for the general pattern): a single HA water_heater card for a +Samsung EHS heat pump's domestic hot water (DHW) loop. It binds the primary +`WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities in +registry/capabilities/ehs.py) and reads the sibling `/power/dhw/vs/0` and +`/temperatures/dhw/vs/0` resources straight from the coordinator snapshot, +the same cross-resource read climate.py uses. -Writes go through `coordinator.async_send_command(bound, (kind, value))`: -DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the -right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs -and applies the optimistic value/settle guard to that same href -- not the -bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets -fresh state back for, power, mode and temperature alike. +Writes go through `coordinator.async_send_command`: DHW's `write_fn` +(ehs._dhw_write) maps each `(kind, value)` payload to the right +`(path_segs, body)`, applying the optimistic value/settle guard to that +resource's own href rather than the bound `/mode/dhw/vs/0` href. 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 -Home Assistant's core `smartthings` integration uses for this exact Samsung -capability over the cloud API (`samsungce.ehsThermostat` / -`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/ -STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF -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). +Power) map onto HA's own standard water_heater states, the same mapping +HA core's `smartthings` integration uses for this exact Samsung capability +(`samsungce.ehsThermostat`), just title-cased to match this OCF resource's +spelling. Reusing HA's standard states means no state translation catalog +entry is needed for them. -Naming is a separate question from that, and the answer here differs from -climate.py's: the AC *is* the device, so its climate card takes the bare -device name (`_attr_name = None`). An EHS unit has two loops, and the DHW -one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target -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'` +Naming differs from climate.py's: the AC *is* the device, so its card takes +the bare device name. An EHS unit has two loops, and DHW isn't "the +device" (siblings are named "Zone Mode"/"Zone Target Temperature"), so this +entity is named through the catalog via `translation_key='dhw'` (entity.water_heater.dhw.name -> "Hot water"). """ @@ -73,8 +64,7 @@ _LOGGER = logging.getLogger(__name__) _MODES_FIELD = "x.com.samsung.da.modes" _SUPPORTED_FIELD = "x.com.samsung.da.supportedModes" -# Device mode <-> HA water_heater operation state -- see the module -# docstring above for the SmartThings-cloud precedent this mirrors. +# Device mode <-> HA water_heater operation state -- see module docstring. _DEVICE_TO_STATE: dict[str, str] = { "Eco": STATE_ECO, "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()} -# Read-side lookup, case-folded. climate.py resolves write codes from the -# unit's own supportedModes because two spellings there mean one HA value -# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can -# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board -# spelling the same code differently ('eco'/'ECO'), and case is the one -# variation worth absorbing rather than warning about. +# Read-side lookup, case-folded: this map is bijective (unlike climate.py's +# 'Wind'/'Fan' -> FAN_ONLY), so the write side uses _STATE_TO_DEVICE +# directly; only the read side needs to absorb a board spelling the same +# code differently ('eco'/'ECO'). _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: super().__init__(coordinator, bound) # 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 - # descriptor's translation_key -- see the module docstring. + # two-loop device and takes a catalog name through translation_key. self._attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.ON_OFF ) - # Raw device codes already logged by _warn_unmapped -- these - # properties are read on every coordinator refresh, so an un-deduped - # warning would spam the log for any unit reporting a genuinely - # unrecognized code. + # Raw device codes already logged by _warn_unmapped -- read on every + # refresh, so un-deduped would spam the log for an unrecognized code. self._warned_unmapped: set[str] = set() 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), same as climate.py's identical helper.""" 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")) def _range(self) -> list | None: - """The device's own (minimum, maximum) pair, or None. - - Both ends together or neither, deliberately -- same rule as - climate._range(). A board reporting minimum but not maximum would - 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. - """ + """The device's own (minimum, maximum) pair, or None. Both ends + together or neither -- same rule as climate._range(); a board + reporting only minimum would otherwise pair it with HA's own + default maximum, silently wrong.""" rep = self._rep(TEMPERATURE_HREF) lo = _num(rep.get("x.com.samsung.da.minimum")) hi = _num(rep.get("x.com.samsung.da.maximum")) @@ -213,8 +194,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity): @property def target_temperature_step(self) -> float: - # `is None`, not `or` -- see issue #160: `or` collapses a genuine 0 - # into the fallback. + # `is None`, not `or` -- `or` would collapse a genuine 0 (issue #160). step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment")) return 0.5 if step is None else step @@ -245,13 +225,11 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity): # -- writes --------------------------------------------------------------- async def async_set_temperature(self, **kwargs) -> None: - # HA's water_heater.set_temperature service takes an optional - # operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same - # as climate.set_temperature does with hvac_mode. Honour it, and set - # it first -- that also powers the loop on when it was off -- so a - # dashboard "boost to 55" button that carries a mode actually changes - # mode, instead of only moving the setpoint. Same fix as the AC's - # (see climate.async_set_temperature). + # HA's water_heater.set_temperature service can carry an optional + # operation_mode; honor it, setting the mode first (which also + # powers the loop on) so a dashboard "boost to 55" button that + # carries a mode actually changes mode, not just the setpoint. Same + # fix as climate.async_set_temperature. operation_mode = kwargs.get("operation_mode") if operation_mode is not None: await self.async_set_operation_mode(operation_mode) diff --git a/docs/investigations/ac-filter-reset.md b/docs/investigations/ac-filter-reset.md new file mode 100644 index 0000000..858b049 --- /dev/null +++ b/docs/investigations/ac-filter-reset.md @@ -0,0 +1,75 @@ +# AC filter-time counter reset: not solved + +`registry/capabilities/airconditioner.py`'s `filter_time` sensor +(`FilterTime_` 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.