import contextlib import json import queue import re from collections import OrderedDict from datetime import datetime from threading import Timer from typing import ( Any, Callable, Collection, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union, ) from urllib.parse import parse_qs, urlparse 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 from platypush.entities.electricity import ( EnergySensor, PowerSensor, VoltageSensor, ) from platypush.entities.humidity import HumiditySensor from platypush.entities.illuminance import IlluminanceSensor 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 ( 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._constants import command_class_by_name from ._state import IdType, State # 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 `_. Configuration required on the zwave-js-ui gateway: * Install the gateway following the instructions reported `here `_. * Configure the following settings from the zwave-js-ui web panel: * Zwave -> Serial Port: The path to your Z-Wave adapter. * Disabled MQTT Gateway: Set to false. * Configure the MQTT server that should be used by the service. * Mqtt -> Name: Unique name that identifies your gateway. * Mqtt -> Prefix: Prefix name for the topics published to MQTT. * Gateway -> Topic type: Set to "ValueID topics". * Gateway -> Payload type: Set to "JSON Time-Value". * Gateway -> Use nodes name instead of numeric nodeIDs: Set to false. * Gateway -> Send Zwave Events: Set to true. * Gateway -> Include Node Info: Set to true. """ # These classes are ignored by the entity parsing logic _ignored_entity_classes = { 'application_status', 'association_command_configuration', 'controller_replication', 'crc16_encap', 'firmware_update_md', 'grouping_name', 'ip_configuration', 'manufacturer_proprietary', 'manufacturer_specific', 'multi_cmd', 'multi_instance', 'multi_instance_association', 'no_operation', 'node_naming', 'non_interoperable', 'proprietary', 'scene_actuator_conf', 'scene_controller_conf', 'sensor_configuration', 'version', 'wake_up', 'zwave_plus_info', } def __init__( self, name: str, host: str, port: int = 1883, topic_prefix: str = 'zwave', timeout: int = 10, tls_certfile: Optional[str] = None, tls_keyfile: Optional[str] = None, tls_version: Optional[str] = None, tls_ciphers: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, **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 :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._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 assert rs.get('success') is True, rs.get('message', 'zwavejs2mqtt error') return rs def _api_request(self, api: str, *args: Any, **kwargs): if len(args) == 1 and isinstance(args[0], dict): args = args[0] # type: ignore payload = json.dumps({'args': args}) ret = self._parse_response( self.publish( topic=self._api_topic(api) + '/set', msg=payload, reply_topic=self._api_topic(api), **self._mqtt_args(**kwargs), ) ) assert not ret or ret.get('success') is True, ret.get('message') return ret.get('result') @staticmethod def _convert_timestamp(t: Optional[int]) -> Optional[datetime]: if t: return datetime.fromtimestamp(t / 1000) return None def _get_scene( self, scene_id: Optional[int] = None, scene_label: Optional[str] = None, **kwargs, ) -> dict: assert not ( scene_id is None and scene_label is None ), 'No scene_id/scene_label specified' 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 @classmethod def group_to_dict(cls, group: dict, node_id: Optional[int] = None) -> dict: return { 'label': group['text'], 'index': group['value'], 'node_id': node_id, 'group_id': f'{node_id}:{group["value"]}' if node_id is not None else None, 'multichannel': group['multiChannel'], 'max_associations': group['maxNodes'], } @classmethod def _get_type(cls, value: dict) -> str: if value.get('list'): if len(value.get('states', [])) > 1: return 'List' if value.get('min') is not None and value.get('max') is not None: return 'Decimal' if value['type'] == 'boolean': return 'Bool' if value['type'] == 'number': return 'Decimal' if value['type'] == 'string': return 'String' return value['type'].capitalize() @classmethod def value_to_dict(cls, value: dict) -> dict: is_read_only = ( value['is_read_only'] if 'is_read_only' in value else value.get('readable') and not value.get('writeable') ) is_write_only = ( value['is_write_only'] if 'is_write_only' in value else not value.get('readable') and value.get('writeable') ) return { 'id': value['id'], 'id_on_network': value['id'], 'value_id': value['id'], 'data': value.get('value'), 'data_items': value['states'] if len(value.get('states', [])) > 1 else None, 'label': value.get( 'label', value.get('propertyName', value.get('property')) ), 'property_id': value.get('property'), 'help': value.get('description', value.get('help')), 'node_id': value.get('nodeId'), 'parent_id': value.get('nodeId'), 'type': cls._get_type(value), 'default': value.get('default'), 'endpoint': value.get('endpoint'), 'stateless': value.get('stateless'), 'command_class': value.get('commandClass'), 'command_class_name': value.get('commandClassName'), 'units': value.get('unit'), 'min': value.get('min'), 'max': value.get('max'), 'is_read_only': is_read_only, 'is_write_only': is_write_only, 'last_update': cls._convert_timestamp(value.get('lastUpdate')), **( {'property_key': value['propertyKey']} if 'propertyKey' in value else {} ), **( {'property_key_name': value['propertyKeyName']} if 'propertyKeyName' in value else {} ), } @staticmethod def _get_value_id(value: dict) -> str: return f'{value["nodeId"]}-{value["commandClass"]}-{value.get("endpoint", 0)}-{value["property"]}' @classmethod def scene_to_dict(cls, scene: dict) -> dict: return { 'scene_id': scene.pop('sceneid', None), 'values': { cls._get_value_id(value): { 'node_id': value['nodeId'], 'value_id': cls._get_value_id(value), 'id_on_network': cls._get_value_id(value), 'data': value.get('value'), } for value in scene.pop('values', []) }, **scene, } @classmethod def node_to_dict(cls, node: dict) -> dict: capabilities = [] if node.get('supportsBeaming'): capabilities += ['beaming'] if node.get('supportsSecurity'): capabilities += ['security'] if node.get('isRouting'): capabilities += ['routing'] if node.get('zwavePlusVersion'): capabilities += ['zwave_plus'] db_link = node.get('dbLink', node.get('db_link', '')) 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] .split(':')[:-1] ) else: device_id = str(node['id']) return { 'node_id': node['id'], 'device_id': device_id.replace('0x', ''), 'name': node.get('name'), 'capabilities': capabilities, 'manufacturer_id': f'0x{node["manufacturerId"]:04x}' if node.get('manufacturerId') else None, 'manufacturer_name': node.get('manufacturer'), 'location': node.get('loc'), 'status': node.get('status'), 'is_available': node.get('available'), 'is_awake': node.get('ready') and node.get('status') != 'Asleep', 'is_beaming_device': node.get('supportsBeaming'), 'is_controller': node.get('isControllerNode'), 'is_failed': node.get('failed'), 'is_frequent_listening_device': node.get('isFrequentListening'), 'is_info_received': node.get('interviewStage') == 'Complete', 'is_initialized': node.get('inited'), 'is_listening_device': node.get('isListening'), 'is_ready': node.get('ready'), 'is_routing_device': node.get('isRouting'), 'is_security_device': node.get('supportsSecurity'), 'is_sleeping': node.get('ready') and node.get('status') == 'Asleep', 'last_update': cls._convert_timestamp(node.get('lastActive')), 'product_id': f'0x{node["productId"]:04x}' if node.get('productId') else None, 'product_type': f'0x{node["productType"]:04x}' if node.get('productType') else None, 'product_name': ' '.join( [node.get('productLabel', ''), node.get('productDescription', '')] ), 'baud_rate': node.get('dataRate'), 'max_baud_rate': node.get('maxBaudRate', node.get('dataRate')), 'device_class': node.get('deviceClass'), 'specific': node.get('deviceClass', {}).get('specific'), 'firmware_version': node.get('firmwareVersion'), 'keep_awake': node.get('keepAwake'), 'db_link': node.get('dbLink'), 'type': node.get('zwavePlusNodeType'), 'role': node.get('zwavePlusRoleType'), 'zwave_plus_version': node.get('zwavePlusVersion'), 'zwave_plus_node_type': node.get('zwavePlusNodeType'), 'zwave_plus_role_type': node.get('zwavePlusRoleType'), 'neighbours': node.get('neighbors', []), 'command_classes': { value['commandClassName'] for value in node.get('values', {}).values() if value.get('commandClassName') }, 'groups': { group['value']: cls.group_to_dict(group, node_id=node['id']) for group in node.get('groups', []) }, 'values': { value['id']: cls.value_to_dict(value) for value in node.get('values', {}).values() }, } 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, ) -> dict: assert node_id or node_name, 'Please provide either a node_id or node_name' if use_cache: node = self._state.nodes.get((node_id, node_name)) if node: 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, value_id: Optional[str] = None, id_on_network: Optional[str] = None, value_label: Optional[str] = None, 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 assert ( value_id or value_label ), 'Please provide either value_id, id_on_network or value_label' if use_cache: 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}' 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): return value.get('property_id') in {'targetValue', 'targetColor'} @staticmethod def _is_current_value(value: Mapping): return value.get('property_id') in {'currentValue', 'currentColor'} def _to_current_value(self, value: dict) -> dict: return self._get_associated_value(value, 'current') def _to_target_value(self, value: dict) -> dict: return self._get_associated_value(value, 'target') def _get_associated_value(self, value: dict, target: str) -> dict: check_func = ( self._is_target_value if target == 'current' else self._is_current_value ) if not check_func(value): return value replace_args = ( ('target', 'current') if target == 'current' else ('current', 'target') ) associated_value_id = '-'.join( value['value_id'].split('-')[:-1] + [value['value_id'].split('-')[-1].replace(*replace_args)] ) associated_value = self._state.values.get(associated_value_id) if associated_value: value = associated_value return value.copy() @staticmethod def _matches_classes(value: Mapping, *names: str): classes = {command_class_by_name[name] for name in names} return value.get('command_class', '') in classes if value else False @classmethod def _is_switch(cls, value: Mapping): return value.get('type') == 'Bool' and not value.get('is_read_only') @classmethod def _is_dimmer(cls, value: Mapping): return value.get('type') == 'Decimal' and not value.get('is_read_only') @classmethod def _is_enum_switch(cls, value: Mapping): return value.get('type') == 'List' and not value.get('is_read_only') @classmethod def _is_light(cls, value: Mapping): return ( cls._matches_classes(value, 'color') and not {'red', 'green', 'blue'}.difference( set(value.get('value', {}).keys()) ) and not value.get('is_read_only') ) @classmethod def _is_configuration_value(cls, value: Mapping): return cls._matches_classes(value, 'configuration') @classmethod def _get_sensor_args( cls, value: Mapping ) -> Tuple[Optional[Type], Optional[Mapping]]: sensor_type, args = None, None if value.get('is_read_only'): if value.get('type') == 'Bool': sensor_type, args = ( BinarySensor, { 'value': value.get('data', False), }, ) elif value.get('type') == 'List': sensor_type, args = ( EnumSensor, { 'value': value.get('data'), 'values': { i['value']: i['text'] for i in value.get('data_items', []) }, }, ) elif value.get('type') == 'Decimal': sensor_type = NumericSensor if re.search(r'\s*power$', value['property_id'], re.IGNORECASE): sensor_type = PowerSensor if re.search(r'\s*voltage$', value['property_id'], re.IGNORECASE): sensor_type = VoltageSensor elif re.search( r'\s*consumption$', value['property_id'], re.IGNORECASE ) or re.search(r'Wh$', (value.get('units') or '')): sensor_type = EnergySensor elif re.search(r'\s*temperature$', value['property_id'], re.IGNORECASE): sensor_type = TemperatureSensor elif re.search( r'\s*(humidity|moisture)$', value['property_id'], re.IGNORECASE ): sensor_type = HumiditySensor elif re.search( r'\s*(illuminance|luminosity)$', value['property_id'], re.IGNORECASE ): sensor_type = IlluminanceSensor args = { 'value': value.get('data'), 'min': value.get('min'), 'max': value.get('max'), 'unit': value.get('units'), } return sensor_type, args @classmethod def _is_battery(cls, value: Mapping): return ( cls._matches_classes(value, 'battery') and value.get('is_read_only') and value.get('type') == 'Decimal' ) def _to_entity_args(self, value: dict) -> dict: value = self._to_current_value(value) args = { 'id': value['id'], 'name': value.get('label'), 'description': value.get('help'), 'is_read_only': value.get('is_read_only'), 'is_write_only': value.get('is_write_only'), 'is_configuration': self._is_configuration_value(value), } if value.get('last_update'): args['updated_at'] = value['last_update'] return args def transform_entities(self, entities: Iterable[dict]) -> Collection[Entity]: transformed_entities = [] for value in entities: if not value or self._matches_classes(value, *self._ignored_entity_classes): continue value = value.copy() current_value = target_value = None if self._is_current_value(value): current_value = value target_value = self._to_target_value(value) elif self._is_target_value(value): current_value = self._to_current_value(value) target_value = value if current_value and target_value: value = self._merge_current_and_target_values( [current_value, target_value] )[0] entity_type = None entity_args = self._to_entity_args(value) sensor_type, sensor_args = self._get_sensor_args(value) if self._is_light(value): entity_type = Light color = value['value'] entity_args['red'] = color['red'] entity_args['green'] = color['green'] entity_args['blue'] = color['blue'] elif self._is_dimmer(value): entity_type = Dimmer entity_args['value'] = value['data'] entity_args['min'] = value['min'] entity_args['max'] = value['max'] elif self._is_enum_switch(value): entity_type = EnumSwitch entity_args['value'] = value['data'] entity_args['values'] = { i['value']: i['text'] for i in value.get('data_items', []) } elif self._is_battery(value): entity_type = Battery entity_args['value'] = value['data'] entity_args['min'] = value['min'] entity_args['max'] = value['max'] entity_args['unit'] = value.get('units', '%') elif self._is_switch(value): entity_type = Switch entity_args['state'] = value['data'] elif sensor_args: entity_type = sensor_type entity_args.update(sensor_args) if entity_type: transformed_entities.append(entity_type(**entity_args)) self._process_parent_entities(entities, transformed_entities) return transformed_entities def _process_parent_entities( self, values: Iterable[Mapping], entities: List[Entity] ): parent_entities: Dict[int, Device] = {} entities_by_id: Dict[str, Entity] = {str(e.id): e for e in entities} for value in values: if not (value and value.get('id')): continue entity = entities_by_id.get(value['id']) if not entity: continue parent_id = value.get('parent_id') if parent_id is None: continue node = self._state.nodes.get(parent_id) if not node: continue node_id = node['node_id'] if node_id not in parent_entities: parent = parent_entities[node_id] = Device( id=node['device_id'], name=node.get('name'), external_url=self._build_external_url(node), reachable=( node.get('is_available', False) and node.get('is_ready', False) ), ) entities.append(parent) parent = parent_entities[node_id] entity.parent = parent entity.reachable = parent.reachable @staticmethod def _build_external_url(node: dict) -> Optional[str]: manufacturer_id = node.get('manufacturer_id') product_id = node.get('product_id') product_type = node.get('product_type') firmware_version = node.get('firmware_version', '0.0') if not (manufacturer_id and product_id and product_type): return None return ( 'https://devices.zwave-js.io/?jumpTo=' f'{manufacturer_id}:{product_type}:{product_id}:{firmware_version}' ) @classmethod def _merge_current_and_target_values(cls, values: Iterable[dict]) -> List[dict]: values_by_id = OrderedDict({v.get('id'): v for v in values}) new_values = OrderedDict() for value in values: value = value.copy() value_id = value.get('id') if not value_id: continue associated_value = None associated_property_id = None current_property_id = None current_value = None value_id_prefix = '-'.join(value_id.split('-')[:-1]) if cls._is_current_value(value): associated_property_id = value['property_id'].replace( 'current', 'target' ) current_property_id = value['property_id'] current_value = value elif cls._is_target_value(value): associated_property_id = value['property_id'].replace( 'target', 'current' ) current_property_id = associated_property_id if associated_property_id: associated_value_id = f'{value_id_prefix}-{associated_property_id}' associated_value = values_by_id.pop(associated_value_id, None) if cls._is_target_value(value): current_value = associated_value if current_value and associated_value and current_property_id: value = value.copy() value_id = f'{value_id_prefix}-{current_property_id}' value['data'] = current_value.get('data') value['id'] = value['value_id'] = value['id_on_network'] = value_id value['is_read_only'] = value['is_write_only'] = False value['label'] = 'Value' value['property_id'] = current_property_id value['last_update'] = ( max( value.get('last_update') or 0, associated_value.get('last_update') or 0, ) or None ) new_values[value_id] = value return list(new_values.values()) def _filter_values( self, command_classes: Optional[Iterable[str]] = None, filter_callback: Optional[Callable[[dict], bool]] = None, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs, ) -> Dict[str, Any]: nodes = ( [self._get_node(node_name=node_name, use_cache=False, **kwargs)] if node_id or node_name else self._get_nodes(**kwargs).values() ) classes: set = { command_class_by_name[command_name] for command_name in (command_classes or []) } values = {} for node in nodes: if not node: continue for value in node.get('values', {}).values(): if (classes and value.get('command_class') not in classes) or ( filter_callback and not filter_callback(value) ): continue value = self._to_current_value(value) values[value['id_on_network']] = value entity_values = self._merge_current_and_target_values(values.values()) self.publish_entities(entity_values) # type: ignore return values def _get_group( self, group_id: Optional[str] = None, 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) group = self._state.groups.get(group_id) if group: return group groups = self._get_groups(**kwargs) assert group_id in groups, f'No such group_id: {group_id}' return groups[group_id] 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 status(self, *_, **kwargs) -> Dict[str, Any]: """ Get the current status of the Z-Wave values. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values(**kwargs) @action def controller_status(self, **kwargs) -> Dict[str, Any]: """ Get the status of the controller. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ msg_queue: queue.Queue = queue.Queue() topic = f'{self.topic_prefix}/driver/status' client = self._get_client(**kwargs) def on_message(_, __, msg): if msg.topic != topic: return msg_queue.put(json.loads(msg.payload)) client.on_message = on_message client.connect() client.subscribe(topic) client.loop_start() try: status = msg_queue.get( block=True, timeout=kwargs.get('timeout', self.timeout) ) except queue.Empty as e: raise TimeoutError('The request timed out') from e finally: client.loop_stop() return { 'state': status, } @action def add_node( # pylint: disable=arguments-differ self, name: str, location: str = '', do_security: bool = False, timeout: int = 30, **kwargs, ): """ Start the inclusion process to add a node to the network. :param name: Name of the node to be added. :param location: Location of the node (default: empty). :param do_security: Whether to initialize the Network Key on the device if it supports the Security CC :param timeout: How long the inclusion process should last, in seconds (default: 30). Specify zero or null for no timeout. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self._api_request( 'startInclusion', 0, { 'name': name, 'location': location, 'force_security': do_security, }, **kwargs, ) if timeout: Timer(timeout, lambda: self._api_request('stopInclusion', **kwargs)).start() @action def remove_node(self, timeout: int = 30, **kwargs): """ Remove a node from the network (or, better, start the exclusion process). :param timeout: How long the exclusion process should last, in seconds (default: 30). :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self._api_request('startExclusion', **kwargs) if timeout: Timer(timeout, lambda: self._api_request('stopExclusion', **kwargs)).start() @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: 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) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: 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 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: 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) @action def request_node_neighbour_update(self, *_, **kwargs): """ Request a neighbours list update. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self._api_request('refreshNeighbors', **kwargs) @action def get_nodes( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Optional[Dict[IdType, dict]]: """ Get the nodes associated to the network. :param node_id: Filter by node_id. :param node_name: Filter by node name. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). :return: List of paired devices. Example output: .. code-block:: json { "1": { "node_id": 1, "device_id": "0115-0400-0001", "name": "Controller", "capabilities": [ "beaming", "routing" ], "manufacturer_id": "0x0115", "manufacturer_name": "Z-Wave.Me", "location": "Living Room", "status": "Alive", "is_available": true, "is_awake": true, "is_beaming_device": true, "is_controller": true, "is_failed": false, "is_frequent_listening_device": false, "is_info_received": true, "is_initialized": true, "is_listening_device": true, "is_ready": true, "is_routing_device": true, "is_security_device": false, "is_sleeping": false, "last_update": "2021-04-05T19:38:07.202000", "product_id": null, "product_type": null, "product_name": "UZB Z-Wave USB Stick", "baud_rate": 100000, "max_baud_rate": null, "device_class": { "basic": 2, "generic": 2, "specific": 1 }, "specific": 1, "firmware_version": null, "keep_awake": false, "db_link": "https://devices.zwave-js.io/?jumpTo=0x0115:0x0400:0x0001:0.0", "zwave_plus_version": null, "zwave_plus_node_type": null, "zwave_plus_role_type": null, "neighbours": [ 3, 4 ], "groups": {}, "values": {} }, "4": { "node_id": 4, "device_id": "010f-0602-1003", "name": "TV Switch", "capabilities": [ "beaming", "routing", "zwave_plus" ], "manufacturer_id": "0x010f", "manufacturer_name": "FibaroGroup", "location": "Living Room", "status": "Alive", "is_available": true, "is_awake": true, "is_beaming_device": true, "is_controller": false, "is_failed": false, "is_frequent_listening_device": false, "is_info_received": true, "is_initialized": true, "is_listening_device": true, "is_ready": true, "is_routing_device": true, "is_security_device": false, "is_sleeping": false, "last_update": "2021-04-06T00:07:44.081000", "product_id": null, "product_type": null, "product_name": "Metered Wall Plug Switch", "baud_rate": 100000, "max_baud_rate": null, "device_class": { "basic": 4, "generic": 16, "specific": 1 }, "specific": 1, "firmware_version": "3.2", "keep_awake": false, "db_link": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0602:0x1003:3.2", "zwave_plus_version": 1, "zwave_plus_node_type": 0, "zwave_plus_role_type": 5, "neighbours": [ 1 ], "groups": { "1": { "label": "Lifeline", "index": 1, "multichannel": true, "max_associations": 1 }, "2": { "label": "On/Off (Button)", "index": 2, "multichannel": true, "max_associations": 10 }, "3": { "label": "On/Off (Power)", "index": 3, "multichannel": true, "max_associations": 10 } }, "values": { "4-37-0-currentValue": { "id": "4-37-0-currentValue", "id_on_network": "4-37-0-currentValue", "value_id": "4-37-0-currentValue", "data": true, "data_items": null, "label": "Current value", "property_id": "currentValue", "help": null, "node_id": 4, "parent_id": 4, "type": "Bool", "default": null, "endpoint": 0, "stateless": false, "command_class": 37, "command_class_name": "Binary Switch", "units": null, "min": null, "max": null, "is_read_only": true, "is_write_only": false, "last_update": "2021-04-05T19:38:07.587000" } } } } :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_id or node_name: return self._get_node( node_id=node_id, node_name=node_name, use_cache=False, **kwargs ) return self._get_nodes(**kwargs) @action def set_node_name( self, new_name: str, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs, ): """ 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: 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, **kwargs), 'name': new_name, } ) ) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: 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, **kwargs), 'location': location, } ) ) @action def heal( self, *_, timeout: Optional[int] = 60, **kwargs ): # pylint: disable=arguments-differ """ Heal network by requesting nodes rediscover their neighbours. :param timeout: Duration of the healing process in seconds (default: 60). Set to zero or null for no timeout. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self._api_request('beginHealingNetwork', **kwargs) if timeout: Timer( timeout, lambda: self._api_request('stopHealingNetwork', **kwargs) ).start() @action def get_value( self, value_id: Optional[str] = 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] :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._get_value( value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, node_name=node_name, use_cache=False, **kwargs, ) @action def set_value( self, *args, data=None, value_id: Optional[str] = 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] :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ # Compatibility layer with the .set_value format used by # the entities frontend if args: value_id = args[0] value = self._get_value( value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, node_name=node_name, **kwargs, ) # Convert to target value if the value is in current value format, # as that would usually be the writeable attribute value = self._to_target_value(value) self._api_request( 'writeValue', { 'nodeId': value['node_id'], 'commandClass': value['command_class'], 'endpoint': value.get('endpoint', 0), 'property': value['property_id'], **( {'propertyKey': value['property_key']} if 'property_key' in value else {} ), }, data, **kwargs, ) @action def set_lights(self, lights, **kwargs): # pylint: disable=arguments-differ """ Set the state for one or more Z-Wave lights. """ lights = [lights] if isinstance(lights, str) else lights for light in lights: self.set_value(light, kwargs) @action def node_heal( # pylint: disable=arguments-differ self, node_id: Optional[int] = None, node_name: Optional[str] = None, **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 kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') self._api_request('healNode', node_id, **kwargs) @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 (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). :param node_id: Select node by node_id. :param node_name: Select node by label. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @action def node_network_update( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ): """ Update the controller with network information (same as :meth:`platypush.plugins.zwave.mqtt.ZwaveMqttPlugin.node_heal`). :param node_id: Select node by node_id. :param node_name: Select node by label. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self.node_heal(node_id=node_id, node_name=node_name, **kwargs) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ if node_name: node_id = self._get_node(node_name=node_name, **kwargs).get('node_id') self._api_request('refreshInfo', node_id, **kwargs) @action def get_dimmers( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['switch_multilevel', 'switch_toggle_multilevel'], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_node_config( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( [ 'configuration', 'ip_configuration', 'association_command_configuration', 'sensor_configuration', ], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_battery_levels( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['battery'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_power_levels( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, Any]: """ Get the power levels of this node. :param node_id: Select node by node_id. :param node_name: Select node by name. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['powerlevel'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_bulbs( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['color'], filter_callback=lambda value: not value['is_read_only'], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_switches( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['switch_binary', 'switch_toggle_binary', 'switch_all'], filter_callback=lambda value: not value['is_read_only'], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_sensors( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['sensor_binary', 'sensor_multilevel', 'sensor_alarm', 'meter'], filter_callback=lambda value: not value['is_write_only'], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_doorlocks( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['door_lock'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_locks( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, Any]: """ Get the locks on the network or associated to a node. :param node_id: Select node by node_id. :param node_name: Select node by name. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['lock'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_usercodes( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['user_code'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_thermostats( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( [ 'thermostat_heating', 'thermostat_mode', 'thermostat_operating_state', 'thermostat_setpoint', 'thermostat_fan_mode', 'thermostat_fan_state', 'thermostat_setback', ], node_id=node_id, node_name=node_name, **kwargs, ) @action def get_protections( self, node_id: Optional[int] = None, node_name: Optional[str] = None, **kwargs ) -> Dict[str, 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ return self._filter_values( ['protection'], node_id=node_id, node_name=node_name, **kwargs ) @action def get_groups(self, **kwargs) -> Dict[IdType, dict]: """ Get the groups on the network. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). :return: A list of the available groups. Example: .. code-block:: json { "2-2": { "label": "Motion", "multichannel": true, "max_associations": 10, "group_id": "2-2", "node_id": 2, "index": 2, "associations": [ 3, 4 ] }, "5-3": { "label": "Tamper", "multichannel": true, "max_associations": 10, "group_id": "5-3", "node_id": 5, "index": 3, "associations": [ 2, 3, 4 ] } ] """ return self._get_groups(**kwargs) @action def get_scenes(self, **kwargs) -> Dict[IdType, dict]: """ Get the scenes configured on the network. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). :return: dict with the following format: .. code-block:: json { 1: { "scene_id": 1, "label": "Scene 1", "values": { "4-37-0-targetValue": { "node_id": 4, "value_id": "4-37-0-targetValue" "data": true }, "3-112-0-Temperature": { "node_id": 3, "value_id": "3-112-0-Temperature", "data": true } } } } """ return self._get_scenes(**kwargs) @action def create_scene(self, label: str, **kwargs): """ Create a new scene. :param label: Scene label. :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, **kwargs) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_removeScene', scene['scene_id']) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_activateScene', scene['scene_id']) @action def scene_add_value( self, data: Optional[Any] = None, value_id: Optional[str] = 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ value = self._get_value( value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, node_name=node_name, **kwargs, ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request( '_addSceneValue', scene['scene_id'], { 'nodeId': value['node_id'], 'commandClass': value['command_class'], 'property': value['property_id'], 'endpoint': value['endpoint'], }, data, kwargs.get('timeout', self.timeout), ) @action def scene_remove_value( self, value_id: Optional[str] = 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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ value = self._get_value( value_id=value_id, value_label=value_label, id_on_network=id_on_network, node_id=node_id, node_name=node_name, **kwargs, ) scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) self._api_request('_removeSceneValue', scene['scene_id'], value['value_id']) @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. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ scene = self._get_scene(scene_id=scene_id, scene_label=scene_label, **kwargs) return scene.get('values', {}) @action def add_node_to_group( # pylint: disable=arguments-differ self, group_id: Optional[str] = None, node_id: Optional[int] = None, endpoint: Optional[int] = None, **kwargs, ): """ Add a node to a group. :param group_id: Group ID. :param node_id: Node ID to be added. :param endpoint: Add a specific endpoint of the node to the group (default: add a node association). :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ group = self._get_group(group_id, **kwargs) assoc = {'nodeId': node_id} if endpoint is not None: assoc['endpoint'] = endpoint self._api_request('addAssociations', group['node_id'], group['index'], [assoc]) @action def remove_node_from_group( # pylint: disable=arguments-differ self, group_id: Optional[str] = None, node_id: Optional[int] = None, endpoint: Optional[int] = None, **kwargs, ): """ Remove a node from a group. :param group_id: Group ID. :param node_id: Node ID to be added. :param endpoint: Node endpoint to remove (default: remove node association). :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ group = self._get_group(group_id, **kwargs) assoc = {'nodeId': node_id} if endpoint is not None: assoc['endpoint'] = endpoint self._api_request( 'removeAssociations', group['node_id'], group['index'], [assoc] ) @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._api_request('hardReset') @action def on(self, device: str, *_, **kwargs): # pylint: disable=arguments-differ """ Turn on a switch on a device. :param device: ``id_on_network`` of the value to be switched on. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self.set_value(data=True, id_on_network=device, **kwargs) @action def off(self, device: str, *_, **kwargs): # pylint: disable=arguments-differ """ Turn off a switch on a device. :param device: ``id_on_network`` of the value to be switched off. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ self.set_value(data=False, id_on_network=device, **kwargs) @action def toggle( # pylint: disable=arguments-differ self, device: str, *_, **kwargs ) -> dict: """ Toggle a switch on a device. :param device: ``id_on_network`` of the value to be toggled. :param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query the default configured device). """ 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': (node['name'] + ' - ' + value.get('label', '[No Label]')), 'on': value['data'], 'id': value['value_id'], } def main(self): self.get_nodes() super().main() # vim:sw=4:ts=4:et: