Skip to content

Commit 4dd3cd8

Browse files
Add push server implementation to enable event handling (#1446)
Co-authored-by: Teemu Rytilahti <[email protected]>
1 parent 0998568 commit 4dd3cd8

File tree

9 files changed

+773
-0
lines changed

9 files changed

+773
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import asyncio
2+
import logging
3+
4+
from miio import Gateway, PushServer
5+
from miio.push_server import EventInfo
6+
7+
_LOGGER = logging.getLogger(__name__)
8+
logging.basicConfig(level="INFO")
9+
10+
gateway_ip = "192.168.1.IP"
11+
token = "TokenTokenToken" # nosec
12+
13+
14+
async def asyncio_demo(loop):
15+
def alarm_callback(source_device, action, params):
16+
_LOGGER.info(
17+
"callback '%s' from '%s', params: '%s'", action, source_device, params
18+
)
19+
20+
push_server = PushServer(gateway_ip)
21+
gateway = Gateway(gateway_ip, token)
22+
23+
await push_server.start()
24+
25+
push_server.register_miio_device(gateway, alarm_callback)
26+
27+
event_info = EventInfo(
28+
action="alarm_triggering",
29+
extra="[1,19,1,111,[0,1],2,0]",
30+
trigger_token=gateway.token,
31+
)
32+
33+
await loop.run_in_executor(None, push_server.subscribe_event, gateway, event_info)
34+
35+
_LOGGER.info("Listening")
36+
37+
await asyncio.sleep(30)
38+
39+
push_server.stop()
40+
41+
42+
if __name__ == "__main__":
43+
loop = asyncio.get_event_loop()
44+
loop.run_until_complete(asyncio_demo(loop))
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import asyncio
2+
import logging
3+
4+
from miio import Gateway, PushServer
5+
from miio.push_server import EventInfo
6+
7+
_LOGGER = logging.getLogger(__name__)
8+
logging.basicConfig(level="INFO")
9+
10+
gateway_ip = "192.168.1.IP"
11+
token = "TokenTokenToken" # nosec
12+
button_sid = "lumi.123456789abcdef"
13+
14+
15+
async def asyncio_demo(loop):
16+
def subdevice_callback(source_device, action, params):
17+
_LOGGER.info(
18+
"callback '%s' from '%s', params: '%s'", action, source_device, params
19+
)
20+
21+
push_server = PushServer(gateway_ip)
22+
gateway = Gateway(gateway_ip, token)
23+
24+
await push_server.start()
25+
26+
push_server.register_miio_device(gateway, subdevice_callback)
27+
28+
await loop.run_in_executor(None, gateway.discover_devices)
29+
30+
button = gateway.devices[button_sid]
31+
32+
event_info = EventInfo(
33+
action="click_ch0",
34+
extra="[1,13,1,85,[0,1],0,0]",
35+
source_sid=button.sid,
36+
source_model=button.zigbee_model,
37+
)
38+
39+
await loop.run_in_executor(None, push_server.subscribe_event, gateway, event_info)
40+
41+
_LOGGER.info("Listening")
42+
43+
await asyncio.sleep(30)
44+
45+
push_server.stop()
46+
47+
48+
if __name__ == "__main__":
49+
loop = asyncio.get_event_loop()
50+
loop.run_until_complete(asyncio_demo(loop))

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ who have helped to extend this to cover not only the vacuum cleaner.
3030
troubleshooting
3131
contributing
3232
device_docs/index
33+
push_server
3334

3435
API <api/miio>

docs/push_server.rst

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
Push Server
2+
===========
3+
4+
The package provides a push server to act on events from devices,
5+
such as those from Zigbee devices connected to a gateway device.
6+
The server itself acts as a miio device receiving the events it has :ref:`subscribed to receive<events_subscribe>`,
7+
and calling the registered callbacks accordingly.
8+
9+
.. note::
10+
11+
While the eventing has been so far tested only on gateway devices, other devices that allow scene definitions on the
12+
mobile app may potentially support this functionality. See :ref:`how to obtain event information<events_obtain>` for details
13+
how to check if your target device supports this functionality.
14+
15+
16+
1. The push server is started and listens for incoming messages (:meth:`PushServer.start`)
17+
2. A miio device and its callback needs to be registered to the push server (:meth:`PushServer.register_miio_device`).
18+
3. A message is sent to the miio device to subscribe a specific event to the push server,
19+
basically a local scene is made with as target the push server (:meth:`PushServer.subscribe_event`).
20+
4. The device will start keep alive communication with the push server (pings).
21+
5. When the device triggers an event (e.g., a button is pressed),
22+
the push server gets notified by the device and executes the registered callback.
23+
24+
25+
Events
26+
------
27+
28+
Events are the triggers for a scene in the mobile app.
29+
Most triggers that can be used in the mobile app can be converted to a event that can be registered to the push server.
30+
For example: pressing a button, opening a door-sensor, motion being detected, vibrating a sensor or flipping a cube.
31+
When such a event happens,
32+
the miio device will immediately send a message to to push server,
33+
which will identify the sender and execute its callback function.
34+
The callback function can be used to act on the event,
35+
for instance when motion is detected turn on the light.
36+
37+
Callbacks
38+
---------
39+
40+
Gateway-like devices will have a single callback for all connected Zigbee devices.
41+
The `source_device` argument is set to the device that caused the event e.g. "lumi.123456789abcdef".
42+
43+
Multiple events of the same device can be subscribed to, for instance both opening and closing a door-sensor.
44+
The `action` argument is set to the action e.g., "open" or "close" ,
45+
that was defined in the :class:`PushServer.EventInfo` used for subscribing to the event.
46+
47+
Lastly, the `params` argument provides additional information about the event, if available.
48+
49+
Therefore, the callback functions need to have the following signature:
50+
51+
.. code-block::
52+
53+
def callback(source_device, action, params):
54+
55+
56+
.. _events_subscribe:
57+
58+
Subscribing to Events
59+
~~~~~~~~~~~~~~~~~~~~~
60+
In order to subscribe to a event a few steps need to be taken,
61+
we assume that a device class has already been initialized to which the events belong:
62+
63+
1. Create a push server instance:
64+
65+
::
66+
67+
server = PushServer(miio_device.ip)
68+
69+
.. note::
70+
71+
The server needs an IP address of a real, working miio device as it connects to it to find the IP address to bind on.
72+
73+
2. Start the server:
74+
75+
::
76+
77+
await push_server.start()
78+
79+
3. Define a callback function:
80+
81+
::
82+
83+
def callback_func(source_device, action, params):
84+
_LOGGER.info("callback '%s' from '%s', params: '%s'", action, source_device, params)
85+
86+
4. Register the miio device to the server and its callback function to receive events from this device:
87+
88+
::
89+
90+
push_server.register_miio_device(miio_device, callback_func)
91+
92+
5. Create an :class:`PushServer.EventInfo` (:ref:`how to obtain event info<obtain_event_info>`)
93+
object with the event to subscribe to:
94+
95+
::
96+
97+
event_info = EventInfo(
98+
action="alarm_triggering",
99+
extra="[1,19,1,111,[0,1],2,0]",
100+
trigger_token=miio_device.token,
101+
)
102+
103+
6. Send a message to the device to subscribe for the event to receive messages on the push_server:
104+
105+
::
106+
107+
push_server.subscribe_event(miio_device, event_info)
108+
109+
7. The callback function should now be called whenever a matching event occurs.
110+
111+
8. You should stop the server when you are done with it.
112+
This will automatically inform all devices with event subscriptions
113+
to stop sending more events to the server.
114+
115+
::
116+
117+
push_server.stop()
118+
119+
120+
.. _obtain_event_info:
121+
122+
Obtaining Event Information
123+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
124+
125+
When you want to support a new type of event in python-miio,
126+
you need to first perform a packet capture of the mobile Xiaomi Home app
127+
to retrieve the necessary information for that event.
128+
129+
1. Prepare your system to capture traffic between the gateway device and your mobile phone. You can, for example, use `BlueStacks emulator <https://www.bluestacks.com>`_ to run the Xiaomi Home app, and `WireShark <https://www.wireshark.org>`_ to capture the network traffic.
130+
2. In the Xiaomi Home app go to `Scene` --> `+` --> for "If" select the device for which you want to make the new event
131+
3. Select the event you want to add
132+
4. For "Then" select the same gateway as the Zigbee device is connected to (or the gateway itself).
133+
5. Select the any action, e.g., "Control nightlight" --> "Switch gateway light color",
134+
and click the finish checkmark and accept the default name.
135+
6. Repeat the steps 3-5 for all new events you want to implement.
136+
7. After you are done, you can remove the created scenes from the app and stop the traffic capture.
137+
8. You can use `devtools/parse_pcap.py` script to parse the captured PCAP files.
138+
139+
::
140+
141+
python devtools/parse_pcap.py <pcap file> --token <token of your gateway>
142+
143+
144+
.. note::
145+
146+
Note, you can repeat `--token` parameter to list all tokens you know to decrypt traffic from all devices:
147+
148+
10. You should now see the decoded communication of between the Xiaomi Home app and your gateway.
149+
11. You should see packets like the following in the output,
150+
the most important information is stored under the `data` key:
151+
152+
::
153+
154+
{
155+
"id" : 1234,
156+
"method" : "send_data_frame",
157+
"params" : {
158+
"cur" : 0,
159+
"data" : "[[\"x.scene.1234567890\",[\"1.0\",1234567890,[\"0\",{\"src\":\"device\",\"key\":\"event.lumi.sensor_magnet.aq2.open\",\"did\":\"lumi.123456789abcde\",\"model\":\"lumi.sensor_magnet.aq2\",\"token\":\"\",\"extra\":\"[1,6,1,0,[0,1],2,0]\",\"timespan\":[\"0 0 * * 0,1,2,3,4,5,6\",\"0 0 * * 0,1,2,3,4,5,6\"]}],[{\"command\":\"lumi.gateway.v3.set_rgb\",\"did\":\"12345678\",\"extra\":\"[1,19,7,85,[40,123456],0,0]\",\"id\":1,\"ip\":\"192.168.1.IP\",\"model\":\"lumi.gateway.v3\",\"token\":\"encrypted0token0we0need000000000\",\"value\":123456}]]]]",
160+
"data_tkn" : 12345,
161+
"total" : 1,
162+
"type" : "scene"
163+
}
164+
}
165+
166+
167+
12. Now, extract the necessary information form the packet capture to create :class:`PushServer.EventInfo` objects.
168+
169+
13. Locate the element containing `"key": "event.*"` in the trace,
170+
this is the event triggering the command in the trace.
171+
The `action` of the `EventInfo` is normally the last part of the `key` value, e.g.,
172+
`open` (from `event.lumi.sensor_magnet.aq2.open`) in the example above.
173+
174+
14. The `extra` parameter is the most important piece containing the event details,
175+
which you can directly copy from the packet capture.
176+
177+
::
178+
179+
event_info = EventInfo(
180+
action="open",
181+
extra="[1,6,1,0,[0,1],2,0]",
182+
)
183+
184+
185+
.. note::
186+
187+
The `action` is an user friendly name of the event, can be set arbitrarily and will be received by the server as the name of the event.
188+
The `extra` is the identification of the event.
189+
190+
Most times this information will be enough, however the :class:`miio.EventInfo` class allows for additional information.
191+
For example, on Zigbee sub-devices you also need to define `source_sid` and `source_model`,
192+
see :ref:`button press <_button_press_example>` for an example.
193+
See the :class:`PushServer.EventInfo` for more detailed documentation.
194+
195+
196+
Examples
197+
--------
198+
199+
Gateway alarm trigger
200+
~~~~~~~~~~~~~~~~~~~~~
201+
202+
The following example shows how to create a push server and make it to listen for alarm triggers from a gateway device.
203+
This is proper async python code that can be executed as a script.
204+
205+
206+
.. literalinclude:: examples/push_server/gateway_alarm_trigger.py
207+
:language: python
208+
209+
210+
211+
.. _button_press_example:
212+
213+
Button press
214+
~~~~~~~~~~~~
215+
216+
The following examples shows a more complex use case of acting on button presses of Aqara Zigbee button.
217+
Since the source device (the button) differs from the communicating device (the gateway),
218+
some additional parameters are needed for the :class:`PushServer.EventInfo`: `source_sid` and `source_model`.
219+
220+
.. literalinclude:: examples/push_server/gateway_button_press.py
221+
:language: python
222+
223+
224+
:py:class:`API <miio.push_server>`

miio/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
)
7979
from miio.powerstrip import PowerStrip
8080
from miio.protocol import Message, Utils
81+
from miio.push_server import EventInfo, PushServer
8182
from miio.pwzn_relay import PwznRelay
8283
from miio.scishare_coffeemaker import ScishareCoffee
8384
from miio.toiletlid import Toiletlid

