diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 923ba382d473c7..b487eb7934e81c 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -9,10 +9,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .entity import ZWaveBaseEntity -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import ZWaveBaseEntity, ZWaveNodeBaseEntity from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -78,54 +77,16 @@ async def async_press(self) -> None: await self._async_set_value(self.info.primary_value, True) -class ZWaveNodePingButton(ButtonEntity): +class ZWaveNodePingButton(ZWaveNodeBaseEntity, ButtonEntity): """Representation of a ping button entity.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.CONFIG - _attr_has_entity_name = True _attr_translation_key = "ping" def __init__(self, driver: Driver, node: ZwaveNode) -> None: """Initialize a ping Z-Wave device button entity.""" - self.node = node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) self._attr_unique_id = f"{self._base_unique_id}.ping" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) - - async def async_added_to_hass(self) -> None: - """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - - # we don't listen for `remove_entity_on_ready_node` signal because this entity - # is created when the node is added which occurs before ready. It only needs to - # be removed if the node is removed from the network. - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 74b856efbe0b29..b86cb84e81b869 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -6,6 +6,7 @@ from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver +from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( SetValueResult, Value as ZwaveValue, @@ -28,7 +29,12 @@ LOGGER, ) from .discovery_data_template import BaseDiscoverySchemaDataTemplate -from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id +from .helpers import ( + get_device_id, + get_device_info, + get_unique_id, + get_valueless_base_unique_id, +) from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo, ZwaveJSConfigEntry @@ -426,3 +432,65 @@ async def _async_set_value( raise HomeAssistantError( f"Unable to set value {value.value_id}: {err}" ) from err + + +class ZWaveNodeBaseEntity(Entity): + """Base entity class for Z-Wave node-level (non-value) entities. + + Used for entities that exist for the whole node rather than a specific + Z-Wave Value (e.g. firmware update, ping button, node status sensor). + """ + + _attr_has_entity_name = True + _attr_should_poll = False + + # Subclasses can opt in to also being removed when a node starts a + # reinterview. Useful for entities whose existence depends on CCs that + # may disappear during reinterview. + _remove_on_reinterview = False + + def __init__(self, driver: Driver, node: ZwaveNode) -> None: + """Initialize a Z-Wave node-level entity.""" + self.driver = driver + self.node = node + + self._base_unique_id = get_valueless_base_unique_id(driver, node) + # device may not be precreated in main handler yet + self._attr_device_info = get_device_info(driver, node) + + async def async_poll_value(self, _: bool) -> None: + """Poll a value (no-op for entities not backed by a Z-Wave Value).""" + # We log an error instead of raising an exception because this service + # call occurs in a separate task since it is called via the dispatcher + # and we don't want to raise the exception in that separate task because + # it is confusing to the user. + LOGGER.error( + "There is no value to refresh for %s so the zwave_js.refresh_value" + " service won't work for it", + self.entity_id, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + if self._remove_on_reinterview: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", + self.async_remove, + ) + ) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index b4bc72e3112f8e..2dfaa063293606 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -116,8 +116,7 @@ NumericSensorDataTemplate, NumericSensorDataTemplateData, ) -from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity, ZWaveNodeBaseEntity from .migrate import async_migrate_statistics_sensors from .models import ( NewZWaveDiscoverySchema, @@ -1033,36 +1032,19 @@ def extra_state_attributes(self) -> dict[str, str] | None: return {ATTR_VALUE: value} -class ZWaveNodeStatusSensor(SensorEntity): +class ZWaveNodeStatusSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a node status sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True _attr_translation_key = "node_status" def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, node: ZwaveNode ) -> None: """Initialize a generic Z-Wave device entity.""" + super().__init__(driver, node) self.config_entry = config_entry - self.node = node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.node_status" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _status_changed(self, _: dict) -> None: @@ -1072,60 +1054,27 @@ def _status_changed(self, _: dict) -> None: async def async_added_to_hass(self) -> None: """Call when entity is added.""" - # Add value_changed callbacks. + await super().async_added_to_hass() for evt in ("wake up", "sleep", "dead", "alive"): self.async_on_remove(self.node.on(evt, self._status_changed)) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - # we don't listen for `remove_entity_on_ready_node` signal because this entity - # is created when the node is added which occurs before ready. It only needs to - # be removed if the node is removed from the network. - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) self._attr_native_value: str = self.node.status.name.lower() self.async_write_ha_state() -class ZWaveControllerStatusSensor(SensorEntity): +class ZWaveControllerStatusSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a controller status sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True _attr_translation_key = "controller_status" def __init__(self, config_entry: ZwaveJSConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" - self.config_entry = config_entry self.controller = driver.controller node = self.controller.own_node assert node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) + self.config_entry = config_entry self._attr_unique_id = f"{self._base_unique_id}.controller_status" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _status_changed(self, _: dict) -> None: @@ -1135,34 +1084,16 @@ def _status_changed(self, _: dict) -> None: async def async_added_to_hass(self) -> None: """Call when entity is added.""" - # Add value_changed callbacks. + await super().async_added_to_hass() self.async_on_remove(self.controller.on("status changed", self._status_changed)) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - # we don't listen for `remove_entity_on_ready_node` signal because this is not - # a regular node - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) self._attr_native_value: str = self.controller.status.name.lower() -class ZWaveStatisticsSensor(SensorEntity): +class ZWaveStatisticsSensor(ZWaveNodeBaseEntity, SensorEntity): """Representation of a node/controller statistics sensor.""" entity_description: ZWaveJSStatisticsSensorEntityDescription - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True def __init__( self, @@ -1172,8 +1103,6 @@ def __init__( description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" - self.entity_description = description - self.config_entry = config_entry self.statistics_src = statistics_src node = ( statistics_src.own_node @@ -1181,22 +1110,10 @@ def __init__( else statistics_src ) assert node - - # Entity class attributes - self._base_unique_id = get_valueless_base_unique_id(driver, node) + super().__init__(driver, node) + self.entity_description = description + self.config_entry = config_entry self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}" - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) - - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) @callback def _statistics_updated(self, event_data: dict) -> None: @@ -1226,20 +1143,7 @@ def _set_statistics( async def async_added_to_hass(self) -> None: """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) + await super().async_added_to_hass() self.async_on_remove( self.statistics_src.on("statistics updated", self._statistics_updated) ) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index c4ba16acb674d8..d744fe89343a95 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -34,7 +34,7 @@ from homeassistant.helpers.restore_state import ExtraStoredData from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER -from .helpers import get_device_info, get_valueless_base_unique_id +from .entity import ZWaveNodeBaseEntity from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 1 @@ -162,12 +162,10 @@ def async_add_firmware_update_entity(node: ZwaveNode) -> None: ) -class ZWaveFirmwareUpdateEntity(UpdateEntity): +class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity): """Representation of a firmware update entity.""" - driver: Driver entity_description: ZWaveUpdateEntityDescription - node: ZwaveNode _attr_entity_category = EntityCategory.CONFIG _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_supported_features = ( @@ -175,8 +173,7 @@ class ZWaveFirmwareUpdateEntity(UpdateEntity): | UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.PROGRESS ) - _attr_has_entity_name = True - _attr_should_poll = False + _remove_on_reinterview = True def __init__( self, @@ -186,9 +183,8 @@ def __init__( entity_description: ZWaveUpdateEntityDescription, ) -> None: """Initialize a Z-Wave device firmware update entity.""" - self.driver = driver + super().__init__(driver, node) self.entity_description = entity_description - self.node = node self._latest_version_firmware: FirmwareUpdateInfo | None = None self._poll_unsub: Callable[[], None] | None = None self._progress_unsub: Callable[[], None] | None = None @@ -199,11 +195,8 @@ def __init__( # Entity class attributes self._attr_name = "Firmware" - self._base_unique_id = get_valueless_base_unique_id(driver, node) self._attr_unique_id = f"{self._base_unique_id}.firmware_update" self._attr_installed_version = node.firmware_version - # device may not be precreated in main handler yet - self._attr_device_info = get_device_info(driver, node) @property def extra_restore_state_data(self) -> ZWaveFirmwareUpdateExtraStoredData: @@ -345,41 +338,9 @@ async def async_install( self._latest_version_firmware = None self._unsub_firmware_events_and_reset_progress() - async def async_poll_value(self, _: bool) -> None: - """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task since it is called via the dispatcher and we don't want to - # raise the exception in that separate task because it is confusing to the user. - LOGGER.error( - "There is no value to refresh for this entity so the zwave_js.refresh_value" - " service won't work for it" - ) - async def async_added_to_hass(self) -> None: """Call when entity is added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self.unique_id}_poll_value", - self.async_poll_value, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity", - self.async_remove, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", - self.async_remove, - ) - ) + await super().async_added_to_hass() # Make sure these variables are set for the elif evaluation state = None diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 422888cab23437..bcee45e264f0ff 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -64,7 +64,9 @@ async def test_ping_entity( blocking=True, ) await hass.async_block_till_done() - assert "There is no value to refresh for this entity" in caplog.text + assert ( + "There is no value to refresh for button.z_wave_thermostat_ping" in caplog.text + ) # Assert a node ping button entity is not created for the controller driver = client.driver diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e5b7d40f712407..91acbd80d2b7fc 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -505,7 +505,7 @@ async def test_node_status_sensor_not_ready( blocking=True, ) await hass.async_block_till_done() - assert "There is no value to refresh for this entity" in caplog.text + assert f"There is no value to refresh for {node_status_entity_id}" in caplog.text @pytest.mark.parametrize( @@ -1125,7 +1125,7 @@ async def test_statistics_sensors( blocking=True, ) await hass.async_block_till_done() - assert caplog.text.count("There is no value to refresh for this entity") == len( + assert caplog.text.count("There is no value to refresh for") == len( [ *CONTROLLER_STATISTICS_SUFFIXES, *CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index b78d202935d405..6400bd157d979c 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -220,7 +220,7 @@ async def test_update_entity_states( blocking=True, ) await hass.async_block_till_done() - assert "There is no value to refresh for this entity" in caplog.text + assert f"There is no value to refresh for {entity_id}" in caplog.text client.async_send_command.return_value = {"updates": []}