From f73e92a34ab79a5fc627c73fa2f22e8d2f734070 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 22 Nov 2025 15:36:47 +0100 Subject: [PATCH] Mark entity unavailable if data can't be fetched (#156928) --- homeassistant/components/lcn/binary_sensor.py | 9 +- homeassistant/components/lcn/climate.py | 18 ++-- homeassistant/components/lcn/cover.py | 22 +++-- homeassistant/components/lcn/light.py | 14 ++- .../components/lcn/quality_scale.yaml | 2 +- homeassistant/components/lcn/sensor.py | 18 ++-- homeassistant/components/lcn/switch.py | 34 +++++--- tests/components/lcn/test_binary_sensor.py | 43 ++++++++- tests/components/lcn/test_climate.py | 87 ++++++++++++++----- tests/components/lcn/test_cover.py | 78 ++++++++++++++++- tests/components/lcn/test_light.py | 60 ++++++++++++- tests/components/lcn/test_sensor.py | 64 +++++++++++++- tests/components/lcn/test_switch.py | 70 ++++++++++++++- 13 files changed, 448 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 9c7b2058a4f9..5d04967631b5 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -73,14 +73,17 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_binary_sensors( - SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_binary_sensors( + SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return - + self._attr_available = True self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 4ca5b0003d71..0874d9166842 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -171,20 +171,22 @@ class LcnClimate(LcnEntity, ClimateEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await asyncio.gather( - self.device_connection.request_status_variable( - self.variable, SCAN_INTERVAL.seconds - ), - self.device_connection.request_status_variable( - self.setpoint, SCAN_INTERVAL.seconds - ), + self._attr_available = any( + await asyncio.gather( + self.device_connection.request_status_variable( + self.variable, SCAN_INTERVAL.seconds + ), + self.device_connection.request_status_variable( + self.setpoint, SCAN_INTERVAL.seconds + ), + ) ) def input_received(self, input_obj: InputType) -> None: """Set temperature value when LCN input object is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusVar): return - + self._attr_available = True if input_obj.get_var() == self.variable: self._attr_current_temperature = float( input_obj.get_value().to_var_unit(self.unit) diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 82fae01d9181..7df79ef02b1d 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -133,13 +133,15 @@ class LcnOutputsCover(LcnEntity, CoverEntity): async def async_update(self) -> None: """Update the state of the entity.""" if not self.device_connection.is_group: - await asyncio.gather( - self.device_connection.request_status_output( - pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds - ), - self.device_connection.request_status_output( - pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds - ), + self._attr_available = any( + await asyncio.gather( + self.device_connection.request_status_output( + pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds + ), + self.device_connection.request_status_output( + pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds + ), + ) ) def input_received(self, input_obj: InputType) -> None: @@ -149,7 +151,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): or input_obj.get_output_id() not in self.output_ids ): return - + self._attr_available = True if input_obj.get_percent() > 0: # motor is on if input_obj.get_output_id() == self.output_ids[0]: self._attr_is_opening = True @@ -272,11 +274,12 @@ class LcnRelayCover(LcnEntity, CoverEntity): self.motor, self.positioning_mode, SCAN_INTERVAL.seconds ) ) - await asyncio.gather(*coros) + self._attr_available = any(await asyncio.gather(*coros)) def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" if isinstance(input_obj, pypck.inputs.ModStatusRelays): + self._attr_available = True self._attr_is_opening = input_obj.is_opening(self.motor.value) self._attr_is_closing = input_obj.is_closing(self.motor.value) @@ -293,6 +296,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): ) and input_obj.motor == self.motor.value ): + self._attr_available = True self._attr_current_cover_position = int(input_obj.position) if self._attr_current_cover_position in [0, 100]: self._attr_is_opening = False diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index e690bb420d1b..1c6dcff836fa 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -149,8 +149,11 @@ class LcnOutputLight(LcnEntity, LightEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_output( - self.output, SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_output( + self.output, SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: @@ -200,12 +203,15 @@ class LcnRelayLight(LcnEntity, LightEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + self._attr_available = ( + await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + is not None + ) def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - + self._attr_available = True self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/quality_scale.yaml b/homeassistant/components/lcn/quality_scale.yaml index eb1ae961aa36..5dceb54da3bc 100644 --- a/homeassistant/components/lcn/quality_scale.yaml +++ b/homeassistant/components/lcn/quality_scale.yaml @@ -25,7 +25,7 @@ rules: status: exempt comment: Integration has no configuration parameters docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index f6a1bf384c12..667ac88f750f 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -133,8 +133,11 @@ class LcnVariableSensor(LcnEntity, SensorEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_variable( - self.variable, SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_variable( + self.variable, SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: @@ -144,7 +147,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): or input_obj.get_var() != self.variable ): return - + self._attr_available = True is_regulator = self.variable.name in SETPOINTS self._attr_native_value = input_obj.get_value().to_var_unit( self.unit, is_regulator @@ -171,15 +174,18 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_led_and_logic_ops( - SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_led_and_logic_ops( + SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusLedsAndLogicOps): return - + self._attr_available = True if self.source in pypck.lcn_defs.LedPort: self._attr_native_value = input_obj.get_led_state( self.source.value diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 0b4156550b82..d370d74d2dd0 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -95,8 +95,11 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_output( - self.output, SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_output( + self.output, SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: @@ -106,7 +109,7 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): or input_obj.get_output_id() != self.output.value ): return - + self._attr_available = True self._attr_is_on = input_obj.get_percent() > 0 self.async_write_ha_state() @@ -142,13 +145,16 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + self._attr_available = ( + await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds) + is not None + ) def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - + self._attr_available = True self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() @@ -183,8 +189,11 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_variable( - self.setpoint_variable, SCAN_INTERVAL.seconds + self._attr_available = ( + await self.device_connection.request_status_variable( + self.setpoint_variable, SCAN_INTERVAL.seconds + ) + is not None ) def input_received(self, input_obj: InputType) -> None: @@ -194,7 +203,7 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity): or input_obj.get_var() != self.setpoint_variable ): return - + self._attr_available = True self._attr_is_on = input_obj.get_value().is_locked_regulator() self.async_write_ha_state() @@ -236,7 +245,12 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity): async def async_update(self) -> None: """Update the state of the entity.""" - await self.device_connection.request_status_locked_keys(SCAN_INTERVAL.seconds) + self._attr_available = ( + await self.device_connection.request_status_locked_keys( + SCAN_INTERVAL.seconds + ) + is not None + ) def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" @@ -245,6 +259,6 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity): or self.key not in pypck.lcn_defs.Key ): return - + self._attr_available = True self._attr_is_on = input_obj.get_state(self.table_id, self.key_id) self.async_write_ha_state() diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index a4712459e78f..acf7bf6d07d9 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -2,18 +2,20 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ModStatusBinSensors from pypck.lcn_addr import LcnAddr from syrupy.assertion import SnapshotAssertion +from homeassistant.components.lcn.binary_sensor import SCAN_INTERVAL from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform BINARY_SENSOR_SENSOR1 = "binary_sensor.testmodule_binary_sensor1" @@ -61,6 +63,43 @@ async def test_pushed_binsensor_status_change( assert state.state == STATE_ON +async def test_availability( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, entry: MockConfigEntry +) -> None: + """Test the availability of binary_sensor entity.""" + await init_integration(hass, entry) + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.object( + MockDeviceConnection, "request_status_binary_sensors", return_value=None + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # response from device -> available + with patch.object( + MockDeviceConnection, + "request_status_binary_sensors", + return_value=ModStatusBinSensors(LcnAddr(0, 7, False), [False] * 8), + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the binary sensor is removed when the config entry is unloaded.""" await init_integration(hass, entry) diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index 91c8b1d5f0c9..c4dc21d787a7 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ModStatusVar, Unknown from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import Var, VarUnit, VarValue @@ -18,6 +19,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.lcn.climate import SCAN_INTERVAL from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.const import ( ATTR_ENTITY_ID, @@ -31,7 +33,9 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform + +CLIMATE_CLIMATE1 = "climate.testmodule_climate1" async def test_setup_lcn_climate( @@ -56,7 +60,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, @@ -69,7 +73,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, @@ -77,7 +81,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state != HVACMode.HEAT @@ -89,7 +93,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, @@ -97,7 +101,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) - lock_regulator.assert_awaited_with(0, False) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state == HVACMode.HEAT @@ -107,7 +111,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> await init_integration(hass, entry) with patch.object(MockDeviceConnection, "lock_regulator") as lock_regulator: - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) state.state = HVACMode.HEAT # command failed @@ -117,7 +121,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, @@ -125,7 +129,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state != HVACMode.OFF @@ -137,7 +141,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, @@ -145,7 +149,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> lock_regulator.assert_awaited_with(0, True, -1) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state == HVACMode.OFF @@ -155,7 +159,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await init_integration(hass, entry) with patch.object(MockDeviceConnection, "var_abs") as var_abs: - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) state.state = HVACMode.HEAT # wrong temperature set via service call with high/low attributes @@ -166,7 +170,7 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.testmodule_climate1", + ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_TARGET_TEMP_LOW: 24.5, ATTR_TARGET_TEMP_HIGH: 25.5, }, @@ -182,13 +186,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.attributes[ATTR_TEMPERATURE] != 25.5 @@ -199,13 +203,13 @@ async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> N await hass.services.async_call( DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.testmodule_climate1", ATTR_TEMPERATURE: 25.5}, + {ATTR_ENTITY_ID: CLIMATE_CLIMATE1, ATTR_TEMPERATURE: 25.5}, blocking=True, ) var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.attributes[ATTR_TEMPERATURE] == 25.5 @@ -226,7 +230,7 @@ async def test_pushed_current_temperature_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 @@ -249,7 +253,7 @@ async def test_pushed_setpoint_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state == HVACMode.HEAT assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -272,7 +276,7 @@ async def test_pushed_lock_status_change( await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state is not None assert state.state == HVACMode.OFF assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -291,11 +295,50 @@ async def test_pushed_wrong_input( await device_connection.async_process_input(Unknown("input")) await hass.async_block_till_done() - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None assert state.attributes[ATTR_TEMPERATURE] is None +async def test_availability( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, entry: MockConfigEntry +) -> None: + """Test the availability of climate entity.""" + await init_integration(hass, entry) + + state = hass.states.get(CLIMATE_CLIMATE1) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.object( + MockDeviceConnection, "request_status_variable", return_value=None + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(CLIMATE_CLIMATE1) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # response from device -> available + with patch.object( + MockDeviceConnection, + "request_status_variable", + return_value=ModStatusVar( + LcnAddr(0, 7, False), Var.R1VARSETPOINT, VarValue.from_celsius(25.5) + ), + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(CLIMATE_CLIMATE1) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry( hass: HomeAssistant, entry: MockConfigEntry, @@ -304,5 +347,5 @@ async def test_unload_config_entry( await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - state = hass.states.get("climate.testmodule_climate1") + state = hass.states.get(CLIMATE_CLIMATE1) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 914ff69febc1..b7f82c8cdda6 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -1,7 +1,8 @@ """Test for the LCN cover platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ( ModStatusMotorPositionBS4, ModStatusMotorPositionModule, @@ -19,6 +20,7 @@ from homeassistant.components.cover import ( DOMAIN as DOMAIN_COVER, CoverState, ) +from homeassistant.components.lcn.cover import SCAN_INTERVAL from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.const import ( ATTR_ENTITY_ID, @@ -34,7 +36,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform COVER_OUTPUTS = "cover.testmodule_cover_outputs" COVER_RELAYS = "cover.testmodule_cover_relays" @@ -522,6 +524,78 @@ async def test_pushed_relays_status_change( assert state.attributes[ATTR_CURRENT_POSITION] == 75 +@pytest.mark.parametrize( + ("entity_id", "request_methods", "return_values"), + [ + ( + COVER_OUTPUTS, + ("request_status_output",), + (ModStatusOutput(LcnAddr(0, 7, False), 0, 100),), + ), + ( + COVER_RELAYS, + ("request_status_relays",), + (ModStatusRelays(LcnAddr(0, 7, False), [False] * 8),), + ), + ( + COVER_RELAYS_BS4, + ("request_status_relays", "request_status_motor_position"), + ( + ModStatusRelays(LcnAddr(0, 7, False), [False] * 8), + ModStatusMotorPositionBS4(LcnAddr(0, 7, False), 1, 50), + ), + ), + ], +) +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entry: MockConfigEntry, + entity_id: str, + request_methods: list[str], + return_values: list[ModStatusOutput | ModStatusRelays], +) -> None: + """Test the availability of cover entity.""" + await init_integration(hass, entry) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.multiple( + MockDeviceConnection, + **{ + request_method: AsyncMock(return_value=None) + for request_method in request_methods + }, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + with patch.multiple( + MockDeviceConnection, + **{ + request_method: AsyncMock(return_value=return_value) + for request_method, return_value in zip( + request_methods, return_values, strict=True + ) + }, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the cover is removed when the config entry is unloaded.""" await init_integration(hass, entry) diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 3e7705df897e..55f8a7ce7b77 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -2,12 +2,15 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ModStatusOutput, ModStatusRelays from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import RelayStateModifier +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.components.lcn.light import SCAN_INTERVAL from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_TRANSITION, @@ -27,7 +30,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform LIGHT_OUTPUT1 = "light.testmodule_light_output1" LIGHT_OUTPUT2 = "light.testmodule_light_output2" @@ -310,6 +313,61 @@ async def test_pushed_relay_status_change( assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("entity_id", "request_method", "return_value"), + [ + ( + LIGHT_OUTPUT1, + "request_status_output", + ModStatusOutput(LcnAddr(0, 7, False), 0, 0), + ), + ( + LIGHT_RELAY1, + "request_status_relays", + ModStatusRelays(LcnAddr(0, 7, False), [False] * 8), + ), + ], +) +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entry: MockConfigEntry, + entity_id: str, + request_method: str, + return_value: ModStatusOutput | ModStatusRelays, +) -> None: + """Test the availability of light entity.""" + await init_integration(hass, entry) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.object(MockDeviceConnection, request_method, return_value=None): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # response from device -> available + with patch.object( + MockDeviceConnection, + request_method, + return_value=return_value, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the light is removed when the config entry is unloaded.""" await init_integration(hass, entry) diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index 85f5b62bf912..625bb037224d 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -2,19 +2,22 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ModStatusLedsAndLogicOps, ModStatusVar from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import LedStatus, LogicOpStatus, Var, VarValue +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.components.lcn.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockConfigEntry, init_integration +from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform SENSOR_VAR1 = "sensor.testmodule_sensor_var1" SENSOR_SETPOINT1 = "sensor.testmodule_sensor_setpoint1" @@ -92,6 +95,63 @@ async def test_pushed_ledlogicop_status_change( assert state.state == "all" +@pytest.mark.parametrize( + ("entity_id", "request_method", "return_value"), + [ + ( + SENSOR_VAR1, + "request_status_variable", + ModStatusVar(LcnAddr(0, 7, False), Var.VAR1, VarValue.from_celsius(20)), + ), + ( + SENSOR_LED6, + "request_status_led_and_logic_ops", + ModStatusLedsAndLogicOps( + LcnAddr(0, 7, False), [LedStatus.OFF] * 12, [LogicOpStatus.NONE] * 4 + ), + ), + ], +) +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entry: MockConfigEntry, + entity_id: str, + request_method: str, + return_value: ModStatusVar | ModStatusLedsAndLogicOps, +) -> None: + """Test the availability of sensor entity.""" + await init_integration(hass, entry) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.object(MockDeviceConnection, request_method, return_value=None): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # response from device -> available + with patch.object( + MockDeviceConnection, + request_method, + return_value=return_value, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the sensor is removed when the config entry is unloaded.""" await init_integration(hass, entry) diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 3187f4ab6e6a..a7624d281534 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pypck.inputs import ( ModStatusKeyLocks, ModStatusOutput, @@ -10,9 +11,11 @@ from pypck.inputs import ( ) from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import KeyLockStateModifier, RelayStateModifier, Var, VarValue +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.components.lcn.switch import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH from homeassistant.const import ( ATTR_ENTITY_ID, @@ -28,7 +31,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, MockDeviceConnection, init_integration -from tests.common import snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform SWITCH_OUTPUT1 = "switch.testmodule_switch_output1" SWITCH_OUTPUT2 = "switch.testmodule_switch_output2" @@ -504,6 +507,71 @@ async def test_pushed_keylock_status_change( assert state.state == STATE_OFF +@pytest.mark.parametrize( + ("entity_id", "request_method", "return_value"), + [ + ( + SWITCH_OUTPUT1, + "request_status_output", + ModStatusOutput(LcnAddr(0, 7, False), 0, 0), + ), + ( + SWITCH_RELAY1, + "request_status_relays", + ModStatusRelays(LcnAddr(0, 7, False), [False] * 8), + ), + ( + SWITCH_REGULATOR1, + "request_status_variable", + ModStatusVar(LcnAddr(0, 7, False), Var.R1VARSETPOINT, VarValue(0x8000)), + ), + ( + SWITCH_KEYLOCKK1, + "request_status_locked_keys", + ModStatusKeyLocks(LcnAddr(0, 7, False), [[False] * 8 for i in range(4)]), + ), + ], +) +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entry: MockConfigEntry, + entity_id: str, + request_method: str, + return_value: ModStatusOutput | ModStatusRelays, +) -> None: + """Test the availability of switch entity.""" + await init_integration(hass, entry) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # no response from device -> unavailable + with patch.object(MockDeviceConnection, request_method, return_value=None): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # response from device -> available + with patch.object( + MockDeviceConnection, + request_method, + return_value=return_value, + ): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the switch is removed when the config entry is unloaded.""" await init_integration(hass, entry)