From 7636477760bfd2ce5f06ad4ac7ceab84257be0e6 Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 16 Jan 2023 13:15:41 -0600 Subject: [PATCH] Add Insteon backlight control support to ISY994, bump PyISY to 3.1.8 (#85981) Co-authored-by: J. Nick Koston --- homeassistant/components/isy994/helpers.py | 14 ++ homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/number.py | 129 ++++++++++++++++-- homeassistant/components/isy994/select.py | 100 ++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 221 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 900a7e59d51..e3c0ce909fa 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import cast from pyisy.constants import ( + BACKLIGHT_SUPPORT, + CMD_BACKLIGHT, ISY_VALUE_UNKNOWN, PROP_BUSY, PROP_COMMS_ERROR, @@ -15,6 +17,7 @@ from pyisy.constants import ( PROTO_PROGRAM, PROTO_ZWAVE, TAG_FOLDER, + UOM_INDEX, ) from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs @@ -277,6 +280,16 @@ def _is_sensor_a_binary_sensor(isy_data: IsyData, node: Group | Node) -> bool: return False +def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None: + """Check if a node supports setting a backlight and add entity.""" + if not getattr(node, "is_backlight_supported", False): + return + if BACKLIGHT_SUPPORT[node.node_def_id] == UOM_INDEX: + isy_data.aux_properties[Platform.SELECT].append((node, CMD_BACKLIGHT)) + return + isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT)) + + def _generate_device_info(node: Node) -> DeviceInfo: """Generate the device info for a root node device.""" isy = node.isy @@ -336,6 +349,7 @@ def _categorize_nodes( isy_data.aux_properties[Platform.SENSOR].append((node, control)) platform = NODE_AUX_FILTERS[control] isy_data.aux_properties[platform].append((node, control)) + _add_backlight_if_supported(isy_data, node) if node.protocol == PROTO_GROUP: isy_data.nodes[ISY_GROUP_PLATFORM].append(node) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 54e5e5db3da..8738896e7bd 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -3,7 +3,7 @@ "name": "Universal Devices ISY/IoX", "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.1.6"], + "requirements": ["pyisy==3.1.8"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index 9c47d721ba3..ada40bb9186 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -4,18 +4,37 @@ from __future__ import annotations from dataclasses import replace from typing import Any -from pyisy.constants import ISY_VALUE_UNKNOWN, PROP_ON_LEVEL +from pyisy.constants import ( + ATTR_ACTION, + CMD_BACKLIGHT, + DEV_BL_ADDR, + DEV_CMD_MEMORY_WRITE, + DEV_MEMORY, + ISY_VALUE_UNKNOWN, + PROP_ON_LEVEL, + TAG_ADDRESS, + UOM_PERCENTAGE, +) from pyisy.helpers import EventListener, NodeProperty +from pyisy.nodes import Node, NodeChangedEvent from pyisy.variables import Variable from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, NumberMode, + RestoreNumber, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_VARIABLES, PERCENTAGE, Platform +from homeassistant.const import ( + CONF_VARIABLES, + PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -42,8 +61,17 @@ CONTROL_DESC = { native_min_value=1.0, native_max_value=100.0, native_step=1.0, - ) + ), + CMD_BACKLIGHT: NumberEntityDescription( + key=CMD_BACKLIGHT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + ), } +BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( @@ -54,7 +82,9 @@ async def async_setup_entry( """Set up ISY/IoX number entities from config entry.""" isy_data = hass.data[DOMAIN][config_entry.entry_id] device_info = isy_data.devices - entities: list[ISYVariableNumberEntity | ISYAuxControlNumberEntity] = [] + entities: list[ + ISYVariableNumberEntity | ISYAuxControlNumberEntity | ISYBacklightNumberEntity + ] = [] var_id = config_entry.options.get(CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING) for node in isy_data.variables[Platform.NUMBER]: @@ -95,15 +125,17 @@ async def async_setup_entry( ) for node, control in isy_data.aux_properties[Platform.NUMBER]: - entities.append( - ISYAuxControlNumberEntity( - node=node, - control=control, - unique_id=f"{isy_data.uid_base(node)}_{control}", - description=CONTROL_DESC[control], - device_info=device_info.get(node.primary_node), - ) - ) + entity_init_info = { + "node": node, + "control": control, + "unique_id": f"{isy_data.uid_base(node)}_{control}", + "description": CONTROL_DESC[control], + "device_info": device_info.get(node.primary_node), + } + if control == CMD_BACKLIGHT: + entities.append(ISYBacklightNumberEntity(**entity_init_info)) + continue + entities.append(ISYAuxControlNumberEntity(**entity_init_info)) async_add_entities(entities) @@ -140,7 +172,10 @@ class ISYAuxControlNumberEntity(ISYAuxControlEntity, NumberEntity): await self._node.set_on_level(value) return - await self._node.send_cmd(self._control, val=value, uom=node_prop.uom) + if not await self._node.send_cmd(self._control, val=value, uom=node_prop.uom): + raise HomeAssistantError( + f"Could not set {self.name} to {value} for {self._node.address}" + ) class ISYVariableNumberEntity(NumberEntity): @@ -198,4 +233,68 @@ class ISYVariableNumberEntity(NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self._node.set_value(value, init=self._init_entity) + if not await self._node.set_value(value, init=self._init_entity): + raise HomeAssistantError( + f"Could not set {self.name} to {value} for {self._node.address}" + ) + + +class ISYBacklightNumberEntity(ISYAuxControlEntity, RestoreNumber): + """Representation of a ISY/IoX Backlight Number entity.""" + + _assumed_state = True # Backlight values aren't read from device + + def __init__( + self, + node: Node, + control: str, + unique_id: str, + description: NumberEntityDescription, + device_info: DeviceInfo | None, + ) -> None: + """Initialize the ISY Backlight number entity.""" + super().__init__(node, control, unique_id, description, device_info) + self._memory_change_handler: EventListener | None = None + self._attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """Load the last known state when added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value + + # Listen to memory writing events to update state if changed in ISY + self._memory_change_handler = self._node.isy.nodes.status_events.subscribe( + self.async_on_memory_write, + event_filter={ + TAG_ADDRESS: self._node.address, + ATTR_ACTION: DEV_MEMORY, + }, + key=self.unique_id, + ) + + @callback + def async_on_memory_write(self, event: NodeChangedEvent, key: str) -> None: + """Handle a memory write event from the ISY Node.""" + if not (BACKLIGHT_MEMORY_FILTER.items() <= event.event_info.items()): + return # This was not a backlight event + value = ranged_value_to_percentage((0, 127), event.event_info["value"]) + if value == self._attr_native_value: + return # Change was from this entity, don't update twice + self._attr_native_value = value + self.async_write_ha_state() + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + + if not await self._node.send_cmd( + CMD_BACKLIGHT, val=int(value), uom=UOM_PERCENTAGE + ): + raise HomeAssistantError( + f"Could not set backlight to {value}% for {self._node.address}" + ) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 807e623768a..49044d43724 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -4,20 +4,31 @@ from __future__ import annotations from typing import cast from pyisy.constants import ( + ATTR_ACTION, + BACKLIGHT_INDEX, + CMD_BACKLIGHT, COMMAND_FRIENDLY_NAME, + DEV_BL_ADDR, + DEV_CMD_MEMORY_WRITE, + DEV_MEMORY, INSTEON_RAMP_RATES, ISY_VALUE_UNKNOWN, PROP_RAMP_RATE, + TAG_ADDRESS, + UOM_INDEX as ISY_UOM_INDEX, UOM_TO_STATES, ) -from pyisy.helpers import NodeProperty +from pyisy.helpers import EventListener, NodeProperty +from pyisy.nodes import Node, NodeChangedEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform, UnitOfTime -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, DOMAIN, UOM_INDEX from .entity import ISYAuxControlEntity @@ -32,6 +43,7 @@ def time_string(i: int) -> str: RAMP_RATE_OPTIONS = [time_string(rate) for rate in INSTEON_RAMP_RATES.values()] +BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( @@ -42,21 +54,26 @@ async def async_setup_entry( """Set up ISY/IoX select entities from config entry.""" isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] device_info = isy_data.devices - entities: list[ISYAuxControlIndexSelectEntity | ISYRampRateSelectEntity] = [] + entities: list[ + ISYAuxControlIndexSelectEntity + | ISYRampRateSelectEntity + | ISYBacklightSelectEntity + ] = [] for node, control in isy_data.aux_properties[Platform.SELECT]: name = COMMAND_FRIENDLY_NAME.get(control, control).replace("_", " ").title() if node.address != node.primary_node: name = f"{node.name} {name}" - node_prop: NodeProperty = node.aux_properties[control] - options = [] if control == PROP_RAMP_RATE: options = RAMP_RATE_OPTIONS - if node_prop.uom == UOM_INDEX: - if options_dict := UOM_TO_STATES.get(node_prop.uom): - options = list(options_dict.values()) + elif control == CMD_BACKLIGHT: + options = BACKLIGHT_INDEX + else: + if uom := node.aux_properties[control].uom == UOM_INDEX: + if options_dict := UOM_TO_STATES.get(uom): + options = list(options_dict.values()) description = SelectEntityDescription( key=f"{node.address}_{control}", @@ -75,6 +92,9 @@ async def async_setup_entry( if control == PROP_RAMP_RATE: entities.append(ISYRampRateSelectEntity(**entity_detail)) continue + if control == CMD_BACKLIGHT: + entities.append(ISYBacklightSelectEntity(**entity_detail)) + continue if node.uom == UOM_INDEX and options: entities.append(ISYAuxControlIndexSelectEntity(**entity_detail)) continue @@ -124,3 +144,63 @@ class ISYAuxControlIndexSelectEntity(ISYAuxControlEntity, SelectEntity): await self._node.send_cmd( self._control, val=self.options.index(option), uom=node_prop.uom ) + + +class ISYBacklightSelectEntity(ISYAuxControlEntity, SelectEntity, RestoreEntity): + """Representation of a ISY/IoX Backlight Select entity.""" + + _assumed_state = True # Backlight values aren't read from device + + def __init__( + self, + node: Node, + control: str, + unique_id: str, + description: SelectEntityDescription, + device_info: DeviceInfo | None, + ) -> None: + """Initialize the ISY Backlight Select entity.""" + super().__init__(node, control, unique_id, description, device_info) + self._memory_change_handler: EventListener | None = None + self._attr_current_option = None + + async def async_added_to_hass(self) -> None: + """Load the last known state when added to hass.""" + await super().async_added_to_hass() + if ( + last_state := await self.async_get_last_state() + ) and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_current_option = last_state.state + + # Listen to memory writing events to update state if changed in ISY + self._memory_change_handler = self._node.isy.nodes.status_events.subscribe( + self.async_on_memory_write, + event_filter={ + TAG_ADDRESS: self._node.address, + ATTR_ACTION: DEV_MEMORY, + }, + key=self.unique_id, + ) + + @callback + def async_on_memory_write(self, event: NodeChangedEvent, key: str) -> None: + """Handle a memory write event from the ISY Node.""" + if not (BACKLIGHT_MEMORY_FILTER.items() <= event.event_info.items()): + return # This was not a backlight event + option = BACKLIGHT_INDEX[event.event_info["value"]] + if option == self._attr_current_option: + return # Change was from this entity, don't update twice + self._attr_current_option = option + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + + if not await self._node.send_cmd( + CMD_BACKLIGHT, val=BACKLIGHT_INDEX.index(option), uom=ISY_UOM_INDEX + ): + raise HomeAssistantError( + f"Could not set backlight to {option} for {self._node.address}" + ) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 6c3879f79ae..3eb29452b81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.6 +pyisy==3.1.8 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91d8acc6d11..72f34bffbe9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1212,7 +1212,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.6 +pyisy==3.1.8 # homeassistant.components.kaleidescape pykaleidescape==1.0.1