Fix add checks for None values and check if DHW is available (#151376)

This commit is contained in:
Willem-Jan van Rootselaar
2025-09-01 16:47:59 +02:00
committed by GitHub
parent d7e6f84d28
commit 581f8a9378
7 changed files with 191 additions and 6 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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:

View File

@@ -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],

View File

@@ -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

View File

@@ -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