Add zha AnalogOutput cluster support (#44092)

* Initial commit
* black
* isort
* Commit suggestion from code review
Co-authored-by: Alexei Chetroi <lexoid@gmail.com>

* pylint
* removed entity cache for present_value
* fix discovery
* move write_attributes to channel
* write_attributes fix
* write_attributes yet another fix
* update_before_add=False
* mains powered test device
* removed test_restore_state
* flake8
* removed async_configure_channel_specific
* don't know what this patch does, removing
* test for async_update
* removed node_descriptor
* fix unit_of_measurement
Co-authored-by: Alexei Chetroi <lexoid@gmail.com>
This commit is contained in:
Shulyaka 2020-12-14 23:32:45 +03:00 committed by GitHub
parent 38d16d3e0c
commit 0b8529a472
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 562 additions and 0 deletions

View file

@ -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):

View file

@ -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,
)

View file

@ -22,6 +22,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import,
fan,
light,
lock,
number,
sensor,
switch,
)

View file

@ -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,

View file

@ -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)

View file

@ -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]

View file

@ -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",