Преглед изворни кода

Standardise translations for energy consumed and produced sensors.

Completes work submitted in PR #2481 using translations
Jason Rumney пре 1 година
родитељ
комит
87bcbc4196
31 измењених фајлова са 1446 додато и 1296 уклоњено
  1. 70 180
      custom_components/tuya_local/__init__.py
  2. 0 1
      custom_components/tuya_local/devices/atorch_at4pw_energymeter.yaml
  3. 14 6
      custom_components/tuya_local/devices/dcenta_dual_meter.yaml
  4. 13 5
      custom_components/tuya_local/devices/dual_clamp_energymeter.yaml
  5. 14 6
      custom_components/tuya_local/devices/matsee_2way_energymeter.yaml
  6. 13 5
      custom_components/tuya_local/devices/matsee_2wayv2_energymeter.yaml
  7. 2 2
      custom_components/tuya_local/devices/parkside_solar_inverter.yaml
  8. 13 5
      custom_components/tuya_local/devices/unknow_2way_energymeter.yaml
  9. 1 1
      custom_components/tuya_local/devices/yagusmart_3pn_energymeter.yaml
  10. 1 15
      custom_components/tuya_local/helpers/config.py
  11. 1065 1070
      custom_components/tuya_local/helpers/device_config.py
  12. 12 0
      custom_components/tuya_local/icons.json
  13. 12 0
      custom_components/tuya_local/translations/bg.json
  14. 12 0
      custom_components/tuya_local/translations/cz.json
  15. 12 0
      custom_components/tuya_local/translations/de.json
  16. 12 0
      custom_components/tuya_local/translations/el.json
  17. 12 0
      custom_components/tuya_local/translations/en.json
  18. 12 0
      custom_components/tuya_local/translations/es.json
  19. 12 0
      custom_components/tuya_local/translations/fr.json
  20. 12 0
      custom_components/tuya_local/translations/hu.json
  21. 12 0
      custom_components/tuya_local/translations/id.json
  22. 12 0
      custom_components/tuya_local/translations/it.json
  23. 12 0
      custom_components/tuya_local/translations/ja.json
  24. 12 0
      custom_components/tuya_local/translations/no-NB.json
  25. 12 0
      custom_components/tuya_local/translations/pl.json
  26. 12 0
      custom_components/tuya_local/translations/pt-BR.json
  27. 12 0
      custom_components/tuya_local/translations/ru.json
  28. 12 0
      custom_components/tuya_local/translations/uk.json
  29. 12 0
      custom_components/tuya_local/translations/ur.json
  30. 12 0
      custom_components/tuya_local/translations/zh-Hans.json
  31. 12 0
      custom_components/tuya_local/translations/zh-Hant.json

+ 70 - 180
custom_components/tuya_local/__init__.py

@@ -31,6 +31,31 @@ _LOGGER = logging.getLogger(__name__)
 NOT_FOUND = "Configuration file for %s not found"
 
 
+def replace_unique_ids(entity_entry, device_id, conf_file, replacements):
+    """Update the unique id of an entry based on a table of replacements."""
+    old_id = entity_entry.unique_id
+    platform = entity_entry.entity_id.split(".", 1)[0]
+    for suffix, new_suffix in replacements.items():
+        if old_id.endswith(suffix):
+            for e in conf_file.all_entities():
+                new_id = e.unique_id(device_id)
+                if e.entity == platform and not e.name and new_id.endswith(new_suffix):
+                    break
+            if e.entity == platform and not e.name and new_id.endswith(new_suffix):
+                _LOGGER.info(
+                    "Migrating %s unique_id %s to %s",
+                    e.entity,
+                    old_id,
+                    new_id,
+                )
+                return {
+                    "new_unique_id": entity_entry.unique_id.replace(
+                        old_id,
+                        new_id,
+                    )
+                }
+
+
 async def async_migrate_entry(hass, entry: ConfigEntry):
     """Migrate to latest config format."""
 
@@ -319,28 +344,9 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
                     "sensor_current_humidity": "sensor_humidity",
                     "sensor_current_temperature": "sensor_temperature",
                 }
-                for suffix, new_suffix in replacements.items():
-                    if old_id.endswith(suffix):
-                        e = conf_file.primary_entity
-                        if e.entity != platform or e.name:
-                            for e in conf_file.secondary_entities():
-                                if e.entity == platform and not e.name:
-                                    break
-                        if e.entity == platform and not e.name:
-                            new_id = e.unique_id(device_id)
-                            if new_id.endswith(new_suffix):
-                                _LOGGER.info(
-                                    "Migrating %s unique_id %s to %s",
-                                    e.entity,
-                                    old_id,
-                                    new_id,
-                                )
-                                return {
-                                    "new_unique_id": entity_entry.unique_id.replace(
-                                        old_id,
-                                        new_id,
-                                    )
-                                }
+                return replace_unique_ids(
+                    entity_entry, device_id, conf_file, replacements
+                )
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13)
         hass.config_entries.async_update_entry(entry, version=13)
@@ -363,8 +369,6 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
         @callback
         def update_unique_id13_2(entity_entry):
             """Update the unique id of an entity entry."""
-            old_id = entity_entry.unique_id
-            platform = entity_entry.entity_id.split(".", 1)[0]
             # Standardistion of entity naming to use translation_key
             replacements = {
                 # special meaning of None to handle _full and _empty variants
@@ -390,30 +394,7 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
                 "switch_defrost": "switch_anti_frost",
                 "switch_frost_protection": "switch_anti_frost",
             }
-            for suffix, new_suffix in replacements.items():
-                if old_id.endswith(suffix):
-                    e = conf_file.primary_entity
-                    if e.entity != platform or e.name:
-                        for e in conf_file.secondary_entities():
-                            if e.entity == platform and not e.name:
-                                break
-                    if e.entity == platform and not e.name:
-                        new_id = e.unique_id(device_id)
-                        if (new_suffix and new_id.endswith(new_suffix)) or (
-                            new_suffix is None and suffix in new_id
-                        ):
-                            _LOGGER.info(
-                                "Migrating %s unique_id %s to %s",
-                                e.entity,
-                                old_id,
-                                new_id,
-                            )
-                            return {
-                                "new_unique_id": entity_entry.unique_id.replace(
-                                    old_id,
-                                    new_id,
-                                )
-                            }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13_2)
         hass.config_entries.async_update_entry(entry, minor_version=2)
@@ -436,8 +417,6 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
         @callback
         def update_unique_id13_3(entity_entry):
             """Update the unique id of an entity entry."""
-            old_id = entity_entry.unique_id
-            platform = entity_entry.entity_id.split(".", 1)[0]
             # Standardistion of entity naming to use translation_key
             replacements = {
                 "light_front_display": "light_display",
@@ -468,28 +447,7 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
                 "switch_uv_lamp": "switch_uv_sterilization",
                 "switch_anti_freeze": "switch_anti_frost",
             }
-            for suffix, new_suffix in replacements.items():
-                if old_id.endswith(suffix):
-                    e = conf_file.primary_entity
-                    if e.entity != platform or e.name:
-                        for e in conf_file.secondary_entities():
-                            if e.entity == platform and not e.name:
-                                break
-                    if e.entity == platform and not e.name:
-                        new_id = e.unique_id(device_id)
-                        if new_suffix and new_id.endswith(new_suffix):
-                            _LOGGER.info(
-                                "Migrating %s unique_id %s to %s",
-                                e.entity,
-                                old_id,
-                                new_id,
-                            )
-                            return {
-                                "new_unique_id": entity_entry.unique_id.replace(
-                                    old_id,
-                                    new_id,
-                                )
-                            }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13_3)
         hass.config_entries.async_update_entry(entry, minor_version=3)
@@ -526,8 +484,6 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
         @callback
         def update_unique_id13_5(entity_entry):
             """Update the unique id of an entity entry."""
-            old_id = entity_entry.unique_id
-            platform = entity_entry.entity_id.split(".", 1)[0]
             # Standardistion of entity naming to use translation_key
             replacements = {
                 "number_countdown": "number_timer",
@@ -536,40 +492,7 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
                 "sensor_countdown_timer": "sensor_time_remaining",
                 "fan": "fan_aroma_diffuser",
             }
-            for suffix, new_suffix in replacements.items():
-                if old_id.endswith(suffix):
-                    e = conf_file.primary_entity
-                    new_id = e.unique_id(device_id)
-                    if (
-                        e.entity != platform
-                        or e.name
-                        or not new_id.endswith(new_suffix)
-                    ):
-                        for e in conf_file.secondary_entities():
-                            new_id = e.unique_id(device_id)
-                            if (
-                                e.entity == platform
-                                and not e.name
-                                and new_id.endswith(new_suffix)
-                            ):
-                                break
-                    if (
-                        e.entity == platform
-                        and not e.name
-                        and new_id.endswith(new_suffix)
-                    ):
-                        _LOGGER.info(
-                            "Migrating %s unique_id %s to %s",
-                            e.entity,
-                            old_id,
-                            new_id,
-                        )
-                        return {
-                            "new_unique_id": entity_entry.unique_id.replace(
-                                old_id,
-                                new_id,
-                            )
-                        }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13_5)
 
@@ -591,8 +514,6 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
         @callback
         def update_unique_id13_6(entity_entry):
             """Update the unique id of an entity entry."""
-            old_id = entity_entry.unique_id
-            platform = entity_entry.entity_id.split(".", 1)[0]
             # Standardistion of entity naming to use translation_key
             replacements = {
                 "switch_sleep_mode": "switch_sleep",
@@ -611,40 +532,7 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
                 "light_light": "light",
                 "light_lights": "light",
             }
-            for suffix, new_suffix in replacements.items():
-                if old_id.endswith(suffix):
-                    e = conf_file.primary_entity
-                    new_id = e.unique_id(device_id)
-                    if (
-                        e.entity != platform
-                        or e.name
-                        or not new_id.endswith(new_suffix)
-                    ):
-                        for e in conf_file.secondary_entities():
-                            new_id = e.unique_id(device_id)
-                            if (
-                                e.entity == platform
-                                and not e.name
-                                and new_id.endswith(new_suffix)
-                            ):
-                                break
-                    if (
-                        e.entity == platform
-                        and not e.name
-                        and new_id.endswith(new_suffix)
-                    ):
-                        _LOGGER.info(
-                            "Migrating %s unique_id %s to %s",
-                            e.entity,
-                            old_id,
-                            new_id,
-                        )
-                        return {
-                            "new_unique_id": entity_entry.unique_id.replace(
-                                old_id,
-                                new_id,
-                            )
-                        }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13_6)
         hass.config_entries.async_update_entry(entry, minor_version=6)
@@ -667,50 +555,52 @@ async def async_migrate_entry(hass, entry: ConfigEntry):
         @callback
         def update_unique_id13_7(entity_entry):
             """Update the unique id of an entity entry."""