miio/push_server/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Async UDP push server acting as a fake miio device to handle event notifications from
2+
other devices."""
3+
4+
# flake8: noqa
5+
from .eventinfo import EventInfo
6+
from .server import PushServer

miio/push_server/eventinfo.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Any, Optional
2+
3+
import attr
4+
5+
6+
@attr.s(auto_attribs=True)
7+
class EventInfo:
8+
"""Event info to register to the push server.
9+
10+
action: user friendly name of the event, can be set arbitrarily and will be received by the server as the name of the event.
11+
extra: the identification of this event, this determines on what event the callback is triggered.
12+
event: defaults to the action.
13+
command_extra: will be received by the push server, hopefully this will allow us to obtain extra information about the event for instance the vibration intesisty or light level that triggered the event (still experimental).
14+
trigger_value: Only needed if the trigger has a certain threshold value (like a temperature for a wheather sensor), a "value" key will be present in the first part of a scene packet capture.
15+
trigger_token: Only needed for protected events like the alarm feature of a gateway, equal to the "token" of the first part of of a scene packet caputure.
16+
source_sid: Normally not needed and obtained from device, only needed for zigbee devices: the "did" key.
17+
source_model: Normally not needed and obtained from device, only needed for zigbee devices: the "model" key.
18+
"""
19+
20+
action: str
21+
extra: str
22+
event: Optional[str] = None
23+
command_extra: str = ""
24+
trigger_value: Optional[Any] = None
25+
trigger_token: str = ""
26+
source_sid: Optional[str] = None
27+
source_model: Optional[str] = None

0 commit comments

Comments
 (0)