From 43ccf1d96745cfcab56a76ebf722b8f163e618c5 Mon Sep 17 00:00:00 2001
From: Maciej Bieniek <bieniu@users.noreply.github.com>
Date: Mon, 1 Nov 2021 17:40:15 +0100
Subject: [PATCH] Handle `None` values in Xiaomi Miio integration (#58880)

* Initial commit

* Improve _handle_coordinator_update()

* Fix entity_description define

* Improve sensor & binary_sensor platforms

* Log None value

* Use coordinator variable

* Improve log strings

* Filter attributes with None values

* Add hasattr condition

* Update homeassistant/components/xiaomi_miio/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
---
 .../components/xiaomi_miio/binary_sensor.py   | 32 +++++++--
 .../components/xiaomi_miio/device.py          | 13 +---
 homeassistant/components/xiaomi_miio/fan.py   |  9 ---
 .../components/xiaomi_miio/humidifier.py      |  9 ---
 .../components/xiaomi_miio/number.py          |  9 ---
 .../components/xiaomi_miio/select.py          | 11 +---
 .../components/xiaomi_miio/sensor.py          | 66 ++++++++++++-------
 .../components/xiaomi_miio/switch.py          |  9 ---
 8 files changed, 72 insertions(+), 86 deletions(-)

diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py
index d33059c20ef..1bdd647da79 100644
--- a/homeassistant/components/xiaomi_miio/binary_sensor.py
+++ b/homeassistant/components/xiaomi_miio/binary_sensor.py
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+import logging
 from typing import Callable
 
 from homeassistant.components.binary_sensor import (
@@ -12,6 +13,7 @@ from homeassistant.components.binary_sensor import (
     BinarySensorEntityDescription,
 )
 from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC
+from homeassistant.core import callback
 
 from . import VacuumCoordinatorDataAttributes
 from .const import (
@@ -30,6 +32,8 @@ from .const import (
 )
 from .device import XiaomiCoordinatedMiioEntity
 
+_LOGGER = logging.getLogger(__name__)
+
 ATTR_NO_WATER = "no_water"
 ATTR_POWERSUPPLY_ATTACHED = "powersupply_attached"
 ATTR_WATER_TANK_DETACHED = "water_tank_detached"
@@ -108,21 +112,29 @@ HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED)
 
 def _setup_vacuum_sensors(hass, config_entry, async_add_entities):
     """Only vacuums with mop should have binary sensor registered."""
-
     if config_entry.data[CONF_MODEL] not in MODELS_VACUUM_WITH_MOP:
         return
 
     device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE)
+    coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
     entities = []
 
     for sensor, description in VACUUM_SENSORS.items():
+        parent_key_data = getattr(coordinator.data, description.parent_key)
+        if getattr(parent_key_data, description.key, None) is None:
+            _LOGGER.debug(
+                "It seems the %s does not support the %s as the initial value is None",
+                config_entry.data[CONF_MODEL],
+                description.key,
+            )
+            continue
         entities.append(
             XiaomiGenericBinarySensor(
                 f"{config_entry.title} {description.name}",
                 device,
                 config_entry,
                 f"{sensor}_{config_entry.unique_id}",
-                hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR],
+                coordinator,
                 description,
             )
         )
@@ -168,18 +180,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
 class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity):
     """Representation of a Xiaomi Humidifier binary sensor."""
 
+    entity_description: XiaomiMiioBinarySensorDescription
+
     def __init__(self, name, device, entry, unique_id, coordinator, description):
         """Initialize the entity."""
         super().__init__(name, device, entry, unique_id, coordinator)
 
-        self.entity_description: XiaomiMiioBinarySensorDescription = description
+        self.entity_description = description
         self._attr_entity_registry_enabled_default = (
             description.entity_registry_enabled_default
         )
+        self._attr_is_on = self._determine_native_value()
 