-            old_id = entity_entry.unique_id
-            platform = entity_entry.entity_id.split(".", 1)[0]
             # Standardistion of entity naming to use translation_key
             replacements = {
                 "sensor_charger_state": "sensor_status",
             }
-            for suffix, new_suffix in replacements.items():
-                if old_id.endswith(suffix):
-                    e = conf_file.primary_entity
-                    new_id = e.unique_id(device_id)
-                    if (
-                        e.entity != platform
-                        or e.name
-                        or not new_id.endswith(new_suffix)
-                    ):
-                        for e in conf_file.secondary_entities():
-                            new_id = e.unique_id(device_id)
-                            if (
-                                e.entity == platform
-                                and not e.name
-                                and new_id.endswith(new_suffix)
-                            ):
-                                break
-                    if (
-                        e.entity == platform
-                        and not e.name
-                        and new_id.endswith(new_suffix)
-                    ):
-                        _LOGGER.info(
-                            "Migrating %s unique_id %s to %s",
-                            e.entity,
-                            old_id,
-                            new_id,
-                        )
-                        return {
-                            "new_unique_id": entity_entry.unique_id.replace(
-                                old_id,
-                                new_id,
-                            )
-                        }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
 
         await async_migrate_entries(hass, entry.entry_id, update_unique_id13_7)
         hass.config_entries.async_update_entry(entry, minor_version=7)
 
+    if entry.version == 13 and entry.minor_version < 8:
+        # Migrate unique ids of existing entities to new id taking into
+        # account translation_key, and standardising naming
+        device_id = entry.unique_id
+        conf_file = await hass.async_add_executor_job(
+            get_config,
+            entry.data[CONF_TYPE],
+        )
+        if conf_file is None:
+            _LOGGER.error(
+                NOT_FOUND,
+                entry.data[CONF_TYPE],
+            )
+            return False
+
+        @callback
+        def update_unique_id13_8(entity_entry):
+            """Update the unique id of an entity entry."""
+            # Standardistion of entity naming to use translation_key
+            replacements = {
+                "sensor_forward_energy": "sensor_energy_consumed",
+                "sensor_total_forward_energy": "sensor_energy_consumed",
+                "sensor_energy_in": "sensor_energy_consumed",
+                "sensor_reverse_energy": "sensor_energy_produced",
+                "sensor_energy_out": "sensor_energy_produced",
+                "sensor_forward_energy_a": "sensor_energy_consumed_a",
+                "sensor_reverse_energy_a": "sensor_energy_produced_a",
+                "sensor_forward_energy_b": "sensor_energy_consumed_b",
+                "sensor_reverse_energy_b": "sensor_energy_produced_b",
+                "sensor_energy_consumption_a": "sensor_energy_consumed_a",
+                "sensor_energy_consumption_b": "sensor_energy_consumed_b",
+            }
+            return replace_unique_ids(entity_entry, device_id, conf_file, replacements)
+
+        await async_migrate_entries(hass, entry.entry_id, update_unique_id13_8)
+        hass.config_entries.async_update_entry(entry, minor_version=8)
+
     return True
 
 

+ 0 - 1
custom_components/tuya_local/devices/atorch_at4pw_energymeter.yaml

@@ -229,7 +229,6 @@ secondary_entities:
         type: boolean
         name: switch
   - entity: sensor
-    name: Energy
     class: energy
     dps:
       - id: 123

+ 14 - 6
custom_components/tuya_local/devices/dcenta_dual_meter.yaml

@@ -4,7 +4,7 @@ products:
     name: Dcenta dual clamp meter
 primary_entity:
   entity: sensor
-  name: Energy consumed
+  translation_key: energy_consumed
   class: energy
   dps:
     - id: 1
@@ -16,7 +16,7 @@ primary_entity:
         - scale: 100
 secondary_entities:
   - entity: sensor
-    name: Energy produced
+    translation_key: energy_produced
     class: energy
     dps:
       - id: 2
@@ -75,7 +75,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumed A
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -92,7 +94,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced A
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -109,7 +113,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumed B
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -126,7 +132,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced B
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:

+ 13 - 5
custom_components/tuya_local/devices/dual_clamp_energymeter.yaml

@@ -95,7 +95,7 @@ secondary_entities:
         name: calibration
   - entity: sensor
     class: energy
-    name: Total forward energy
+    translation_key: energy_consumed
     category: diagnostic
     dps:
       - id: 110
@@ -116,7 +116,9 @@ secondary_entities:
         mapping:
           - scale: 100
   - entity: sensor
-    name: Forward energy A
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -131,7 +133,9 @@ secondary_entities:
         type: integer
         name: calibration
   - entity: sensor
-    name: Reverse energy A
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -146,7 +150,9 @@ secondary_entities:
         type: integer
         name: calibration
   - entity: sensor
-    name: Forward energy B
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -161,7 +167,9 @@ secondary_entities:
         type: integer
         name: calibration
   - entity: sensor
-    name: Reverse energy B
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:

+ 14 - 6
custom_components/tuya_local/devices/matsee_2way_energymeter.yaml

@@ -6,7 +6,7 @@ products:
     name: MatSee Plus bidirectional 2 channel clamp meter
 primary_entity:
   entity: sensor
-  name: Energy consumed
+  translation_key: energy_consumed
   class: energy
   dps:
     - id: 1
@@ -28,7 +28,7 @@ primary_entity:
         - scale: 100
 secondary_entities:
   - entity: sensor
-    name: Energy produced
+    translation_key: energy_produced
     class: energy
     dps:
       - id: 2
@@ -91,7 +91,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumed A
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -108,7 +110,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced A
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -125,7 +129,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumed B
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -142,7 +148,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced B
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:

+ 13 - 5
custom_components/tuya_local/devices/matsee_2wayv2_energymeter.yaml

@@ -20,7 +20,7 @@ primary_entity:
         - scale: 100
 secondary_entities:
   - entity: sensor
-    name: Energy produced
+    translation_key: energy_produced
     class: energy
     dps:
       - id: 2
@@ -128,7 +128,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumption B
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -156,7 +158,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced B
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -231,7 +235,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy consumption A
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -259,7 +265,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Energy produced A
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:

+ 2 - 2
custom_components/tuya_local/devices/parkside_solar_inverter.yaml

@@ -4,7 +4,7 @@ products:
     name: Parkside PG-300
 primary_entity:
   entity: sensor
-  name: Energy out
+  translation_key: energy_produced
   class: energy
   dps:
     - id: 2
@@ -22,7 +22,7 @@ primary_entity:
       type: string
 secondary_entities:
   - entity: sensor
-    name: Energy in
+    translation_key: energy_consumed
     class: energy
     category: diagnostic
     dps:

+ 13 - 5
custom_components/tuya_local/devices/unknow_2way_energymeter.yaml

@@ -23,7 +23,7 @@ primary_entity:
         - scale: 100
 secondary_entities:
   - entity: sensor
-    name: Reverse energy
+    translation_key: energy_produced
     class: energy
     dps:
       - id: 2
@@ -147,7 +147,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Forward energy A
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -178,7 +180,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Reverse energy A
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: A
     class: energy
     category: diagnostic
     dps:
@@ -266,7 +270,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Forward energy B
+    translation_key: energy_consumed_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:
@@ -297,7 +303,9 @@ secondary_entities:
         mapping:
           - scale: 1000
   - entity: sensor
-    name: Reverse energy B
+    translation_key: energy_produced_x
+    translation_placeholders:
+      x: B
     class: energy
     category: diagnostic
     dps:

+ 1 - 1
custom_components/tuya_local/devices/yagusmart_3pn_energymeter.yaml

@@ -27,7 +27,7 @@ primary_entity:
       optional: true
 secondary_entities:
   - entity: sensor
-    name: Reverse energy
+    translation_key: energy_produced
     class: energy
     category: diagnostic
     dps:

+ 1 - 15
custom_components/tuya_local/helpers/config.py

@@ -25,21 +25,7 @@ async def async_tuya_setup_platform(
     )
     if cfg is None:
         raise ValueError(f"No device config found for {discovery_info}")
-    ecfg = cfg.primary_entity
-    if ecfg.entity == platform:
-        try:
-            data[ecfg.config_id] = entity_class(device, ecfg)
-            entities.append(data[ecfg.config_id])
-            _LOGGER.debug("Adding %s for %s", platform, ecfg.config_id)
-        except Exception as e:
-            _LOGGER.error(
-                "Error adding %s for %s: %s",
-                ecfg.config_id,
-                cfg.config,
-                e,
-            )
-
-    for ecfg in cfg.secondary_entities():
+    for ecfg in cfg.all_entities():
         if ecfg.entity == platform:
             try:
                 data[ecfg.config_id] = entity_class(device, ecfg)

+ 1065 - 1070
custom_components/tuya_local/helpers/device_config.py

