diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/EnumSwitch.vue b/platypush/backend/http/webapp/src/components/panels/Entities/EnumSwitch.vue new file mode 100644 index 00000000..501c87bd --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/EnumSwitch.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json index 4fba8844..8158aa48 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json +++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json @@ -87,6 +87,14 @@ } }, + "enum_switch": { + "name": "Switch", + "name_plural": "Switches", + "icon": { + "class": "fas fa-gauge" + } + }, + "switch": { "name": "Switch", "name_plural": "Switches", diff --git a/platypush/entities/switches.py b/platypush/entities/switches.py index 86a72e90..6adc9d51 100644 --- a/platypush/entities/switches.py +++ b/platypush/entities/switches.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, ForeignKey, Boolean +from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, JSON from .devices import Device, entity_types_registry @@ -20,3 +20,23 @@ if not entity_types_registry.get('Switch'): entity_types_registry['Switch'] = Switch else: Switch = entity_types_registry['Switch'] + + +if not entity_types_registry.get('EnumSwitch'): + + class EnumSwitch(Device): + __tablename__ = 'enum_switch' + + id = Column( + Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True + ) + value = Column(String) + values = Column(JSON) + + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } + + entity_types_registry['EnumSwitch'] = EnumSwitch +else: + EnumSwitch = entity_types_registry['EnumSwitch'] diff --git a/platypush/plugins/zigbee/mqtt/__init__.py b/platypush/plugins/zigbee/mqtt/__init__.py index 9fe0970f..1db41ec6 100644 --- a/platypush/plugins/zigbee/mqtt/__init__.py +++ b/platypush/plugins/zigbee/mqtt/__init__.py @@ -3,7 +3,7 @@ import re import threading from queue import Queue -from typing import Optional, List, Any, Dict, Union +from typing import Optional, List, Any, Dict, Union, Tuple from platypush.entities import manages from platypush.entities.batteries import Battery @@ -17,7 +17,7 @@ from platypush.entities.humidity import HumiditySensor from platypush.entities.lights import Light from platypush.entities.linkquality import LinkQuality from platypush.entities.sensors import Sensor, BinarySensor, NumericSensor -from platypush.entities.switches import Switch +from platypush.entities.switches import Switch, EnumSwitch from platypush.entities.temperature import TemperatureSensor from platypush.message import Mapping from platypush.message.response import Response @@ -173,8 +173,6 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] } def transform_entities(self, devices): - from platypush.entities.switches import Switch - compatible_entities = [] for dev in devices: if not dev: @@ -197,6 +195,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] light_info = self._get_light_meta(dev) switch_info = self._get_switch_meta(dev) sensors = self._get_sensors(dev) + enum_switches = self._get_enum_switches(dev) if light_info: compatible_entities.append( @@ -263,6 +262,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] if sensors: compatible_entities += sensors + if enum_switches: + compatible_entities += enum_switches return super().transform_entities(compatible_entities) # type: ignore @@ -906,22 +907,63 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] (default: query the default configured device). """ msg = (values or {}).copy() + reply_topic = self._topic(device) + if property: msg[property] = value + stored_property = next( + iter( + exposed + for exposed in ( + self._info.get('devices_by_addr', {}) + .get(device, {}) + .get('definition', {}) + .get('exposes', {}) + ) + if exposed.get('property') == property + ), + None, + ) + + if stored_property and self._is_write_only(stored_property): + # Don't wait for an update from a value that is not readable + reply_topic = None properties = self.publish( topic=self._topic(device + '/set'), - reply_topic=self._topic(device), + reply_topic=reply_topic, msg=msg, **self._mqtt_args(**kwargs), ).output # type: ignore[reportGeneralTypeIssues] - if property: + if property and reply_topic: assert property in properties, 'No such property: ' + property return {property: properties[property]} return properties + @action + def set_value( + self, device: str, property: Optional[str] = None, data=None, **kwargs + ): + """ + Entity-compatible way of setting a value on a node. + + :param device: Device friendly name, IEEE address or internal entity ID + in ``
:`` format. + :param property: Name of the property to set. If not specified here, it + should be specified on ``device`` in ``
:`` + format. + :param kwargs: Extra arguments to be passed to + :meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query + the default configured device). + """ + dev, prop = self._ieee_address(device, with_property=True) + if not property: + property = prop + + self.device_set(dev, property, data, **kwargs) + @action def device_check_ota_updates(self, device: str, **kwargs) -> dict: """ @@ -1436,7 +1478,9 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] ) @staticmethod - def _ieee_address(device: Union[dict, str]) -> str: + def _ieee_address( + device: Union[dict, str], with_property=False + ) -> Union[str, Tuple[str, Optional[str]]]: # Entity value IDs are stored in the `
:` # format. Therefore, we need to split by `:` if we want to # retrieve the original address. @@ -1447,8 +1491,14 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] # IEEE address + property format if re.search(r'^0x[0-9a-fA-F]{16}:', dev): - return dev.split(':')[0] - return dev + parts = dev.split(':') + return ( + (parts[0], parts[1] if len(parts) > 1 else None) + if with_property + else parts[0] + ) + + return (dev, None) if with_property else dev @classmethod def _get_switch_meta(cls, device_info: dict) -> dict: @@ -1538,6 +1588,41 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init] return sensors + @classmethod + def _get_enum_switches(cls, device_info: dict) -> List[Sensor]: + devices = [] + exposes = [ + exposed + for exposed in (device_info.get('definition', {}) or {}).get('exposes', []) + if ( + exposed.get('property') + and exposed.get('access', 0) & 2 + and exposed.get('type') == 'enum' + and exposed.get('values') + ) + ] + + for exposed in exposes: + devices.append( + EnumSwitch( + id=f'{device_info["ieee_address"]}:{exposed["property"]}', + name=( + device_info.get('friendly_name', '[Unnamed device]') + + ' [' + + exposed.get('description', '') + + ']' + ), + value=device_info.get(exposed['property']), + values=exposed.get('values', []), + description=exposed.get('description'), + is_read_only=cls._is_read_only(exposed), + is_write_only=cls._is_write_only(exposed), + data=device_info, + ) + ) + + return devices + @classmethod def _get_light_meta(cls, device_info: dict) -> dict: exposes = (device_info.get('definition', {}) or {}).get('exposes', [])