diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index f394f1f611e..d8ac220a449 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -203,105 +203,80 @@ class MoldIndicator(SensorEntity): def _async_setup_sensor(self) -> None: """Set up the sensor and start tracking state changes.""" - @callback - def mold_indicator_sensors_state_listener( - event: Event[EventStateChangedData], - ) -> None: - """Handle for state changes for dependent sensors.""" - new_state = event.data["new_state"] - old_state = event.data["old_state"] - entity = event.data["entity_id"] - _LOGGER.debug( - "Sensor state change for %s that had old state %s and new state %s", - entity, - old_state, - new_state, - ) - - if self._update_sensor(entity, old_state, new_state): - if self._preview_callback: - calculated_state = self._async_calculate_state() - self._preview_callback( - calculated_state.state, calculated_state.attributes - ) - # only write state to the state machine if we are not in preview mode - else: - self.async_schedule_update_ha_state(True) - - @callback - def mold_indicator_startup() -> None: - """Add listeners and get 1st state.""" - _LOGGER.debug("Startup for %s", self.entity_id) - + self.async_on_remove( async_track_state_change_event( self.hass, - list(self._entities.values()), - mold_indicator_sensors_state_listener, + self._entities.values(), + self._async_mold_indicator_sensor_state_listener, + ) + ) + + # Replay current state of source entities + for entity_id in self._entities.values(): + state = self.hass.states.get(entity_id) + state_event: Event[EventStateChangedData] = Event( + "", {"entity_id": entity_id, "new_state": state, "old_state": None} + ) + self._async_mold_indicator_sensor_state_listener( + state_event, update_state=False ) - # Read initial state - indoor_temp = self.hass.states.get(self._entities[CONF_INDOOR_TEMP]) - outdoor_temp = self.hass.states.get(self._entities[CONF_OUTDOOR_TEMP]) - indoor_hum = self.hass.states.get(self._entities[CONF_INDOOR_HUMIDITY]) + self._recalculate() - schedule_update = self._update_sensor( - self._entities[CONF_INDOOR_TEMP], None, indoor_temp - ) + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback(calculated_state.state, calculated_state.attributes) - schedule_update = ( - False - if not self._update_sensor( - self._entities[CONF_OUTDOOR_TEMP], None, outdoor_temp - ) - else schedule_update - ) + @callback + def _async_mold_indicator_sensor_state_listener( + self, event: Event[EventStateChangedData], update_state: bool = True + ) -> None: + """Handle state changes for dependent sensors.""" + entity_id = event.data["entity_id"] + new_state = event.data["new_state"] - schedule_update = ( - False - if not self._update_sensor( - self._entities[CONF_INDOOR_HUMIDITY], None, indoor_hum - ) - else schedule_update - ) + _LOGGER.debug( + "Sensor state change for %s that had old state %s and new state %s", + entity_id, + event.data["old_state"], + new_state, + ) - if schedule_update and not self._preview_callback: - self.async_schedule_update_ha_state(True) - if self._preview_callback: - # re-calculate dewpoint and mold indicator - self._calc_dewpoint() - self._calc_moldindicator() - if self._attr_native_value is None: - self._attr_available = False - else: - self._attr_available = True - calculated_state = self._async_calculate_state() - self._preview_callback( - calculated_state.state, calculated_state.attributes - ) - - mold_indicator_startup() - - def _update_sensor( - self, entity: str, old_state: State | None, new_state: State | None - ) -> bool: - """Update information based on new sensor states.""" - _LOGGER.debug("Sensor update for %s", entity) - if new_state is None: - return False - - # If old_state is not set and new state is unknown then it means - # that the sensor just started up - if old_state is None and new_state.state == STATE_UNKNOWN: - return False - - if entity == self._entities[CONF_INDOOR_TEMP]: + # update state depending on which sensor changed + if entity_id == self._entities[CONF_INDOOR_TEMP]: self._indoor_temp = self._get_temperature_from_state(new_state) - elif entity == self._entities[CONF_OUTDOOR_TEMP]: + elif entity_id == self._entities[CONF_OUTDOOR_TEMP]: self._outdoor_temp = self._get_temperature_from_state(new_state) - elif entity == self._entities[CONF_INDOOR_HUMIDITY]: + elif entity_id == self._entities[CONF_INDOOR_HUMIDITY]: self._indoor_hum = self._get_humidity_from_state(new_state) - return True + if not update_state: + return + + self._recalculate() + + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback(calculated_state.state, calculated_state.attributes) + # only write state to the state machine if we are not in preview mode + else: + self.async_write_ha_state() + + @callback + def _recalculate(self) -> None: + """Recalculate mold indicator from cached sensor values.""" + # Check if all sensors are available + if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + self._attr_available = False + self._attr_native_value = None + self._dewpoint = None + self._crit_temp = None + return + + # Calculate dewpoint and mold indicator + self._calc_dewpoint() + self._calc_moldindicator() + self._attr_available = self._attr_native_value is not None def _get_value_from_state( self, diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py index 6c4cb1a94d2..04e0de721b3 100644 --- a/tests/components/mold_indicator/test_sensor.py +++ b/tests/components/mold_indicator/test_sensor.py @@ -214,6 +214,7 @@ async def test_unknown_sensor(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() await hass.async_start() + await hass.async_block_till_done() hass.states.async_set( "test.indoortemp", @@ -292,6 +293,7 @@ async def test_sensor_changed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() await hass.async_start() + await hass.async_block_till_done() hass.states.async_set( "test.indoortemp", "30", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} @@ -310,3 +312,126 @@ async def test_sensor_changed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert hass.states.get("sensor.mold_indicator").state == "23" + + +@pytest.mark.parametrize("new_state", [STATE_UNAVAILABLE, STATE_UNKNOWN]) +async def test_unavailable_sensor_recovery(hass: HomeAssistant, new_state: str) -> None: + """Test recovery when sensor becomes unavailable/unknown and then available again.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "mold_indicator", + "indoor_temp_sensor": "test.indoortemp", + "outdoor_temp_sensor": "test.outdoortemp", + "indoor_humidity_sensor": "test.indoorhumidity", + "calibration_factor": 2.0, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Initial state should be valid + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == "68" + + # Set indoor temp to unavailable + hass.states.async_set( + "test.indoortemp", + new_state, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == STATE_UNAVAILABLE + assert moldind.attributes.get(ATTR_DEWPOINT) is None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None + + # Recover by setting a valid value - should immediately work + hass.states.async_set( + "test.indoortemp", "20", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == "68" + assert moldind.attributes.get(ATTR_DEWPOINT) is not None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is not None + + +async def test_all_sensors_unavailable_recovery(hass: HomeAssistant) -> None: + """Test recovery when all sensors become unavailable and then available again.""" + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "platform": "mold_indicator", + "indoor_temp_sensor": "test.indoortemp", + "outdoor_temp_sensor": "test.outdoortemp", + "indoor_humidity_sensor": "test.indoorhumidity", + "calibration_factor": 2.0, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Initial state should be valid + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == "68" + + # Set all sensors to unavailable + hass.states.async_set( + "test.indoortemp", + STATE_UNAVAILABLE, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "test.outdoortemp", + STATE_UNAVAILABLE, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "test.indoorhumidity", + STATE_UNAVAILABLE, + {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}, + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == STATE_UNAVAILABLE + + # Recover all sensors one by one + hass.states.async_set( + "test.indoortemp", "20", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == STATE_UNAVAILABLE # Still unavailable, needs all sensors + + hass.states.async_set( + "test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == STATE_UNAVAILABLE # Still unavailable, needs humidity + + hass.states.async_set( + "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} + ) + await hass.async_block_till_done() + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.state == "68" # Now should recover fully + assert moldind.attributes.get(ATTR_DEWPOINT) is not None + assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is not None