@@ -1,1070 +1,1065 @@
-"""
-Config parser for Tuya Local devices.
-"""
-
-import logging
-from base64 import b64decode, b64encode
-from collections.abc import Sequence
-from datetime import datetime
-from fnmatch import fnmatch
-from numbers import Number
-from os import walk
-from os.path import dirname, exists, join, splitext
-
-from homeassistant.util import slugify
-from homeassistant.util.yaml import load_yaml
-
-import custom_components.tuya_local.devices as config_dir
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def _typematch(vtype, value):
-    # Workaround annoying legacy of bool being a subclass of int in Python
-    if vtype is int and isinstance(value, bool):
-        return False
-
-    # Allow integers to pass as floats.
-    if vtype is float and isinstance(value, Number):
-        return True
-
-    if isinstance(value, vtype):
-        return True
-    # Allow values embedded in strings if they can be converted
-    # But not for bool, as everything can be converted to bool
-    elif isinstance(value, str) and vtype is not bool:
-        try:
-            vtype(value)
-            return True
-        except ValueError:
-            return False
-    return False
-
-
-def _scale_range(r, s):
-    "Scale range r by factor s"
-    return (r["min"] / s, r["max"] / s)
-
-
-_unsigned_fmts = {
-    1: "B",
-    2: "H",
-    3: "3s",
-    4: "I",
-}
-
-_signed_fmts = {
-    1: "b",
-    2: "h",
-    3: "3s",
-    4: "i",
-}
-
-
-def _bytes_to_fmt(bytes, signed=False):
-    """Convert a byte count to an unpack format."""
-    fmt = _signed_fmts if signed else _unsigned_fmts
-
-    if bytes in fmt:
-        return fmt[bytes]
-    else:
-        return f"{bytes}s"
-
-
-def _equal_or_in(value1, values2):
-    """Return true if value1 is the same as values2, or appears in values2."""
-    if not isinstance(values2, str) and isinstance(values2, Sequence):
-        return value1 in values2
-    else:
-        return value1 == values2
-
-
-def _remove_duplicates(seq):
-    """Remove dulicates from seq, maintaining order."""
-    if not seq:
-        return []
-    seen = set()
-    adder = seen.add
-    return [x for x in seq if not (x in seen or adder(x))]
-
-
-class TuyaDeviceConfig:
-    """Representation of a device config for Tuya Local devices."""
-
-    def __init__(self, fname):
-        """Initialize the device config.
-        Args:
-            fname (string): The filename of the yaml config to load."""
-        _CONFIG_DIR = dirname(config_dir.__file__)
-        self._fname = fname
-        filename = join(_CONFIG_DIR, fname)
-        self._config = load_yaml(filename)
-        _LOGGER.debug("Loaded device config %s", fname)
-
-    @property
-    def name(self):
-        """Return the friendly name for this device."""
-        return self._config["name"]
-
-    @property
-    def config(self):
-        """Return the config file associated with this device."""
-        return self._fname
-
-    @property
-    def config_type(self):
-        """Return the config type associated with this device."""
-        return splitext(self._fname)[0]
-
-    @property
-    def legacy_type(self):
-        """Return the legacy conf_type associated with this device."""
-        return self._config.get("legacy_type", self.config_type)
-
-    @property
-    def primary_entity(self):
-        """Return the primary type of entity for this device."""
-        return TuyaEntityConfig(
-            self,
-            self._config["primary_entity"],
-            primary=True,
-        )
-
-    def secondary_entities(self):
-        """Iterate through entites for any secondary entites supported."""
-        for conf in self._config.get("secondary_entities", {}):
-            yield TuyaEntityConfig(self, conf)
-
-    def all_entities(self):
-        """Iterate through all entities for this device."""
-        yield self.primary_entity
-        for e in self.secondary_entities():
-            yield e
-
-    def matches(self, dps):
-        required_dps = self._get_required_dps()
-
-        missing_dps = [dp for dp in required_dps if dp.id not in dps.keys()]
-        if len(missing_dps) > 0:
-            _LOGGER.debug(
-                "Not match for %s, missing required DPs: %s",
-                self.name,
-                [{dp.id: dp.type.__name__} for dp in missing_dps],
-            )
-
-        incorrect_type_dps = [
-            dp
-            for dp in self._get_all_dps()
-            if dp.id in dps.keys() and not _typematch(dp.type, dps[dp.id])
-        ]
-        if len(incorrect_type_dps) > 0:
-            _LOGGER.debug(
-                "Not match for %s, DPs have incorrect type: %s",
-                self.name,
-                [{dp.id: dp.type.__name__} for dp in incorrect_type_dps],
-            )
-
-        return len(missing_dps) == 0 and len(incorrect_type_dps) == 0
-
-    def _get_all_dps(self):
-        all_dps_list = [d for d in self.primary_entity.dps()]
-        all_dps_list += [d for dev in self.secondary_entities() for d in dev.dps()]
-        return all_dps_list
-
-    def _get_required_dps(self):
-        required_dps_list = [d for d in self._get_all_dps() if not d.optional]
-        return required_dps_list
-
-    def _entity_match_analyse(self, entity, keys, matched, dps):
-        """
-        Determine whether this entity can be a match for the dps
-          Args:
-            entity - the TuyaEntityConfig to check against
-            keys - the unmatched keys for the device
-            matched - the matched keys for the device
-            dps - the dps values to be matched
-        Side Effects:
-            Moves items from keys to matched if they match dps
-        Return Value:
-            True if all dps in entity could be matched to dps, False otherwise
-        """
-        all_dp = keys + matched
-        for d in entity.dps():
-            if (d.id not in all_dp and not d.optional) or (
-                d.id in all_dp and not _typematch(d.type, dps[d.id])
-            ):
-                return False
-            if d.id in keys:
-                matched.append(d.id)
-                keys.remove(d.id)
-        return True
-
-    def match_quality(self, dps):
-        """Determine the match quality for the provided dps map."""
-        keys = list(dps.keys())
-        matched = []
-        if "updated_at" in keys:
-            keys.remove("updated_at")
-        total = len(keys)
-        if total < 1 or not self._entity_match_analyse(
-            self.primary_entity,
-            keys,
-            matched,
-            dps,
-        ):
-            return 0
-
-        for e in self.secondary_entities():
-            if not self._entity_match_analyse(e, keys, matched, dps):
-                return 0
-
-        return round((total - len(keys)) * 100 / total)
-
-
-class TuyaEntityConfig:
-    """Representation of an entity config for a supported entity."""
-
-    def __init__(self, device, config, primary=False):
-        self._device = device
-        self._config = config
-        self._is_primary = primary
-
-    @property
-    def name(self):
-        """The friendly name for this entity."""
-        return self._config.get("name")
-
-    @property
-    def translation_key(self):
-        """The translation key for this entity."""
-        return self._config.get("translation_key", self.device_class)
-
-    @property
-    def translation_only_key(self):
-        """The translation key for this entity, not used for unique_id"""
-        return self._config.get("translation_only_key")
-
-    @property
-    def translation_placeholders(self):
-        """The translation placeholders for this entity."""
-        return self._config.get("translation_placeholders", {})
-
-    def unique_id(self, device_uid):
-        """Return a suitable unique_id for this entity."""
-        return f"{device_uid}-{slugify(self.config_id)}"
-
-    @property
-    def entity_category(self):
-        return self._config.get("category")
-
-    @property
-    def deprecated(self):
-        """Return whether this entity is deprecated."""
-        return "deprecated" in self._config.keys()
-
-    @property
-    def deprecation_message(self):
-        """Return a deprecation message for this entity"""
-        replacement = self._config.get(
-            "deprecated", "nothing, this warning has been raised in error"
-        )
-        return (
-            f"The use of {self.entity} for {self._device.name} is "
-            f"deprecated and should be replaced by {replacement}."
-        )
-
-    @property
-    def entity(self):
-        """The entity type of this entity."""
-        return self._config["entity"]
-
-    @property
-    def config_id(self):
-        """The identifier for this entity in the config."""
-        own_name = self._config.get("name")
-        if own_name:
-            return f"{self.entity}_{slugify(own_name)}"
-        if self.translation_key:
-            slug = f"{self.entity}_{self.translation_key}"
-            for key, value in self.translation_placeholders.items():
-                if key in slug:
-                    slug = slug.replace(key, str(value))
-                else:
-                    slug = f"{slug}_{value}"
-            return slug
-        return self.entity
-
-    @property
-    def device_class(self):
-        """The device class of this entity."""
-        return self._config.get("class")
-
-    def icon(self, device):
-        """Return the icon for this entity, with state as given."""
-        icon = self._config.get("icon", None)
-        priority = self._config.get("icon_priority", 100)
-
-        for d in self.dps():
-            rule = d.icon_rule(device)
-            if rule and rule["priority"] < priority:
-                icon = rule["icon"]
-                priority = rule["priority"]
-        return icon
-
-    @property
-    def mode(self):
-        """Return the mode (used by Number entities)."""
-        return self._config.get("mode")
-
-    def dps(self):
-        """Iterate through the list of dps for this entity."""
-        for d in self._config["dps"]:
-            yield TuyaDpsConfig(self, d)
-
-    def find_dps(self, name):
-        """Find a dps with the specified name."""
-        for d in self.dps():
-            if d.name == 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."""
-
-    def __init__(self, entity, config):
-        self._entity = entity
-        self._config = config
-        self.stringify = False
-
-    @property
-    def id(self):
-        return str(self._config["id"])
-
-    @property
-    def type(self):
-        t = self._config["type"]
-        types = {
-            "boolean": bool,
-            "integer": int,
-            "string": str,
-            "float": float,
-            "bitfield": int,
-            "json": str,
-            "base64": str,
-            "utf16b64": str,
-            "hex": str,
-            "unixtime": int,
-        }
-        return types.get(t)
-
-    @property
-    def rawtype(self):
-        return self._config["type"]
-
-    @property
-    def name(self):
-        return self._config["name"]
-
-    @property
-    def optional(self):
-        return self._config.get("optional", False)
-
-    @property
-    def persist(self):
-        return self._config.get("persist", True)
-
-    @property
-    def force(self):
-        return self._config.get("force", False)
-
-    @property
-    def sensitive(self):
-        return self._config.get("sensitive", False)
-
-    @property
-    def format(self):
-        fmt = self._config.get("format")
-        if fmt:
-            unpack_fmt = ">"
-            ranges = []
-            names = []
-            for f in fmt:
-                name = f.get("name")
-                b = f.get("bytes", 1)
-                r = f.get("range")
-                if r:
-                    mn = r.get("min")
-                    mx = r.get("max")
-                else:
-                    mn = 0
-                    mx = 256**b - 1
-
-                unpack_fmt = unpack_fmt + _bytes_to_fmt(b, mn < 0)
-                ranges.append({"min": mn, "max": mx})
-                names.append(name)
-            _LOGGER.debug("format of %s found", unpack_fmt)
-            return {"format": unpack_fmt, "ranges": ranges, "names": names}
-
-        return None
-
-    def mask(self, device):
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping:
-            mask = mapping.get("mask")
-            if mask:
-                return int(mask, 16)
-
-    def endianness(self, device):
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping:
-            endianness = mapping.get("endianness")
-            if endianness:
-                return endianness
-        return "big"
-
-    def get_value(self, device):
-        """Return the value of the dps from the given device."""
-        mask = self.mask(device)
-        bytevalue = self.decoded_value(device)
-        if mask and isinstance(bytevalue, bytes):
-            value = int.from_bytes(bytevalue, self.endianness(device))
-            scale = mask & (1 + ~mask)
-            return self._map_from_dps((value & mask) // scale, device)
-        else:
-            return self._map_from_dps(device.get_property(self.id), device)
-
-    def decoded_value(self, device):
-        v = self._map_from_dps(device.get_property(self.id), device)
-        if self.rawtype == "hex" and isinstance(v, str):
-            try:
-                return bytes.fromhex(v)
-            except ValueError:
-                _LOGGER.warning(
-                    "%s sent invalid hex '%s' for %s",
-                    device.name,
-                    v,
-                    self.name,
-                )
-                return None
-
-        elif self.rawtype == "base64" and isinstance(v, str):
-            try:
-                return b64decode(v)
-            except ValueError:
-                _LOGGER.warning(
-                    "%s sent invalid base64 '%s' for %s",
-                    device.name,
-                    v,
-                    self.name,
-                )
-                return None
-        else:
-            return v
-
-    def encode_value(self, v):
-        if self.rawtype == "hex":
-            return v.hex()
-        elif self.rawtype == "base64":
-            return b64encode(v).decode("utf-8")
-        elif self.rawtype == "unixtime" and isinstance(v, datetime):
-            return v.timestamp()
-        else:
-            return v
-
-    def _match(self, matchdata, value):
-        """Return true val1 matches val2"""
-        if self.rawtype == "bitfield" and matchdata:
-            try:
-                return (int(value) & int(matchdata)) != 0
-            except (TypeError, ValueError):
-                return False
-        else:
-            return str(value) == str(matchdata)
-
-    async def async_set_value(self, device, value):
-        """Set the value of the dps in the given device to given value."""
-        if self.readonly:
-            raise TypeError(f"{self.name} is read only")
-        if self.invalid_for(value, device):
-            raise AttributeError(f"{self.name} cannot be set at this time")
-        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():
-            _LOGGER.debug(
-                "No mapping for dpid %s (%s), unable to determine valid values",
-                self.id,
-                self.name,
-            )
-            return []
-        val = []
-        for m in self._config["mapping"]:
-            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"])
-                if r_dps:
-                    val = val + r_dps.values(device)
-            for c in m.get("conditions", {}):
-                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"])
-                    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 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"])
-                        if r_dps:
-                            c_val = c_val + r_dps.values(device)
-                # if given, the conditional mapping is an override
-                if c_val:
-                    _LOGGER.debug(
-                        "Overriding %s values %s with %s",
-                        self.name,
-                        val,
-                        c_val,
-                    )
-
-                    val = c_val
-                    break
-        _LOGGER.debug("%s values: %s", self.name, val)
-        return _remove_duplicates(val)
-
-    @property
-    def default(self):
-        """Return the default value for a dp."""
-        if "mapping" not in self._config.keys():
-            _LOGGER.debug(
-                "No mapping for %s, unable to determine default value",
-                self.name,
-            )
-            return None
-        for m in self._config["mapping"]:
-            if m.get("default", False):
-                return m.get("value", m.get("dps_val", None))
-            for c in m.get("conditions", {}):
-                if c.get("default", False):
-                    return c.get("value", m.get("value", m.get("dps_val", None)))
-
-    def range(self, device, scaled=True):
-        """Return the range for this dps if configured."""
-        scale = self.scale(device) if scaled else 1
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        r = self._config.get("range")
-        if mapping:
-            _LOGGER.debug("Considering mapping for range of %s", self.name)
-            cond = self._active_condition(mapping, device)
-            if cond:
-                r = cond.get("range", r)
-
-        if r and "min" in r and "max" in r:
-            return _scale_range(r, scale)
-        else:
-            return None
-
-    def scale(self, device):
-        scale = 1
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping:
-            scale = mapping.get("scale", 1)
-            cond = self._active_condition(mapping, device)
-            if cond:
-                scale = cond.get("scale", scale)
-        return scale
-
-    def precision(self, device):
-        if self.type is int:
-            scale = self.scale(device)
-            precision = 0
-            while scale > 1.0:
-                scale /= 10.0
-                precision += 1
-            return precision
-
-    @property
-    def suggested_display_precision(self):
-        return self._config.get("precision")
-
-    def step(self, device, scaled=True):
-        step = 1
-        scale = self.scale(device) if scaled else 1
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        if mapping:
-            _LOGGER.debug("Considering mapping for step of %s", self.name)
-            step = mapping.get("step", 1)
-
-            cond = self._active_condition(mapping, device)
-            if cond:
-                constraint = mapping.get("constraint", self.name)
-                _LOGGER.debug("Considering condition on %s", constraint)
-                step = cond.get("step", step)
-        if step != 1 or scale != 1:
-            _LOGGER.debug(
-                "Step for %s is %s with scale %s",
-                self.name,
-                step,
-                scale,
-            )
-        return step / scale if scaled else step
-
-    @property
-    def readonly(self):
-        return self._config.get("readonly", False)
-
-    def invalid_for(self, value, device):
-        mapping = self._find_map_for_value(value, device)
-        if mapping:
-            cond = self._active_condition(mapping, device)
-            if cond:
-                return cond.get("invalid", False)
-        return False
-
-    @property
-    def hidden(self):
-        return self._config.get("hidden", False)
-
-    @property
-    def unit(self):
-        return self._config.get("unit")
-
-    @property
-    def state_class(self):
-        """The state class of this measurement."""
-        return self._config.get("class")
-
-    def _find_map_for_dps(self, value):
-        default = None
-        for m in self._config.get("mapping", {}):
-            if "dps_val" not in m:
-                default = m
-            elif self._match(m["dps_val"], value):
-                return m
-        return default
-
-    def _correct_type(self, result):
-        """Convert value to the correct type for this dp."""
-        if self.type is int:
-            _LOGGER.debug("Rounding %s", self.name)
-            result = int(round(result))
-        elif self.type is bool:
-            result = True if result else False
-        elif self.type is float:
-            result = float(result)
-        elif self.type is str:
-            result = str(result)
-            if self.rawtype == "utf16b64":
-                result = b64encode(result.encode("utf-16-be")).decode("utf-8")
-
-        if self.stringify:
-            result = str(result)
-
-        return result
-
-    def _map_from_dps(self, val, device):
-        if val is not None and self.type is not str and isinstance(val, str):
-            try:
-                val = self.type(val)
-                self.stringify = True
-            except ValueError:
-                self.stringify = False
-        else:
-            self.stringify = False
-
-        # decode utf-16 base64 strings first, so normal strings can be matched
-        if self.rawtype == "utf16b64" and isinstance(val, str):
-            try:
-                val = b64decode(val).decode("utf-16-be")
-            except ValueError:
-                _LOGGER.warning("Invalid utf16b64 %s", val)
-
-        result = val
-        scale = self.scale(device)
-        replaced = False
-
-        mapping = self._find_map_for_dps(val)
-        if mapping:
-            invert = mapping.get("invert", False)
-            redirect = mapping.get("value_redirect")
-            mirror = mapping.get("value_mirror")
-            replaced = "value" in mapping
-            result = mapping.get("value", result)
-            target_range = mapping.get("target_range")
-
-            cond = self._active_condition(mapping, device)
-            if cond:
-                if cond.get("invalid", False):
-                    return None
-                replaced = replaced or "value" in cond
-                result = cond.get("value", result)
-                redirect = cond.get("value_redirect", redirect)
-                mirror = cond.get("value_mirror", mirror)
-                target_range = cond.get("target_range", target_range)
-
-                for m in cond.get("mapping", {}):
-                    if str(m.get("dps_val")) == str(result):
-                        replaced = "value" in m
-                        result = m.get("value", result)
-
-            if redirect:
-                _LOGGER.debug("Redirecting %s to %s", self.name, redirect)
-                r_dps = self._entity.find_dps(redirect)
-                if r_dps:
-                    return r_dps.get_value(device)
-            if mirror:
-                r_dps = self._entity.find_dps(mirror)
-                if r_dps:
-                    return r_dps.get_value(device)
-
-            if invert and isinstance(result, Number):
-                r = self._config.get("range")
-                if r and "min" in r and "max" in r:
-                    result = -1 * result + r["min"] + r["max"]
-                    replaced = True
-
-            if target_range and isinstance(result, Number):
-                r = self._config.get("range")
-                if r and "max" in r and "max" in target_range:
-                    from_min = r.get("min", 0)
-                    from_max = r["max"]
-                    to_min = target_range.get("min", 0)
-                    to_max = target_range["max"]
-                    result = to_min + (
-                        (result - from_min) * (to_max - to_min) / (from_max - from_min)
-                    )
-                    replaced = True
-
-            if scale != 1 and isinstance(result, Number):
-                result = result / scale
-                replaced = True
-
-        if self.rawtype == "unixtime" and isinstance(result, int):
-            try:
-                result = datetime.fromtimestamp(result)
-                replaced = True
-            except Exception:
-                _LOGGER.warning("Invalid timestamp %d", result)
-
-        if replaced:
-            _LOGGER.debug(
-                "%s: Mapped dps %s value from %s to %s",
-                self._entity._device.name,
-                self.id,
-                val,
-                result,
-            )
-
-        return result
-
-    def _find_map_for_value(self, value, device):
-        default = None
-        nearest = None
-        distance = float("inf")
-        for m in self._config.get("mapping", {}):
-            if "dps_val" not in m:
-                default = m
-            # The following avoids further matching on the above case
-            # and in the null mapping case, which is intended to be
-            # a one-way map to prevent the entity showing as unavailable
-            # when no value is being reported by the device.
-            if m.get("dps_val") is None:
-                continue
-            if "value" in m and str(m["value"]) == str(value):
-                return m
-            if (
-                "value" in m
-                and isinstance(m["value"], Number)
-                and isinstance(value, Number)
-            ):
-                d = abs(m["value"] - value)
-                if d < distance:
-                    distance = d
-                    nearest = m
-
-            if "value" not in m and "value_mirror" in m:
-                r_dps = self._entity.find_dps(m["value_mirror"])
-                if r_dps and str(r_dps.get_value(device)) == str(value):
-                    return m
-
-            for c in m.get("conditions", {}):
-                if "value" in c and str(c["value"]) == str(value):
-                    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 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"),
-                        )
-                    ):
-                        return m
-                if "value" not in c and "value_mirror" in c:
-                    r_dps = self._entity.find_dps(c["value_mirror"])
-                    if r_dps and str(r_dps.get_value(device)) == str(value):
-                        return m
-        if nearest:
-            return nearest
-        return default
-
-    def _active_condition(self, mapping, device, value=None):
-        constraint = mapping.get("constraint", self.name)
-        conditions = mapping.get("conditions")
-        c_match = None
-        if constraint and conditions:
-            c_dps = self._entity.find_dps(constraint)
-            # base64 and hex have to be decoded
-            c_val = (
-                None
-                if c_dps is None
-                else (
-                    c_dps.get_value(device)
-                    if c_dps.rawtype == "base64" or c_dps.rawtype == "hex"
-                    else device.get_property(c_dps.id)
-                )
-            )
-            for cond in conditions:
-                if c_val is not None and (_equal_or_in(c_val, cond.get("dps_val"))):
-                    c_match = cond
-                # Case where matching None, need extra checks to ensure we
-                # are not just defaulting and it is really a match
-                elif (
-                    c_val is None
-                    and c_dps is not None
-                    and "dps_val" in cond
-                    and cond.get("dps_val") is None
-                ):
-                    c_match = cond
-                # when changing, another condition may become active
-                # return that if it exists over a current condition
-                if value is not None and value == cond.get("value"):
-                    return cond
-
-        return c_match
-
-    def get_values_to_set(self, device, value):
-        """Return the dps values that would be set when setting to value"""
-        result = value
-        dps_map = {}
-        if self.readonly:
-            return dps_map
-
-        mapping = self._find_map_for_value(value, device)
-        scale = self.scale(device)
-        mask = None
-        if mapping:
-            replaced = False
-            redirect = mapping.get("value_redirect")
-            invert = mapping.get("invert", False)
-            mask = mapping.get("mask")
-            endianness = mapping.get("endianness", "big")
-            target_range = mapping.get("target_range")
-            step = mapping.get("step")
-            if not isinstance(step, Number):
-                step = None
-            if "dps_val" in mapping and not mapping.get("hidden", False):
-                result = mapping["dps_val"]
-                replaced = True
-            # Conditions may have side effect of setting another value.
-            cond = self._active_condition(mapping, device, value)
-            if cond:
-                cval = cond.get("value")
-                if cval is None:
-                    r_dps = cond.get("value_mirror")
-                    if r_dps:
-                        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))
-                    cond_dpsval = cond.get("dps_val")
-                    single_match = isinstance(cond_dpsval, str) or (
-                        not isinstance(cond_dpsval, Sequence)
-                    )
-                    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,
-                        )
-                        dps_map.update(c_dps.get_values_to_set(device, c_val))
-
-                # Allow simple conditional mapping overrides
-                for m in cond.get("mapping", {}):
-                    if m.get("value") == value and not m.get("hidden", False):
-                        result = m.get("dps_val", result)
-
-                step = cond.get("step", step)
-                redirect = cond.get("value_redirect", redirect)
-                target_range = cond.get("target_range", target_range)
-
-            if redirect:
-                _LOGGER.debug("Redirecting %s to %s", self.name, redirect)
-                r_dps = self._entity.find_dps(redirect)
-                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)
-                result = result * scale
-                remap = self._find_map_for_value(result, device)
-                if (
-                    remap
-                    and "dps_val" in remap
-                    and "dps_val" not in mapping
-                    and not remap.get("hidden", False)
-                ):
-                    result = remap["dps_val"]
-                replaced = True
-
-            if target_range and isinstance(result, Number):
-                r = self._config.get("range")
-                if r and "max" in r and "max" in target_range:
-                    from_min = target_range.get("min", 0)
-                    from_max = target_range["max"]
-                    to_min = r.get("min", 0)
-                    to_max = r["max"]
-                    result = to_min + (
-                        (result - from_min) * (to_max - to_min) / (from_max - from_min)
-                    )
-                    replaced = True
-
-            if invert:
-                r = self._config.get("range")
-                if r and "min" in r and "max" in r:
-                    result = -1 * result + r["min"] + r["max"]
-                    replaced = True
-
-            if step and isinstance(result, Number):
-                _LOGGER.debug("Stepping %s to %s", result, step)
-                result = step * round(float(result) / step)
-                remap = self._find_map_for_value(result, device)
-                if (
-                    remap
-                    and "dps_val" in remap
-                    and "dps_val" not in mapping
-                    and not remap.get("hidden", False)
-                ):
-                    result = remap["dps_val"]
-                replaced = True
-
-            if replaced:
-                _LOGGER.debug(
-                    "%s: Mapped dps %s to %s from %s",
-                    self._entity._device.name,
-                    self.id,
-                    result,
-                    value,
-                )
-
-        r = self.range(device, scaled=False)
-        if r and isinstance(result, Number):
-            mn = r[0]
-            mx = r[1]
-            if round(result) < mn or round(result) > mx:
-                # Output scaled values in the error message
-                r = self.range(device, scaled=True)
-                mn = r[0]
-                mx = r[1]
-                raise ValueError(f"{self.name} ({value}) must be between {mn} and {mx}")
-
-        if mask and isinstance(result, Number):
-            # mask is in hex, 2 digits/characters per byte
-            length = int(len(mask) / 2)
-            # Convert to int
-            mask = int(mask, 16)
-            mask_scale = mask & (1 + ~mask)
-            current_value = int.from_bytes(self.decoded_value(device), endianness)
-            result = (current_value & ~mask) | (mask & int(result * mask_scale))
-            result = self.encode_value(result.to_bytes(length, endianness))
-
-        dps_map[self.id] = self._correct_type(result)
-        return dps_map
-
-    def icon_rule(self, device):
-        mapping = self._find_map_for_dps(device.get_property(self.id))
-        icon = None
-        priority = 100
-        if mapping:
-            icon = mapping.get("icon", icon)
-            priority = mapping.get("icon_priority", 10 if icon else 100)
-            cond = self._active_condition(mapping, device)
-            if cond and cond.get("icon_priority", 10) < priority:
-                icon = cond.get("icon", icon)
-                priority = cond.get("icon_priority", 10 if icon else 100)
-
-        return {"priority": priority, "icon": icon}
-
-
-def available_configs():
-    """List the available config files."""
-    _CONFIG_DIR = dirname(config_dir.__file__)
-
-    for path, dirs, files in walk(_CONFIG_DIR):
-        for basename in sorted(files):
-            if fnmatch(basename, "*.yaml"):
-                yield basename
-
-
-def possible_matches(dps):
-    """Return possible matching configs for a given set of dps values."""
-    for cfg in available_configs():
-        parsed = TuyaDeviceConfig(cfg)
-        try:
-            if parsed.matches(dps):
-                yield parsed
-        except TypeError:
-            _LOGGER.error("Parse error in %s", cfg)
-
-
-def get_config(conf_type):
-    """
-    Return a config to use with config_type.
-    """
-    _CONFIG_DIR = dirname(config_dir.__file__)
-    fname = conf_type + ".yaml"
-    fpath = join(_CONFIG_DIR, fname)
-    if exists(fpath):
-        return TuyaDeviceConfig(fname)
-    else:
-        return config_for_legacy_use(conf_type)
-
-
-def config_for_legacy_use(conf_type):
-    """
-    Return a config to use with config_type for legacy transition.
-    Note: as there are two variants for Kogan Socket, this is not guaranteed
-    to be the correct config for the device, so only use it for looking up
-    the legacy class during the transition period.
-    """
-    for cfg in available_configs():
-        parsed = TuyaDeviceConfig(cfg)
-        if parsed.legacy_type == conf_type:
-            return parsed
-
-    return None
+"""
+Config parser for Tuya Local devices.
+"""
+
+import logging
+from base64 import b64decode, b64encode
+from collections.abc import Sequence
+from datetime import datetime
+from fnmatch import fnmatch
+from numbers import Number
+from os import walk
+from os.path import dirname, exists, join, splitext
+
+from homeassistant.util import slugify
+from homeassistant.util.yaml import load_yaml
+
+import custom_components.tuya_local.devices as config_dir
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _typematch(vtype, value):
+    # Workaround annoying legacy of bool being a subclass of int in Python
+    if vtype is int and isinstance(value, bool):
+        return False
+
+    # Allow integers to pass as floats.
+    if vtype is float and isinstance(value, Number):
+        return True
+
+    if isinstance(value, vtype):
+        return True
+    # Allow values embedded in strings if they can be converted
+    # But not for bool, as everything can be converted to bool
+    elif isinstance(value, str) and vtype is not bool:
+        try:
+            vtype(value)
+            return True
+        except ValueError:
+            return False
+    return False
+
+
+def _scale_range(r, s):
+    "Scale range r by factor s"
+    return (r["min"] / s, r["max"] / s)
+
+
+_unsigned_fmts = {
+    1: "B",
+    2: "H",
+    3: "3s",
+    4: "I",
+}
+
+_signed_fmts = {
+    1: "b",
+    2: "h",
+    3: "3s",
+    4: "i",
+}
+
+
+def _bytes_to_fmt(bytes, signed=False):
+    """Convert a byte count to an unpack format."""
+    fmt = _signed_fmts if signed else _unsigned_fmts
+
+    if bytes in fmt:
+        return fmt[bytes]
+    else:
+        return f"{bytes}s"
+
+
+def _equal_or_in(value1, values2):
+    """Return true if value1 is the same as values2, or appears in values2."""
+    if not isinstance(values2, str) and isinstance(values2, Sequence):
+        return value1 in values2
+    else:
+        return value1 == values2
+
+
+def _remove_duplicates(seq):
+    """Remove dulicates from seq, maintaining order."""
+    if not seq:
+        return []
+    seen = set()
+    adder = seen.add
+    return [x for x in seq if not (x in seen or adder(x))]
+
+
+class TuyaDeviceConfig:
+    """Representation of a device config for Tuya Local devices."""
+
+    def __init__(self, fname):
+        """Initialize the device config.
+        Args:
+            fname (string): The filename of the yaml config to load."""
+        _CONFIG_DIR = dirname(config_dir.__file__)
+        self._fname = fname
+        filename = join(_CONFIG_DIR, fname)
+        self._config = load_yaml(filename)
+        _LOGGER.debug("Loaded device config %s", fname)
+
+    @property
+    def name(self):
+        """Return the friendly name for this device."""
+        return self._config["name"]
+
+    @property
+    def config(self):
+        """Return the config file associated with this device."""
+        return self._fname
+
+    @property
+    def config_type(self):
+        """Return the config type associated with this device."""
+        return splitext(self._fname)[0]
+
+    @property
+    def legacy_type(self):
+        """Return the legacy conf_type associated with this device."""
+        return self._config.get("legacy_type", self.config_type)
+
+    @property
+    def primary_entity(self):
+        """Return the primary type of entity for this device."""
+        return TuyaEntityConfig(
+            self,
+            self._config["primary_entity"],
+            primary=True,
+        )
+
+    def secondary_entities(self):
+        """Iterate through entites for any secondary entites supported."""
+        for conf in self._config.get("secondary_entities", {}):
+            yield TuyaEntityConfig(self, conf)
+
+    def all_entities(self):
+        """Iterate through all entities for this device."""
+        yield self.primary_entity
+        for e in self.secondary_entities():
+            yield e
+
+    def matches(self, dps):
+        required_dps = self._get_required_dps()
+
+        missing_dps = [dp for dp in required_dps if dp.id not in dps.keys()]
+        if len(missing_dps) > 0:
+            _LOGGER.debug(
+                "Not match for %s, missing required DPs: %s",
+                self.name,
+                [{dp.id: dp.type.__name__} for dp in missing_dps],
+            )
+
+        incorrect_type_dps = [
+            dp
+            for dp in self._get_all_dps()
+            if dp.id in dps.keys() and not _typematch(dp.type, dps[dp.id])
+        ]
+        if len(incorrect_type_dps) > 0:
+            _LOGGER.debug(
+                "Not match for %s, DPs have incorrect type: %s",
+                self.name,
+                [{dp.id: dp.type.__name__} for dp in incorrect_type_dps],
+            )
+
+        return len(missing_dps) == 0 and len(incorrect_type_dps) == 0
+
+    def _get_all_dps(self):
+        all_dps_list = []
+        all_dps_list += [d for dev in self.all_entities() for d in dev.dps()]
+        return all_dps_list
+
+    def _get_required_dps(self):
+        required_dps_list = [d for d in self._get_all_dps() if not d.optional]
+        return required_dps_list
+
+    def _entity_match_analyse(self, entity, keys, matched, dps):
+        """
+        Determine whether this entity can be a match for the dps
+          Args:
+            entity - the TuyaEntityConfig to check against
+            keys - the unmatched keys for the device
+            matched - the matched keys for the device
+            dps - the dps values to be matched
+        Side Effects:
+            Moves items from keys to matched if they match dps
+        Return Value:
+            True if all dps in entity could be matched to dps, False otherwise
+        """
+        all_dp = keys + matched
+        for d in entity.dps():
+            if (d.id not in all_dp and not d.optional) or (
+                d.id in all_dp and not _typematch(d.type, dps[d.id])
+            ):
+                return False
+            if d.id in keys:
+                matched.append(d.id)
+                keys.remove(d.id)
+        return True
+
+    def match_quality(self, dps):
+        """Determine the match quality for the provided dps map."""
+        keys = list(dps.keys())
+        matched = []
+        if "updated_at" in keys:
+            keys.remove("updated_at")
+        total = len(keys)
+        if total < 1:
+            return 0
+
+        for e in self.all_entities():
+            if not self._entity_match_analyse(e, keys, matched, dps):
+                return 0
+
+        return round((total - len(keys)) * 100 / total)
+
+
+class TuyaEntityConfig:
+    """Representation of an entity config for a supported entity."""
+
+    def __init__(self, device, config, primary=False):
+        self._device = device
+        self._config = config
+        self._is_primary = primary
+
+    @property
+    def name(self):
+        """The friendly name for this entity."""
+        return self._config.get("name")
+
+    @property
+    def translation_key(self):
+        """The translation key for this entity."""
+        return self._config.get("translation_key", self.device_class)
+
+    @property
+    def translation_only_key(self):
+        """The translation key for this entity, not used for unique_id"""
+        return self._config.get("translation_only_key")
+
+    @property
+    def translation_placeholders(self):
+        """The translation placeholders for this entity."""
+        return self._config.get("translation_placeholders", {})
+
+    def unique_id(self, device_uid):
+        """Return a suitable unique_id for this entity."""
+        return f"{device_uid}-{slugify(self.config_id)}"
+
+    @property
+    def entity_category(self):
+        return self._config.get("category")
+
+    @property
+    def deprecated(self):
+        """Return whether this entity is deprecated."""
+        return "deprecated" in self._config.keys()
+
+    @property
+    def deprecation_message(self):
+        """Return a deprecation message for this entity"""
+        replacement = self._config.get(
+            "deprecated", "nothing, this warning has been raised in error"
+        )
+        return (
+            f"The use of {self.entity} for {self._device.name} is "
+            f"deprecated and should be replaced by {replacement}."
+        )
+
+    @property
+    def entity(self):
+        """The entity type of this entity."""
+        return self._config["entity"]
+
+    @property
+    def config_id(self):
+        """The identifier for this entity in the config."""
+        own_name = self._config.get("name")
+        if own_name:
+            return f"{self.entity}_{slugify(own_name)}"
+        if self.translation_key:
+            slug = f"{self.entity}_{self.translation_key}"
+            for key, value in self.translation_placeholders.items():
+                if key in slug:
+                    slug = slug.replace(key, str(value))
+                else:
+                    slug = f"{slug}_{value}"
+            return slug
+        return self.entity
+
+    @property
+    def device_class(self):
+        """The device class of this entity."""
+        return self._config.get("class")
+
+    def icon(self, device):
+        """Return the icon for this entity, with state as given."""
+        icon = self._config.get("icon", None)
+        priority = self._config.get("icon_priority", 100)
+
+        for d in self.dps():
+            rule = d.icon_rule(device)
+            if rule and rule["priority"] < priority:
+                icon = rule["icon"]
+                priority = rule["priority"]
+        return icon
+
+    @property
+    def mode(self):
+        """Return the mode (used by Number entities)."""
+        return self._config.get("mode")
+
+    def dps(self):
+        """Iterate through the list of dps for this entity."""
+        for d in self._config["dps"]:
+            yield TuyaDpsConfig(self, d)
+
+    def find_dps(self, name):
+        """Find a dps with the specified name."""
+        for d in self.dps():
+            if d.name == 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."""
+
+    def __init__(self, entity, config):
+        self._entity = entity
+        self._config = config
+        self.stringify = False
+
+    @property
+    def id(self):
+        return str(self._config["id"])
+
+    @property
+    def type(self):
+        t = self._config["type"]
+        types = {
+            "boolean": bool,
+            "integer": int,
+            "string": str,
+            "float": float,
+            "bitfield": int,
+            "json": str,
+            "base64": str,
+            "utf16b64": str,
+            "hex": str,
+            "unixtime": int,
+        }
+        return types.get(t)
+
+    @property
+    def rawtype(self):
+        return self._config["type"]
+
+    @property
+    def name(self):
+        return self._config["name"]
+
+    @property
+    def optional(self):
+        return self._config.get("optional", False)
+
+    @property
+    def persist(self):
+        return self._config.get("persist", True)
+
+    @property
+    def force(self):
+        return self._config.get("force", False)
+
+    @property
+    def sensitive(self):
+        return self._config.get("sensitive", False)
+
+    @property
+    def format(self):
+        fmt = self._config.get("format")
+        if fmt:
+            unpack_fmt = ">"
+            ranges = []
+            names = []
+            for f in fmt:
+                name = f.get("name")
+                b = f.get("bytes", 1)
+                r = f.get("range")
+                if r:
+                    mn = r.get("min")
+                    mx = r.get("max")
+                else:
+                    mn = 0
+                    mx = 256**b - 1
+
+                unpack_fmt = unpack_fmt + _bytes_to_fmt(b, mn < 0)
+                ranges.append({"min": mn, "max": mx})
+                names.append(name)
+            _LOGGER.debug("format of %s found", unpack_fmt)
+            return {"format": unpack_fmt, "ranges": ranges, "names": names}
+
+        return None
+
+    def mask(self, device):
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        if mapping:
+            mask = mapping.get("mask")
+            if mask:
+                return int(mask, 16)
+
+    def endianness(self, device):
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        if mapping:
+            endianness = mapping.get("endianness")
+            if endianness:
+                return endianness
+        return "big"
+
+    def get_value(self, device):
+        """Return the value of the dps from the given device."""
+        mask = self.mask(device)
+        bytevalue = self.decoded_value(device)
+        if mask and isinstance(bytevalue, bytes):
+            value = int.from_bytes(bytevalue, self.endianness(device))
+            scale = mask & (1 + ~mask)
+            return self._map_from_dps((value & mask) // scale, device)
+        else:
+            return self._map_from_dps(device.get_property(self.id), device)
+
+    def decoded_value(self, device):
+        v = self._map_from_dps(device.get_property(self.id), device)
+        if self.rawtype == "hex" and isinstance(v, str):
+            try:
+                return bytes.fromhex(v)
+            except ValueError:
+                _LOGGER.warning(
+                    "%s sent invalid hex '%s' for %s",
+                    device.name,
+                    v,
+                    self.name,
+                )
+                return None
+
+        elif self.rawtype == "base64" and isinstance(v, str):
+            try:
+                return b64decode(v)
+            except ValueError:
+                _LOGGER.warning(
+                    "%s sent invalid base64 '%s' for %s",
+                    device.name,
+                    v,
+                    self.name,
+                )
+                return None
+        else:
+            return v
+
+    def encode_value(self, v):
+        if self.rawtype == "hex":
+            return v.hex()
+        elif self.rawtype == "base64":
+            return b64encode(v).decode("utf-8")
+        elif self.rawtype == "unixtime" and isinstance(v, datetime):
+            return v.timestamp()
+        else:
+            return v
+
+    def _match(self, matchdata, value):
+        """Return true val1 matches val2"""
+        if self.rawtype == "bitfield" and matchdata:
+            try:
+                return (int(value) & int(matchdata)) != 0
+            except (TypeError, ValueError):
+                return False
+        else:
+            return str(value) == str(matchdata)
+
+    async def async_set_value(self, device, value):
+        """Set the value of the dps in the given device to given value."""
+        if self.readonly:
+            raise TypeError(f"{self.name} is read only")
+        if self.invalid_for(value, device):
+            raise AttributeError(f"{self.name} cannot be set at this time")
+        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():
+            _LOGGER.debug(
+                "No mapping for dpid %s (%s), unable to determine valid values",
+                self.id,
+                self.name,
+            )
+            return []
+        val = []
+        for m in self._config["mapping"]:
+            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"])
+                if r_dps:
+                    val = val + r_dps.values(device)
+            for c in m.get("conditions", {}):
+                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"])
+                    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 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"])
+                        if r_dps:
+                            c_val = c_val + r_dps.values(device)
+                # if given, the conditional mapping is an override
+                if c_val:
+                    _LOGGER.debug(
+                        "Overriding %s values %s with %s",
+                        self.name,
+                        val,
+                        c_val,
+                    )
+
+                    val = c_val
+                    break
+        _LOGGER.debug("%s values: %s", self.name, val)
+        return _remove_duplicates(val)
+
+    @property
+    def default(self):
+        """Return the default value for a dp."""
+        if "mapping" not in self._config.keys():
+            _LOGGER.debug(
+                "No mapping for %s, unable to determine default value",
+                self.name,
+            )
+            return None
+        for m in self._config["mapping"]:
+            if m.get("default", False):
+                return m.get("value", m.get("dps_val", None))
+            for c in m.get("conditions", {}):
+                if c.get("default", False):
+                    return c.get("value", m.get("value", m.get("dps_val", None)))
+
+    def range(self, device, scaled=True):
+        """Return the range for this dps if configured."""
+        scale = self.scale(device) if scaled else 1
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        r = self._config.get("range")
+        if mapping:
+            _LOGGER.debug("Considering mapping for range of %s", self.name)
+            cond = self._active_condition(mapping, device)
+            if cond:
+                r = cond.get("range", r)
+
+        if r and "min" in r and "max" in r:
+            return _scale_range(r, scale)
+        else:
+            return None
+
+    def scale(self, device):
+        scale = 1
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        if mapping:
+            scale = mapping.get("scale", 1)
+            cond = self._active_condition(mapping, device)
+            if cond:
+                scale = cond.get("scale", scale)
+        return scale
+
+    def precision(self, device):
+        if self.type is int:
+            scale = self.scale(device)
+            precision = 0
+            while scale > 1.0:
+                scale /= 10.0
+                precision += 1
+            return precision
+
+    @property
+    def suggested_display_precision(self):
+        return self._config.get("precision")
+
+    def step(self, device, scaled=True):
+        step = 1
+        scale = self.scale(device) if scaled else 1
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        if mapping:
+            _LOGGER.debug("Considering mapping for step of %s", self.name)
+            step = mapping.get("step", 1)
+
+            cond = self._active_condition(mapping, device)
+            if cond:
+                constraint = mapping.get("constraint", self.name)
+                _LOGGER.debug("Considering condition on %s", constraint)
+                step = cond.get("step", step)
+        if step != 1 or scale != 1:
+            _LOGGER.debug(
+                "Step for %s is %s with scale %s",
+                self.name,
+                step,
+                scale,
+            )
+        return step / scale if scaled else step
+
+    @property
+    def readonly(self):
+        return self._config.get("readonly", False)
+
+    def invalid_for(self, value, device):
+        mapping = self._find_map_for_value(value, device)
+        if mapping:
+            cond = self._active_condition(mapping, device)
+            if cond:
+                return cond.get("invalid", False)
+        return False
+
+    @property
+    def hidden(self):
+        return self._config.get("hidden", False)
+
+    @property
+    def unit(self):
+        return self._config.get("unit")
+
+    @property
+    def state_class(self):
+        """The state class of this measurement."""
+        return self._config.get("class")
+
+    def _find_map_for_dps(self, value):
+        default = None
+        for m in self._config.get("mapping", {}):
+            if "dps_val" not in m:
+                default = m
+            elif self._match(m["dps_val"], value):
+                return m
+        return default
+
+    def _correct_type(self, result):
+        """Convert value to the correct type for this dp."""
+        if self.type is int:
+            _LOGGER.debug("Rounding %s", self.name)
+            result = int(round(result))
+        elif self.type is bool:
+            result = True if result else False
+        elif self.type is float:
+            result = float(result)
+        elif self.type is str:
+            result = str(result)
+            if self.rawtype == "utf16b64":
+                result = b64encode(result.encode("utf-16-be")).decode("utf-8")
+
+        if self.stringify:
+            result = str(result)
+
+        return result
+
+    def _map_from_dps(self, val, device):
+        if val is not None and self.type is not str and isinstance(val, str):
+            try:
+                val = self.type(val)
+                self.stringify = True
+            except ValueError:
+                self.stringify = False
+        else:
+            self.stringify = False
+
+        # decode utf-16 base64 strings first, so normal strings can be matched
+        if self.rawtype == "utf16b64" and isinstance(val, str):
+            try:
+                val = b64decode(val).decode("utf-16-be")
+            except ValueError:
+                _LOGGER.warning("Invalid utf16b64 %s", val)
+
+        result = val
+        scale = self.scale(device)
+        replaced = False
+
+        mapping = self._find_map_for_dps(val)
+        if mapping:
+            invert = mapping.get("invert", False)
+            redirect = mapping.get("value_redirect")
+            mirror = mapping.get("value_mirror")
+            replaced = "value" in mapping
+            result = mapping.get("value", result)
+            target_range = mapping.get("target_range")
+
+            cond = self._active_condition(mapping, device)
+            if cond:
+                if cond.get("invalid", False):
+                    return None
+                replaced = replaced or "value" in cond
+                result = cond.get("value", result)
+                redirect = cond.get("value_redirect", redirect)
+                mirror = cond.get("value_mirror", mirror)
+                target_range = cond.get("target_range", target_range)
+
+                for m in cond.get("mapping", {}):
+                    if str(m.get("dps_val")) == str(result):
+                        replaced = "value" in m
+                        result = m.get("value", result)
+
+            if redirect:
+                _LOGGER.debug("Redirecting %s to %s", self.name, redirect)
+                r_dps = self._entity.find_dps(redirect)
+                if r_dps:
+                    return r_dps.get_value(device)
+            if mirror:
+                r_dps = self._entity.find_dps(mirror)
+                if r_dps:
+                    return r_dps.get_value(device)
+
+            if invert and isinstance(result, Number):
+                r = self._config.get("range")
+                if r and "min" in r and "max" in r:
+                    result = -1 * result + r["min"] + r["max"]
+                    replaced = True
+
+            if target_range and isinstance(result, Number):
+                r = self._config.get("range")
+                if r and "max" in r and "max" in target_range:
+                    from_min = r.get("min", 0)
+                    from_max = r["max"]
+                    to_min = target_range.get("min", 0)
+                    to_max = target_range["max"]
+                    result = to_min + (
+                        (result - from_min) * (to_max - to_min) / (from_max - from_min)
+                    )
+                    replaced = True
+
+            if scale != 1 and isinstance(result, Number):
+                result = result / scale
+                replaced = True
+
+        if self.rawtype == "unixtime" and isinstance(result, int):
+            try:
+                result = datetime.fromtimestamp(result)
+                replaced = True
+            except Exception:
+                _LOGGER.warning("Invalid timestamp %d", result)
+
+        if replaced:
+            _LOGGER.debug(
+                "%s: Mapped dps %s value from %s to %s",
+                self._entity._device.name,
+                self.id,
+                val,
+                result,
+            )
+
+        return result
+
+    def _find_map_for_value(self, value, device):
+        default = None
+        nearest = None
+        distance = float("inf")
+        for m in self._config.get("mapping", {}):
+            if "dps_val" not in m:
+                default = m
+            # The following avoids further matching on the above case
+            # and in the null mapping case, which is intended to be
+            # a one-way map to prevent the entity showing as unavailable
+            # when no value is being reported by the device.
+            if m.get("dps_val") is None:
+                continue
+            if "value" in m and str(m["value"]) == str(value):
+                return m
+            if (
+                "value" in m
+                and isinstance(m["value"], Number)
+                and isinstance(value, Number)
+            ):
+                d = abs(m["value"] - value)
+                if d < distance:
+                    distance = d
+                    nearest = m
+
+            if "value" not in m and "value_mirror" in m:
+                r_dps = self._entity.find_dps(m["value_mirror"])
+                if r_dps and str(r_dps.get_value(device)) == str(value):
+                    return m
+
+            for c in m.get("conditions", {}):
+                if "value" in c and str(c["value"]) == str(value):
+                    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 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"),
+                        )
+                    ):
+                        return m
+                if "value" not in c and "value_mirror" in c:
+                    r_dps = self._entity.find_dps(c["value_mirror"])
+                    if r_dps and str(r_dps.get_value(device)) == str(value):
+                        return m
+        if nearest:
+            return nearest
+        return default
+
+    def _active_condition(self, mapping, device, value=None):
+        constraint = mapping.get("constraint", self.name)
+        conditions = mapping.get("conditions")
+        c_match = None
+        if constraint and conditions:
+            c_dps = self._entity.find_dps(constraint)
+            # base64 and hex have to be decoded
+            c_val = (
+                None
+                if c_dps is None
+                else (
+                    c_dps.get_value(device)
+                    if c_dps.rawtype == "base64" or c_dps.rawtype == "hex"
+                    else device.get_property(c_dps.id)
+                )
+            )
+            for cond in conditions:
+                if c_val is not None and (_equal_or_in(c_val, cond.get("dps_val"))):
+                    c_match = cond
+                # Case where matching None, need extra checks to ensure we
+                # are not just defaulting and it is really a match
+                elif (
+                    c_val is None
+                    and c_dps is not None
+                    and "dps_val" in cond
+                    and cond.get("dps_val") is None
+                ):
+                    c_match = cond
+                # when changing, another condition may become active
+                # return that if it exists over a current condition
+                if value is not None and value == cond.get("value"):
+                    return cond
+
+        return c_match
+
+    def get_values_to_set(self, device, value):
+        """Return the dps values that would be set when setting to value"""
+        result = value
+        dps_map = {}
+        if self.readonly:
+            return dps_map
+
+        mapping = self._find_map_for_value(value, device)
+        scale = self.scale(device)
+        mask = None
+        if mapping:
+            replaced = False
+            redirect = mapping.get("value_redirect")
+            invert = mapping.get("invert", False)
+            mask = mapping.get("mask")
+            endianness = mapping.get("endianness", "big")
+            target_range = mapping.get("target_range")
+            step = mapping.get("step")
+            if not isinstance(step, Number):
+                step = None
+            if "dps_val" in mapping and not mapping.get("hidden", False):
+                result = mapping["dps_val"]
+                replaced = True
+            # Conditions may have side effect of setting another value.
+            cond = self._active_condition(mapping, device, value)
+            if cond:
+                cval = cond.get("value")
+                if cval is None:
+                    r_dps = cond.get("value_mirror")
+                    if r_dps:
+                        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))
+                    cond_dpsval = cond.get("dps_val")
+                    single_match = isinstance(cond_dpsval, str) or (
+                        not isinstance(cond_dpsval, Sequence)
+                    )
+                    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,
+                        )
+                        dps_map.update(c_dps.get_values_to_set(device, c_val))
+
+                # Allow simple conditional mapping overrides
+                for m in cond.get("mapping", {}):
+                    if m.get("value") == value and not m.get("hidden", False):
+                        result = m.get("dps_val", result)
+
+                step = cond.get("step", step)
+                redirect = cond.get("value_redirect", redirect)
+                target_range = cond.get("target_range", target_range)
+
+            if redirect:
+                _LOGGER.debug("Redirecting %s to %s", self.name, redirect)
+                r_dps = self._entity.find_dps(redirect)
+                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)
+                result = result * scale
+                remap = self._find_map_for_value(result, device)
+                if (
+                    remap
+                    and "dps_val" in remap
+                    and "dps_val" not in mapping
+                    and not remap.get("hidden", False)
+                ):
+                    result = remap["dps_val"]
+                replaced = True
+
+            if target_range and isinstance(result, Number):
+                r = self._config.get("range")
+                if r and "max" in r and "max" in target_range:
+                    from_min = target_range.get("min", 0)
+                    from_max = target_range["max"]
+                    to_min = r.get("min", 0)
+                    to_max = r["max"]
+                    result = to_min + (
+                        (result - from_min) * (to_max - to_min) / (from_max - from_min)
+                    )
+                    replaced = True
+
+            if invert:
+                r = self._config.get("range")
+                if r and "min" in r and "max" in r:
+                    result = -1 * result + r["min"] + r["max"]
+                    replaced = True
+
+            if step and isinstance(result, Number):
+                _LOGGER.debug("Stepping %s to %s", result, step)
+                result = step * round(float(result) / step)
+                remap = self._find_map_for_value(result, device)
+                if (
+                    remap
+                    and "dps_val" in remap
+                    and "dps_val" not in mapping
+                    and not remap.get("hidden", False)
+                ):
+                    result = remap["dps_val"]
+                replaced = True
+
+            if replaced:
+                _LOGGER.debug(
+                    "%s: Mapped dps %s to %s from %s",
+                    self._entity._device.name,
+                    self.id,
+                    result,
+                    value,
+                )
+
+        r = self.range(device, scaled=False)
+        if r and isinstance(result, Number):
+            mn = r[0]
+            mx = r[1]
+            if round(result) < mn or round(result) > mx:
+                # Output scaled values in the error message
+                r = self.range(device, scaled=True)
+                mn = r[0]
+                mx = r[1]
+                raise ValueError(f"{self.name} ({value}) must be between {mn} and {mx}")
+
+        if mask and isinstance(result, Number):
+            # mask is in hex, 2 digits/characters per byte
+            length = int(len(mask) / 2)
+            # Convert to int
+            mask = int(mask, 16)
+            mask_scale = mask & (1 + ~mask)
+            current_value = int.from_bytes(self.decoded_value(device), endianness)
+            result = (current_value & ~mask) | (mask & int(result * mask_scale))
+            result = self.encode_value(result.to_bytes(length, endianness))
+
+        dps_map[self.id] = self._correct_type(result)
+        return dps_map
+
+    def icon_rule(self, device):
+        mapping = self._find_map_for_dps(device.get_property(self.id))
+        icon = None
+        priority = 100
+        if mapping:
+            icon = mapping.get("icon", icon)
+            priority = mapping.get("icon_priority", 10 if icon else 100)
+            cond = self._active_condition(mapping, device)
+            if cond and cond.get("icon_priority", 10) < priority:
+                icon = cond.get("icon", icon)
+                priority = cond.get("icon_priority", 10 if icon else 100)
+
+        return {"priority": priority, "icon": icon}
+
+
+def available_configs():
+    """List the available config files."""
+    _CONFIG_DIR = dirname(config_dir.__file__)
+
+    for path, dirs, files in walk(_CONFIG_DIR):
+        for basename in sorted(files):
+            if fnmatch(basename, "*.yaml"):
+                yield basename
+
+
+def possible_matches(dps):
+    """Return possible matching configs for a given set of dps values."""
+    for cfg in available_configs():
+        parsed = TuyaDeviceConfig(cfg)
+        try:
+            if parsed.matches(dps):
+                yield parsed
+        except TypeError:
+            _LOGGER.error("Parse error in %s", cfg)
+
+
+def get_config(conf_type):
+    """
+    Return a config to use with config_type.
+    """
+    _CONFIG_DIR = dirname(config_dir.__file__)
+    fname = conf_type + ".yaml"
+    fpath = join(_CONFIG_DIR, fname)
+    if exists(fpath):
+        return TuyaDeviceConfig(fname)
+    else:
+        return config_for_legacy_use(conf_type)
+
+
+def config_for_legacy_use(conf_type):
+    """
+    Return a config to use with config_type for legacy transition.
+    Note: as there are two variants for Kogan Socket, this is not guaranteed
+    to be the correct config for the device, so only use it for looking up
+    the legacy class during the transition period.
+    """
+    for cfg in available_configs():
+        parsed = TuyaDeviceConfig(cfg)
+        if parsed.legacy_type == conf_type:
+            return parsed
+
+    return None