-    @property
-    def is_on(self):
-        """Return true if the binary sensor is on."""
+    @callback
+    def _handle_coordinator_update(self) -> None:
+        self._attr_is_on = self._determine_native_value()
+
+        super()._handle_coordinator_update()
+
+    def _determine_native_value(self):
+        """Determine native value."""
         if self.entity_description.parent_key is not None:
             return self._extract_value_from_attribute(
                 getattr(self.coordinator.data, self.entity_description.parent_key),
diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py
index 8203b021ef9..488f4cc066f 100644
--- a/homeassistant/components/xiaomi_miio/device.py
+++ b/homeassistant/components/xiaomi_miio/device.py
@@ -169,17 +169,8 @@ class XiaomiCoordinatedMiioEntity(CoordinatorEntity):
             return cls._parse_datetime_datetime(value)
         if isinstance(value, datetime.timedelta):
             return cls._parse_time_delta(value)
-        if isinstance(value, float):
-            return value
-        if isinstance(value, int):
-            return value
-
-        _LOGGER.warning(
-            "Could not determine how to parse state value of type %s for state %s and attribute %s",
-            type(value),
-            type(state),
-            attribute,
-        )
+        if value is None:
+            _LOGGER.debug("Attribute %s is None, this is unexpected", attribute)
 
         return value
 
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index 01304008b76..07ec4613270 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -1,7 +1,6 @@
 """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier."""
 from abc import abstractmethod
 import asyncio
-from enum import Enum
 import logging
 import math
 
@@ -363,14 +362,6 @@ class XiaomiGenericAirPurifier(XiaomiGenericDevice):
 
         return None
 
-    @staticmethod
-    def _extract_value_from_attribute(state, attribute):
-        value = getattr(state, attribute)
-        if isinstance(value, Enum):
-            return value.value
-
-        return value
-
     @callback
     def _handle_coordinator_update(self):
         """Fetch state from the device."""
diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py
index 411d1428c70..9896bf8f0ea 100644
--- a/homeassistant/components/xiaomi_miio/humidifier.py
+++ b/homeassistant/components/xiaomi_miio/humidifier.py
@@ -1,5 +1,4 @@
 """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity."""
-from enum import Enum
 import logging
 import math
 
@@ -124,14 +123,6 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity):
         """Return true if device is on."""
         return self._state
 
-    @staticmethod
-    def _extract_value_from_attribute(state, attribute):
-        value = getattr(state, attribute)
-        if isinstance(value, Enum):
-            return value.value
-
-        return value
-
     @property
     def mode(self):
         """Get the current mode."""
diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py
index 1461f33add6..161a690a0df 100644
--- a/homeassistant/components/xiaomi_miio/number.py
+++ b/homeassistant/components/xiaomi_miio/number.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from enum import Enum
 
 from homeassistant.components.number import NumberEntity, NumberEntityDescription
 from homeassistant.const import DEGREE, ENTITY_CATEGORY_CONFIG, TIME_MINUTES
@@ -285,14 +284,6 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity):
             return False
         return super().available
 
-    @staticmethod
-    def _extract_value_from_attribute(state, attribute):
-        value = getattr(state, attribute)
-        if isinstance(value, Enum):
-            return value.value
-
-        return value
-
     async def async_set_value(self, value):
         """Set an option of the miio device."""
         method = getattr(self, self.entity_description.method)
diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py
index 2753fb09786..ec1be6f3219 100644
--- a/homeassistant/components/xiaomi_miio/select.py
+++ b/homeassistant/components/xiaomi_miio/select.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from enum import Enum
 
 from miio.airfresh import LedBrightness as AirfreshLedBrightness
 from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness
@@ -126,14 +125,6 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity):
         self._attr_options = list(description.options)
         self.entity_description = description
 
-    @staticmethod
-    def _extract_value_from_attribute(state, attribute):
-        value = getattr(state, attribute)
-        if isinstance(value, Enum):
-            return value.value
-
-        return value
-
 
 class XiaomiAirHumidifierSelector(XiaomiSelector):
     """Representation of a Xiaomi Air Humidifier selector."""
@@ -153,7 +144,7 @@ class XiaomiAirHumidifierSelector(XiaomiSelector):
         )
         # Sometimes (quite rarely) the device returns None as the LED brightness so we
         # check that the value is not None before updating the state.
