diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index e4216fee6ef..527e6888212 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -87,7 +87,7 @@ NODE_PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, ] -NODE_AUX_PROP_PLATFORMS = [Platform.SENSOR] +NODE_AUX_PROP_PLATFORMS = [Platform.SENSOR, Platform.NUMBER] PROGRAM_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -308,7 +308,7 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { }, } NODE_AUX_FILTERS: dict[str, Platform] = { - PROP_ON_LEVEL: Platform.SENSOR, + PROP_ON_LEVEL: Platform.NUMBER, PROP_RAMP_RATE: Platform.SENSOR, } diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 263100c90eb..70f2abfe39e 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -35,6 +35,7 @@ from .const import ( ISY_GROUP_PLATFORM, KEY_ACTIONS, KEY_STATUS, + NODE_AUX_FILTERS, NODE_FILTERS, NODE_PLATFORMS, PROGRAM_PLATFORMS, @@ -331,7 +332,10 @@ def _categorize_nodes( if getattr(node, "is_dimmable", False): aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties) for control in aux_controls: + # Deprecated all aux properties as sensors. Update in 2023.5.0 to remove extras. isy_data.aux_properties[Platform.SENSOR].append((node, control)) + platform = NODE_AUX_FILTERS[control] + isy_data.aux_properties[platform].append((node, control)) if node.protocol == PROTO_GROUP: isy_data.nodes[ISY_GROUP_PLATFORM].append(node) diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 4a590c3eb4b..ee81fb0b63d 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -10,14 +10,19 @@ from pyisy.nodes import Node from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE from .entity import ISYNodeEntity -from .services import async_setup_light_services +from .services import ( + SERVICE_SET_ON_LEVEL, + async_log_deprecated_service_call, + async_setup_light_services, +) ATTR_LAST_BRIGHTNESS = "last_brightness" @@ -125,6 +130,18 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): async def async_set_on_level(self, value: int) -> None: """Set the ON Level for a device.""" + entity_registry = er.async_get(self.hass) + async_log_deprecated_service_call( + self.hass, + call=ServiceCall(domain=DOMAIN, service=SERVICE_SET_ON_LEVEL), + alternate_service="number.set_value", + alternate_target=entity_registry.async_get_entity_id( + Platform.NUMBER, + DOMAIN, + f"{self._node.isy.uuid}_{self._node.address}_OL", + ), + breaks_in_ha_version="2023.5.0", + ) await self._node.set_on_level(value) async def async_set_ramp_rate(self, value: int) -> None: diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index 3cc4fbe770c..a64d7df1225 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -1,22 +1,49 @@ """Support for ISY number entities.""" from __future__ import annotations +from dataclasses import replace from typing import Any +from pyisy.constants import COMMAND_FRIENDLY_NAME, ISY_VALUE_UNKNOWN, PROP_ON_LEVEL from pyisy.helpers import EventListener, NodeProperty +from pyisy.nodes import Node from pyisy.variables import Variable -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_VARIABLES, Platform +from homeassistant.const import CONF_VARIABLES, PERCENTAGE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) -from .const import CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING, DOMAIN +from .const import ( + CONF_VAR_SENSOR_STRING, + DEFAULT_VAR_SENSOR_STRING, + DOMAIN, + UOM_8_BIT_RANGE, +) from .helpers import convert_isy_value_to_hass ISY_MAX_SIZE = (2**32) / 2 +ON_RANGE = (1, 255) # Off is not included +CONTROL_DESC = { + PROP_ON_LEVEL: NumberEntityDescription( + key=PROP_ON_LEVEL, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + native_min_value=1.0, + native_max_value=100.0, + native_step=1.0, + ) +} async def async_setup_entry( @@ -27,7 +54,7 @@ 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] = [] + entities: list[ISYVariableNumberEntity | ISYAuxControlNumberEntity] = [] var_id = config_entry.options.get(CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING) for node in isy_data.variables[Platform.NUMBER]: @@ -43,15 +70,10 @@ async def async_setup_entry( native_min_value=-min_max, native_max_value=min_max, ) - description_init = NumberEntityDescription( + description_init = replace( + description, key=f"{node.address}_init", name=f"{node.name} Initial Value", - icon="mdi:counter", - entity_registry_enabled_default=False, - native_unit_of_measurement=None, - native_step=step, - native_min_value=-min_max, - native_max_value=min_max, entity_category=EntityCategory.CONFIG, ) @@ -73,9 +95,88 @@ 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), + ) + ) async_add_entities(entities) +class ISYAuxControlNumberEntity(NumberEntity): + """Representation of a ISY/IoX Aux Control Number entity.""" + + _attr_mode = NumberMode.SLIDER + _attr_should_poll = False + + def __init__( + self, + node: Node, + control: str, + unique_id: str, + description: NumberEntityDescription, + device_info: DeviceInfo | None, + ) -> None: + """Initialize the ISY Aux Control Number entity.""" + self._node = node + name = COMMAND_FRIENDLY_NAME.get(control, control).replace("_", " ").title() + if node.address != node.primary_node: + name = f"{node.name} {name}" + self._attr_name = name + self._control = control + self.entity_description = description + self._attr_has_entity_name = node.address == node.primary_node + self._attr_unique_id = unique_id + self._attr_device_info = device_info + self._change_handler: EventListener | None = None + + async def async_added_to_hass(self) -> None: + """Subscribe to the node control change events.""" + self._change_handler = self._node.control_events.subscribe(self.async_on_update) + + @callback + def async_on_update(self, event: NodeProperty) -> None: + """Handle a control event from the ISY Node.""" + if event.control != self._control: + return + self.async_write_ha_state() + + @property + def native_value(self) -> float | int | None: + """Return the state of the variable.""" + node_prop: NodeProperty = self._node.aux_properties[self._control] + if node_prop.value == ISY_VALUE_UNKNOWN: + return None + + if ( + self.entity_description.native_unit_of_measurement == PERCENTAGE + and node_prop.uom == UOM_8_BIT_RANGE # Insteon 0-255 + ): + return ranged_value_to_percentage(ON_RANGE, node_prop.value) + return int(node_prop.value) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + node_prop: NodeProperty = self._node.aux_properties[self._control] + + if self.entity_description.native_unit_of_measurement == PERCENTAGE: + value = ( + percentage_to_ranged_value(ON_RANGE, round(value)) + if node_prop.uom == UOM_8_BIT_RANGE + else value + ) + if self._control == PROP_ON_LEVEL: + await self._node.set_on_level(value) + return + + await self._node.send_cmd(self._control, val=value, uom=node_prop.uom) + + class ISYVariableNumberEntity(NumberEntity): """Representation of an ISY variable as a number entity device.""" diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 097beab4caf..3566767ab7c 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -7,7 +7,6 @@ from pyisy.constants import ( COMMAND_FRIENDLY_NAME, ISY_VALUE_UNKNOWN, PROP_BATTERY_LEVEL, - PROP_BUSY, PROP_COMMS_ERROR, PROP_ENERGY_MODE, PROP_HEAT_COOL_STATE, @@ -28,7 +27,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -53,7 +52,6 @@ AUX_DISABLED_BY_DEFAULT_EXACT = { PROP_RAMP_RATE, PROP_STATUS, } -SKIP_AUX_PROPERTIES = {PROP_BUSY, PROP_COMMS_ERROR, PROP_STATUS} # Reference pyisy.constants.COMMAND_FRIENDLY_NAME for API details. # Note: "LUMIN"/Illuminance removed, some devices use non-conformant "%" unit @@ -260,6 +258,22 @@ class ISYAuxSensorEntity(ISYSensorEntity): """Return the target value.""" return None if self.target is None else self.target.value + async def async_added_to_hass(self) -> None: + """Subscribe to the node control change events. + + Overloads the default ISYNodeEntity updater to only update when + this control is changed on the device and prevent duplicate firing + of `isy994_control` events. + """ + self._change_handler = self._node.control_events.subscribe(self.async_on_update) + + @callback + def async_on_update(self, event: NodeProperty) -> None: + """Handle a control event from the ISY Node.""" + if event.control != self._control: + return + self.async_write_ha_state() + class ISYSensorVariableEntity(ISYEntity, SensorEntity): """Representation of an ISY variable as a sensor device.""" diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index c9daa828970..a0af494834d 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -136,8 +136,8 @@ rename_node: selector: text: set_on_level: - name: Set On Level - description: Send a ISY set_on_level command to a Node. + name: Set On Level (Deprecated) + description: "Send a ISY set_on_level command to a Node. Deprecated: Use On Level Number entity instead." target: entity: integration: isy994