+ 12 - 0
custom_components/tuya_local/icons.json

@@ -238,6 +238,18 @@
             },
             "time_remaining": {
                 "default": "mdi:timer"
+            },
+            "energy_consumed": {
+                "default": "mdi:flash"
+            },
+            "energy_produced": {
+                "default": "mdi:solar-power"
+            },
+            "energy_consumed_x": {
+                "default": "mdi:flash"
+            },
+            "energy_produced_x": {
+                "default": "mdi:solar-power"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/bg.json

@@ -618,6 +618,18 @@
                     "done": "Готвенето завърши",
                     "pause": "Пауза за готвене"
                 }
+            },
+            "energy_produced": {
+                "name": "Произведена енергия"
+            },
+            "energy_consumed": {
+                "name": "Консумирана енергия"
+            },
+            "energy_produced_x": {
+                "name": "Произведена енергия {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Консумирана енергия {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/cz.json

@@ -617,6 +617,18 @@
                     "done": "Vaření dokončeno",
                     "pause": "Vaření pozastaveno"
                 }
+            },
+            "energy_produced": {
+                "name": "Vyprodukována energie"
+            },
+            "energy_consumed": {
+                "name": "Spotřebována energie"
+            },
+            "energy_produced_x": {
+                "name": "Vyprodukována energie {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Spotřebována energie {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/de.json

@@ -616,6 +616,18 @@
                     "done": "Kochen abgeschlossen",
                     "pause": "Kochen pausiert"
                 }
