From 161bce713d7eee25354074f641477339f53b39f3 Mon Sep 17 00:00:00 2001 From: Jason Rumney Date: Sun, 29 Sep 2024 00:27:58 +0900 Subject: [PATCH] device config: handle feature flag dps in entities and mapping lists Allow feature flag dps to be used to hide mappings and disable entities. For entities, this also affects their default hidden state, but this may depend on startup timing, as HA likely checks this early during startup, when the device communication may not be available yet. At least though they will show as Unavailable and the user can hide them, rather than having a false value that misleads as to that feature working. Issue #2337 --- .../tuya_local/devices/README.md | 24 +++++++++ .../tuya_local/helpers/device_config.py | 53 +++++++++++++------ custom_components/tuya_local/helpers/mixin.py | 4 +- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/custom_components/tuya_local/devices/README.md b/custom_components/tuya_local/devices/README.md index 46845f1c3e..0307f680f4 100644 --- a/custom_components/tuya_local/devices/README.md +++ b/custom_components/tuya_local/devices/README.md @@ -332,6 +332,18 @@ example, some devices have a "Manual" mode, which is automatically selected when adjustments are made to other settings, but should not be available as an explicit mode for the user to select. +### `available` + +*Optional.* + +This works the similarly to `hidden` above, but instead of a boolean +value, this should be set to the name of an attribute, which returns a +boolean value, so that the value can be dynamically hidden or shown. A +typical use is where variants of a device use the same config, and +have a flag attribute that indicates whether certain features are +available or not. The mapping will be hidden from the values list when +the referenced attribute is showing `false`, and shown when it is `true`. + ### `scale` *Optional, default=1.* @@ -532,6 +544,18 @@ Note that each condition must specify a `dps_val` to match againt. If you want t ``` +## Generic dps + +The following dps may be defined for any entity type. The names should be +avoided for any extra attribute that is not for the listed purpose. + +- **available** (optional, string) a dp name that returns a boolean indicating +whether the entity should show as available or not (even when it appears to be +returning valid state). This may be used to disable entities that the device +indicates it does not support, through a feature flag dp. This should only be +used when the device is permanently indicating a missing feature, as HA may +hide the entity if it is marked as unavailable early enough during startup. + ## Entity types Entities have specific mappings of dp names to functions. Any unrecognized dp name is added to the entity as a read-only extra attribute, so can be observed and queried from HA, but if you need to be able to change it, you should split it into its own entity of an appropriate type (number, select, switch for example). diff --git a/custom_components/tuya_local/helpers/device_config.py b/custom_components/tuya_local/helpers/device_config.py index b00f2ac849..c24007e603 100644 --- a/custom_components/tuya_local/helpers/device_config.py +++ b/custom_components/tuya_local/helpers/device_config.py @@ -289,7 +289,7 @@ def device_class(self): return self._config.get("class") def icon(self, device): - """Return the icon for this device, with state as given.""" + """Return the icon for this entity, with state as given.""" icon = self._config.get("icon", None) priority = self._config.get("icon_priority", 100) @@ -317,6 +317,13 @@ def find_dps(self, name): return d return None + def available(self, device): + """Return whether this entity should be available, with state as given.""" + avail_dp = self.find_dps("available") + if avail_dp and device.has_returned_state: + return avail_dp.get_value(device) + return True + class TuyaDpsConfig: """Representation of a dps config.""" @@ -479,6 +486,13 @@ async def async_set_value(self, device, value): settings = self.get_values_to_set(device, value) await device.async_set_properties(settings) + def should_show_mapping(self, mapping, device): + """Determine if this mapping should be shown in the list of values.""" + if "value" not in mapping or mapping.get("hidden", False): + return False + avail_dp = self._entity.find_dps(mapping.get("available")) + return avail_dp.get_value(device) if avail_dp else True + def values(self, device): """Return the possible values a dps can take.""" if "mapping" not in self._config.keys(): @@ -490,29 +504,33 @@ def values(self, device): return [] val = [] for m in self._config["mapping"]: - if "value" in m and not m.get("hidden", False): + if self.should_show_mapping(m, device): val.append(m["value"]) # If there is mirroring without override, include mirrored values elif "value_mirror" in m: r_dps = self._entity.find_dps(m["value_mirror"]) - val = val + r_dps.values(device) + if r_dps: + val = val + r_dps.values(device) for c in m.get("conditions", {}): - if "value" in c and not c.get("hidden", False): + if self.should_show_mapping(c, device): val.append(c["value"]) elif "value_mirror" in c: r_dps = self._entity.find_dps(c["value_mirror"]) - val = val + r_dps.values(device) + if r_dps: + val = val + r_dps.values(device) cond = self._active_condition(m, device) if cond and "mapping" in cond: _LOGGER.debug("Considering conditional mappings") c_val = [] for m2 in cond["mapping"]: - if "value" in m2 and not m2.get("hidden", False): + if self.should_show_mapping(m2, device): c_val.append(m2["value"]) + elif "value_mirror" in m: r_dps = self._entity.find_dps(m["value_mirror"]) - c_val = c_val + r_dps.values(device) + if r_dps: + c_val = c_val + r_dps.values(device) # if given, the conditional mapping is an override if c_val: _LOGGER.debug( @@ -696,10 +714,12 @@ def _map_from_dps(self, val, device): if redirect: _LOGGER.debug("Redirecting %s to %s", self.name, redirect) r_dps = self._entity.find_dps(redirect) - return r_dps.get_value(device) + if r_dps: + return r_dps.get_value(device) if mirror: r_dps = self._entity.find_dps(mirror) - return r_dps.get_value(device) + if r_dps: + return r_dps.get_value(device) if invert and isinstance(result, Number): r = self._config.get("range") @@ -768,7 +788,7 @@ def _find_map_for_value(self, value, device): if "value" not in m and "value_mirror" in m: r_dps = self._entity.find_dps(m["value_mirror"]) - if str(r_dps.get_value(device)) == str(value): + if r_dps and str(r_dps.get_value(device)) == str(value): return m for c in m.get("conditions", {}): @@ -776,7 +796,7 @@ def _find_map_for_value(self, value, device): c_dp = self._entity.find_dps(m.get("constraint", self.name)) # only consider the condition a match if we can change # the dp to match, or it already matches - if (c_dp.id != self.id and not c_dp.readonly) or ( + if (c_dp and c_dp.id != self.id and not c_dp.readonly) or ( _equal_or_in( device.get_property(c_dp.id), c.get("dps_val"), @@ -785,7 +805,7 @@ def _find_map_for_value(self, value, device): return m if "value" not in c and "value_mirror" in c: r_dps = self._entity.find_dps(c["value_mirror"]) - if str(r_dps.get_value(device)) == str(value): + if r_dps and str(r_dps.get_value(device)) == str(value): return m if nearest: return nearest @@ -856,7 +876,9 @@ def get_values_to_set(self, device, value): if cval is None: r_dps = cond.get("value_mirror") if r_dps: - cval = self._entity.find_dps(r_dps).get_value(device) + mirror = self._entity.find_dps(r_dps) + if mirror: + cval = mirror.get_value(device) if cval == value: c_dps = self._entity.find_dps(mapping.get("constraint", self.name)) @@ -864,7 +886,7 @@ def get_values_to_set(self, device, value): single_match = isinstance(cond_dpsval, str) or ( not isinstance(cond_dpsval, Sequence) ) - if c_dps.id != self.id and single_match: + if c_dps and c_dps.id != self.id and single_match: c_val = c_dps._map_from_dps( cond.get("dps_val", device.get_property(c_dps.id)), device, @@ -883,7 +905,8 @@ def get_values_to_set(self, device, value): if redirect: _LOGGER.debug("Redirecting %s to %s", self.name, redirect) r_dps = self._entity.find_dps(redirect) - return r_dps.get_values_to_set(device, value) + if r_dps: + return r_dps.get_values_to_set(device, value) if scale != 1 and isinstance(result, Number): _LOGGER.debug("Scaling %s by %s", result, scale) diff --git a/custom_components/tuya_local/helpers/mixin.py b/custom_components/tuya_local/helpers/mixin.py index 57886f5419..4c6a0742de 100644 --- a/custom_components/tuya_local/helpers/mixin.py +++ b/custom_components/tuya_local/helpers/mixin.py @@ -37,7 +37,7 @@ def should_poll(self): @property def available(self): - return self._device.has_returned_state + return self._device.has_returned_state and self._config.available(self._device) @property def has_entity_name(self): @@ -99,7 +99,7 @@ def extra_state_attributes(self): @property def entity_registry_enabled_default(self): """Disable deprecated entities on new installations""" - return not self._config.deprecated + return not self._config.deprecated and self._config.available(self._device) async def async_update(self): await self._device.async_refresh()