-        if led_brightness:
+        if led_brightness is not None:
             self._current_led_brightness = led_brightness
             self.async_write_ha_state()
 
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
index e1e2d91ad1a..f818a809a5c 100644
--- a/homeassistant/components/xiaomi_miio/sensor.py
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -48,6 +48,7 @@ from homeassistant.const import (
     TIME_SECONDS,
     VOLUME_CUBIC_METERS,
 )
+from homeassistant.core import callback
 
 from . import VacuumCoordinatorDataAttributes
 from .const import (
@@ -529,17 +530,27 @@ VACUUM_SENSORS = {
 
 
 def _setup_vacuum_sensors(hass, config_entry, async_add_entities):
+    """Set up the Xiaomi vacuum sensors."""
     device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE)
+    coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
     entities = []
 
     for sensor, description in VACUUM_SENSORS.items():
+        parent_key_data = getattr(coordinator.data, description.parent_key)
+        if getattr(parent_key_data, description.key, None) is None:
+            _LOGGER.debug(
+                "It seems the %s does not support the %s as the initial value is None",
+                config_entry.data[CONF_MODEL],
+                description.key,
+            )
+            continue
         entities.append(
             XiaomiGenericSensor(
                 f"{config_entry.title} {description.name}",
                 device,
                 config_entry,
                 f"{sensor}_{config_entry.unique_id}",
-                hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR],
+                coordinator,
                 description,
             )
         )
@@ -637,23 +648,41 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
 class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity):
     """Representation of a Xiaomi generic sensor."""
 
-    def __init__(
-        self,
-        name,
-        device,
-        entry,
-        unique_id,
-        coordinator,
-        description: XiaomiMiioSensorDescription,
-    ):
+    entity_description: XiaomiMiioSensorDescription
+
+    def __init__(self, name, device, entry, unique_id, coordinator, description):
         """Initialize the entity."""
         super().__init__(name, device, entry, unique_id, coordinator)
+        self.entity_description = description
         self._attr_unique_id = unique_id
-        self.entity_description: XiaomiMiioSensorDescription = description
+        self._attr_native_value = self._determine_native_value()
+        self._attr_extra_state_attributes = self._extract_attributes(coordinator.data)
 
-    @property
-    def native_value(self):
-        """Return the state of the device."""
+    @callback
+    def _extract_attributes(self, data):
+        """Return state attributes with valid values."""
+        return {
+            attr: value
+            for attr in self.entity_description.attributes
+            if hasattr(data, attr)
+            and (value := self._extract_value_from_attribute(data, attr)) is not None
+        }
+
+    @callback
+    def _handle_coordinator_update(self):
+        """Fetch state from the device."""
+        native_value = self._determine_native_value()
+        # Sometimes (quite rarely) the device returns None as the sensor value so we
+        # check that the value is not None before updating the state.
+        if native_value is not None:
+            self._attr_native_value = native_value
+            self._attr_extra_state_attributes = self._extract_attributes(
+                self.coordinator.data
+            )
+            self.async_write_ha_state()
+
+    def _determine_native_value(self):
+        """Determine native value."""
         if self.entity_description.parent_key is not None:
             return self._extract_value_from_attribute(
                 getattr(self.coordinator.data, self.entity_description.parent_key),
@@ -664,15 +693,6 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity):
             self.coordinator.data, self.entity_description.key
         )
 
-    @property
-    def extra_state_attributes(self):
-        """Return the state attributes."""
-        return {
-            attr: self._extract_value_from_attribute(self.coordinator.data, attr)
-            for attr in self.entity_description.attributes
-            if hasattr(self.coordinator.data, attr)
-        }
-
 
 class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity):
     """Representation of a Xiaomi Air Quality Monitor."""
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
index 5c29253ae73..ab825e2485d 100644
--- a/homeassistant/components/xiaomi_miio/switch.py
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 
 import asyncio
 from dataclasses import dataclass
-from enum import Enum
 from functools import partial
 import logging
 
@@ -474,14 +473,6 @@ class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity):
             return False
         return super().available
 
-    @staticmethod
-    def _extract_value_from_attribute(state, attribute):
-        value = getattr(state, attribute)
-        if isinstance(value, Enum):
-            return value.value
-
-        return value
-
     async def async_turn_on(self, **kwargs) -> None:
         """Turn on an option of the miio device."""
         method = getattr(self, self.entity_description.method_on)