+            },
+            "energy_produced": {
+                "name": "Produzierte Energie"
+            },
+            "energy_consumed": {
+                "name": "Verbrauchte Energie"
+            },
+            "energy_produced_x": {
+                "name": "Produzierte Energie {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Verbrauchte Energie {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/el.json

@@ -617,6 +617,18 @@
                     "done": "Ολοκληρώθηκε",
                     "pause": "Παύση μαγειρέματος"
                 }
+            },
+            "energy_produced": {
+                "name": "Ενέργεια που παράχθηκε"
+            },
+            "energy_consumed": {
+                "name": "Ενέργεια που καταναλώθηκε"
+            },
+            "energy_produced_x": {
+                "name": "Ενέργεια που παράχθηκε {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Ενέργεια που καταναλώθηκε {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/en.json

@@ -617,6 +617,18 @@
                     "done": "Cooking Completed",
                     "pause": "Cooking Paused"
                 }
+            },
+            "energy_produced": {
+                "name": "Energy produced"
+            },
+            "energy_consumed": {
+                "name": "Energy consumed"
+            },
+            "energy_produced_x": {
+                "name": "Energy produced {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energy consumed {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/es.json

@@ -617,6 +617,18 @@
                     "done": "Cocción completada",
                     "pause": "Pausa en la cocción"
                 }
