diff --git a/.coveragerc b/.coveragerc index ca3da77447a..7e5714fdc73 100644 --- a/.coveragerc +++ b/.coveragerc @@ -612,6 +612,7 @@ omit = homeassistant/components/isy994/lock.py homeassistant/components/isy994/models.py homeassistant/components/isy994/number.py + homeassistant/components/isy994/select.py homeassistant/components/isy994/sensor.py homeassistant/components/isy994/services.py homeassistant/components/isy994/switch.py diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 527e6888212..f70065cd7bc 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, Platform.NUMBER] +NODE_AUX_PROP_PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.NUMBER] PROGRAM_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -309,7 +309,7 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { } NODE_AUX_FILTERS: dict[str, Platform] = { PROP_ON_LEVEL: Platform.NUMBER, - PROP_RAMP_RATE: Platform.SENSOR, + PROP_RAMP_RATE: Platform.SELECT, } UOM_FRIENDLY_NAME = { diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 162845be92c..a880025ad9f 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -18,7 +18,7 @@ from pyisy.variables import Variable from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN @@ -189,3 +189,42 @@ class ISYProgramEntity(ISYEntity): if self._node.last_update != EMPTY_TIME: attr["status_last_update"] = self._node.last_update return attr + + +class ISYAuxControlEntity(Entity): + """Representation of a ISY/IoX Aux Control base entity.""" + + _attr_should_poll = False + + def __init__( + self, + node: Node, + control: str, + unique_id: str, + description: EntityDescription, + device_info: DeviceInfo | None, + ) -> None: + """Initialize the ISY Aux Control Number entity.""" + self._node = node + self._control = control + 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.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.""" + # Only watch for our control changing or the node being enabled/disabled + if event.control != self._control: + return + self.async_write_ha_state() diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index ee81fb0b63d..0b62f2bd144 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -146,4 +146,16 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity): async def async_set_ramp_rate(self, value: int) -> None: """Set the Ramp Rate 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="select.select_option", + alternate_target=entity_registry.async_get_entity_id( + Platform.NUMBER, + DOMAIN, + f"{self._node.isy.uuid}_{self._node.address}_RR", + ), + breaks_in_ha_version="2023.5.0", + ) await self._node.set_ramp_rate(value) diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index a64d7df1225..9c47d721ba3 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -4,9 +4,8 @@ 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.constants import 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 ( @@ -30,6 +29,7 @@ from .const import ( DOMAIN, UOM_8_BIT_RANGE, ) +from .entity import ISYAuxControlEntity from .helpers import convert_isy_value_to_hass ISY_MAX_SIZE = (2**32) / 2 @@ -58,12 +58,11 @@ async def async_setup_entry( var_id = config_entry.options.get(CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING) for node in isy_data.variables[Platform.NUMBER]: - step = 10 ** (-1 * node.prec) - min_max = ISY_MAX_SIZE / (10**node.prec) + step = 10 ** (-1 * int(node.prec)) + min_max = ISY_MAX_SIZE / (10 ** int(node.prec)) description = NumberEntityDescription( key=node.address, name=node.name, - icon="mdi:counter", entity_registry_enabled_default=var_id in node.name, native_unit_of_measurement=None, native_step=step, @@ -108,43 +107,10 @@ async def async_setup_entry( async_add_entities(entities) -class ISYAuxControlNumberEntity(NumberEntity): +class ISYAuxControlNumberEntity(ISYAuxControlEntity, 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: diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py new file mode 100644 index 00000000000..decf95d6489 --- /dev/null +++ b/homeassistant/components/isy994/select.py @@ -0,0 +1,127 @@ +"""Support for ISY select entities.""" +from __future__ import annotations + +from typing import cast + +from pyisy.constants import ( + COMMAND_FRIENDLY_NAME, + INSTEON_RAMP_RATES, + ISY_VALUE_UNKNOWN, + PROP_RAMP_RATE, + UOM_TO_STATES, +) +from pyisy.helpers import NodeProperty + +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.helpers.entity_platform import AddEntitiesCallback + +from .const import _LOGGER, DOMAIN, UOM_INDEX +from .entity import ISYAuxControlEntity +from .models import IsyData + + +def time_string(i: int) -> str: + """Return a formatted ramp rate time string.""" + if i >= 60: + return f"{(float(i)/60):.1f} {UnitOfTime.MINUTES}" + return f"{i} {UnitOfTime.SECONDS}" + + +RAMP_RATE_OPTIONS = [time_string(rate) for rate in INSTEON_RAMP_RATES.values()] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up ISY/IoX select entities from config entry.""" + isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] + isy = isy_data.root + device_info = isy_data.devices + entities: list[ISYAuxControlIndexSelectEntity | ISYRampRateSelectEntity] = [] + + 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()) + + description = SelectEntityDescription( + key=f"{node.address}_{control}", + name=name, + entity_category=EntityCategory.CONFIG, + options=options, + ) + entity_detail = { + "node": node, + "control": control, + "unique_id": f"{isy.uuid}_{node.address}_{control}", + "description": description, + "device_info": device_info.get(node.primary_node), + } + + if control == PROP_RAMP_RATE: + entities.append(ISYRampRateSelectEntity(**entity_detail)) + continue + if node.uom == UOM_INDEX and options: + entities.append(ISYAuxControlIndexSelectEntity(**entity_detail)) + continue + # Future: support Node Server custom index UOMs + _LOGGER.debug( + "ISY missing node index unit definitions for %s: %s", node.name, name + ) + async_add_entities(entities) + + +class ISYRampRateSelectEntity(ISYAuxControlEntity, SelectEntity): + """Representation of a ISY/IoX Aux Control Ramp Rate Select entity.""" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + node_prop: NodeProperty = self._node.aux_properties[self._control] + if node_prop.value == ISY_VALUE_UNKNOWN: + return None + + return RAMP_RATE_OPTIONS[int(node_prop.value)] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + + await self._node.set_ramp_rate(RAMP_RATE_OPTIONS.index(option)) + + +class ISYAuxControlIndexSelectEntity(ISYAuxControlEntity, SelectEntity): + """Representation of a ISY/IoX Aux Control Index Select entity.""" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + node_prop: NodeProperty = self._node.aux_properties[self._control] + if node_prop.value == ISY_VALUE_UNKNOWN: + return None + + if options_dict := UOM_TO_STATES.get(node_prop.uom): + return cast(str, options_dict.get(node_prop.value, node_prop.value)) + return cast(str, node_prop.formatted) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + node_prop: NodeProperty = self._node.aux_properties[self._control] + + await self._node.send_cmd( + self._control, val=self.options.index(option), uom=node_prop.uom + ) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 3566767ab7c..35c699f2b71 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -46,6 +46,7 @@ from .helpers import convert_isy_value_to_hass # Disable general purpose and redundant sensors by default AUX_DISABLED_BY_DEFAULT_MATCH = ["GV", "DO"] AUX_DISABLED_BY_DEFAULT_EXACT = { + PROP_COMMS_ERROR, PROP_ENERGY_MODE, PROP_HEAT_COOL_STATE, PROP_ON_LEVEL, diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index a0af494834d..e336eaa574b 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -152,8 +152,8 @@ set_on_level: min: 0 max: 255 set_ramp_rate: - name: Set ramp rate - description: Send a ISY set_ramp_rate command to a Node. + name: Set ramp rate (Deprecated) + description: "Send a ISY set_ramp_rate command to a Node. Deprecated: Use On Level Number entity instead." target: entity: integration: isy994