From 581f8a93787e163055fbb15834bd19e2b6fd84d7 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 1 Sep 2025 16:47:59 +0200 Subject: [PATCH] Fix add checks for None values and check if DHW is available (#151376) --- homeassistant/components/bsblan/climate.py | 4 ++ .../components/bsblan/config_flow.py | 2 +- homeassistant/components/bsblan/sensor.py | 26 ++++++++- .../components/bsblan/water_heater.py | 24 +++++++- tests/components/bsblan/test_climate.py | 44 +++++++++++++++ tests/components/bsblan/test_sensor.py | 42 ++++++++++++++ tests/components/bsblan/test_water_heater.py | 55 +++++++++++++++++++ 7 files changed, 191 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index bef0388a57d..5d181c07444 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -81,11 +81,15 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): @property def current_temperature(self) -> float | None: """Return the current temperature.""" + if self.coordinator.data.state.current_temperature is None: + return None return self.coordinator.data.state.current_temperature.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" + if self.coordinator.data.state.target_temperature is None: + return None return self.coordinator.data.state.target_temperature.value @property diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 5f4f67a114a..72e053ad140 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -25,7 +25,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize BSBLan flow.""" - self.host: str | None = None + self.host: str = "" self.port: int = DEFAULT_PORT self.mac: str | None = None self.passkey: str | None = None diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 7f3f7f48afc..f28c7a2decf 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -28,6 +28,7 @@ class BSBLanSensorEntityDescription(SensorEntityDescription): """Describes BSB-Lan sensor entity.""" value_fn: Callable[[BSBLanCoordinatorData], StateType] + exists_fn: Callable[[BSBLanCoordinatorData], bool] = lambda data: True SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = ( @@ -37,7 +38,12 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.sensor.current_temperature.value, + value_fn=lambda data: ( + data.sensor.current_temperature.value + if data.sensor.current_temperature is not None + else None + ), + exists_fn=lambda data: data.sensor.current_temperature is not None, ), BSBLanSensorEntityDescription( key="outside_temperature", @@ -45,7 +51,12 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.sensor.outside_temperature.value, + value_fn=lambda data: ( + data.sensor.outside_temperature.value + if data.sensor.outside_temperature is not None + else None + ), + exists_fn=lambda data: data.sensor.outside_temperature is not None, ), ) @@ -57,7 +68,16 @@ async def async_setup_entry( ) -> None: """Set up BSB-Lan sensor based on a config entry.""" data = entry.runtime_data - async_add_entities(BSBLanSensor(data, description) for description in SENSOR_TYPES) + + # Only create sensors for available data points + entities = [ + BSBLanSensor(data, description) + for description in SENSOR_TYPES + if description.exists_fn(data.coordinator.data) + ] + + if entities: + async_add_entities(entities) class BSBLanSensor(BSBLanEntity, SensorEntity): diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index a3aee4cdc15..248d7def849 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -41,6 +41,18 @@ async def async_setup_entry( ) -> None: """Set up BSBLAN water heater based on a config entry.""" data = entry.runtime_data + + # Only create water heater entity if DHW (Domestic Hot Water) is available + # Check if we have any DHW-related data indicating water heater support + dhw_data = data.coordinator.data.dhw + if ( + dhw_data.operating_mode is None + and dhw_data.nominal_setpoint is None + and dhw_data.dhw_actual_value_top_temperature is None + ): + # No DHW functionality available, skip water heater setup + return + async_add_entities([BSBLANWaterHeater(data)]) @@ -61,23 +73,31 @@ class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity): # Set temperature limits based on device capabilities self._attr_temperature_unit = data.coordinator.client.get_temperature_unit - self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value - self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value + if data.coordinator.data.dhw.reduced_setpoint is not None: + self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value + if data.coordinator.data.dhw.nominal_setpoint_max is not None: + self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value @property def current_operation(self) -> str | None: """Return current operation.""" + if self.coordinator.data.dhw.operating_mode is None: + return None current_mode = self.coordinator.data.dhw.operating_mode.desc return OPERATION_MODES.get(current_mode) @property def current_temperature(self) -> float | None: """Return the current temperature.""" + if self.coordinator.data.dhw.dhw_actual_value_top_temperature is None: + return None return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" + if self.coordinator.data.dhw.nominal_setpoint is None: + return None return self.coordinator.data.dhw.nominal_setpoint.value async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 41d566fc375..f35f0c7bdf3 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -91,6 +91,50 @@ async def test_climate_entity_properties( assert state.attributes["preset_mode"] == PRESET_ECO +async def test_climate_without_current_temperature_sensor( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test climate entity when current temperature sensor is not available.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set current_temperature to None to simulate no temperature sensor + mock_bsblan.state.return_value.current_temperature = None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not crash and current_temperature should be None in attributes + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["current_temperature"] is None + + +async def test_climate_without_target_temperature_sensor( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test climate entity when target temperature sensor is not available.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set target_temperature to None to simulate no temperature sensor + mock_bsblan.state.return_value.target_temperature = None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not crash and target temperature should be None in attributes + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["temperature"] is None + + @pytest.mark.parametrize( "mode", [HVACMode.HEAT, HVACMode.AUTO, HVACMode.OFF], diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index ba2af40f319..fdfe8fec06b 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -28,3 +28,45 @@ async def test_sensor_entity_properties( """Test the sensor entity properties.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_not_created_when_data_unavailable( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensors are not created when sensor data is not available.""" + # Set all sensor data to None to simulate no sensors available + mock_bsblan.sensor.return_value.current_temperature = None + mock_bsblan.sensor.return_value.outside_temperature = None + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + + # Should not create any sensor entities + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + sensor_entities = [entry for entry in entity_entries if entry.domain == "sensor"] + assert len(sensor_entities) == 0 + + +async def test_partial_sensors_created_when_some_data_available( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test only available sensors are created when some sensor data is available.""" + # Only current temperature available, outside temperature not + mock_bsblan.sensor.return_value.outside_temperature = None + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + + # Should create only the current temperature sensor + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + sensor_entities = [entry for entry in entity_entries if entry.domain == "sensor"] + assert len(sensor_entities) == 1 + assert sensor_entities[0].entity_id == ENTITY_CURRENT_TEMP diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index 173498b14ff..466da1e6fda 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -50,6 +50,33 @@ async def test_water_heater_states( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_water_heater_no_dhw_capability( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that no water heater entity is created when DHW capability is missing.""" + # Mock DHW data to simulate no water heater capability + mock_bsblan.hot_water_state.return_value.operating_mode = None + mock_bsblan.hot_water_state.return_value.nominal_setpoint = None + mock_bsblan.hot_water_state.return_value.dhw_actual_value_top_temperature = None + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + # Verify no water heater entity was created + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + water_heater_entities = [ + entity for entity in entities if entity.domain == Platform.WATER_HEATER + ] + + assert len(water_heater_entities) == 0 + + async def test_water_heater_entity_properties( hass: HomeAssistant, mock_bsblan: AsyncMock, @@ -208,3 +235,31 @@ async def test_operation_mode_error( }, blocking=True, ) + + +async def test_water_heater_no_sensors( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test water heater when sensors are not available.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + # Set all sensors to None to simulate missing sensors + mock_bsblan.hot_water_state.return_value.operating_mode = None + mock_bsblan.hot_water_state.return_value.dhw_actual_value_top_temperature = None + mock_bsblan.hot_water_state.return_value.nominal_setpoint = None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should not crash and properties should return None + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get("current_operation") is None + assert state.attributes.get("current_temperature") is None + assert state.attributes.get("temperature") is None