+            },
+            "energy_produced": {
+                "name": "Energía producida"
+            },
+            "energy_consumed": {
+                "name": "Energía consumida"
+            },
+            "energy_produced_x": {
+                "name": "Energía producida {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energía consumida {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/fr.json

@@ -617,6 +617,18 @@
                     "done": "Cuisson terminée",
                     "pause": "Cuisson en pause"
                 }
+            },
+            "energy_produced": {
+                "name": "Énergie produite"
+            },
+            "energy_consumed": {
+                "name": "Énergie consommée"
+            },
+            "energy_produced_x": {
+                "name": "Énergie produite {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Énergie consommée {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/hu.json

@@ -618,6 +618,18 @@
                     "done": "Főzés befejezve",
                     "pause": "Főzés szüneteltetve"
                 }
+            },
+            "energy_produced": {
+                "name": "Termelt energia"
+            },
+            "energy_consumed": {
+                "name": "Fogyasztott energia"
+            },
+            "energy_produced_x": {
+                "name": "Termelt energia {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Fogyasztott energia {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/id.json

@@ -617,6 +617,18 @@
                     "done": "Memasak selesai",
                     "pause": "Memasak dijeda"
                 }
+            },
+            "energy_produced": {
+                "name": "Energi yang dihasilkan"
+            },
+            "energy_consumed": {
+                "name": "Energi yang dikonsumsi"
+            },
+            "energy_produced_x": {
+                "name": "Energi yang dihasilkan {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energi yang dikonsumsi {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/it.json

@@ -618,6 +618,18 @@
                     "done": "Cottura completata",
                     "pause": "Cottura in pausa"
                 }
