diff --git a/platypush/plugins/zwave/__init__.py b/platypush/plugins/zwave/__init__.py index c2d3ee8e..e69de29b 100644 --- a/platypush/plugins/zwave/__init__.py +++ b/platypush/plugins/zwave/__init__.py @@ -1,1265 +0,0 @@ -from typing import Any, Dict, Optional, List, Union - -from platypush.backend.zwave import ZwaveBackend -from platypush.context import get_backend -from platypush.plugins import action -from platypush.plugins.zwave._base import ZwaveBasePlugin - - -class ZwavePlugin(ZwaveBasePlugin): - """ - This plugin interacts with the devices on a Z-Wave network started through the - :class:`platypush.backend.zwave.ZwaveBackend` backend. - - .. note:: - - This plugin is deprecated, since the underlying ``python-openzwave`` is - quite buggy and largely unmaintained. - - Use the `zwave.mqtt` plugin instead - (:class:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin`). - - Requires: - - * **python-openzwave** (``pip install python-openzwave``) - * The :class:`platypush.backend.zwave.ZwaveBackend` backend configured and running. - - """ - - @staticmethod - def _get_backend() -> ZwaveBackend: - backend = get_backend('zwave') - if not backend: - raise AssertionError('Z-Wave backend not configured') - - return backend - - @classmethod - def _get_network(cls): - backend = cls._get_backend() - if not backend.network: - backend.start_network() - - assert backend.network - return backend.network - - @classmethod - def _get_controller(cls): - return cls._get_network().controller - - @action - def start_network(self): - backend = self._get_backend() - backend.start_network() - - @action - def stop_network(self): - backend = self._get_backend() - backend.stop_network() - - @action - def status(self) -> Dict[str, Any]: - """ - Get the status of the controller. - - :return: dict - """ - backend = self._get_backend() - network = self._get_network() - controller = self._get_controller() - - return { - 'device': backend.device, - 'state': network.state_str, - 'stats': controller.stats, - } - - @action - def add_node(self, do_security=False, **_): - """ - Start the inclusion process to add a node to the network. - - :param do_security: Whether to initialize the Network Key on the device if it supports the Security CC - """ - controller = self._get_controller() - controller.add_node(do_security) - - @action - def remove_node(self, **_): - """ - Remove a node from the network. - """ - controller = self._get_controller() - controller.remove_node() - self.write_config() - - @action - def remove_failed_node( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Remove a failed node from the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - controller = self._get_controller() - node = self._get_node(node_id=node_id, node_name=node_name) - controller.remove_failed_node(node.node_id) - self.write_config() - - @action - def replace_failed_node( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Replace a failed node on the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - controller = self._get_controller() - node = self._get_node(node_id=node_id, node_name=node_name) - controller.replace_failed_node(node.node_id) - self.write_config() - - @action - def replication_send( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Send node information from the primary to the secondary controller. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - controller = self._get_controller() - node = self._get_node(node_id=node_id, node_name=node_name) - controller.replication_send(node.node_id) - - @action - def request_network_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Request a network update to a node. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - controller = self._get_controller() - node = self._get_node(node_id=node_id, node_name=node_name) - controller.request_network_update(node.node_id) - - @action - def request_node_neighbour_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Request a neighbours list update to a node. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - controller = self._get_controller() - node = self._get_node(node_id=node_id, node_name=node_name) - controller.request_node_neighbor_update(node.node_id) - - @staticmethod - def value_to_dict(value) -> Dict[str, Any]: - if not value: - return {} - - return { - 'command_class': value.node.get_command_class_as_string(value.command_class) - if value.command_class - else None, - 'data': value.data, - 'data_as_string': value.data_as_string, - 'data_items': list(value.data_items) - if isinstance(value.data_items, set) - else value.data_items, - 'genre': value.genre, - 'help': value.help, - 'home_id': value.home_id, - 'id_on_network': value.id_on_network - if value.parent_id is not None - and value.command_class is not None - and value.instance is not None - and value.index is not None - else None, - 'index': value.index, - 'instance': value.instance, - 'is_polled': value.is_polled, - 'is_read_only': value.is_read_only, - 'is_set': value.is_set, - 'is_write_only': value.is_write_only, - 'label': value.label, - 'last_update': value.last_update, - 'min': value.min, - 'max': value.max, - 'object_id': value.object_id, - 'outdated': value.outdated, - 'parent_id': value.parent_id, - 'poll_intensity': value.poll_intensity, - 'precision': value.precision, - 'type': value.type, - 'units': value.units, - 'use_cache': value.use_cache, - 'value_id': value.value_id, - } - - @staticmethod - def group_to_dict(group) -> Dict[str, Any]: - if not group: - return {} - - return { - 'index': group.index, - 'label': group.label, - 'max_associations': group.max_associations, - 'associations': group.associations, - } - - @classmethod - def node_to_dict(cls, node) -> Dict[str, Any]: - if not node: - return {} - - # noinspection PyProtectedMember - return { - 'node_id': node.node_id, - 'home_id': node.home_id, - 'capabilities': list(node.capabilities), - 'command_classes': [ - node.get_command_class_as_string(cc) - for cc in node.command_classes - if cc - ] - if hasattr(node, 'command_classes') - else [], - 'device_type': node.device_type if hasattr(node, 'device_type') else '', - 'groups': { - group_id: cls.group_to_dict(group) - for group_id, group in node.groups.items() - }, - 'is_awake': node.is_awake if hasattr(node, 'is_awake') else False, - 'is_failed': node.is_failed if hasattr(node, 'is_failed') else False, - 'is_beaming_device': node.is_beaming_device - if hasattr(node, 'is_beaming_device') - else False, - 'is_frequent_listening_device': node.is_frequent_listening_device - if hasattr(node, 'is_frequent_listening_device') - else False, - 'is_info_received': node.is_info_received - if hasattr(node, 'is_info_received') - else False, - 'is_listening_device': node.is_listening_device - if hasattr(node, 'is_listening_device') - else False, - 'is_locked': node.is_locked if hasattr(node, 'is_locked') else False, - 'is_ready': node.is_ready if hasattr(node, 'is_ready') else False, - 'is_routing_device': node.is_routing_device - if hasattr(node, 'is_routing_device') - else False, - 'is_security_device': node.is_security_device - if hasattr(node, 'is_security_device') - else False, - 'is_sleeping': node.is_sleeping if hasattr(node, 'is_sleeping') else False, - 'last_update': node.last_update if hasattr(node, 'last_update') else None, - 'location': node.location if hasattr(node, 'location') else None, - 'manufacturer_id': node.manufacturer_id - if hasattr(node, 'manufacturer_id') - else None, - 'manufacturer_name': node.manufacturer_name - if hasattr(node, 'manufacturer_name') - else None, - 'max_baud_rate': node.max_baud_rate - if hasattr(node, 'max_baud_rate') - else None, - 'neighbours': list(node.neighbors or []) - if hasattr(node, 'neighbours') - else [], - 'name': node.name if hasattr(node, 'name') else None, - 'outdated': node.outdated if hasattr(node, 'outdated') else False, - 'product_id': node.product_id if hasattr(node, 'product_id') else None, - 'product_name': node.product_name - if hasattr(node, 'product_name') - else None, - 'product_type': node.product_type - if hasattr(node, 'product_type') - else None, - 'query_stage': node.query_stage if hasattr(node, 'query_stage') else None, - 'role': node.role if hasattr(node, 'role') else None, - 'security': node.security if hasattr(node, 'security') else None, - 'specific': node.specific if hasattr(node, 'specific') else None, - 'type': node.type if hasattr(node, 'type') else None, - 'use_cache': node.use_cache if hasattr(node, 'use_cache') else False, - 'version': node.version if hasattr(node, 'version') else None, - 'values': { - value.id_on_network: cls.value_to_dict(value) - for _, value in (node.values or {}).items() - if value.index is not None - and value.instance is not None - and value.command_class - and value.parent_id is not None - and value._network - and value._network.home_id is not None - } - if hasattr(node, 'values') - else {}, - } - - def _get_node(self, node_id: Optional[int] = None, node_name: Optional[str] = None): - assert ( - node_id is not None or node_name is not None - ), 'Specify either node_id or name' - nodes = self._get_network().nodes - - if node_id is not None: - assert node_id in nodes, 'No such node_id: {}'.format(node_id) - return nodes[node_id] - - nodes = [n for n in nodes.values() if n.name == node_name] - assert nodes, 'No such node name: {}'.format(node_name) - return nodes[0] - - def _get_groups(self) -> dict: - return { - group_index: group - for node in self._get_network().nodes.values() - for group_index, group in node.groups.items() - } - - def _get_group( - self, group_index: Optional[int] = None, group_label: Optional[str] = None - ): - assert ( - group_index is not None or group_label is not None - ), 'Specify either group_index or label' - groups = self._get_groups() - - if group_index is not None: - assert group_index in groups, 'No such group_index: {}'.format(group_index) - return groups[group_index] - - groups = [g for g in groups.values() if g.label == group_label] - assert groups, 'No such group label: {}'.format(group_label) - return groups[0] - - def _get_scene( - self, scene_id: Optional[int] = None, scene_label: Optional[str] = None - ): - assert ( - scene_id is not None or scene_label is not None - ), 'Specify either scene_id or label' - scenes = self._get_network().get_scenes() - - if scene_id is not None: - assert scene_id in scenes, 'No such scene_id: {}'.format(scene_id) - return scenes[scene_id] - - scenes = [s for s in scenes.values() if s['label'] == scene_label] - assert scenes, 'No such scene label: {}'.format(scene_label) - return scenes[0] - - @action - def get_nodes( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[str, Any]: - """ - Get the nodes associated to the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - if node_id is not None or node_name is not None: - return self.node_to_dict( - self._get_node(node_id=node_id, node_name=node_name) - ) - - return { - node_id: self.node_to_dict(node) - for node_id, node in self._get_network().nodes.items() - } - - @action - def get_node_stats( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[str, Any]: - """ - Get the statistics of a node on the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - return node.stats - - @action - def set_node_name( - self, - new_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Rename a node on the network. - - :param new_name: New name for the node. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.name = new_name - self.write_config() - - @action - def set_node_product_name( - self, - product_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Set the product name of a node. - - :param product_name: Product name. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.product_name = product_name - self.write_config() - - @action - def set_node_manufacturer_name( - self, - manufacturer_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Set the manufacturer name of a node. - - :param manufacturer_name: Manufacturer name. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.manufacturer_name = manufacturer_name - self.write_config() - - @action - def set_node_location( - self, - location: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Set the location of a node. - - :param location: Node location. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.location = location - self.write_config() - - @action - def cancel_command(self): - """ - Cancel the current running command. - """ - self._get_controller().cancel_command() - - @action - def kill_command(self): - """ - Immediately terminate any running command on the controller and release the lock. - """ - self._get_controller().kill_command() - - @action - def set_controller_name(self, name: str): - """ - Set the name of the controller on the network. - - :param name: New controller name. - """ - self._get_controller().name = name - self.write_config() - - @action - def get_capabilities(self) -> List[str]: - """ - Get the capabilities of the controller. - """ - return list(self._get_controller().capabilities) - - @action - def receive_configuration(self): - """ - Receive the configuration from the primary controller on the network. Requires a primary controller active. - """ - self._get_controller().receive_configuration() - - @action - def transfer_primary_role(self): - """ - Add a new controller to the network and make it the primary. - The existing primary will become a secondary controller. - """ - self._get_controller().transfer_primary_role() - - @action - def heal(self, refresh_routes: bool = False): - """ - Heal network by requesting nodes rediscover their neighbors. - - :param refresh_routes: Whether to perform return routes initialization (default: ``False``). - """ - self._get_network().heal(refresh_routes) - - @action - def switch_all(self, state: bool): - """ - Switch all the connected devices on/off. - - :param state: True (switch on) or False (switch off). - """ - self._get_network().switch_all(state) - - @action - def test(self, count: int = 1): - """ - Send a number of test messages to every node and record results. - - :param count: The number of test messages to send. - """ - self._get_network().test(count) - - def _get_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - value_label: Optional[str] = None, - ): - assert (value_id is not None or id_on_network is not None) or ( - (node_id is not None or node_name is not None) and value_label is not None - ), 'Specify either value_id, id_on_network, or [node_id/node_name, value_label]' - - if value_id is not None: - return self._get_network().get_value(value_id) - if id_on_network is not None: - values = [ - value - for node in self._get_network().nodes.values() - for value in node.values.values() - if value.id_on_network == id_on_network - ] - - assert values, 'No such value ID: {}'.format(id_on_network) - return values[0] - - node = self._get_node(node_id=node_id, node_name=node_name) - values = [v for v in node.values.values() if v.label == value_label] - assert values, 'No such value on node "{}": "{}"'.format(node.name, value_label) - return values[0] - - @action - def get_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Get a value on the network. - - :param value_id: Select by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - return self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node_id, - node_name=node_name, - value_label=value_label, - ).to_dict() - - @action - def set_value( - self, - data, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Set a value. - - :param data: Data to set for the value. - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - from openzwave.node import ZWaveNode - - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node_id, - node_name=node_name, - value_label=value_label, - ) - new_val = value.check_data(data) - assert new_val is not None, 'Invalid value passed to the property' - node: ZWaveNode = self._get_network().nodes[value.node.node_id] - node.values[value.value_id].data = new_val - self.write_config() - - @action - def set_value_label( - self, - new_label: str, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Change the label/name of a value. - - :param new_label: New value label. - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node_id, - node_name=node_name, - value_label=value_label, - ) - value.label = new_label - self.write_config() - - @action - def node_add_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Add a value to a node. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by label. - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node.node_id, - value_label=value_label, - ) - node.add_value(value.value_id) - self.write_config() - - @action - def node_remove_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Remove a value from a node. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node.node_id, - value_label=value_label, - ) - node.remove_value(value.value_id) - self.write_config() - - @action - def node_heal( - self, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - refresh_routes: bool = False, - ): - """ - Heal network node by requesting the node to rediscover their neighbours. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - :param refresh_routes: Whether to perform return routes initialization. (default: ``False``). - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.heal(refresh_routes) - - @action - def node_update_neighbours( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Ask a node to update its neighbours table. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.neighbor_update() - - @action - def node_network_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Update the controller with network information. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.network_update() - - @action - def node_refresh_info( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ): - """ - Fetch up-to-date information about the node. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - node.refresh_info() - - def _get_values( - self, item: str, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - nodes = ( - [self._get_node(node_id=node_id, node_name=node_name)] - if node_id or node_name - else self._get_network().nodes.values() - ) - - return { - value.id_on_network: { - 'node_id': node.node_id, - 'node_name': node.name, - **self.value_to_dict(value), - } - for node in nodes - for value in getattr(node, 'get_' + item)().values() - } - - @action - def get_dimmers( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the dimmers on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - return self._get_values('dimmers', node_id=node_id, node_name=node_name) - - @action - def get_node_config( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the configuration values of a node or of all the nodes on the network. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - return self._get_values('configs', node_id=node_id, node_name=node_name) - - @action - def get_battery_levels( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the battery levels of a node or of all the nodes on the network. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('battery_levels', node_id=node_id, node_name=node_name) - - @action - def get_power_levels( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the power levels of this node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('power_levels', node_id=node_id, node_name=node_name) - - @action - def get_bulbs( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the bulbs/LEDs on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('rgbbulbs', node_id=node_id, node_name=node_name) - - @action - def get_switches( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the switches on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('switches', node_id=node_id, node_name=node_name) - - @action - def get_sensors( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the sensors on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('sensors', node_id=node_id, node_name=node_name) - - @action - def get_doorlocks( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the doorlocks on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('doorlocks', node_id=node_id, node_name=node_name) - - @action - def get_usercodes( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the usercodes on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('usercodes', node_id=node_id, node_name=node_name) - - @action - def get_thermostats( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the thermostats on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('thermostats', node_id=node_id, node_name=node_name) - - @action - def get_protections( - self, node_id: Optional[int] = None, node_name: Optional[str] = None - ) -> Dict[int, Any]: - """ - Get the protection-compatible devices on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - return self._get_values('protections', node_id=node_id, node_name=node_name) - - @action - def get_groups(self) -> Dict[int, Any]: - """ - Get the groups on the network. - """ - return { - group_index: self.group_to_dict(group) - for group_index, group in self._get_groups().items() - } - - @action - def get_scenes(self) -> Dict[str, Any]: - """ - Get the scenes configured on the network. - """ - network = self._get_network() - if not network.get_scenes(): - return {} - - return network.scenes_to_dict() - - @action - def create_scene(self, label: str): - """ - Create a new scene. - - :param label: Scene label. - """ - self._get_network().create_scene(label) - self.write_config() - - @action - def remove_scene( - self, scene_id: Optional[int] = None, scene_label: Optional[str] = None - ): - """ - Remove a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - """ - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - self._get_network().remove_scene(scene.scene_id) - self.write_config() - - @action - def activate_scene( - self, scene_id: Optional[int] = None, scene_label: Optional[str] = None - ): - """ - Activate a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - """ - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - scene.activate() - - @action - def set_scene_label( - self, - new_label: str, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - ): - """ - Rename a scene/set the scene label. - - :param new_label: New label. - :param scene_id: Select by scene_id. - :param scene_label: Select by current scene label. - """ - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - scene.label = new_label - self.write_config() - - @action - def scene_add_value( - self, - data: Optional[Any] = None, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Add a value to a scene. - - WARNING: This method actually doesn't work, by own admission of the - `OpenZWave developer - `_. - - - :param data: Data to set for the value (default: current value data). - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - :param scene_id: Select scene by scene_id. - :param scene_label: Select scene by scene label. - """ - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node_id, - node_name=node_name, - value_label=value_label, - ) - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - data = data if data is not None else value.data - data = value.check_data(data) - assert data is not None, 'Invalid value passed to the property' - - scene.add_value(value.value_id, data) - self.write_config() - - @action - def scene_remove_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Remove a value from a scene. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - :param scene_id: Select scene by scene_id. - :param scene_label: Select scene by scene label. - """ - value = self._get_value( - value_id=value_id, - id_on_network=id_on_network, - node_id=node_id, - node_name=node_name, - value_label=value_label, - ) - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - scene.remove_value(value.value_id) - self.write_config() - - @action - def get_scene_values( - self, scene_id: Optional[int] = None, scene_label: Optional[str] = None - ) -> dict: - """ - Get the values associated to a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - :return: value_id -> value (as a dict) mapping. - """ - scene = self._get_scene(scene_id=scene_id, scene_label=scene_label) - return {v.value_id: v.to_dict() for v in scene.get_values().items()} - - @action - def create_button( - self, - button_id: Union[int, str], - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Create a handheld button on a device. Only intended for bridge firmware controllers. - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - self._get_controller().create_button(node.node_id, button_id) - self.write_config() - - @action - def delete_button( - self, - button_id: Union[int, str], - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Delete a button association from a device. Only intended for bridge firmware controllers. - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - node = self._get_node(node_id=node_id, node_name=node_name) - self._get_controller().delete_button(node.node_id, button_id) - self.write_config() - - @action - def add_node_to_group( - self, - group_index: Optional[int] = None, - group_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Add a node to a group. - - :param group_index: Select group by group index. - :param group_label: Select group by group label. - :param node_id: Select node by node_id. - :param node_name: Select node by node name. - :return: - """ - node = self._get_node(node_id=node_id, node_name=node_name) - group = self._get_group(group_index=group_index, group_label=group_label) - group.add_association(node.node_id) - self.write_config() - - @action - def remove_node_from_group( - self, - group_index: Optional[int] = None, - group_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Remove a node from a group. - - :param group_index: Select group by group index. - :param group_label: Select group by group label. - :param node_id: Select node by node_id. - :param node_name: Select node by node name. - :return: - """ - node = self._get_node(node_id=node_id, node_name=node_name) - group = self._get_group(group_index=group_index, group_label=group_label) - group.remove_association(node.node_id) - self.write_config() - - @action - def create_new_primary(self): - """ - Create a new primary controller on the network when the previous primary fails. - """ - self._get_controller().create_new_primary() - self.write_config() - - @action - def hard_reset(self): - """ - Perform a hard reset of the controller. It erases its network configuration settings. - The controller becomes a primary controller ready to add devices to a new network. - """ - self._get_controller().hard_reset() - - @action - def soft_reset(self): - """ - Perform a soft reset of the controller. - Resets a controller without erasing its network configuration settings. - """ - self._get_controller().soft_reset() - - @action - def write_config(self): - """ - Store the current configuration of the network to the user directory. - """ - self._get_network().write_config() - - @action - def on(self, device: str, *_, **__): - """ - Turn on a switch on a device. - - :param device: ``id_on_network`` of the value to be switched on. - """ - # noinspection PyUnresolvedReferences - switches = self.get_switches().output - assert ( - device in switches - ), 'No such id_on_network associated to a switch: {}'.format(device) - return self.set_value(data=True, id_on_network=device) - - @action - def off(self, device: str, *_, **__): - """ - Turn off a switch on a device. - - :param device: ``id_on_network`` of the value to be switched off. - """ - # noinspection PyUnresolvedReferences - switches = self.get_switches().output - assert ( - device in switches - ), 'No such id_on_network associated to a switch: {}'.format(device) - return self.set_value(data=False, id_on_network=device) - - @action - def toggle(self, device: str, *_, **__): - """ - Toggle a switch on a device. - - :param device: ``id_on_network`` of the value to be toggled. - """ - # noinspection PyUnresolvedReferences - switches = self.get_switches().output - assert ( - device in switches - ), 'No such id_on_network associated to a switch: {}'.format(device) - data = switches[device]['data'] - return self.set_value(data=not data, id_on_network=device) - - @property - def switches(self) -> List[dict]: - # noinspection PyUnresolvedReferences - devices = self.get_switches().output.values() - return [ - { - 'name': '{} - {}'.format( - dev.get('node_name', '[No Name]'), dev.get('label', '[No Label]') - ), - 'on': dev.get('data'), - 'id': dev.get('id_on_network'), - } - for dev in devices - ] - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zwave/_base.py b/platypush/plugins/zwave/_base.py deleted file mode 100644 index 8f5356f3..00000000 --- a/platypush/plugins/zwave/_base.py +++ /dev/null @@ -1,911 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict, Optional, List, Union - -from platypush.entities import ( - DimmerEntityManager, - EnumSwitchEntityManager, - LightEntityManager, - SensorEntityManager, - SwitchEntityManager, -) -from platypush.plugins import Plugin, action - - -class ZwaveBasePlugin( - DimmerEntityManager, - EnumSwitchEntityManager, - LightEntityManager, - SensorEntityManager, - SwitchEntityManager, - Plugin, - ABC, -): - """ - Base class for Z-Wave plugins. - """ - - @abstractmethod - @action - def start_network(self): - raise NotImplementedError - - @abstractmethod - @action - def stop_network(self): - raise NotImplementedError - - @abstractmethod - @action - def status(self) -> Dict[str, Any]: # pylint: disable=arguments-differ - """ - Get the status of the controller. - """ - raise NotImplementedError - - @abstractmethod - @action - def add_node(self, *args, **kwargs): - """ - Start the inclusion process to add a node to the network. - """ - raise NotImplementedError - - @abstractmethod - @action - def remove_node(self): - """ - Remove a node from the network. - """ - raise NotImplementedError - - @abstractmethod - @action - def remove_failed_node( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Remove a failed node from the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def replace_failed_node( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Replace a failed node on the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def replication_send( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Send node information from the primary to the secondary controller. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def request_network_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Request a network update to a node. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def request_node_neighbour_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Request a neighbours list update to a node. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_nodes( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """ - Get the nodes associated to the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_node_stats( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """ - Get the statistics of a node on the network. - - :param node_id: Filter by node_id. - :param node_name: Filter by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_node_name( - self, - new_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - ): - """ - Rename a node on the network. - - :param new_name: New name for the node. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_node_product_name( - self, - product_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Set the product name of a node. - - :param product_name: Product name. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_node_manufacturer_name( - self, - manufacturer_name: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Set the manufacturer name of a node. - - :param manufacturer_name: Manufacturer name. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_node_location( - self, - location: str, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Set the location of a node. - - :param location: Node location. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def cancel_command(self): - """ - Cancel the current running command. - """ - raise NotImplementedError - - @abstractmethod - @action - def kill_command(self): - """ - Immediately terminate any running command on the controller and release the lock. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_controller_name(self, name: str, **kwargs): - """ - Set the name of the controller on the network. - - :param name: New controller name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_capabilities(self, **kwargs) -> List[str]: - """ - Get the capabilities of the controller. - """ - raise NotImplementedError - - @abstractmethod - @action - def receive_configuration(self, **kwargs): - """ - Receive the configuration from the primary controller on the network. Requires a primary controller active. - """ - raise NotImplementedError - - @abstractmethod - @action - def transfer_primary_role(self, **kwargs): - """ - Add a new controller to the network and make it the primary. - The existing primary will become a secondary controller. - """ - raise NotImplementedError - - @abstractmethod - @action - def heal(self, refresh_routes: bool = False, **kwargs): - """ - Heal network by requesting nodes rediscover their neighbors. - - :param refresh_routes: Whether to perform return routes initialization (default: ``False``). - """ - raise NotImplementedError - - @abstractmethod - @action - def switch_all(self, state: bool, **kwargs): - """ - Switch all the connected devices on/off. - - :param state: True (switch on) or False (switch off). - """ - raise NotImplementedError - - @abstractmethod - @action - def test(self, count: int = 1, **kwargs): - """ - Send a number of test messages to every node and record results. - - :param count: The number of test messages to send. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ) -> Dict[str, Any]: - """ - Get a value on the network. - - :param value_id: Select by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - raise NotImplementedError - - @abstractmethod - @action - def set_value( - self, - data, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Set a value. - - :param data: Data to set for the value. - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - raise NotImplementedError - - @action - def set(self, entity: str, value: Any, **kwargs): - return self.set_value( - value_id=entity, id_on_network=entity, data=value, **kwargs - ) - - @abstractmethod - @action - def set_value_label( - self, - new_label: str, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Change the label/name of a value. - - :param new_label: New value label. - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - """ - raise NotImplementedError - - @abstractmethod - @action - def node_add_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Add a value to a node. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by label. - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def node_remove_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Remove a value from a node. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def node_heal( - self, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - refresh_routes: bool = False, - **kwargs - ): - """ - Heal network node by requesting the node to rediscover their neighbours. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - :param refresh_routes: Whether to perform return routes initialization. (default: ``False``). - """ - raise NotImplementedError - - @abstractmethod - @action - def node_update_neighbours( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Ask a node to update its neighbours table. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def node_network_update( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Update the controller with network information. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def node_refresh_info( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ): - """ - Fetch up-to-date information about the node. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_dimmers( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the dimmers on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_node_config( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the configuration values of a node or of all the nodes on the network. - - :param node_id: Select node by node_id. - :param node_name: Select node by label. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_battery_levels( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the battery levels of a node or of all the nodes on the network. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_power_levels( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the power levels of this node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_bulbs( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the bulbs/LEDs on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_switches( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the switches on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_sensors( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the sensors on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_doorlocks( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the doorlocks on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_usercodes( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the usercodes on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_thermostats( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the thermostats on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_protections( - self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Dict[int, Any]: - """ - Get the protection-compatible devices on the network or associated to a node. - - :param node_id: Select node by node_id. - :param node_name: Select node by name. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_groups(self, **kwargs) -> Dict[int, Any]: - """ - Get the groups on the network. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_scenes(self, **kwargs) -> Dict[str, Any]: - """ - Get the scenes configured on the network. - """ - raise NotImplementedError - - @abstractmethod - @action - def create_scene(self, label: str, **kwargs): - """ - Create a new scene. - - :param label: Scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def remove_scene( - self, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - **kwargs - ): - """ - Remove a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def activate_scene( - self, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - **kwargs - ): - """ - Activate a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def set_scene_label( - self, - new_label: str, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - **kwargs - ): - """ - Rename a scene/set the scene label. - - :param new_label: New label. - :param scene_id: Select by scene_id. - :param scene_label: Select by current scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def scene_add_value( - self, - data: Optional[Any] = None, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Add a value to a scene. - - :param data: Data to set for the value (default: current value data). - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - :param scene_id: Select scene by scene_id. - :param scene_label: Select scene by scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def scene_remove_value( - self, - value_id: Optional[int] = None, - id_on_network: Optional[str] = None, - value_label: Optional[str] = None, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Remove a value from a scene. - - :param value_id: Select value by value_id. - :param id_on_network: Select value by id_on_network. - :param value_label: Select value by [node_id/node_name, value_label] - :param node_id: Select value by [node_id/node_name, value_label] - :param node_name: Select value by [node_id/node_name, value_label] - :param scene_id: Select scene by scene_id. - :param scene_label: Select scene by scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def get_scene_values( - self, - scene_id: Optional[int] = None, - scene_label: Optional[str] = None, - **kwargs - ) -> dict: - """ - Get the values associated to a scene. - - :param scene_id: Select by scene_id. - :param scene_label: Select by scene label. - """ - raise NotImplementedError - - @abstractmethod - @action - def create_button( - self, - button_id: Union[int, str], - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Create a handheld button on a device. Only intended for bridge firmware controllers. - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def delete_button( - self, - button_id: Union[int, str], - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Delete a button association from a device. Only intended for bridge firmware controllers. - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def add_node_to_group( - self, - group_index: Optional[int] = None, - group_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Add a node to a group. - - :param group_index: Select group by group index. - :param group_label: Select group by group label. - :param node_id: Select node by node_id. - :param node_name: Select node by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def remove_node_from_group( - self, - group_index: Optional[int] = None, - group_label: Optional[str] = None, - node_id: Optional[int] = None, - node_name: Optional[str] = None, - **kwargs - ): - """ - Remove a node from a group. - - :param group_index: Select group by group index. - :param group_label: Select group by group label. - :param node_id: Select node by node_id. - :param node_name: Select node by node name. - """ - raise NotImplementedError - - @abstractmethod - @action - def create_new_primary(self, **kwargs): - """ - Create a new primary controller on the network when the previous primary fails. - """ - raise NotImplementedError - - @abstractmethod - @action - def hard_reset(self, **kwargs): - """ - Perform a hard reset of the controller. It erases its network configuration settings. - The controller becomes a primary controller ready to add devices to a new network. - """ - raise NotImplementedError - - @abstractmethod - @action - def soft_reset(self, **kwargs): - """ - Perform a soft reset of the controller. - Resets a controller without erasing its network configuration settings. - """ - raise NotImplementedError - - @abstractmethod - @action - def write_config(self, **kwargs): - """ - Store the current configuration of the network to the user directory. - """ - raise NotImplementedError - - @abstractmethod - @action - def on(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ - """ - Turn on a switch on a device. - - :param device: ``id_on_network`` of the value to be switched on. - """ - raise NotImplementedError - - @abstractmethod - @action - def off(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ - """ - Turn off a switch on a device. - - :param device: ``id_on_network`` of the value to be switched off. - """ - raise NotImplementedError - - @abstractmethod - @action - def toggle(self, device: str, *args, **kwargs): # pylint: disable=arguments-differ - """ - Toggle a switch on a device. - - :param device: ``id_on_network`` of the value to be toggled. - """ - raise NotImplementedError - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zwave/manifest.yaml b/platypush/plugins/zwave/manifest.yaml deleted file mode 100644 index 101c95c4..00000000 --- a/platypush/plugins/zwave/manifest.yaml +++ /dev/null @@ -1,7 +0,0 @@ -manifest: - events: {} - install: - pip: - - python-openzwave - package: platypush.plugins.zwave - type: plugin diff --git a/platypush/plugins/zwave/mqtt/__init__.py b/platypush/plugins/zwave/mqtt/__init__.py index 58999b73..77ad1744 100644 --- a/platypush/plugins/zwave/mqtt/__init__.py +++ b/platypush/plugins/zwave/mqtt/__init__.py @@ -1,3 +1,4 @@ +import contextlib import json import queue import re @@ -20,7 +21,16 @@ from typing import ( ) from urllib.parse import parse_qs, urlparse -from platypush.entities import Entity +from typing_extensions import override + +from platypush.entities import ( + DimmerEntityManager, + EnumSwitchEntityManager, + Entity, + LightEntityManager, + SensorEntityManager, + SwitchEntityManager, +) from platypush.entities.batteries import Battery from platypush.entities.devices import Device from platypush.entities.dimmers import Dimmer @@ -35,24 +45,43 @@ from platypush.entities.lights import Light from platypush.entities.sensors import BinarySensor, EnumSensor, NumericSensor from platypush.entities.switches import EnumSwitch, Switch from platypush.entities.temperature import TemperatureSensor -from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent +from platypush.message.event.zwave import ( + ZwaveEvent, + ZwaveNodeAddedEvent, + ZwaveNodeAsleepEvent, + ZwaveNodeAwakeEvent, + ZwaveNodeEvent, + ZwaveNodeReadyEvent, + ZwaveNodeRemovedEvent, + ZwaveNodeRenamedEvent, + ZwaveValueChangedEvent, + ZwaveValueRemovedEvent, +) from platypush.context import get_bus from platypush.message.response import Response from platypush.plugins import RunnablePlugin from platypush.plugins.mqtt import MqttPlugin, action -from platypush.plugins.zwave._base import ZwaveBasePlugin from platypush.plugins.zwave._constants import command_class_by_name -_NOT_IMPLEMENTED_ERR = NotImplementedError('Not implemented by zwave.mqtt') +from ._state import IdType, State -class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): +# pylint: disable=too-many-ancestors +class ZwaveMqttPlugin( + MqttPlugin, + RunnablePlugin, + DimmerEntityManager, + EnumSwitchEntityManager, + LightEntityManager, + SensorEntityManager, + SwitchEntityManager, +): """ This plugin allows you to manage a Z-Wave network over MQTT through `zwave-js-ui `_. - For historical reasons, it is advised to enabled this plugin together + For historical reasons, it is advised to enable this plugin together with the ``zwave.mqtt`` backend, or you may lose the ability to listen to asynchronous events. @@ -80,15 +109,14 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): Triggers: - * :class:`platypush.message.event.zwave.ZwaveNodeEvent` when a node attribute changes. - * :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` when a node is added to the network. - * :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` when a node is removed from the network. - * :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` when a node is renamed. - * :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` when a node is ready. - * :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` when the value of a node on the network - changes. - * :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` when a node goes into sleep mode. - * :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` when a node goes back into awake mode. + * :class:`platypush.message.event.zwave.ZwaveNodeEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeAddedEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeRemovedEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeRenamedEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeReadyEvent` + * :class:`platypush.message.event.zwave.ZwaveValueChangedEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeAsleepEvent` + * :class:`platypush.message.event.zwave.ZwaveNodeAwakeEvent` """ @@ -121,7 +149,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): def __init__( self, name: str, - host: str = 'localhost', + host: str, port: int = 1883, topic_prefix: str = 'zwave', timeout: int = 10, @@ -134,75 +162,73 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): **kwargs, ): """ - :param name: Gateway name, as configured from the zwavejs2mqtt web panel from Mqtt -> Name. - :param host: MQTT broker host, as configured from the zwavejs2mqtt web panel from Mqtt -> Host - (default: ``localhost``). - :param port: MQTT broker listen port, as configured from the zwavejs2mqtt web panel from Mqtt -> Port - (default: 1883). - :param topic_prefix: MQTT topic prefix, as specified from the zwavejs2mqtt web panel from Mqtt -> Prefix - (default: ``zwave``). - :param timeout: If the command expects from a response, then this timeout value will be used - (default: 60 seconds). - :param tls_cafile: If the connection requires TLS/SSL, specify the certificate authority file - (default: None) - :param tls_certfile: If the connection requires TLS/SSL, specify the certificate file (default: None) - :param tls_keyfile: If the connection requires TLS/SSL, specify the key file (default: None) - :param tls_version: If the connection requires TLS/SSL, specify the minimum TLS supported version - (default: None) - :param tls_ciphers: If the connection requires TLS/SSL, specify the supported ciphers (default: None) - :param username: If the connection requires user authentication, specify the username (default: None) - :param password: If the connection requires user authentication, specify the password (default: None) + :param name: Gateway name, as configured from the zwavejs2mqtt web + panel from Mqtt -> Name. + :param host: MQTT broker host, as configured from the zwavejs2mqtt web + panel from Mqtt -> Host + :param port: MQTT broker listen port, as configured from the + zwavejs2mqtt web panel from Mqtt -> Port (default: 1883). + :param topic_prefix: MQTT topic prefix, as specified from the + zwavejs2mqtt web panel from Mqtt -> Prefix (default: ``zwave``). + :param timeout: If the command expects from a response, then this + timeout value will be used (default: 10 seconds). + :param tls_cafile: If the connection requires TLS/SSL, specify the + certificate authority file (default: None) + :param tls_certfile: If the connection requires TLS/SSL, specify the + certificate file (default: None) + :param tls_keyfile: If the connection requires TLS/SSL, specify the key + file (default: None) + :param tls_version: If the connection requires TLS/SSL, specify the + minimum TLS supported version (default: None) + :param tls_ciphers: If the connection requires TLS/SSL, specify the + supported ciphers (default: None) + :param username: If the connection requires user authentication, + specify the username (default: None) + :param password: If the connection requires user authentication, + specify the password (default: None) """ + self.topic_prefix = topic_prefix + self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name + self.events_topic = self.base_topic.format('_EVENTS') super().__init__( host=host, port=port, + topics=[ + self.events_topic + '/node/' + topic + for topic in [ + 'node_ready', + 'node_sleep', + 'node_value_updated', + 'node_metadata_updated', + 'node_wakeup', + ] + ], tls_certfile=tls_certfile, tls_keyfile=tls_keyfile, tls_version=tls_version, tls_ciphers=tls_ciphers, username=username, password=password, + timeout=timeout, **kwargs, ) - self.topic_prefix = topic_prefix - self.base_topic = topic_prefix + '/{}/ZWAVE_GATEWAY-' + name - self.events_topic = self.base_topic.format('_EVENTS') - self.timeout = timeout - self._info: Mapping[str, dict] = { - 'devices': {}, - 'groups': {}, - } - - self._nodes_cache: Dict[str, dict] = { - 'by_id': {}, - 'by_name': {}, - } - - self._values_cache: Dict[str, dict] = { - 'by_id': {}, - 'by_label': {}, - } - - self._scenes_cache: Dict[str, dict] = { - 'by_id': {}, - 'by_label': {}, - } - - self._groups_cache: Dict[str, dict] = {} + self._state = State() def _api_topic(self, api: str) -> str: return self.base_topic.format('_CLIENTS') + f'/api/{api}' @staticmethod def _parse_response(response: Union[dict, Response]) -> dict: + if isinstance(response, Response): + assert not response.is_error(), response.errors[0] + rs: dict = ( response.output if isinstance(response, Response) else response - ) # type: ignore[reportGeneralTypeIssues] + ) # type: ignore assert rs.get('success') is True, rs.get('message', 'zwavejs2mqtt error') - return rs def _api_request(self, api: str, *args: Any, **kwargs): @@ -234,25 +260,16 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): scene_label: Optional[str] = None, **kwargs, ) -> dict: - assert scene_id or scene_label, 'No scene_id/scene_label specified' - if scene_id in self._scenes_cache['by_id']: - return self._scenes_cache['by_id'][scene_id] - if scene_label in self._scenes_cache['by_label']: - return self._scenes_cache['by_label'][scene_label] + assert not ( + scene_id is None and scene_label is None + ), 'No scene_id/scene_label specified' - # noinspection PyUnresolvedReferences - scenes = self.get_scenes(**kwargs).output # type: ignore[reportGeneralTypeIssues] - scene = None - - if scene_id in scenes: - scene = scenes[scene_id] - self._scenes_cache['by_id'][scene_id] = scene - else: - scenes = [s for s in scenes if s['label'] == scene_label] - if scenes: - scene = scenes[0] - self._scenes_cache['by_label'][scene_label] = scene + scene = self._state.scenes.get((scene_id, scene_label)) + if scene: + return scene + self._refresh_scenes(**kwargs) + scene = self._state.scenes.get((scene_id, scene_label)) assert scene, f'No such scene: scene_id={scene_id}, scene_label={scene_label}' return scene @@ -367,6 +384,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): device_id = node.get('hexId') if not device_id: if db_link: + # noinspection PyTypeChecker device_id = ':'.join( parse_qs(urlparse(db_link).query) .get('jumpTo', '')[0] @@ -437,44 +455,96 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): }, } + def _refresh_groups(self, **kwargs): + nodes = self._get_nodes(**kwargs) + groups = { + group['group_id']: { + **group, + 'associations': [ + assoc['nodeId'] + for assoc in ( + self._api_request('getAssociations', node_id, group['index']) + or [] + ) + ], + } + for node_id, node in nodes.items() + for group in node.get('groups', {}).values() + } + + self._state.groups.add(*groups.values(), overwrite=True) + + def _get_groups(self, **kwargs) -> Dict[IdType, dict]: + self._refresh_groups(**kwargs) + return self._state.groups.by_id + + def _refresh_scenes(self, **kwargs): + scenes = { + scene.get('sceneid'): self.scene_to_dict(scene) + for scene in (self._api_request('_getScenes', **kwargs) or []) + } + + self._state.scenes.add(*scenes.values(), overwrite=True) + + def _get_scenes(self, **kwargs) -> Dict[IdType, dict]: + self._refresh_scenes(**kwargs) + return self._state.scenes.by_id + + def _refresh_nodes(self, **kwargs): + nodes = { + node['id']: {'id': node['id'], **self.node_to_dict(node)} + for node in (self._api_request('getNodes', **kwargs) or []) + } + + values = [ + value + for node in nodes.values() + for value in node.get('values', {}).values() + ] + + # Process events for the newly received nodes + for node_id, node in nodes.items(): + if node_id not in self._state.nodes: + self._dispatch_event(ZwaveNodeAddedEvent, node=node, fetch_node=False) + elif node['name'] != self._state.nodes.get('name'): + self._dispatch_event(ZwaveNodeRenamedEvent, node=node, fetch_node=False) + + # Check if any previous node is now missing + for node_id, node in self._state.nodes.by_id.items(): + if node_id not in nodes: + self._dispatch_event(ZwaveNodeRemovedEvent, node=node, fetch_node=False) + + self._state.nodes.add(*nodes.values(), overwrite=True) + self._state.values.add(*values, overwrite=True) + self.publish_entities([self._to_current_value(v) for v in values]) + + def _get_nodes(self, **kwargs) -> Dict[IdType, dict]: + self._refresh_nodes(**kwargs) + return self._state.nodes.by_id + def _get_node( self, node_id: Optional[int] = None, node_name: Optional[str] = None, use_cache: bool = True, + make_request: bool = True, **kwargs, - ) -> Optional[dict]: + ) -> dict: assert node_id or node_name, 'Please provide either a node_id or node_name' if use_cache: - if node_id and node_id in self._nodes_cache['by_id']: - return self._nodes_cache['by_id'][node_id] - if node_name and node_name in self._nodes_cache['by_name']: - return self._nodes_cache['by_name'][node_name] + node = self._state.nodes.get((node_id, node_name)) + if node: + return node - response = { - node['id']: self.node_to_dict(node) - for node in (self._api_request('getNodes', **kwargs) or []) - } - - node = None - if node_id: - node = response.get(node_id) - else: - ret = [node for node in response.values() if node['name'] == node_name] - if ret: - node = ret[0] - - if node: - self._nodes_cache['by_id'][node['node_id']] = node - if node['name']: - self._nodes_cache['by_name'][node['name']] = node - - for value in node.get('values', {}).values(): - self._values_cache['by_id'][value['id']] = value - if value['label']: - self._values_cache['by_label'][value['label']] = value - - return node + assert make_request, f'No such node: {node_id or node_name}' + self._refresh_nodes(**kwargs) + return self._get_node( + node_id=node_id, + node_name=node_name, + use_cache=True, + make_request=False, + **kwargs, + ) def _get_value( self, @@ -484,7 +554,8 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): node_id: Optional[int] = None, node_name: Optional[str] = None, use_cache: bool = True, - **_, + make_request: bool = True, + **kwargs, ) -> Dict[str, Any]: # Unlike python-openzwave, value_id and id_on_network are the same on zwavejs2mqtt value_id = value_id or id_on_network @@ -493,44 +564,29 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): ), 'Please provide either value_id, id_on_network or value_label' if use_cache: - if value_id and value_id in self._values_cache['by_id']: - return self._values_cache['by_id'][value_id] - if value_label and value_label in self._values_cache['by_label']: - return self._values_cache['by_label'][value_label] + value = self._state.values.get((value_id, value_label)) + if value: + if node_id or node_name: + node = self._state.nodes.get((node_id, node_name)) + assert node, f'No such node: {node_id or node_name}' + assert ( + value['node_id'] == node['id'] + ), f'No such value on node {node_id or node_name}: {value_id or value_label}' - nodes = [] - if node_id or node_name: - nodes = [ - self._get_node(node_id=node_id, node_name=node_name, use_cache=False) - ] + return value - if not nodes: - nodes = self.get_nodes().output # type: ignore[reportGeneralTypeIssues] - assert nodes, 'No nodes found on the network' - nodes = nodes.values() # type: ignore - - if value_id: - values = [ - node['values'][value_id] - for node in nodes - if node and value_id in node.get('values', {}) - ] - else: - values = [ - value - for node in nodes - for value in (node or {}).get('values', {}).values() - if node and value.get('label') == value_label - ] - - assert values, f'No such value: {value_id or value_label}' - value = values[0] - self._values_cache['by_id'][value['id']] = value - if value['label']: - self._values_cache['by_label'][value['label']] = value - - self.publish_entities([self._to_current_value(value)]) - return value + assert make_request, f'No such value: {value_id or value_label}' + self._refresh_nodes(**kwargs) + return self._get_value( + value_id=value_id, + id_on_network=id_on_network, + value_label=value_label, + node_id=node_id, + node_name=node_name, + use_cache=True, + make_request=False, + **kwargs, + ) @staticmethod def _is_target_value(value: Mapping): @@ -563,14 +619,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): + [value['value_id'].split('-')[-1].replace(*replace_args)] ) - associated_value = self._values_cache['by_id'].get( - associated_value_id, - self._nodes_cache['by_id'] - .get(value['node_id'], {}) - .get('values', {}) - .get(associated_value_id), - ) - + associated_value = self._state.values.get(associated_value_id) if associated_value: value = associated_value @@ -763,7 +812,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): if parent_id is None: continue - node = self._nodes_cache['by_id'].get(parent_id) + node = self._state.nodes.get(parent_id) if not node: continue @@ -808,7 +857,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): if not value_id: continue - associated_value_id = None associated_value = None associated_property_id = None current_property_id = None @@ -856,7 +904,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): def _filter_values( self, - command_classes: Optional[Iterable[str]] = None, # type: ignore[reportGeneralTypeIssues] + command_classes: Optional[Iterable[str]] = None, filter_callback: Optional[Callable[[dict], bool]] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, @@ -865,7 +913,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): nodes = ( [self._get_node(node_name=node_name, use_cache=False, **kwargs)] if node_id or node_name - else self.get_nodes(**kwargs).output.values() # type: ignore[reportGeneralTypeIssues] + else self._get_nodes(**kwargs).values() ) classes: set = { @@ -898,32 +946,127 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): group_index: Optional[int] = None, **kwargs, ) -> dict: + assert not ( + group_id is None and group_index is None + ), 'No group_id/group_index specified' group_id = group_id or str(group_index) - assert group_id is not None, 'No group_id/group_index specified' - group = self._groups_cache.get(group_id) + group = self._state.groups.get(group_id) if group: return group - groups = self.get_groups(**kwargs).output # type: ignore[reportGeneralTypeIssues] + groups = self._get_groups(**kwargs) assert group_id in groups, f'No such group_id: {group_id}' return groups[group_id] - @action - def start_network(self, **_): - """ - Start the network (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR + @override + def on_mqtt_message(self): + def handler(_, __, msg): + if not msg.topic.startswith(self.events_topic): + return + + topic = ( + msg.topic[(len(self.events_topic) + 1) :].split('/').pop() # noqa: E203 + ) + + data = msg.payload.decode() + if not data: + return + + with contextlib.suppress(ValueError, TypeError): + data = json.loads(data)['data'] + + try: + if topic == 'node_value_updated': + self._dispatch_event( + ZwaveValueChangedEvent, node=data[0], value=data[1] + ) + elif topic == 'node_metadata_updated': + self._dispatch_event(ZwaveNodeEvent, node=data[0]) + elif topic == 'node_sleep': + self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0]) + elif topic == 'node_wakeup': + self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0]) + elif topic == 'node_ready': + self._dispatch_event(ZwaveNodeReadyEvent, node=data[0]) + elif topic == 'node_removed': + self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0]) + except Exception as e: + self.logger.exception(e) + + return handler + + def _dispatch_event( + self, + event_type: Type[ZwaveEvent], + node: dict, + value: Optional[dict] = None, + fetch_node: bool = True, + **kwargs, + ): + node_id = node.get('id') + assert node_id is not None, 'No node ID specified' + + if fetch_node: + node = kwargs['node'] = self._get_node(node_id) + else: + kwargs['node'] = node + + node_values = node.get('values', {}) + + if node and value: + # Infer the value_id structure if it's not provided on the event + value_id = value.get('id') + if value_id is None: + value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}" + if 'propertyKey' in value: + value_id += '-' + str(value['propertyKey']) + + # Prepend the node_id to value_id if it's not available in node['values'] + # (compatibility with more recent versions of ZwaveJS that don't provide + # the value_id on the events) + if value_id not in node_values: + value_id = f"{node_id}-{value_id}" + + if value_id not in node_values: + self.logger.warning( + 'value_id %s not found on node %s', value_id, node_id + ) + return + + if 'newValue' in value: + node_values[value_id]['data'] = value['newValue'] + + value = kwargs['value'] = node_values[value_id] + + if issubclass(event_type, ZwaveNodeEvent): + # If this node_id wasn't cached before, then it's a new node + if node_id not in self._state.nodes: + event_type = ZwaveNodeAddedEvent + # If the name has changed, we have a rename event + elif node['name'] != (self._state.nodes.get(node_id) or {}).get('name'): + event_type = ZwaveNodeRenamedEvent + + if event_type == ZwaveNodeRemovedEvent: + # If the node has been removed, remove it from the cache + self._state.nodes.pop(node_id, None) + else: + # Otherwise, update the cached instance + self._state.nodes.add(node) + values = node.get('values', {}).values() + self._state.values.add(*values) + + evt = event_type(**kwargs) + get_bus().post(evt) + + if ( + value + and issubclass(event_type, ZwaveValueChangedEvent) + and event_type != ZwaveValueRemovedEvent + ): + self.publish_entities([value]) @action - def stop_network(self, **_): - """ - Stop the network (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def status(self, **kwargs) -> Dict[str, Any]: + def status(self, *_, **kwargs) -> Dict[str, Any]: """ Get the current status of the Z-Wave values. @@ -950,12 +1093,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): msg_queue.put(json.loads(msg.payload)) client.on_message = on_message - client.connect( - kwargs.get('host', self.host), - kwargs.get('port', self.port), - keepalive=kwargs.get('timeout', self.timeout), - ) - + client.connect() client.subscribe(topic) client.loop_start() @@ -1032,7 +1170,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node_id = (self._get_node(node_name=node_name) or {}).get('node_id') + node_id = self._get_node(node_name=node_name).get('node_id') assert node_id is not None, f'No such node_id: {node_id}' self._api_request('removeFailedNode', node_id, **kwargs) @@ -1050,18 +1188,11 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node_id = (self._get_node(node_name=node_name) or {}).get('node_id') + node_id = self._get_node(node_name=node_name).get('node_id') assert node_id is not None, f'No such node_id: {node_id}' self._api_request('replaceFailedNode', node_id, **kwargs) - @action - def replication_send(self, **_): # pylint: disable=arguments-differ - """ - Send node information from the primary to the secondary controller (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - @action def request_network_update( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs @@ -1075,7 +1206,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node_id = (self._get_node(node_name=node_name) or {}).get('node_id') + node_id = self._get_node(node_name=node_name).get('node_id') assert node_id is not None, f'No such node_id: {node_id}' self._api_request('refreshInfo', node_id, **kwargs) @@ -1093,7 +1224,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): @action def get_nodes( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs - ) -> Optional[Dict[str, Any]]: + ) -> Optional[Dict[IdType, dict]]: """ Get the nodes associated to the network. @@ -1167,7 +1298,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): "zwave_plus" ], "manufacturer_id": "0x010f", - "manufacturer_name": "Fibargroup", + "manufacturer_name": "FibaroGroup", "location": "Living Room", "status": "Alive", "is_available": true, @@ -1261,22 +1392,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): node_id=node_id, node_name=node_name, use_cache=False, **kwargs ) - nodes = { - node['id']: self.node_to_dict(node) - for node in (self._api_request('getNodes', **kwargs) or []) - } - - self._nodes_cache['by_id'] = nodes - self._nodes_cache['by_name'] = {node['name']: node for node in nodes.values()} - - return nodes - - @action - def get_node_stats(self, *_, **__): - """ - Get the statistics of a node on the network (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR + return self._get_nodes(**kwargs) @action def set_node_name( @@ -1296,35 +1412,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node_id = (self._get_node(node_name=node_name, **kwargs) or {}).get( - 'node_id' - ) + node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') assert node_id, f'No such node: {node_id}' self._api_request('setNodeName', node_id, new_name, **kwargs) get_bus().post( ZwaveNodeRenamedEvent( node={ - **(self._get_node(node_id=node_id) or {}), + **self._get_node(node_id=node_id, **kwargs), 'name': new_name, } ) ) - @action - def set_node_product_name(self, **_): # pylint: disable=arguments-differ - """ - Set the product name of a node (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def set_node_manufacturer_name(self, **_): # pylint: disable=arguments-differ - """ - Set the manufacturer name of a node (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - @action def set_node_location( self, @@ -1343,68 +1443,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node = self._get_node(node_name=node_name, **kwargs) - assert node, f'No such node: {node_name}' - node_id = node.get('node_id') + node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') assert node_id, f'No such node: {node_id}' self._api_request('setNodeLocation', node_id, location, **kwargs) get_bus().post( ZwaveNodeEvent( node={ - **(self._get_node(node_id=node_id) or {}), + **self._get_node(node_id=node_id, **kwargs), 'location': location, } ) ) - @action - def cancel_command(self, **_): - """ - Cancel the current running command (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def kill_command(self, **_): - """ - Immediately terminate any running command on the controller and release the lock (not implemented by - zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def set_controller_name(self, **_): # pylint: disable=arguments-differ - """ - Set the name of the controller on the network (not implemented: use - :meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.set_node_name` instead). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def get_capabilities(self, **_) -> List[str]: - """ - Get the capabilities of the controller (not implemented: use - :meth:`platypush.plugin.zwave.mqtt.ZwaveMqttPlugin.get_nodes` instead). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def receive_configuration(self, **_): - """ - Receive the configuration from the primary controller on the network. Requires a primary controller active - (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def transfer_primary_role(self, **_): - """ - Add a new controller to the network and make it the primary. - The existing primary will become a secondary controller (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - @action def heal( self, *_, timeout: Optional[int] = 60, **kwargs @@ -1422,20 +1473,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): timeout, lambda: self._api_request('stopHealingNetwork', **kwargs) ).start() - @action - def switch_all(self, **_): # pylint: disable=arguments-differ - """ - Switch all the connected devices on/off (not implemented). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def test(self, *_, **__): - """ - Send a number of test messages to every node and record results (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - @action def get_value( self, @@ -1534,27 +1571,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): for light in lights: self.set_value(light, kwargs) - @action - def set_value_label(self, **_): # pylint: disable=arguments-differ - """ - Change the label/name of a value (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def node_add_value(self, **_): # pylint: disable=arguments-differ - """ - Add a value to a node (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def node_remove_value(self, **_): # pylint: disable=arguments-differ - """ - Remove a value from a node (not implemented by zwavejs2mqtt). - """ - raise _NOT_IMPLEMENTED_ERR - @action def node_heal( # pylint: disable=arguments-differ self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs @@ -1568,9 +1584,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node = self._get_node(node_name=node_name, **kwargs) - assert node, f'No such node: {node_name}' - node_id = node.get('node_id') + node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') self._api_request('healNode', node_id, **kwargs) @action @@ -1616,9 +1630,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): (default: query the default configured device). """ if node_name: - node = self._get_node(node_name=node_name, **kwargs) - assert node, f'No such node: {node_name}' - node_id = node.get('node_id') + node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') self._api_request('refreshInfo', node_id, **kwargs) @action @@ -1848,7 +1860,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): ) @action - def get_groups(self, **kwargs) -> Dict[str, dict]: + def get_groups(self, **kwargs) -> Dict[IdType, dict]: """ Get the groups on the network. @@ -1888,26 +1900,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): ] """ - nodes = self.get_nodes(**kwargs).output # type: ignore[reportGeneralTypeIssues] - self._groups_cache = { - group['group_id']: { - **group, - 'associations': [ - assoc['nodeId'] - for assoc in ( - self._api_request('getAssociations', node_id, group['index']) - or [] - ) - ], - } - for node_id, node in nodes.items() - for group in node.get('groups', {}).values() - } - - return self._groups_cache + return self._get_groups(**kwargs) @action - def get_scenes(self, **_) -> Dict[int, Dict[str, Any]]: + def get_scenes(self, **kwargs) -> Dict[IdType, dict]: """ Get the scenes configured on the network. @@ -1937,13 +1933,10 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): } """ - return { - scene.get('sceneid'): self.scene_to_dict(scene) - for scene in (self._api_request('_getScenes') or []) - } + return self._get_scenes(**kwargs) @action - def create_scene(self, label: str, **_): + def create_scene(self, label: str, **kwargs): """ Create a new scene. @@ -1951,7 +1944,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ - self._api_request('_createScene', label) + self._api_request('_createScene', label, **kwargs) @action def remove_scene( @@ -1989,19 +1982,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_activateScene', scene['scene_id']) - @action - def set_scene_label(self, *_, **__): - """ - Rename a scene/set the scene label. - - :param new_label: New label. - :param scene_id: Select by scene_id. - :param scene_label: Select by current scene label. - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - @action def scene_add_value( self, @@ -2106,34 +2086,6 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) return scene.get('values', {}) - @action - def create_button(self, *_, **__): - """ - Create a handheld button on a device. Only intended for bridge firmware controllers - (not implemented by zwavejs2mqtt). - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def delete_button(self, *_, **__): - """ - Delete a button association from a device. Only intended for bridge firmware controllers. - (not implemented by zwavejs2mqtt). - - :param button_id: The ID of the button. - :param node_id: Filter by node_id. - :param node_name: Filter by current node name. - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - @action def add_node_to_group( # pylint: disable=arguments-differ self, @@ -2184,51 +2136,16 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): 'removeAssociations', group['node_id'], group['index'], [assoc] ) - @action - def create_new_primary(self, **_): - """ - Create a new primary controller on the network when the previous primary fails - (not implemented by zwavejs2mqtt). - - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - @action def hard_reset(self, **_): """ Perform a hard reset of the controller. It erases its network configuration settings. The controller becomes a primary controller ready to add devices to a new network. - - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). """ self._api_request('hardReset') @action - def soft_reset(self, **_): - """ - Perform a soft reset of the controller. - Resets a controller without erasing its network configuration settings (not implemented by zwavejs2). - - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def write_config(self, **_): - """ - Store the current configuration of the network to the user directory (not implemented by zwavejs2mqtt). - - :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` - (default: query the default configured device). - """ - raise _NOT_IMPLEMENTED_ERR - - @action - def on(self, device: str, *_, **kwargs): + def on(self, device: str, *_, **kwargs): # pylint: disable=arguments-differ """ Turn on a switch on a device. @@ -2239,7 +2156,7 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): self.set_value(data=True, id_on_network=device, **kwargs) @action - def off(self, device: str, *_, **kwargs): + def off(self, device: str, *_, **kwargs): # pylint: disable=arguments-differ """ Turn off a switch on a device. @@ -2250,7 +2167,9 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): self.set_value(data=False, id_on_network=device, **kwargs) @action - def toggle(self, device: str, *_, **kwargs) -> dict: + def toggle( # pylint: disable=arguments-differ + self, device: str, *_, **kwargs + ) -> dict: """ Toggle a switch on a device. @@ -2261,26 +2180,19 @@ class ZwaveMqttPlugin(MqttPlugin, RunnablePlugin, ZwaveBasePlugin): value = self._get_value(id_on_network=device, use_cache=False, **kwargs) value['data'] = not value['data'] self.set_value(data=value['data'], id_on_network=device, **kwargs) + node = self._state.nodes.get(value['node_id']) + assert node, f'Node {value["node_id"]} not found' return { - 'name': ( - self._nodes_cache['by_id'][value['node_id']]['name'] - + ' - ' - + value.get('label', '[No Label]') - ), + 'name': (node['name'] + ' - ' + value.get('label', '[No Label]')), 'on': value['data'], 'id': value['value_id'], } + @override def main(self): - from ._listener import ZwaveMqttListener - - listener = ZwaveMqttListener() - listener.start() - self.wait_stop() - - listener.stop() - listener.join() + self.get_nodes() + super().main() # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zwave/mqtt/_listener.py b/platypush/plugins/zwave/mqtt/_listener.py deleted file mode 100644 index d85f76cb..00000000 --- a/platypush/plugins/zwave/mqtt/_listener.py +++ /dev/null @@ -1,197 +0,0 @@ -import contextlib -import json -from queue import Queue, Empty -from typing import Optional, Type - -from platypush.backend.mqtt import MqttBackend -from platypush.context import get_bus, get_plugin - -from platypush.config import Config -from platypush.message.event.zwave import ( - ZwaveEvent, - ZwaveNodeAddedEvent, - ZwaveValueChangedEvent, - ZwaveNodeRemovedEvent, - ZwaveNodeRenamedEvent, - ZwaveNodeReadyEvent, - ZwaveNodeEvent, - ZwaveNodeAsleepEvent, - ZwaveNodeAwakeEvent, - ZwaveValueRemovedEvent, -) - - -class ZwaveMqttListener(MqttBackend): - """ - Internal MQTT listener for ``zwave.mqtt`` events. - """ - - def __init__(self, *args, **kwargs): - self._nodes = {} - self._groups = {} - self._last_state = None - self._events_queue = Queue() - self.events_topic = self.plugin.events_topic - self.server_info = { - 'host': self.plugin.host, - 'port': self.plugin.port or self._default_mqtt_port, - 'tls_cafile': self.plugin.tls_cafile, - 'tls_certfile': self.plugin.tls_certfile, - 'tls_ciphers': self.plugin.tls_ciphers, - 'tls_keyfile': self.plugin.tls_keyfile, - 'tls_version': self.plugin.tls_version, - 'username': self.plugin.username, - 'password': self.plugin.password, - } - - listeners = [ - { - **self.server_info, - 'topics': [ - self.plugin.events_topic + '/node/' + topic - for topic in [ - 'node_ready', - 'node_sleep', - 'node_value_updated', - 'node_metadata_updated', - 'node_wakeup', - ] - ], - } - ] - - super().__init__( - *args, - subscribe_default_topic=False, - listeners=listeners, - **kwargs, - ) - - self.client_id = ( - str(self.client_id or Config.get('device_id')) + '-zwavejs-mqtt' - ) - - @property - def plugin(self): - from platypush.plugins.zwave.mqtt import ZwaveMqttPlugin - - plugin: Optional[ZwaveMqttPlugin] = get_plugin('zwave.mqtt') - assert plugin, 'The zwave.mqtt plugin is not configured' - return plugin - - def _dispatch_event( - self, - event_type: Type[ZwaveEvent], - node: dict, - value: Optional[dict] = None, - **kwargs, - ): - node_id = node.get('id') - assert node_id is not None, 'No node ID specified' - - # This is far from efficient (we are querying the latest version of the whole - # node for every event we receive), but this is the best we can do with recent - # versions of ZWaveJS that only transmit partial representations of the node and - # the value. The alternative would be to come up with a complex logic for merging - # cached and new values, with the risk of breaking back-compatibility with earlier - # implementations of zwavejs2mqtt. - node = kwargs['node'] = self.plugin.get_nodes(node_id).output # type: ignore - node_values = node.get('values', {}) - - if node and value: - # Infer the value_id structure if it's not provided on the event - value_id = value.get('id') - if value_id is None: - value_id = f"{value['commandClass']}-{value.get('endpoint', 0)}-{value['property']}" - if 'propertyKey' in value: - value_id += '-' + str(value['propertyKey']) - - # Prepend the node_id to value_id if it's not available in node['values'] - # (compatibility with more recent versions of ZwaveJS that don't provide - # the value_id on the events) - if value_id not in node_values: - value_id = f"{node_id}-{value_id}" - - if value_id not in node_values: - self.logger.warning( - 'value_id %s not found on node %s', value_id, node_id - ) - return - - value = kwargs['value'] = node_values[value_id] - - if issubclass(event_type, ZwaveNodeEvent): - # If the node has been removed, remove it from the cache - if event_type == ZwaveNodeRemovedEvent: - self._nodes.pop(node_id, None) - # If this node_id wasn't cached before, then it's a new node - elif node_id not in self._nodes: - event_type = ZwaveNodeAddedEvent - # If the name has changed, we have a rename event - elif node['name'] != self._nodes[node_id]['name']: - event_type = ZwaveNodeRenamedEvent - # If nothing relevant has changed, update the cached instance and return - else: - self._nodes[node_id] = node - return - - evt = event_type(**kwargs) - self._events_queue.put(evt) - - if ( - value - and issubclass(event_type, ZwaveValueChangedEvent) - and event_type != ZwaveValueRemovedEvent - ): - self.plugin.publish_entities([kwargs['value']]) # type: ignore - - def on_mqtt_message(self): - def handler(_, __, msg): - if not msg.topic.startswith(self.events_topic): - return - - topic = ( - msg.topic[(len(self.events_topic) + 1) :].split('/').pop() # noqa: E203 - ) - data = msg.payload.decode() - if not data: - return - - with contextlib.suppress(ValueError, TypeError): - data = json.loads(data)['data'] - - try: - if topic == 'node_value_updated': - self._dispatch_event( - ZwaveValueChangedEvent, node=data[0], value=data[1] - ) - elif topic == 'node_metadata_updated': - self._dispatch_event(ZwaveNodeEvent, node=data[0]) - elif topic == 'node_sleep': - self._dispatch_event(ZwaveNodeAsleepEvent, node=data[0]) - elif topic == 'node_wakeup': - self._dispatch_event(ZwaveNodeAwakeEvent, node=data[0]) - elif topic == 'node_ready': - self._dispatch_event(ZwaveNodeReadyEvent, node=data[0]) - elif topic == 'node_removed': - self._dispatch_event(ZwaveNodeRemovedEvent, node=data[0]) - except Exception as e: - self.logger.exception(e) - - return handler - - def run(self): - super().run() - self.logger.debug('Refreshing Z-Wave nodes') - self._nodes = self.plugin.get_nodes().output # type: ignore - - while not self.should_stop(): - try: - evt = self._events_queue.get(block=True, timeout=1) - except Empty: - continue - - get_bus().post(evt) - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/zwave/mqtt/_state.py b/platypush/plugins/zwave/mqtt/_state.py new file mode 100644 index 00000000..7cec2641 --- /dev/null +++ b/platypush/plugins/zwave/mqtt/_state.py @@ -0,0 +1,97 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional, Tuple, Union +from threading import RLock + +IdType = Union[str, int] +_CacheMap = Dict[IdType, dict] + + +@dataclass +class Cache: + """ + Cache class for Z-Wave entities. + """ + + by_id: _CacheMap = field(default_factory=dict) + by_name: _CacheMap = field(default_factory=dict) + _lock: RLock = field(default_factory=RLock) + + def add(self, *objs: dict, overwrite: bool = False) -> None: + """ + Add objects to the cache. + """ + with self._lock: + if overwrite: + self.clear() + + for obj in objs: + obj_id = obj.get("id") + if obj_id: + self.by_id[obj_id] = obj + + name = self._get_name(obj) + if name: + self.by_name[name] = obj + + def get( + self, + obj: Optional[Union[IdType, Tuple[Optional[IdType], Optional[IdType]]]] = None, + default=None, + ) -> Optional[dict]: + """ + Get an object from the cache, by ID or name/label. + """ + if obj is None: + return None + + if isinstance(obj, tuple): + return self.get(obj[0], self.get(obj[1], default)) + + return self.by_id.get(obj, self.by_name.get(obj, default)) + + def __contains__(self, obj: Optional[IdType]) -> bool: + """ + Check if an object with the given ID/name is in the cache. + """ + return obj in self.by_id or obj in self.by_name + + def clear(self) -> None: + """ + Clear the cache. + """ + with self._lock: + self.by_id.clear() + self.by_name.clear() + + def pop(self, obj_id: IdType, default=None) -> Optional[dict]: + """ + Remove and return an object from the cache. + """ + with self._lock: + obj = self.by_id.pop(obj_id, default) + if not obj: + return obj + + name = self._get_name(obj) + if name: + self.by_name.pop(name, None) + return obj + + @staticmethod + def _get_name(obj: dict) -> Optional[str]: + """ + @return The name/label of an object. + """ + return obj.get("name", obj.get("label")) + + +@dataclass +class State: + """ + State of the Z-Wave network. + """ + + nodes: Cache = field(default_factory=Cache) + values: Cache = field(default_factory=Cache) + scenes: Cache = field(default_factory=Cache) + groups: Cache = field(default_factory=Cache)