diff --git a/docs/device_docs/gateway.rst b/docs/device_docs/gateway.rst new file mode 100644 index 000000000..ae2cdcc71 --- /dev/null +++ b/docs/device_docs/gateway.rst @@ -0,0 +1,28 @@ +Gateway +======= + +Adding support for new Zigbee devices +------------------------------------- + +Once the event information is obtained as :ref:`described in the push server docs`, +a new event for a Zigbee device connected to a gateway can be implemented as follows: + +1. Open `miio/gateway/devices/subdevices.yaml` file and search for the target device for the new event. +2. Add an entry for the new event: + +.. code-block:: yaml + + properties: + - property: is_open # the new property of this device (optional) + default: False # default value of the property when the device is initialized (optional) + push_properties: + open: # the event you added, see the decoded packet capture `\"key\":\"event.lumi.sensor_magnet.aq2.open\"` take this equal to everything after the model + property: is_open # the property as listed above that this event will link to (optional) + value: True # the value the property as listed above will be set to if this event is received (optional) + extra: "[1,6,1,0,[0,1],2,0]" # the identification of this event, see the decoded packet capture `\"extra\":\"[1,6,1,0,[0,1],2,0]\"` + close: + property: is_open + value: False + extra: "[1,6,1,0,[0,0],2,0]" + +3. Create a pull request to get the event added to this library. diff --git a/miio/gateway/alarm.py b/miio/gateway/alarm.py index ea54cb59e..0ec013a22 100644 --- a/miio/gateway/alarm.py +++ b/miio/gateway/alarm.py @@ -1,9 +1,14 @@ """Xiaomi Gateway Alarm implementation.""" +import logging from datetime import datetime +from ..exceptions import DeviceException +from ..push_server import EventInfo from .gatewaydevice import GatewayDevice +_LOGGER = logging.getLogger(__name__) + class Alarm(GatewayDevice): """Class representing the Xiaomi Gateway Alarm.""" @@ -61,3 +66,29 @@ def set_triggering_volume(self, volume): def last_status_change_time(self) -> datetime: """Return the last time the alarm changed status.""" return datetime.fromtimestamp(self._gateway.send("get_arming_time").pop()) + + def subscribe_events(self): + """subscribe to the alarm events using the push server.""" + if self._gateway._push_server is None: + raise DeviceException( + "Can not install push callback without a PushServer instance" + ) + + event_info = EventInfo( + action="alarm_triggering", + extra="[1,19,1,111,[0,1],2,0]", + trigger_token=self._gateway.token, + ) + + event_id = self._gateway._push_server.subscribe_event(self._gateway, event_info) + if event_id is None: + return False + + self._event_ids.append(event_id) + return True + + def unsubscribe_events(self): + """Unsubscibe from events registered in the gateway memory.""" + for event_id in self._event_ids: + self._gateway._push_server.unsubscribe_event(self._gateway, event_id) + self._event_ids.remove(event_id) diff --git a/miio/gateway/devices/subdevice.py b/miio/gateway/devices/subdevice.py index 7484b13c3..0901a4d4e 100644 --- a/miio/gateway/devices/subdevice.py +++ b/miio/gateway/devices/subdevice.py @@ -1,13 +1,20 @@ """Xiaomi Gateway subdevice base class.""" import logging -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Dict, List, Optional import attr import click from ...click_common import command -from ..gateway import GATEWAY_MODEL_EU, GATEWAY_MODEL_ZIG3, GatewayException +from ...exceptions import DeviceException +from ...push_server import EventInfo +from ..gateway import ( + GATEWAY_MODEL_EU, + GATEWAY_MODEL_ZIG3, + GatewayCallback, + GatewayException, +) _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: @@ -60,6 +67,10 @@ def __init__( self.setter = model_info.get("setter") + self.push_events = model_info.get("push_properties", []) + self._event_ids: List[str] = [] + self._registered_callbacks: Dict[str, GatewayCallback] = {} + def __repr__(self): return "".format( self.device_type, @@ -260,3 +271,69 @@ def get_firmware_version(self) -> Optional[int]: ex, ) return self._fw_ver + + def register_callback(self, id: str, callback: GatewayCallback): + """Register a external callback function for updates of this subdevice.""" + if id in self._registered_callbacks: + _LOGGER.error( + "A callback with id '%s' was already registed, overwriting previous callback", + id, + ) + self._registered_callbacks[id] = callback + + def remove_callback(self, id: str): + """Remove a external callback using its id.""" + self._registered_callbacks.pop(id) + + def push_callback(self, action: str, params: str): + """Push callback received from the push server.""" + if action not in self.push_events: + _LOGGER.error( + "Received unregistered action '%s' callback for sid '%s' model '%s'", + action, + self.sid, + self.model, + ) + + event = self.push_events[action] + prop = event.get("property") + value = event.get("value") + if prop is not None and value is not None: + self._props[prop] = value + + for callback in self._registered_callbacks.values(): + callback(action, params) + + def subscribe_events(self): + """subscribe to all subdevice events using the push server.""" + if self._gw._push_server is None: + raise DeviceException( + "Can not install push callback without a PushServer instance" + ) + + result = True + for action in self.push_events: + event_info = EventInfo( + action=action, + extra=self.push_events[action]["extra"], + source_sid=self.sid, + source_model=self.zigbee_model, + event=self.push_events[action].get("event", None), + command_extra=self.push_events[action].get("command_extra", ""), + trigger_value=self.push_events[action].get("trigger_value"), + ) + + event_id = self._gw._push_server.subscribe_event(self._gw, event_info) + if event_id is None: + result = False + continue + + self._event_ids.append(event_id) + + return result + + def unsubscribe_events(self): + """Unsubscibe from events registered in the gateway memory.""" + for event_id in self._event_ids: + self._gw._push_server.unsubscribe_event(self._gw, event_id) + self._event_ids.remove(event_id) diff --git a/miio/gateway/devices/subdevices.yaml b/miio/gateway/devices/subdevices.yaml index cbbdd0416..5fdd69023 100644 --- a/miio/gateway/devices/subdevices.yaml +++ b/miio/gateway/devices/subdevices.yaml @@ -14,6 +14,16 @@ type: Gateway class: None +# Explanation push properties: +# push_properties: +# l_click_ch0: = action event that you receive back from the gateway (can be changed to any arbitrary string) +# property: last_press = name of property to wich this event is coupled +# value: "long_click_ch0" = the value to wich the coupled property schould be set upon receiving this event +# extra: "[1,13,1,85,[0,0],0,0]" = "[a,b,c,d,[e,f],g,h]" +# c = part of the device that caused the event (1 = left switch, 2 = right switch, 3 = both switches) +# f = event number on which this event is fired (0 = long_click/close, 1 = click/open, 2 = double_click) + + # Weather sensor - zigbee_id: lumi.sensor_ht.v1 model: WSDCGQ01LM @@ -60,6 +70,18 @@ name: Door sensor type: Magnet class: SubDevice + properties: + - property: is_open + default: False + push_properties: + open: + property: is_open + value: True + extra: "[1,6,1,0,[0,1],2,0]" + close: + property: is_open + value: False + extra: "[1,6,1,0,[0,0],2,0]" - zigbee_id: lumi.sensor_magnet.aq2 model: MCCGQ11LM @@ -67,6 +89,18 @@ name: Door sensor type: Magnet class: SubDevice + properties: + - property: is_open + default: False + push_properties: + open: + property: is_open + value: True + extra: "[1,6,1,0,[0,1],2,0]" + close: + property: is_open + value: False + extra: "[1,6,1,0,[0,0],2,0]" # Motion sensor - zigbee_id: lumi.sensor_motion.v2 @@ -75,6 +109,18 @@ name: Motion sensor type: Motion class: SubDevice + properties: + - property: motion + default: False + push_properties: + motion: + property: motion + value: True + extra: "[1,1030,1,0,[0,1],0,0]" + no_motion: + property: motion + value: False + extra: "[1,1030,1,8,[4,120],2,0]" - zigbee_id: lumi.sensor_motion.aq2 model: RTCGQ11LM @@ -82,6 +128,21 @@ name: Motion sensor type: Motion class: SubDevice + properties: + - property: motion + default: False + push_properties: + motion: + property: motion + value: True + extra: "[1,1030,1,0,[0,1],0,0]" + no_motion: + property: motion + value: False + extra: "[1,1030,1,8,[4,120],2,0]" + #illumination: + # extra: "[1,1024,1,0,[3,20],0,0]" + # trigger_value: {"max":20, "min":0} # Cube - zigbee_id: lumi.sensor_cube.v1 @@ -90,6 +151,36 @@ name: Cube type: Cube class: SubDevice + properties: + - property: last_event + default: "none" + push_properties: + move: + property: last_event + value: "move" + extra: "[1,18,2,85,[6,256],0,0]" + flip90: + property: last_event + value: "flip90" + extra: "[1,18,2,85,[6,64],0,0]" + flip180: + property: last_event + value: "flip180" + extra: "[1,18,2,85,[6,128],0,0]" + taptap: + property: last_event + value: "taptap" + extra: "[1,18,2,85,[6,512],0,0]" + shakeair: + property: last_event + value: "shakeair" + extra: "[1,18,2,85,[0,0],0,0]" + rotate: + property: last_event + value: "rotate" + extra: "[1,12,3,85,[1,0],0,0]" + event: "rotate" + command_extra: "[1,19,7,1006,[42,[6066005667474548,12,3,85,0]],0,0]" - zigbee_id: lumi.sensor_cube.aqgl01 model: MFKZQ01LM @@ -97,6 +188,36 @@ name: Cube type: Cube class: SubDevice + properties: + - property: last_event + default: "none" + push_properties: + move: + property: last_event + value: "move" + extra: "[1,18,2,85,[6,256],0,0]" + flip90: + property: last_event + value: "flip90" + extra: "[1,18,2,85,[6,64],0,0]" + flip180: + property: last_event + value: "flip180" + extra: "[1,18,2,85,[6,128],0,0]" + taptap: + property: last_event + value: "taptap" + extra: "[1,18,2,85,[6,512],0,0]" + shakeair: + property: last_event + value: "shakeair" + extra: "[1,18,2,85,[0,0],0,0]" + rotate: + property: last_event + value: "rotate" + extra: "[1,12,3,85,[1,0],0,0]" + event: "rotate" + command_extra: "[1,19,7,1006,[42,[6066005667474548,12,3,85,0]],0,0]" # Curtain - zigbee_id: lumi.curtain @@ -394,6 +515,22 @@ name: Vibration sensor type: VibrationSensor class: Vibration + properties: + - property: last_event + default: "none" + push_properties: + vibrate: + property: last_event + value: "vibrate" + extra: "[1,257,1,85,[0,1],0,0]" + tilt: + property: last_event + value: "tilt" + extra: "[1,257,1,85,[0,2],0,0]" + free_fall: + property: last_event + value: "free_fall" + extra: "[1,257,1,85,[0,3],0,0]" # Thermostats - zigbee_id: lumi.airrtc.tcpecn02 @@ -410,6 +547,46 @@ name: Remote switch double type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" + l_click_ch1: + property: last_press + value: "long_click_ch1" + extra: "[1,13,2,85,[0,0],0,0]" + click_ch1: + property: last_press + value: "click_ch1" + extra: "[1,13,2,85,[0,1],0,0]" + d_click_ch1: + property: last_press + value: "double_click_ch1" + extra: "[1,13,2,85,[0,2],0,0]" + both_l_click: + property: last_press + value: "both_long_click" + extra: "[1,13,3,85,[0,0],0,0]" + both_click: + property: last_press + value: "both_click" + extra: "[1,13,3,85,[0,1],0,0]" + both_d_click: + property: last_press + value: "both_double_click" + extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.sensor_86sw1.v1 model: WXKG03LM 2016 @@ -417,6 +594,22 @@ name: Remote switch single type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b186acn01 model: WXKG03LM 2018 @@ -424,6 +617,22 @@ name: Remote switch single type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b286acn01 model: WXKG02LM 2018 @@ -431,6 +640,46 @@ name: Remote switch double type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" + l_click_ch1: + property: last_press + value: "long_click_ch1" + extra: "[1,13,2,85,[0,0],0,0]" + click_ch1: + property: last_press + value: "click_ch1" + extra: "[1,13,2,85,[0,1],0,0]" + d_click_ch1: + property: last_press + value: "double_click_ch1" + extra: "[1,13,2,85,[0,2],0,0]" + both_l_click: + property: last_press + value: "both_long_click" + extra: "[1,13,3,85,[0,0],0,0]" + both_click: + property: last_press + value: "both_click" + extra: "[1,13,3,85,[0,1],0,0]" + both_d_click: + property: last_press + value: "both_double_click" + extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.remote.b186acn02 model: WXKG06LM @@ -438,6 +687,22 @@ name: D1 remote switch single type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.remote.b286acn02 model: WXKG07LM @@ -445,6 +710,46 @@ name: D1 remote switch double type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" + l_click_ch1: + property: last_press + value: "long_click_ch1" + extra: "[1,13,2,85,[0,0],0,0]" + click_ch1: + property: last_press + value: "click_ch1" + extra: "[1,13,2,85,[0,1],0,0]" + d_click_ch1: + property: last_press + value: "double_click_ch1" + extra: "[1,13,2,85,[0,2],0,0]" + both_l_click: + property: last_press + value: "both_long_click" + extra: "[1,13,3,85,[0,0],0,0]" + both_click: + property: last_press + value: "both_click" + extra: "[1,13,3,85,[0,1],0,0]" + both_d_click: + property: last_press + value: "both_double_click" + extra: "[1,13,3,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.v2 model: WXKG01LM @@ -452,6 +757,22 @@ name: Button type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.aq2 model: WXKG11LM 2015 @@ -459,6 +780,22 @@ name: Button type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" - zigbee_id: lumi.sensor_switch.aq3 model: WXKG12LM @@ -466,6 +803,30 @@ name: Button type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" + l_click_pres: + property: last_press + value: "long_click_press" + extra: "[1,13,1,85,[0,16],0,0]" + shake: + property: last_press + value: "shake" + extra: "[1,13,1,85,[0,18],0,0]" - zigbee_id: lumi.remote.b1acn01 model: WXKG11LM 2018 @@ -473,6 +834,22 @@ name: Button type: RemoteSwitch class: SubDevice + properties: + - property: last_press + default: "none" + push_properties: + l_click_ch0: + property: last_press + value: "long_click_ch0" + extra: "[1,13,1,85,[0,0],0,0]" + click_ch0: + property: last_press + value: "click_ch0" + extra: "[1,13,1,85,[0,1],0,0]" + d_click_ch0: + property: last_press + value: "double_click_ch0" + extra: "[1,13,1,85,[0,2],0,0]" # Switches - zigbee_id: lumi.ctrl_neutral2 diff --git a/miio/gateway/gateway.py b/miio/gateway/gateway.py index c9fa3cb5d..2a3b4e37f 100644 --- a/miio/gateway/gateway.py +++ b/miio/gateway/gateway.py @@ -3,7 +3,7 @@ import logging import os import sys -from typing import Dict +from typing import Callable, Dict, List import click import yaml @@ -37,6 +37,8 @@ GATEWAY_MODEL_AC_V3, ] +GatewayCallback = Callable[[str, str], None] + class GatewayException(DeviceException): """Exception for the Xioami Gateway communication.""" @@ -99,6 +101,7 @@ def __init__( lazy_discover: bool = True, *, model: str = None, + push_server=None, ) -> None: super().__init__(ip, token, start_id, debug, lazy_discover, model=model) @@ -111,6 +114,13 @@ def __init__( self._subdevice_model_map = None self._did = None + self._push_server = push_server + self._event_ids: List[str] = [] + self._registered_callbacks: Dict[str, GatewayCallback] = {} + + if self._push_server is not None: + self._push_server.register_miio_device(self, self.push_callback) + def _get_unknown_model(self): for model_info in self.subdevice_model_map: if model_info.get("type_id") == -1: @@ -387,3 +397,43 @@ def get_illumination(self): raise GatewayException( "Got an exception while getting gateway illumination" ) from ex + + def register_callback(self, id: str, callback: GatewayCallback): + """Register a external callback function for updates of this subdevice.""" + if id in self._registered_callbacks: + _LOGGER.error( + "A callback with id '%s' was already registed, overwriting previous callback", + id, + ) + self._registered_callbacks[id] = callback + + def remove_callback(self, id: str): + """Remove a external callback using its id.""" + self._registered_callbacks.pop(id) + + def gateway_push_callback(self, action: str, params: str): + """Callback from the push server regarding the gateway itself.""" + for callback in self._registered_callbacks.values(): + callback(action, params) + + def push_callback(self, source_device: str, action: str, params: str): + """Callback from the push server.""" + if source_device == str(self.device_id): + self.gateway_push_callback(action, params) + return + + if source_device not in self.devices: + _LOGGER.error( + "'%s' callback from device '%s' not from a known device", + action, + source_device, + ) + return + + device = self.devices[source_device] + device.push_callback(action, params) + + def close(self): + """Cleanup all subscribed events and registered callbacks.""" + if self._push_server is not None: + self._push_server.unregister_miio_device(self) diff --git a/miio/gateway/gatewaydevice.py b/miio/gateway/gatewaydevice.py index da181ab70..2e70f6793 100644 --- a/miio/gateway/gatewaydevice.py +++ b/miio/gateway/gatewaydevice.py @@ -1,7 +1,7 @@ """Xiaomi Gateway device base class.""" import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List from ..exceptions import DeviceException @@ -28,3 +28,4 @@ def __init__( ) self._gateway = parent + self._event_ids: List[str] = [] diff --git a/miio/push_server/__init__.py b/miio/push_server/__init__.py index c8e93fd53..dc8a5a38a 100644 --- a/miio/push_server/__init__.py +++ b/miio/push_server/__init__.py @@ -3,4 +3,4 @@ # flake8: noqa from .eventinfo import EventInfo -from .server import PushServer +from .server import PushServer, PushServerCallback diff --git a/miio/push_server/server.py b/miio/push_server/server.py index eb6409a16..66816fbf3 100644 --- a/miio/push_server/server.py +++ b/miio/push_server/server.py @@ -3,6 +3,7 @@ import socket from json import dumps from random import randint +from typing import Callable, Optional from ..device import Device from ..protocol import Utils @@ -15,6 +16,8 @@ FAKE_DEVICE_ID = "120009025" FAKE_DEVICE_MODEL = "chuangmi.plug.v3" +PushServerCallback = Callable[[str, str, str], None] + def calculated_token_enc(token): token_bytes = bytes.fromhex(token) @@ -84,7 +87,7 @@ def stop(self): self._listen_couroutine.close() self._listen_couroutine = None - def register_miio_device(self, device: Device, callback): + def register_miio_device(self, device: Device, callback: PushServerCallback): """Register a miio device to this push server.""" if device.ip is None: _LOGGER.error( @@ -124,7 +127,7 @@ def unregister_miio_device(self, device: Device): self._registered_devices.pop(device.ip) _LOGGER.debug("push server: unregistered miio device with ip %s", device.ip) - def subscribe_event(self, device: Device, event_info: EventInfo): + def subscribe_event(self, device: Device, event_info: EventInfo) -> Optional[str]: """Subscribe to a event such that the device will start pushing data for that event.""" if device.ip not in self._registered_devices: @@ -164,7 +167,7 @@ def subscribe_event(self, device: Device, event_info: EventInfo): return event_id - def unsubscribe_event(self, device: Device, event_id): + def unsubscribe_event(self, device: Device, event_id: str): """Unsubscribe from a event by id.""" result = device.send("miIO.xdel", [event_id]) if result == ["ok"]: @@ -203,7 +206,7 @@ def _create_udp_server(self): def _construct_event( # nosec self, - event_id, + event_id: str, info: EventInfo, device: Device, ):