From fc07d3a1599a49ec306abf45d9cc81b563a32c44 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 4 Mar 2019 00:22:42 -0500 Subject: [PATCH] Add storage helper to ZHA and use it for the device node descriptor (#21500) * node descriptor implementation add info to device info disable pylint rule check for success * review comments * send manufacturer code for get attr value for mfg clusters * ST report configs * do zdo task first * add guard * use faster reporting config * disable false positive pylint --- .../components/zha/core/channels/__init__.py | 65 +++++++- homeassistant/components/zha/core/const.py | 3 + homeassistant/components/zha/core/device.py | 54 ++++--- homeassistant/components/zha/core/gateway.py | 31 +++- homeassistant/components/zha/core/store.py | 146 ++++++++++++++++++ 5 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/zha/core/store.py diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a070343b775df7..59b433c5f61c2c 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -18,8 +18,13 @@ safe_read, get_attr_id_by_name) from ..const import ( CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, - ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL + ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL, ZDO_CHANNEL ) +from ..store import async_get_registry + +NODE_DESCRIPTOR_REQUEST = 0x0002 +MAINS_POWERED = 1 +BATTERY_OR_UNKNOWN = 0 ZIGBEE_CHANNEL_REGISTRY = {} _LOGGER = logging.getLogger(__name__) @@ -181,11 +186,16 @@ async def async_update(self): async def get_attribute_value(self, attribute, from_cache=True): """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._zha_device.manufacturer_code + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code result = await safe_read( self._cluster, [attribute], allow_cache=from_cache, - only_cache=from_cache + only_cache=from_cache, + manufacturer=manufacturer ) return result.get(attribute) @@ -235,14 +245,21 @@ async def async_initialize(self, from_cache): class ZDOChannel: """Channel for ZDO events.""" + POWER_SOURCES = { + MAINS_POWERED: 'Mains', + BATTERY_OR_UNKNOWN: 'Battery or Unknown' + } + def __init__(self, cluster, device): """Initialize ZDOChannel.""" - self.name = 'zdo' + self.name = ZDO_CHANNEL self._cluster = cluster self._zha_device = device self._status = ChannelStatus.CREATED self._unique_id = "{}_ZDO".format(device.name) self._cluster.add_listener(self) + self.power_source = None + self.manufacturer_code = None @property def unique_id(self): @@ -271,10 +288,52 @@ def permit_duration(self, duration): async def async_initialize(self, from_cache): """Initialize channel.""" + entry = (await async_get_registry( + self._zha_device.hass)).async_get_or_create(self._zha_device) + _LOGGER.debug("entry loaded from storage: %s", entry) + if entry is not None: + self.power_source = entry.power_source + self.manufacturer_code = entry.manufacturer_code + + if self.power_source is None: + self.power_source = BATTERY_OR_UNKNOWN + + if self.manufacturer_code is None and not from_cache: + # this should always be set. This is from us not doing + # this previously so lets set it up so users don't have + # to reconfigure every device. + await self.async_get_node_descriptor(False) + entry = (await async_get_registry( + self._zha_device.hass)).async_update(self._zha_device) + _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED + async def async_get_node_descriptor(self, from_cache): + """Request the node descriptor from the device.""" + from zigpy.zdo.types import Status + + if from_cache: + return + + node_descriptor = await self._cluster.request( + NODE_DESCRIPTOR_REQUEST, + self._cluster.device.nwk, tries=3, delay=2) + + def get_bit(byteval, idx): + return int(((byteval & (1 << idx)) != 0)) + + if node_descriptor is not None and\ + node_descriptor[0] == Status.SUCCESS: + mac_capability_flags = node_descriptor[2].mac_capability_flags + + self.power_source = get_bit(mac_capability_flags, 2) + self.manufacturer_code = node_descriptor[2].manufacturer_code + + _LOGGER.debug("node descriptor: %s", node_descriptor) + async def async_configure(self): """Configure channel.""" + await self.async_get_node_descriptor(False) self._status = ChannelStatus.CONFIGURED diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 757ffbaa328c4b..ecaa1c9bd20b1c 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -71,6 +71,7 @@ ATTR_LEVEL = 'level' +ZDO_CHANNEL = 'zdo' ON_OFF_CHANNEL = 'on_off' ATTRIBUTE_CHANNEL = 'attribute' BASIC_CHANNEL = 'basic' @@ -91,6 +92,8 @@ QUIRK_APPLIED = 'quirk_applied' QUIRK_CLASS = 'quirk_class' +MANUFACTURER_CODE = 'manufacturer_code' +POWER_SOURCE = 'power_source' class RadioType(enum.Enum): diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 06b33a418fbb7b..fb57b0dbf3937c 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,10 +17,10 @@ ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, - QUIRK_CLASS, BASIC_CHANNEL + QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE ) -from .channels import EventRelayChannel -from .channels.general import BasicChannel +from .channels import EventRelayChannel, ZDOChannel +from .store import async_get_registry _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,6 @@ def __init__(self, hass, zigpy_device, zha_gateway): self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) - self.power_source = None self.status = DeviceStatus.CREATED @property @@ -84,12 +83,12 @@ def ieee(self): @property def manufacturer(self): - """Return ieee address for device.""" + """Return manufacturer for device.""" return self._manufacturer @property def model(self): - """Return ieee address for device.""" + """Return model for device.""" return self._model @property @@ -115,7 +114,15 @@ def last_seen(self): @property def manufacturer_code(self): """Return manufacturer code for device.""" - # will eventually get this directly from Zigpy + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).manufacturer_code + return None + + @property + def power_source(self): + """Return True if sensor is available.""" + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).power_source return None @property @@ -164,7 +171,9 @@ def device_info(self): MODEL: self.model, NAME: self.name or ieee, QUIRK_APPLIED: self.quirk_applied, - QUIRK_CLASS: self.quirk_class + QUIRK_CLASS: self.quirk_class, + MANUFACTURER_CODE: self.manufacturer_code, + POWER_SOURCE: ZDOChannel.POWER_SOURCES.get(self.power_source) } def add_cluster_channel(self, cluster_channel): @@ -186,19 +195,19 @@ async def async_configure(self): _LOGGER.debug('%s: started configuration', self.name) await self._execute_channel_tasks('async_configure') _LOGGER.debug('%s: completed configuration', self.name) + entry = (await async_get_registry( + self.hass)).async_create_or_update(self) + _LOGGER.debug('%s: stored in registry: %s', self.name, entry) async def async_initialize(self, from_cache=False): """Initialize channels.""" _LOGGER.debug('%s: started initialization', self.name) await self._execute_channel_tasks('async_initialize', from_cache) - if BASIC_CHANNEL in self.cluster_channels: - self.power_source = self.cluster_channels.get( - BASIC_CHANNEL).get_power_source() - _LOGGER.debug( - '%s: power source: %s', - self.name, - BasicChannel.POWER_SOURCES.get(self.power_source) - ) + _LOGGER.debug( + '%s: power source: %s', + self.name, + ZDOChannel.POWER_SOURCES.get(self.power_source) + ) self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) @@ -206,9 +215,18 @@ async def _execute_channel_tasks(self, task_name, *args): """Gather and execute a set of CHANNEL tasks.""" channel_tasks = [] semaphore = asyncio.Semaphore(3) + zdo_task = None for channel in self.all_channels: - channel_tasks.append( - self._async_create_task(semaphore, channel, task_name, *args)) + if channel.name == ZDO_CHANNEL: + # pylint: disable=E1111 + zdo_task = self._async_create_task( + semaphore, channel, task_name, *args) + else: + channel_tasks.append( + self._async_create_task( + semaphore, channel, task_name, *args)) + if zdo_task is not None: + await zdo_task await asyncio.gather(*channel_tasks) async def _async_create_task(self, semaphore, channel, func_name, *args): diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 595d32b1c2bb30..dcaf0d4a3baffe 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -27,9 +27,8 @@ from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( - AttributeListeningChannel, EventRelayChannel, ZDOChannel + AttributeListeningChannel, EventRelayChannel, ZDOChannel, MAINS_POWERED ) -from .channels.general import BasicChannel from .channels.registry import ZIGBEE_CHANNEL_REGISTRY from .helpers import convert_ieee @@ -38,6 +37,7 @@ SENSOR_TYPES = {} BINARY_SENSOR_TYPES = {} SMARTTHINGS_HUMIDITY_CLUSTER = 64581 +SMARTTHINGS_ACCELERATION_CLUSTER = 64514 EntityReference = collections.namedtuple( 'EntityReference', 'reference_id zha_device cluster_channels device_info') @@ -163,15 +163,14 @@ async def async_device_initialized(self, device, is_new_join): # configure the device await zha_device.async_configure() elif not zha_device.available and zha_device.power_source is not None\ - and zha_device.power_source != BasicChannel.BATTERY\ - and zha_device.power_source != BasicChannel.UNKNOWN: + and zha_device.power_source == MAINS_POWERED: # the device is currently marked unavailable and it isn't a battery # powered device so we should be able to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, "with power source: {}".format( - BasicChannel.POWER_SOURCES.get(zha_device.power_source) + ZDOChannel.POWER_SOURCES.get(zha_device.power_source) ) ) await zha_device.async_initialize(from_cache=False) @@ -453,6 +452,7 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + NO_SENSOR_CLUSTERS.append(SMARTTHINGS_ACCELERATION_CLUSTER) BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) @@ -575,6 +575,27 @@ def establish_device_mappings(): 50 ) }], + SMARTTHINGS_ACCELERATION_CLUSTER: [{ + 'attr': 'acceleration', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'x_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'y_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'z_axis', + 'config': REPORT_CONFIG_ASAP + }], + SMARTTHINGS_HUMIDITY_CLUSTER: [{ + 'attr': 'measured_value', + 'config': ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + 50 + ) + }], zcl.clusters.measurement.PressureMeasurement.cluster_id: [{ 'attr': 'measured_value', 'config': REPORT_CONFIG_DEFAULT diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py new file mode 100644 index 00000000000000..b13b6d8fd8026b --- /dev/null +++ b/homeassistant/components/zha/core/store.py @@ -0,0 +1,146 @@ +"""Data storage helper for ZHA.""" +import logging +from collections import OrderedDict +# pylint: disable=W0611 +from typing import MutableMapping # noqa: F401 +from typing import cast + +import attr + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +DATA_REGISTRY = 'zha_storage' + +STORAGE_KEY = 'zha.storage' +STORAGE_VERSION = 1 +SAVE_DELAY = 10 + + +@attr.s(slots=True, frozen=True) +class ZhaDeviceEntry: + """Zha Device storage Entry.""" + + name = attr.ib(type=str, default=None) + ieee = attr.ib(type=str, default=None) + power_source = attr.ib(type=int, default=None) + manufacturer_code = attr.ib(type=int, default=None) + + +class ZhaDeviceStorage: + """Class to hold a registry of zha devices.""" + + def __init__(self, hass: HomeAssistantType) -> None: + """Initialize the zha device storage.""" + self.hass = hass + self.devices = {} # type: MutableMapping[str, ZhaDeviceEntry] + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + @callback + def async_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + device_entry = ZhaDeviceEntry( + name=device.name, + ieee=str(device.ieee), + power_source=device.power_source, + manufacturer_code=device.manufacturer_code + + ) + self.devices[device_entry.ieee] = device_entry + + return self.async_update(device) + + @callback + def async_get_or_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + if ieee_str in self.devices: + return self.devices[ieee_str] + return self.async_create(device) + + @callback + def async_create_or_update(self, device) -> ZhaDeviceEntry: + """Create or update a ZhaDeviceEntry.""" + if str(device.ieee) in self.devices: + return self.async_update(device) + return self.async_create(device) + + async def async_delete(self, ieee: str) -> None: + """Delete ZhaDeviceEntry.""" + del self.devices[ieee] + self.async_schedule_save() + + @callback + def async_update(self, device) -> ZhaDeviceEntry: + """Update name of ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + old = self.devices[ieee_str] + + changes = {} + + if device.power_source != old.power_source: + changes['power_source'] = device.power_source + + if device.manufacturer_code != old.manufacturer_code: + changes['manufacturer_code'] = device.manufacturer_code + + new = self.devices[ieee_str] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + + async def async_load(self) -> None: + """Load the registry of zha device entries.""" + data = await self._store.async_load() + + devices = OrderedDict() # type: OrderedDict[str, ZhaDeviceEntry] + + if data is not None: + for device in data['devices']: + devices[device['ieee']] = ZhaDeviceEntry( + name=device['name'], + ieee=device['ieee'], + power_source=device['power_source'], + manufacturer_code=device['manufacturer_code'] + ) + + self.devices = devices + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the registry of zha devices.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict: + """Return data for the registry of zha devices to store in a file.""" + data = {} + + data['devices'] = [ + { + 'name': entry.name, + 'ieee': entry.ieee, + 'power_source': entry.power_source, + 'manufacturer_code': entry.manufacturer_code, + } for entry in self.devices.values() + ] + + return data + + +@bind_hass +async def async_get_registry(hass: HomeAssistantType) -> ZhaDeviceStorage: + """Return zha device storage instance.""" + task = hass.data.get(DATA_REGISTRY) + + if task is None: + async def _load_reg() -> ZhaDeviceStorage: + registry = ZhaDeviceStorage(hass) + await registry.async_load() + return registry + + task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + + return cast(ZhaDeviceStorage, await task)