+            },
+            "energy_produced": {
+                "name": "Energia prodotta"
+            },
+            "energy_consumed": {
+                "name": "Energia consumata"
+            },
+            "energy_produced_x": {
+                "name": "Energia prodotta {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energia consumata {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/ja.json

@@ -617,6 +617,18 @@
                     "done": "調理完了",
                     "pause": "調理一時停止"
                 }
+            },
+            "energy_produced": {
+                "name": "発電量"
+            },
+            "energy_consumed": {
+                "name": "消費電力"
+            },
+            "energy_produced_x": {
+                "name": "発電量{x}"
+            },
+            "energy_consumed_x": {
+                "name": "消費電力{x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/no-NB.json

@@ -618,6 +618,18 @@
                     "done": "Ferdig",
                     "pause": "Matlaging pause"
                 }
+            },
+            "energy_produced": {
+                "name": "Energi produsert"
+            },
+            "energy_consumed": {
+                "name": "Energi forbrukt"
+            },
+            "energy_produced_x": {
+                "name": "Energi produsert {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energi forbrukt {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/pl.json

@@ -618,6 +618,18 @@
                     "done": "Gotowanie zakończone",
                     "pause": "Gotowanie wstrzymane"
                 }
+            },
+            "energy_produced": {
+                "name": "Energia wyprodukowana"
+            },
+            "energy_consumed": {
+                "name": "Energia zużyta"
+            },
+            "energy_produced_x": {
+                "name": "Energia wyprodukowana {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energia zużyta {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/pt-BR.json

@@ -617,6 +617,18 @@
                     "done": "Concluído",
                     "pause": "Pausa no cozimento"
                 }
