From cf3ca816a87e5eca0c55e4143cb62811da975594 Mon Sep 17 00:00:00 2001 From: shbatm Date: Fri, 6 Jan 2023 19:15:02 -0600 Subject: [PATCH] Add query button entities to ISY994 devices and hub (#85337) --- homeassistant/components/isy994/button.py | 56 ++++++++++++++++ homeassistant/components/isy994/const.py | 9 +++ homeassistant/components/isy994/helpers.py | 40 ++++++------ homeassistant/components/isy994/services.py | 64 ++++++++++++++++++- homeassistant/components/isy994/services.yaml | 4 +- homeassistant/components/isy994/strings.json | 13 ++++ .../components/isy994/translations/en.json | 15 ++++- 7 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/isy994/button.py diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py new file mode 100644 index 00000000000..81f914e1e03 --- /dev/null +++ b/homeassistant/components/isy994/button.py @@ -0,0 +1,56 @@ +"""Representation of ISY/IoX buttons.""" +from __future__ import annotations + +from pyisy import ISY +from pyisy.nodes import Node + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN as ISY994_DOMAIN, ISY994_ISY, ISY994_NODES + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up ISY/IoX button from config entry.""" + hass_isy_data = hass.data[ISY994_DOMAIN][config_entry.entry_id] + isy: ISY = hass_isy_data[ISY994_ISY] + uuid = isy.configuration["uuid"] + entities: list[ISYNodeQueryButtonEntity] = [] + for node in hass_isy_data[ISY994_NODES][Platform.BUTTON]: + entities.append(ISYNodeQueryButtonEntity(node, f"{uuid}_{node.address}")) + + # Add entity to query full system + entities.append(ISYNodeQueryButtonEntity(isy, uuid)) + + async_add_entities(entities) + + +class ISYNodeQueryButtonEntity(ButtonEntity): + """Representation of a device query button entity.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + + def __init__(self, node: Node | ISY, base_unique_id: str) -> None: + """Initialize a query ISY device button entity.""" + self._node = node + + # Entity class attributes + self._attr_name = "Query" + self._attr_unique_id = f"{base_unique_id}_query" + self._attr_device_info = DeviceInfo( + identifiers={(ISY994_DOMAIN, base_unique_id)} + ) + + async def async_press(self) -> None: + """Press the button.""" + self.hass.async_create_task(self._node.query()) diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index fa250fd4ef1..de064bff312 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -76,6 +76,7 @@ KEY_STATUS = "status" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.FAN, @@ -189,6 +190,14 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { ], # Does a startswith() match; include the dot FILTER_ZWAVE_CAT: (["104", "112", "138"] + list(map(str, range(148, 180)))), }, + Platform.BUTTON: { + # No devices automatically sorted as buttons at this time. Query buttons added elsewhere. + FILTER_UOM: [], + FILTER_STATES: [], + FILTER_NODE_DEF_ID: [], + FILTER_INSTEON_TYPE: [], + FILTER_ZWAVE_CAT: [], + }, Platform.SENSOR: { # This is just a more-readable way of including MOST uoms between 1-100 # (Remember that range() is non-inclusive of the stop value) diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 0000e7678a0..54d2890c84c 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -16,12 +16,6 @@ from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs from pyisy.variables import Variables -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -95,7 +89,7 @@ def _check_for_insteon_type( works for Insteon device. "Node Server" (v5+) and Z-Wave and others will not have a type. """ - if not hasattr(node, "protocol") or node.protocol != PROTO_INSTEON: + if node.protocol != PROTO_INSTEON: return False if not hasattr(node, "type") or node.type is None: # Node doesn't have a type (non-Insteon device most likely) @@ -115,34 +109,34 @@ def _check_for_insteon_type( subnode_id = int(node.address.split(" ")[-1], 16) # FanLinc, which has a light module as one of its nodes. - if platform == FAN and subnode_id == SUBNODE_FANLINC_LIGHT: - hass_isy_data[ISY994_NODES][LIGHT].append(node) + if platform == Platform.FAN and subnode_id == SUBNODE_FANLINC_LIGHT: + hass_isy_data[ISY994_NODES][Platform.LIGHT].append(node) return True # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3 - if platform == CLIMATE and subnode_id in ( + if platform == Platform.CLIMATE and subnode_id in ( SUBNODE_CLIMATE_COOL, SUBNODE_CLIMATE_HEAT, ): - hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node) + hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR].append(node) return True # IOLincs which have a sensor and relay on 2 different nodes if ( - platform == BINARY_SENSOR + platform == Platform.BINARY_SENSOR and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS) and subnode_id == SUBNODE_IOLINC_RELAY ): - hass_isy_data[ISY994_NODES][SWITCH].append(node) + hass_isy_data[ISY994_NODES][Platform.SWITCH].append(node) return True # Smartenit EZIO2X4 if ( - platform == SWITCH + platform == Platform.SWITCH and device_type.startswith(TYPE_EZIO2X4) and subnode_id in SUBNODE_EZIO2X4_SENSORS ): - hass_isy_data[ISY994_NODES][BINARY_SENSOR].append(node) + hass_isy_data[ISY994_NODES][Platform.BINARY_SENSOR].append(node) return True hass_isy_data[ISY994_NODES][platform].append(node) @@ -159,7 +153,7 @@ def _check_for_zwave_cat( This is for (presumably) every version of the ISY firmware, but only works for Z-Wave Devices with the devtype.cat property. """ - if not hasattr(node, "protocol") or node.protocol != PROTO_ZWAVE: + if node.protocol != PROTO_ZWAVE: return False if not hasattr(node, "zwave_props") or node.zwave_props is None: @@ -292,11 +286,15 @@ def _categorize_nodes( # Don't import this node as a device at all continue - if hasattr(node, "protocol") and node.protocol == PROTO_GROUP: + if hasattr(node, "parent_node") and node.parent_node is None: + # This is a physical device / parent node, add a query button + hass_isy_data[ISY994_NODES][Platform.BUTTON].append(node) + + if node.protocol == PROTO_GROUP: hass_isy_data[ISY994_NODES][ISY_GROUP_PLATFORM].append(node) continue - if getattr(node, "protocol", None) == PROTO_INSTEON: + if node.protocol == PROTO_INSTEON: for control in node.aux_properties: hass_isy_data[ISY994_NODES][SENSOR_AUX].append((node, control)) @@ -305,7 +303,7 @@ def _categorize_nodes( # determine if it should be a binary_sensor. if _is_sensor_a_binary_sensor(hass_isy_data, node): continue - hass_isy_data[ISY994_NODES][SENSOR].append(node) + hass_isy_data[ISY994_NODES][Platform.SENSOR].append(node) continue # We have a bunch of different methods for determining the device type, @@ -323,7 +321,7 @@ def _categorize_nodes( continue # Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes. - hass_isy_data[ISY994_NODES][SENSOR].append(node) + hass_isy_data[ISY994_NODES][Platform.SENSOR].append(node) def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: @@ -348,7 +346,7 @@ def _categorize_programs(hass_isy_data: dict, programs: Programs) -> None: ) continue - if platform != BINARY_SENSOR: + if platform != Platform.BINARY_SENSOR: actions = entity_folder.get_by_name(KEY_ACTIONS) if not actions or actions.protocol != PROTO_PROGRAM: _LOGGER.warning( diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 52e229c1b62..6825cab7cd2 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -13,12 +13,14 @@ from homeassistant.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, SERVICE_RELOAD, + Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import entity_service_call from .const import _LOGGER, DOMAIN, ISY994_ISY @@ -181,7 +183,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Handle a system query service call.""" address = service.data.get(CONF_ADDRESS) isy_name = service.data.get(CONF_ISY) - + entity_registry = er.async_get(hass) for config_entry_id in hass.data[DOMAIN]: isy = hass.data[DOMAIN][config_entry_id][ISY994_ISY] if isy_name and isy_name != isy.configuration["name"]: @@ -195,11 +197,31 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy.configuration["uuid"], ) await isy.query(address) + async_log_deprecated_service_call( + hass, + call=service, + alternate_service="button.press", + alternate_target=entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{isy.configuration['uuid']}_{address}_query", + ), + breaks_in_ha_version="2023.5.0", + ) return _LOGGER.debug( "Requesting system query of ISY %s", isy.configuration["uuid"] ) await isy.query() + async_log_deprecated_service_call( + hass, + call=service, + alternate_service="button.press", + alternate_target=entity_registry.async_get_entity_id( + Platform.BUTTON, DOMAIN, f"{isy.configuration['uuid']}_query" + ), + breaks_in_ha_version="2023.5.0", + ) async def async_run_network_resource_service_handler(service: ServiceCall) -> None: """Handle a network resource service call.""" @@ -447,3 +469,43 @@ def async_setup_light_services(hass: HomeAssistant) -> None: platform.async_register_entity_service( SERVICE_SET_RAMP_RATE, SERVICE_SET_RAMP_RATE_SCHEMA, "async_set_ramp_rate" ) + + +@callback +def async_log_deprecated_service_call( + hass: HomeAssistant, + call: ServiceCall, + alternate_service: str, + alternate_target: str | None, + breaks_in_ha_version: str, +) -> None: + """Log a warning about a deprecated service call.""" + deprecated_service = f"{call.domain}.{call.service}" + alternate_target = alternate_target or "this device" + + async_create_issue( + hass, + DOMAIN, + f"deprecated_service_{deprecated_service}", + breaks_in_ha_version=breaks_in_ha_version, + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_service", + translation_placeholders={ + "alternate_service": alternate_service, + "alternate_target": alternate_target, + "deprecated_service": deprecated_service, + }, + ) + + _LOGGER.warning( + ( + 'The "%s" service is deprecated and will be removed in %s; use the "%s" ' + 'service and pass it a target entity ID of "%s"' + ), + deprecated_service, + breaks_in_ha_version, + alternate_service, + alternate_target, + ) diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index d91ec37d611..90715b162d7 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -168,8 +168,8 @@ set_ramp_rate: min: 0 max: 31 system_query: - name: System query - description: Request the ISY Query the connected devices. + name: System query (Deprecated) + description: "Request the ISY Query the connected devices. Deprecated: Use device Query button entity." fields: address: name: Address diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 821f8889978..6382e20e2fb 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -53,5 +53,18 @@ "last_heartbeat": "Last Heartbeat Time", "websocket_status": "Event Socket Status" } + }, + "issues": { + "deprecated_service": { + "title": "The {deprecated_service} service will be removed", + "fix_flow": { + "step": { + "confirm": { + "title": "The {deprecated_service} service will be removed", + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." + } + } + } + } } } diff --git a/homeassistant/components/isy994/translations/en.json b/homeassistant/components/isy994/translations/en.json index 3610b35c194..cc5d26b6cdd 100644 --- a/homeassistant/components/isy994/translations/en.json +++ b/homeassistant/components/isy994/translations/en.json @@ -53,5 +53,18 @@ "last_heartbeat": "Last Heartbeat Time", "websocket_status": "Event Socket Status" } + }, + "issues": { + "deprecated_service": { + "fix_flow": { + "step": { + "confirm": { + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`.", + "title": "The {deprecated_service} service will be removed" + } + } + }, + "title": "The {deprecated_service} service will be removed" + } } -} \ No newline at end of file +}