diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index fa4883ae5a2..d105572c182 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -4,6 +4,7 @@ from typing import Any, Coroutine, List, Optional import zigpy.exceptions import zigpy.zcl.clusters.general as general +from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later @@ -19,6 +20,7 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) +from ..helpers import retryable_req from .base import ClientChannel, ZigbeeChannel, parse_and_log_command @@ -34,12 +36,85 @@ class AnalogInput(ZigbeeChannel): REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] +@registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id) class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + @property + def present_value(self) -> Optional[float]: + """Return cached value of present_value.""" + return self.cluster.get("present_value") + + @property + def min_present_value(self) -> Optional[float]: + """Return cached value of min_present_value.""" + return self.cluster.get("min_present_value") + + @property + def max_present_value(self) -> Optional[float]: + """Return cached value of max_present_value.""" + return self.cluster.get("max_present_value") + + @property + def resolution(self) -> Optional[float]: + """Return cached value of resolution.""" + return self.cluster.get("resolution") + + @property + def relinquish_default(self) -> Optional[float]: + """Return cached value of relinquish_default.""" + return self.cluster.get("relinquish_default") + + @property + def description(self) -> Optional[str]: + """Return cached value of description.""" + return self.cluster.get("description") + + @property + def engineering_units(self) -> Optional[int]: + """Return cached value of engineering_units.""" + return self.cluster.get("engineering_units") + + @property + def application_type(self) -> Optional[int]: + """Return cached value of application_type.""" + return self.cluster.get("application_type") + + async def async_set_present_value(self, value: float) -> bool: + """Update present_value.""" + try: + res = await self.cluster.write_attributes({"present_value": value}) + except zigpy.exceptions.ZigbeeException as ex: + self.error("Could not set value: %s", ex) + return False + if isinstance(res, list) and all( + [record.status == Status.SUCCESS for record in res[0]] + ): + return True + return False + + @retryable_req(delays=(1, 1, 3)) + def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: + """Initialize channel.""" + return self.fetch_config(from_cache) + + async def fetch_config(self, from_cache: bool) -> None: + """Get the channel configuration.""" + attributes = [ + "min_present_value", + "max_present_value", + "resolution", + "relinquish_default", + "description", + "engineering_units", + "application_type", + ] + # just populates the cache, if not already done + await self.get_attributes(attributes, from_cache=from_cache) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) class AnalogValue(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 12d928e172a..1d3f767353b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -18,6 +18,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.lock import DOMAIN as LOCK +from homeassistant.components.number import DOMAIN as NUMBER from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -71,6 +72,7 @@ BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" CHANNEL_ANALOG_INPUT = "analog_input" +CHANNEL_ANALOG_OUTPUT = "analog_output" CHANNEL_ATTRIBUTE = "attribute" CHANNEL_BASIC = "basic" CHANNEL_COLOR = "light_color" @@ -110,6 +112,7 @@ COMPONENTS = ( FAN, LIGHT, LOCK, + NUMBER, SENSOR, SWITCH, ) diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 05a12bc2284..e071a523321 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -22,6 +22,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, fan, light, lock, + number, sensor, switch, ) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index e2b4056cfaa..4dcccc98c05 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -14,6 +14,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.lock import DOMAIN as LOCK +from homeassistant.components.number import DOMAIN as NUMBER from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH @@ -61,6 +62,7 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.closures.DoorLock.cluster_id: LOCK, zcl.clusters.closures.WindowCovering.cluster_id: COVER, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, + zcl.clusters.general.AnalogOutput.cluster_id: NUMBER, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, zcl.clusters.general.OnOff.cluster_id: SWITCH, zcl.clusters.general.PowerConfiguration.cluster_id: SENSOR, diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py new file mode 100644 index 00000000000..b4772e51742 --- /dev/null +++ b/homeassistant/components/zha/number.py @@ -0,0 +1,339 @@ +"""Support for ZHA AnalogOutput cluster.""" +import functools +import logging + +from homeassistant.components.number import DOMAIN, NumberEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .core import discovery +from .core.const import ( + CHANNEL_ANALOG_OUTPUT, + DATA_ZHA, + DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +_LOGGER = logging.getLogger(__name__) + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) + + +UNITS = { + 0: "Square-meters", + 1: "Square-feet", + 2: "Milliamperes", + 3: "Amperes", + 4: "Ohms", + 5: "Volts", + 6: "Kilo-volts", + 7: "Mega-volts", + 8: "Volt-amperes", + 9: "Kilo-volt-amperes", + 10: "Mega-volt-amperes", + 11: "Volt-amperes-reactive", + 12: "Kilo-volt-amperes-reactive", + 13: "Mega-volt-amperes-reactive", + 14: "Degrees-phase", + 15: "Power-factor", + 16: "Joules", + 17: "Kilojoules", + 18: "Watt-hours", + 19: "Kilowatt-hours", + 20: "BTUs", + 21: "Therms", + 22: "Ton-hours", + 23: "Joules-per-kilogram-dry-air", + 24: "BTUs-per-pound-dry-air", + 25: "Cycles-per-hour", + 26: "Cycles-per-minute", + 27: "Hertz", + 28: "Grams-of-water-per-kilogram-dry-air", + 29: "Percent-relative-humidity", + 30: "Millimeters", + 31: "Meters", + 32: "Inches", + 33: "Feet", + 34: "Watts-per-square-foot", + 35: "Watts-per-square-meter", + 36: "Lumens", + 37: "Luxes", + 38: "Foot-candles", + 39: "Kilograms", + 40: "Pounds-mass", + 41: "Tons", + 42: "Kilograms-per-second", + 43: "Kilograms-per-minute", + 44: "Kilograms-per-hour", + 45: "Pounds-mass-per-minute", + 46: "Pounds-mass-per-hour", + 47: "Watts", + 48: "Kilowatts", + 49: "Megawatts", + 50: "BTUs-per-hour", + 51: "Horsepower", + 52: "Tons-refrigeration", + 53: "Pascals", + 54: "Kilopascals", + 55: "Bars", + 56: "Pounds-force-per-square-inch", + 57: "Centimeters-of-water", + 58: "Inches-of-water", + 59: "Millimeters-of-mercury", + 60: "Centimeters-of-mercury", + 61: "Inches-of-mercury", + 62: "°C", + 63: "°K", + 64: "°F", + 65: "Degree-days-Celsius", + 66: "Degree-days-Fahrenheit", + 67: "Years", + 68: "Months", + 69: "Weeks", + 70: "Days", + 71: "Hours", + 72: "Minutes", + 73: "Seconds", + 74: "Meters-per-second", + 75: "Kilometers-per-hour", + 76: "Feet-per-second", + 77: "Feet-per-minute", + 78: "Miles-per-hour", + 79: "Cubic-feet", + 80: "Cubic-meters", + 81: "Imperial-gallons", + 82: "Liters", + 83: "Us-gallons", + 84: "Cubic-feet-per-minute", + 85: "Cubic-meters-per-second", + 86: "Imperial-gallons-per-minute", + 87: "Liters-per-second", + 88: "Liters-per-minute", + 89: "Us-gallons-per-minute", + 90: "Degrees-angular", + 91: "Degrees-Celsius-per-hour", + 92: "Degrees-Celsius-per-minute", + 93: "Degrees-Fahrenheit-per-hour", + 94: "Degrees-Fahrenheit-per-minute", + 95: None, + 96: "Parts-per-million", + 97: "Parts-per-billion", + 98: "%", + 99: "Percent-per-second", + 100: "Per-minute", + 101: "Per-second", + 102: "Psi-per-Degree-Fahrenheit", + 103: "Radians", + 104: "Revolutions-per-minute", + 105: "Currency1", + 106: "Currency2", + 107: "Currency3", + 108: "Currency4", + 109: "Currency5", + 110: "Currency6", + 111: "Currency7", + 112: "Currency8", + 113: "Currency9", + 114: "Currency10", + 115: "Square-inches", + 116: "Square-centimeters", + 117: "BTUs-per-pound", + 118: "Centimeters", + 119: "Pounds-mass-per-second", + 120: "Delta-Degrees-Fahrenheit", + 121: "Delta-Degrees-Kelvin", + 122: "Kilohms", + 123: "Megohms", + 124: "Millivolts", + 125: "Kilojoules-per-kilogram", + 126: "Megajoules", + 127: "Joules-per-degree-Kelvin", + 128: "Joules-per-kilogram-degree-Kelvin", + 129: "Kilohertz", + 130: "Megahertz", + 131: "Per-hour", + 132: "Milliwatts", + 133: "Hectopascals", + 134: "Millibars", + 135: "Cubic-meters-per-hour", + 136: "Liters-per-hour", + 137: "Kilowatt-hours-per-square-meter", + 138: "Kilowatt-hours-per-square-foot", + 139: "Megajoules-per-square-meter", + 140: "Megajoules-per-square-foot", + 141: "Watts-per-square-meter-Degree-Kelvin", + 142: "Cubic-feet-per-second", + 143: "Percent-obscuration-per-foot", + 144: "Percent-obscuration-per-meter", + 145: "Milliohms", + 146: "Megawatt-hours", + 147: "Kilo-BTUs", + 148: "Mega-BTUs", + 149: "Kilojoules-per-kilogram-dry-air", + 150: "Megajoules-per-kilogram-dry-air", + 151: "Kilojoules-per-degree-Kelvin", + 152: "Megajoules-per-degree-Kelvin", + 153: "Newton", + 154: "Grams-per-second", + 155: "Grams-per-minute", + 156: "Tons-per-hour", + 157: "Kilo-BTUs-per-hour", + 158: "Hundredths-seconds", + 159: "Milliseconds", + 160: "Newton-meters", + 161: "Millimeters-per-second", + 162: "Millimeters-per-minute", + 163: "Meters-per-minute", + 164: "Meters-per-hour", + 165: "Cubic-meters-per-minute", + 166: "Meters-per-second-per-second", + 167: "Amperes-per-meter", + 168: "Amperes-per-square-meter", + 169: "Ampere-square-meters", + 170: "Farads", + 171: "Henrys", + 172: "Ohm-meters", + 173: "Siemens", + 174: "Siemens-per-meter", + 175: "Teslas", + 176: "Volts-per-degree-Kelvin", + 177: "Volts-per-meter", + 178: "Webers", + 179: "Candelas", + 180: "Candelas-per-square-meter", + 181: "Kelvins-per-hour", + 182: "Kelvins-per-minute", + 183: "Joule-seconds", + 185: "Square-meters-per-Newton", + 186: "Kilogram-per-cubic-meter", + 187: "Newton-seconds", + 188: "Newtons-per-meter", + 189: "Watts-per-meter-per-degree-Kelvin", +} + +ICONS = { + 0: "mdi:temperature-celsius", + 1: "mdi:water-percent", + 2: "mdi:gauge", + 3: "mdi:speedometer", + 4: "mdi:percent", + 5: "mdi:air-filter", + 6: "mdi:fan", + 7: "mdi:flash", + 8: "mdi:current-ac", + 9: "mdi:flash", + 10: "mdi:flash", + 11: "mdi:flash", + 12: "mdi:counter", + 13: "mdi:thermometer-lines", + 14: "mdi:timer", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation Analog Output from config entry.""" + entities_to_create = hass.data[DATA_ZHA][DOMAIN] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + update_before_add=False, + ), + ) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + +@STRICT_MATCH(channel_names=CHANNEL_ANALOG_OUTPUT) +class ZhaNumber(ZhaEntity, NumberEntity): + """Representation of a ZHA Number entity.""" + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this entity.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._analog_output_channel = self.cluster_channels.get(CHANNEL_ANALOG_OUTPUT) + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._analog_output_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def value(self): + """Return the current value.""" + return self._analog_output_channel.present_value + + @property + def min_value(self): + """Return the minimum value.""" + min_present_value = self._analog_output_channel.min_present_value + if min_present_value is not None: + return min_present_value + return 0 + + @property + def max_value(self): + """Return the maximum value.""" + max_present_value = self._analog_output_channel.max_present_value + if max_present_value is not None: + return max_present_value + return 1023 + + @property + def step(self): + """Return the value step.""" + resolution = self._analog_output_channel.resolution + if resolution is not None: + return resolution + return super().step + + @property + def name(self): + """Return the name of the number entity.""" + description = self._analog_output_channel.description + if description is not None and len(description) > 0: + return f"{super().name} {description}" + return super().name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + application_type = self._analog_output_channel.application_type + if application_type is not None: + return ICONS.get(application_type >> 16, super().icon) + return super().icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + engineering_units = self._analog_output_channel.engineering_units + return UNITS.get(engineering_units) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle value update from channel.""" + self.async_write_ha_state() + + async def async_set_value(self, value): + """Update the current value from HA.""" + num_value = float(value) + if await self._analog_output_channel.async_set_present_value(num_value): + self.async_write_ha_state() + + async def async_update(self): + """Attempt to retrieve the state of the entity.""" + await super().async_update() + _LOGGER.debug("polling current state") + if self._analog_output_channel: + value = await self._analog_output_channel.get_attribute_value( + "present_value", from_cache=False + ) + _LOGGER.debug("read value=%s", value) diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py new file mode 100644 index 00000000000..947bad37e01 --- /dev/null +++ b/tests/components/zha/test_number.py @@ -0,0 +1,130 @@ +"""Test zha analog output.""" +import pytest +import zigpy.profiles.zha +import zigpy.types +import zigpy.zcl.clusters.general as general +import zigpy.zcl.foundation as zcl_f + +from homeassistant.components.number import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from .common import ( + async_enable_traffic, + async_test_rejoin, + find_entity_id, + send_attributes_report, +) + +from tests.async_mock import call, patch +from tests.common import mock_coro + + +@pytest.fixture +def zigpy_analog_output_device(zigpy_device_mock): + """Zigpy analog_output device.""" + + endpoints = { + 1: { + "device_type": zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + "in_clusters": [general.AnalogOutput.cluster_id, general.Basic.cluster_id], + "out_clusters": [], + } + } + return zigpy_device_mock(endpoints) + + +async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_device): + """Test zha number platform.""" + + cluster = zigpy_analog_output_device.endpoints.get(1).analog_output + cluster.PLUGGED_ATTR_READS = { + "present_value": 15.0, + "max_present_value": 100.0, + "min_present_value": 0.0, + "relinquish_default": 50.0, + "resolution": 1.0, + "description": "PWM1", + "engineering_units": 98, + "application_type": 4 * 0x10000, + } + zha_device = await zha_device_joined_restored(zigpy_analog_output_device) + # one for present_value and one for the rest configuration attributes + assert cluster.read_attributes.call_count == 2 + assert "max_present_value" in cluster.read_attributes.call_args[0][0] + assert "min_present_value" in cluster.read_attributes.call_args[0][0] + assert "relinquish_default" in cluster.read_attributes.call_args[0][0] + assert "resolution" in cluster.read_attributes.call_args[0][0] + assert "description" in cluster.read_attributes.call_args[0][0] + assert "engineering_units" in cluster.read_attributes.call_args[0][0] + assert "application_type" in cluster.read_attributes.call_args[0][0] + + entity_id = await find_entity_id(DOMAIN, zha_device, hass) + assert entity_id is not None + + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the number was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + assert cluster.read_attributes.call_count == 2 + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + assert cluster.read_attributes.call_count == 4 + + # test that the state has changed from unavailable to 15.0 + assert hass.states.get(entity_id).state == "15.0" + + # test attributes + assert hass.states.get(entity_id).attributes.get("min") == 0.0 + assert hass.states.get(entity_id).attributes.get("max") == 100.0 + assert hass.states.get(entity_id).attributes.get("step") == 1.0 + assert hass.states.get(entity_id).attributes.get("icon") == "mdi:percent" + assert hass.states.get(entity_id).attributes.get("unit_of_measurement") == "%" + assert ( + hass.states.get(entity_id).attributes.get("friendly_name") + == "FakeManufacturer FakeModel e769900a analog_output PWM1" + ) + + # change value from device + assert cluster.read_attributes.call_count == 4 + await send_attributes_report(hass, cluster, {0x0055: 15}) + assert hass.states.get(entity_id).state == "15.0" + + # update value from device + await send_attributes_report(hass, cluster, {0x0055: 20}) + assert hass.states.get(entity_id).state == "20.0" + + # change value from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]), + ): + # set value via UI + await hass.services.async_call( + DOMAIN, "set_value", {"entity_id": entity_id, "value": 30.0}, blocking=True + ) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"present_value": 30.0}) + cluster.PLUGGED_ATTR_READS["present_value"] = 30.0 + + # test rejoin + assert cluster.read_attributes.call_count == 4 + await async_test_rejoin(hass, zigpy_analog_output_device, [cluster], (1,)) + assert hass.states.get(entity_id).state == "30.0" + assert cluster.read_attributes.call_count == 6 + + # update device value with failed attribute report + cluster.PLUGGED_ATTR_READS["present_value"] = 40.0 + # validate the entity still contains old value + assert hass.states.get(entity_id).state == "30.0" + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == "40.0" + assert cluster.read_attributes.call_count == 7 + assert "present_value" in cluster.read_attributes.call_args[0][0] diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 136af1f4be9..1ea52d4e604 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -3612,6 +3612,8 @@ DEVICES = [ "sensor.digi_xbee3_77665544_analog_input_2", "sensor.digi_xbee3_77665544_analog_input_3", "sensor.digi_xbee3_77665544_analog_input_4", + "number.digi_xbee3_77665544_analog_output", + "number.digi_xbee3_77665544_analog_output_2", ], "entity_map": { ("switch", "00:11:22:33:44:55:66:77-208-6"): { @@ -3714,6 +3716,16 @@ DEVICES = [ "entity_class": "AnalogInput", "entity_id": "sensor.digi_xbee3_77665544_analog_input_5", }, + ("number", "00:11:22:33:44:55:66:77-218-13"): { + "channels": ["analog_output"], + "entity_class": "ZhaNumber", + "entity_id": "number.digi_xbee3_77665544_analog_output", + }, + ("number", "00:11:22:33:44:55:66:77-219-13"): { + "channels": ["analog_output"], + "entity_class": "ZhaNumber", + "entity_id": "number.digi_xbee3_77665544_analog_output_2", + }, }, "event_channels": ["232:0x0008"], "manufacturer": "Digi",