+            },
+            "energy_produced": {
+                "name": "Energia produzida"
+            },
+            "energy_consumed": {
+                "name": "Energia consumida"
+            },
+            "energy_produced_x": {
+                "name": "Energia produzida {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Energia consumida {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/ru.json

@@ -617,6 +617,18 @@
                     "done": "Готово",
                     "pause": "Приготовление приостановлено"
                 }
+            },
+            "energy_produced": {
+                "name": "Производимая энергия"
+            },
+            "energy_consumed": {
+                "name": "Потребляемая энергия"
+            },
+            "energy_produced_x": {
+                "name": "Производимая энергия {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Потребляемая энергия {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/uk.json

@@ -620,6 +620,18 @@
                     "done": "Готово",
                     "pause": "Приготування призупинено"
                 }
+            },
+            "energy_produced": {
+                "name": "Вироблена енергія"
+            },
+            "energy_consumed": {
+                "name": "Спожита енергія"
+            },
+            "energy_produced_x": {
+                "name": "Вироблена енергія {x}"
+            },
+            "energy_consumed_x": {
+                "name": "Спожита енергія {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/ur.json

@@ -620,6 +620,18 @@
                     "done": "پکانا مکمل ہوگیا",
                     "pause": "پکانا معطل ہوگیا"
                 }
+            },
+            "energy_produced": {
+                "name": "پیدا کردہ بجلی"
+            },
+            "energy_consumed": {
+                "name": "استعمال کردہ بجلی"
+            },
+            "energy_produced_x": {
+                "name": "پیدا کردہ بجلی {x}"
+            },
+            "energy_consumed_x": {
+                "name": "استعمال کردہ بجلی {x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/zh-Hans.json

@@ -617,6 +617,18 @@
                     "done": "烹饪完成",
                     "pause": "烹饪暂停"
                 }
+            },
+            "energy_produced": {
+                "name": "产生的能量"
+            },
+            "energy_consumed": {
+                "name": "消耗的能量"
+            },
+            "energy_produced_x": {
+                "name": "产生的能量{x}"
+            },
+            "energy_consumed_x": {
+                "name": "消耗的能量{x}"
             }
         },
         "switch": {

+ 12 - 0
custom_components/tuya_local/translations/zh-Hant.json

@@ -618,6 +618,18 @@
                     "done": "烹飪完成",
                     "pause": "烹飪暫停"
                 }
+            },
+            "energy_produced": {
+                "name": "能量產生"
+            },
+            "energy_consumed": {
+                "name": "能量消耗"
+            },
+            "energy_produced_x": {
+                "name": "能量產生{x}"
+            },
+            "energy_consumed_x": {
+                "name": "能量消耗{x}"
             }
         },
         "switch": {