pywemo 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pywemo/README.md +69 -0
- pywemo/__init__.py +33 -0
- pywemo/color.py +79 -0
- pywemo/discovery.py +194 -0
- pywemo/exceptions.py +94 -0
- pywemo/ouimeaux_device/LICENSE +12 -0
- pywemo/ouimeaux_device/__init__.py +679 -0
- pywemo/ouimeaux_device/api/__init__.py +1 -0
- pywemo/ouimeaux_device/api/attributes.py +131 -0
- pywemo/ouimeaux_device/api/db_orm.py +197 -0
- pywemo/ouimeaux_device/api/long_press.py +168 -0
- pywemo/ouimeaux_device/api/rules_db.py +467 -0
- pywemo/ouimeaux_device/api/service.py +363 -0
- pywemo/ouimeaux_device/api/wemo_services.py +25 -0
- pywemo/ouimeaux_device/api/wemo_services.pyi +241 -0
- pywemo/ouimeaux_device/api/xsd/__init__.py +1 -0
- pywemo/ouimeaux_device/api/xsd/device.py +3888 -0
- pywemo/ouimeaux_device/api/xsd/device.xsd +95 -0
- pywemo/ouimeaux_device/api/xsd/service.py +3872 -0
- pywemo/ouimeaux_device/api/xsd/service.xsd +93 -0
- pywemo/ouimeaux_device/api/xsd_types.py +222 -0
- pywemo/ouimeaux_device/bridge.py +506 -0
- pywemo/ouimeaux_device/coffeemaker.py +92 -0
- pywemo/ouimeaux_device/crockpot.py +157 -0
- pywemo/ouimeaux_device/dimmer.py +70 -0
- pywemo/ouimeaux_device/humidifier.py +223 -0
- pywemo/ouimeaux_device/insight.py +191 -0
- pywemo/ouimeaux_device/lightswitch.py +11 -0
- pywemo/ouimeaux_device/maker.py +54 -0
- pywemo/ouimeaux_device/motion.py +6 -0
- pywemo/ouimeaux_device/outdoor_plug.py +6 -0
- pywemo/ouimeaux_device/switch.py +32 -0
- pywemo/py.typed +0 -0
- pywemo/ssdp.py +372 -0
- pywemo/subscribe.py +782 -0
- pywemo/util.py +139 -0
- pywemo-1.4.0.dist-info/LICENSE +54 -0
- pywemo-1.4.0.dist-info/METADATA +192 -0
- pywemo-1.4.0.dist-info/RECORD +40 -0
- pywemo-1.4.0.dist-info/WHEEL +4 -0
pywemo/subscribe.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Module to listen for WeMo events.
|
|
2
|
+
|
|
3
|
+
Example usage:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import pywemo
|
|
7
|
+
# The SubscriptionRegistry maintains push subscriptions to each endpoint
|
|
8
|
+
# of a device.
|
|
9
|
+
registry = pywemo.SubscriptionRegistry()
|
|
10
|
+
registry.start()
|
|
11
|
+
|
|
12
|
+
device = ... # See example of discovering devices in the pywemo module.
|
|
13
|
+
|
|
14
|
+
# Start subscribing to push notifications of state changes.
|
|
15
|
+
registry.register(device)
|
|
16
|
+
|
|
17
|
+
def push_notification(device, event, params):
|
|
18
|
+
'''Notify device of state change and get new device state.'''
|
|
19
|
+
processed_update = device.subscription_update(event, params)
|
|
20
|
+
state = device.get_state(force_update=not processed_update)
|
|
21
|
+
print(f"Device state: {state}")
|
|
22
|
+
|
|
23
|
+
# Register a callback to receive state push notifications.
|
|
24
|
+
registry.on(device, None, push_notification)
|
|
25
|
+
|
|
26
|
+
# Do some work.
|
|
27
|
+
# time.sleep(60)
|
|
28
|
+
|
|
29
|
+
# Stop the registry
|
|
30
|
+
registry.unregister(device)
|
|
31
|
+
registry.stop()
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import collections
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import sched
|
|
40
|
+
import secrets
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
import warnings
|
|
44
|
+
from collections.abc import Iterable, MutableMapping
|
|
45
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
46
|
+
from typing import Any, Callable
|
|
47
|
+
|
|
48
|
+
import requests
|
|
49
|
+
from lxml import etree as et
|
|
50
|
+
|
|
51
|
+
from .exceptions import SubscriptionRegistryFailed
|
|
52
|
+
from .ouimeaux_device import Device
|
|
53
|
+
from .ouimeaux_device.api.long_press import VIRTUAL_DEVICE_UDN
|
|
54
|
+
from .ouimeaux_device.api.service import REQUESTS_TIMEOUT
|
|
55
|
+
from .ouimeaux_device.dimmer import DimmerV2
|
|
56
|
+
from .ouimeaux_device.insight import Insight
|
|
57
|
+
from .util import get_callback_address
|
|
58
|
+
|
|
59
|
+
# Subscription event types.
|
|
60
|
+
EVENT_TYPE_BINARY_STATE = "BinaryState"
|
|
61
|
+
EVENT_TYPE_INSIGHT_PARAMS = "InsightParams"
|
|
62
|
+
EVENT_TYPE_LONG_PRESS = "LongPress"
|
|
63
|
+
|
|
64
|
+
LOG = logging.getLogger(__name__)
|
|
65
|
+
NS = "{urn:schemas-upnp-org:event-1-0}"
|
|
66
|
+
RESPONSE_SUCCESS = "<html><body><h1>200 OK</h1></body></html>"
|
|
67
|
+
RESPONSE_NOT_FOUND = "<html><body><h1>404 Not Found</h1></body></html>"
|
|
68
|
+
SUBSCRIPTION_RETRY = 60
|
|
69
|
+
|
|
70
|
+
VIRTUAL_SETUP_XML = f"""<?xml version="1.0"?>
|
|
71
|
+
<root xmlns="urn:Belkin:device-1-0">
|
|
72
|
+
<specVersion>
|
|
73
|
+
<major>1</major>
|
|
74
|
+
<minor>0</minor>
|
|
75
|
+
</specVersion>
|
|
76
|
+
<device>
|
|
77
|
+
<deviceType>urn:Belkin:device:controllee:1</deviceType>
|
|
78
|
+
<friendlyName>pywemo virtual device</friendlyName>
|
|
79
|
+
<manufacturer>pywemo</manufacturer>
|
|
80
|
+
<manufacturerURL>https://github.com/pywemo/pywemo</manufacturerURL>
|
|
81
|
+
<modelDescription>pywemo virtual device</modelDescription>
|
|
82
|
+
<modelName>Socket</modelName>
|
|
83
|
+
<modelNumber>1.0</modelNumber>
|
|
84
|
+
<hwVersion>v1</hwVersion>
|
|
85
|
+
<modelURL>http://www.belkin.com/plugin/</modelURL>
|
|
86
|
+
<serialNumber>PyWemoVirtualDevice</serialNumber>
|
|
87
|
+
<firmwareVersion>WeMo_US_2.00.2769.PVT</firmwareVersion>
|
|
88
|
+
<UDN>{VIRTUAL_DEVICE_UDN}</UDN>
|
|
89
|
+
<binaryState>0</binaryState>
|
|
90
|
+
<serviceList>
|
|
91
|
+
<service>
|
|
92
|
+
<serviceType>urn:Belkin:service:basicevent:1</serviceType>
|
|
93
|
+
<serviceId>urn:Belkin:serviceId:basicevent1</serviceId>
|
|
94
|
+
<controlURL>/upnp/control/basicevent1</controlURL>
|
|
95
|
+
<eventSubURL>/upnp/event/basicevent1</eventSubURL>
|
|
96
|
+
<SCPDURL>/eventservice.xml</SCPDURL>
|
|
97
|
+
</service>
|
|
98
|
+
</serviceList>
|
|
99
|
+
<presentationURL>/pluginpres.html</presentationURL>
|
|
100
|
+
</device>
|
|
101
|
+
</root>"""
|
|
102
|
+
|
|
103
|
+
SOAP_ACTION_RESPONSE = {
|
|
104
|
+
'"urn:Belkin:service:basicevent:1#GetBinaryState"': """<s:Envelope
|
|
105
|
+
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
|
106
|
+
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
107
|
+
<s:Body>
|
|
108
|
+
<u:GetBinaryStateResponse xmlns:u="urn:Belkin:service:basicevent:1">
|
|
109
|
+
<BinaryState>0</BinaryState>
|
|
110
|
+
</u:GetBinaryStateResponse>
|
|
111
|
+
</s:Body></s:Envelope>""",
|
|
112
|
+
'"urn:Belkin:service:basicevent:1#SetBinaryState"': """<s:Envelope
|
|
113
|
+
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
|
114
|
+
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
115
|
+
<s:Body>
|
|
116
|
+
<u:SetBinaryStateResponse xmlns:u="urn:Belkin:service:basicevent:1">
|
|
117
|
+
<BinaryState>0</BinaryState>
|
|
118
|
+
</u:SetBinaryStateResponse>
|
|
119
|
+
</s:Body></s:Envelope>""",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ERROR_SOAP_ACTION_RESPONSE = """<?xml version='1.0' encoding='UTF-8'?>
|
|
123
|
+
<s:Envelope
|
|
124
|
+
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
|
125
|
+
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
126
|
+
<s:Body>
|
|
127
|
+
<s:Fault>
|
|
128
|
+
<faultcode>SOAP-ENV:Server</faultcode>
|
|
129
|
+
<faultstring>Unknown Action</faultstring>
|
|
130
|
+
<detail>The requested SOAP action is not handled by pyWeMo</detail>
|
|
131
|
+
</s:Fault>
|
|
132
|
+
</s:Body>
|
|
133
|
+
</s:Envelope>"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Subscription:
|
|
137
|
+
"""Subscription to a single UPnP service endpoint."""
|
|
138
|
+
|
|
139
|
+
scheduler_event: sched.Event | None = None
|
|
140
|
+
"""Scheduler Event used to periodically maintain the subscription."""
|
|
141
|
+
|
|
142
|
+
scheduler_active: bool = True
|
|
143
|
+
"""
|
|
144
|
+
Controls whether or not the subscription will continue to be periodically
|
|
145
|
+
scheduled by the Scheduler. Set to False when the device is unregistered.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
expiration_time: float = 0.0
|
|
149
|
+
"""Time that the subscription will expire, or 0.0 if not subscribed.
|
|
150
|
+
time.time() value.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
event_received: bool = False
|
|
154
|
+
"""Has a notification event been received for this subscription?"""
|
|
155
|
+
|
|
156
|
+
subscription_id: str | None = None
|
|
157
|
+
"""Subscription Identifier (SID) used to maintain/refresh the subscription.
|
|
158
|
+
`None` when the subscription is not active.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
default_timeout_seconds: int = 300
|
|
162
|
+
"""
|
|
163
|
+
Request that the device keep the subscription active for this number of
|
|
164
|
+
seconds.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
device: Device
|
|
168
|
+
"""WeMo device instance."""
|
|
169
|
+
|
|
170
|
+
callback_port: int
|
|
171
|
+
"""HTTP port used by devices to send event notifications."""
|
|
172
|
+
|
|
173
|
+
service_name: str
|
|
174
|
+
"""Name of the subscription endpoint service."""
|
|
175
|
+
|
|
176
|
+
path: str
|
|
177
|
+
"""Unique path used to for the subscription callback."""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self, device: Device, callback_port: int, service_name: str
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Initialize a new subscription."""
|
|
183
|
+
self.device = device
|
|
184
|
+
self.callback_port = callback_port
|
|
185
|
+
self.service_name = service_name
|
|
186
|
+
self.path = f"/sub/{service_name}/{secrets.token_urlsafe(24)}"
|
|
187
|
+
|
|
188
|
+
def __repr__(self) -> str:
|
|
189
|
+
"""Return a string representation of the Subscription."""
|
|
190
|
+
return f'<Subscription {self.service_name} "{self.device.name}">'
|
|
191
|
+
|
|
192
|
+
def maintain(self) -> int:
|
|
193
|
+
"""Start/renew the UPnP subscription.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The duration of the subscription in seconds.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
requests.RequestException on error.
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
response = self._subscribe()
|
|
203
|
+
if response.status_code == 412: # Precondition Failed.
|
|
204
|
+
# Invalid parameters were used for the subscription request.
|
|
205
|
+
# This typically happens when the subscription_id becomes
|
|
206
|
+
# invalid. Send an UNSUBSCRIBE for safety and then attempt to
|
|
207
|
+
# subscribe again.
|
|
208
|
+
self._unsubscribe()
|
|
209
|
+
|
|
210
|
+
# Also reset the `event_received` boolean at this point. A 412
|
|
211
|
+
# response code to a subscription renewal also happens when a
|
|
212
|
+
# device restarts. When a device loses power and power is
|
|
213
|
+
# restored it's possible that the device's state has changed.
|
|
214
|
+
# We need to reconfirm that the initial event is received again
|
|
215
|
+
# so that the device state is reported properly. And for
|
|
216
|
+
# devices that don't report their initial state, it's important
|
|
217
|
+
# that clients are aware that they should begin polling the
|
|
218
|
+
# device again.
|
|
219
|
+
self.event_received = False
|
|
220
|
+
|
|
221
|
+
# Try the subscription again.
|
|
222
|
+
response = self._subscribe()
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
except requests.RequestException:
|
|
225
|
+
self._reset_subscription()
|
|
226
|
+
raise
|
|
227
|
+
|
|
228
|
+
return self._update_subscription(response.headers)
|
|
229
|
+
|
|
230
|
+
def cancel(self) -> None:
|
|
231
|
+
"""Cancel a subscription."""
|
|
232
|
+
if self.expiration_time > time.time():
|
|
233
|
+
try:
|
|
234
|
+
self._unsubscribe()
|
|
235
|
+
except requests.RequestException:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
self._reset_subscription()
|
|
239
|
+
|
|
240
|
+
def _subscribe(self) -> requests.Response:
|
|
241
|
+
"""Start/renew a subscription with a UPnP SUBSCRIBE request.
|
|
242
|
+
|
|
243
|
+
Will renew an existing subscription if one exists, otherwise a new
|
|
244
|
+
subscription will be created.
|
|
245
|
+
"""
|
|
246
|
+
if self.subscription_id: # Renew existing subscription.
|
|
247
|
+
headers = {"SID": self.subscription_id}
|
|
248
|
+
else: # Start a new subscription.
|
|
249
|
+
callback_address = get_callback_address(
|
|
250
|
+
host=self.device.host,
|
|
251
|
+
port=self.callback_port,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
callback = f"<http://{callback_address}{self.path}>"
|
|
255
|
+
headers = {"CALLBACK": callback, "NT": "upnp:event"}
|
|
256
|
+
headers["TIMEOUT"] = f"Second-{self.default_timeout_seconds}"
|
|
257
|
+
return requests.request(
|
|
258
|
+
method="SUBSCRIBE",
|
|
259
|
+
url=self.url,
|
|
260
|
+
headers=headers,
|
|
261
|
+
timeout=REQUESTS_TIMEOUT,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _unsubscribe(self) -> None:
|
|
265
|
+
"""Remove the subscription on the WeMo with a UPnP UNSUBSCRIBE request.
|
|
266
|
+
|
|
267
|
+
Only sends the UNSUBSCRIBE message if there is an existing
|
|
268
|
+
subscription. Does nothing if there is no subscription.
|
|
269
|
+
"""
|
|
270
|
+
if self.subscription_id:
|
|
271
|
+
requests.request(
|
|
272
|
+
method="UNSUBSCRIBE",
|
|
273
|
+
url=self.url,
|
|
274
|
+
headers={"SID": self.subscription_id},
|
|
275
|
+
timeout=REQUESTS_TIMEOUT,
|
|
276
|
+
)
|
|
277
|
+
self.subscription_id = None
|
|
278
|
+
|
|
279
|
+
def _update_subscription(self, headers: MutableMapping[str, str]) -> int:
|
|
280
|
+
"""Update UPnP subscription parameters from SUBSCRIBE response headers.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The duration of the subscription in seconds.
|
|
284
|
+
"""
|
|
285
|
+
self.subscription_id = headers.get("SID", self.subscription_id)
|
|
286
|
+
if timeout_header := headers.get("TIMEOUT", None):
|
|
287
|
+
timeout = min(
|
|
288
|
+
int(timeout_header.replace("Second-", "")),
|
|
289
|
+
self.default_timeout_seconds,
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
timeout = self.default_timeout_seconds
|
|
293
|
+
self.expiration_time = timeout + time.time()
|
|
294
|
+
return timeout
|
|
295
|
+
|
|
296
|
+
def _reset_subscription(self) -> None:
|
|
297
|
+
"""Mark a subscription as no longer active.
|
|
298
|
+
|
|
299
|
+
`self.is_subscribed` will return False after this call.
|
|
300
|
+
"""
|
|
301
|
+
self.event_received = False
|
|
302
|
+
self.expiration_time = 0.0
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def url(self) -> str:
|
|
306
|
+
"""URL for the UPnP subscription endoint."""
|
|
307
|
+
return self.device.services[self.service_name].eventSubURL
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def is_subscribed(self) -> bool:
|
|
311
|
+
"""Return True if the subscription is active, False otherwise.
|
|
312
|
+
|
|
313
|
+
Verifies that the subscription is within its expected lifetime and that
|
|
314
|
+
at least one event notification has been received.
|
|
315
|
+
|
|
316
|
+
There will always be at least one event notification because the UPnP
|
|
317
|
+
spec states that the device will send an event notification when the
|
|
318
|
+
subscription is first accepted.
|
|
319
|
+
"""
|
|
320
|
+
return self.event_received and self.expiration_time > time.time()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class HTTPServer(ThreadingHTTPServer):
|
|
324
|
+
"""ThreadingHTTPServer with an 'outer' attribute."""
|
|
325
|
+
|
|
326
|
+
outer: SubscriptionRegistry
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _start_server(port: int | None) -> HTTPServer:
|
|
330
|
+
"""Find a valid open port and start the HTTP server."""
|
|
331
|
+
requested_port = port or os.getenv("PYWEMO_HTTP_SERVER_PORT")
|
|
332
|
+
if requested_port is not None:
|
|
333
|
+
start_port = int(requested_port)
|
|
334
|
+
ports_to_check = 1
|
|
335
|
+
else:
|
|
336
|
+
start_port = 8989
|
|
337
|
+
ports_to_check = 128
|
|
338
|
+
|
|
339
|
+
for offset in range(0, ports_to_check):
|
|
340
|
+
port = start_port + offset
|
|
341
|
+
try:
|
|
342
|
+
return HTTPServer(("", port), RequestHandler)
|
|
343
|
+
except OSError as error:
|
|
344
|
+
last_error = error
|
|
345
|
+
raise last_error
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cancel_events(
|
|
349
|
+
scheduler: sched.scheduler, subscriptions: Iterable[Subscription]
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Cancel pending scheduler events."""
|
|
352
|
+
for subscription in subscriptions:
|
|
353
|
+
try:
|
|
354
|
+
if subscription.scheduler_event is not None:
|
|
355
|
+
scheduler.cancel(subscription.scheduler_event)
|
|
356
|
+
except ValueError:
|
|
357
|
+
# event might execute and be removed from queue
|
|
358
|
+
# concurrently. Safe to ignore
|
|
359
|
+
pass
|
|
360
|
+
if subscription.scheduler_active:
|
|
361
|
+
scheduler.enter(0, 0, subscription.cancel)
|
|
362
|
+
# Prevent the subscription from being scheduled again.
|
|
363
|
+
subscription.scheduler_active = False
|
|
364
|
+
subscription.scheduler_event = None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class RequestHandler(BaseHTTPRequestHandler):
|
|
368
|
+
"""Handles subscription responses and long press actions from devices.
|
|
369
|
+
|
|
370
|
+
Subscription responses:
|
|
371
|
+
Pywemo can subscribe to Wemo devices. When subscribed, the Wemo device
|
|
372
|
+
will send notifications when the state of the device changes. The
|
|
373
|
+
do_NOTIFY method below is called when a Wemo device changes state.
|
|
374
|
+
|
|
375
|
+
Long press actions:
|
|
376
|
+
Wemo devices can control the state of other Wemo devices based on the
|
|
377
|
+
rules configured for the device. A long press rule is activated whenever
|
|
378
|
+
the button on the Wemo device is pressed for 2 seconds. The long press
|
|
379
|
+
rule is meant to be used to control the state of another device (turn
|
|
380
|
+
on/off/toggle). However for pywemo's use, a long press rule can be used
|
|
381
|
+
to trigger an event notification. This is implemented by configuring the
|
|
382
|
+
Wemo device to "control the state" of a virtual Wemo device. The virtual
|
|
383
|
+
device is implemented by this class.
|
|
384
|
+
|
|
385
|
+
The do_GET/do_POST/do_SUBSCRIBE methods below implement a virtual Wemo
|
|
386
|
+
device. The virtual device receives requests to change its state from
|
|
387
|
+
other Wemo devices on the network. When a Wemo device is configured to
|
|
388
|
+
change the state of the virtual device via a long press rule the
|
|
389
|
+
following sequence occurs:
|
|
390
|
+
|
|
391
|
+
1. The Wemo device will attempt to locate the virtual device on the
|
|
392
|
+
network. This is handled by the pywemo.ssdp.DiscoveryResponder class. See
|
|
393
|
+
the documentation there for more information about this step.
|
|
394
|
+
|
|
395
|
+
2. The Wemo device will fetch /setup.xml from do_GET to learn of the
|
|
396
|
+
virtual device details.
|
|
397
|
+
|
|
398
|
+
3. The Wemo device will subscribe to BinaryState notifications from the
|
|
399
|
+
virtual device. The virtual device does not send any BinaryState
|
|
400
|
+
notifications, but this step seems to be necessary before the next step
|
|
401
|
+
can happen. This step is implemented by the do_SUBSCRIBE method.
|
|
402
|
+
|
|
403
|
+
4. When a person presses the button on the Wemo for 2 seconds a long
|
|
404
|
+
press rule is triggered. If the long press rule is configured with an
|
|
405
|
+
action for the virtual device, the Wemo device will then call the do_POST
|
|
406
|
+
method to update the BinaryState of the virtual device. This doesn't
|
|
407
|
+
actually update any state, rather the virtual device then delivers the
|
|
408
|
+
event notification to any event listeners configured to receive events
|
|
409
|
+
from the pywemo SubscriptionRegistry. The event type for a long press
|
|
410
|
+
action is EVENT_TYPE_LONG_PRESS.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
timeout = 10
|
|
414
|
+
"""Do not wait for more than 10 seconds for any request to complete."""
|
|
415
|
+
|
|
416
|
+
server: HTTPServer
|
|
417
|
+
server_version = f"{BaseHTTPRequestHandler.server_version} UPnP/1.0"
|
|
418
|
+
|
|
419
|
+
def do_NOTIFY(self) -> None: # pylint: disable=invalid-name
|
|
420
|
+
"""Handle subscription responses received from devices."""
|
|
421
|
+
sender_ip, _ = self.client_address
|
|
422
|
+
outer = self.server.outer
|
|
423
|
+
# Security consideration: Given that the subscription paths are
|
|
424
|
+
# randomized, I considered removing the host/IP check below. However,
|
|
425
|
+
# since these requests are not encrypted, it is possible for someone
|
|
426
|
+
# to observe the random URL path. I therefore have kept the host/IP
|
|
427
|
+
# check as a defense-in-depth strategy for preventing the device state
|
|
428
|
+
# from being changed by someone who could observe the http requests.
|
|
429
|
+
if (
|
|
430
|
+
# pylint: disable=protected-access
|
|
431
|
+
subscription := outer._subscription_paths.get(self.path)
|
|
432
|
+
) is None or subscription.device.host != sender_ip:
|
|
433
|
+
LOG.warning(
|
|
434
|
+
"Received %s event for unregistered device %s",
|
|
435
|
+
self.path,
|
|
436
|
+
sender_ip,
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
doc = self._get_xml_from_http_body()
|
|
440
|
+
for propnode in doc.findall(f"./{NS}property"):
|
|
441
|
+
for property_ in list(propnode):
|
|
442
|
+
outer.event(
|
|
443
|
+
subscription.device,
|
|
444
|
+
property_.tag,
|
|
445
|
+
property_.text or "",
|
|
446
|
+
path=self.path,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
self._send_response(200, RESPONSE_SUCCESS)
|
|
450
|
+
|
|
451
|
+
def do_GET(self) -> None: # pylint: disable=invalid-name
|
|
452
|
+
"""Handle GET requests for a Virtual WeMo device."""
|
|
453
|
+
if self.path.endswith("/setup.xml"):
|
|
454
|
+
self._send_response(
|
|
455
|
+
200, VIRTUAL_SETUP_XML, content_type="text/xml"
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
self._send_response(404, RESPONSE_NOT_FOUND)
|
|
459
|
+
|
|
460
|
+
def do_POST(self) -> None: # pylint: disable=invalid-name
|
|
461
|
+
"""Handle POST requests for a Virtual WeMo device."""
|
|
462
|
+
if self.path.endswith("/upnp/control/basicevent1"):
|
|
463
|
+
sender_ip, _ = self.client_address
|
|
464
|
+
outer = self.server.outer
|
|
465
|
+
for (
|
|
466
|
+
device
|
|
467
|
+
) in outer._subscriptions: # pylint: disable=protected-access
|
|
468
|
+
if device.host != sender_ip:
|
|
469
|
+
continue
|
|
470
|
+
doc = self._get_xml_from_http_body()
|
|
471
|
+
if binary_state := doc.findtext(".//BinaryState"):
|
|
472
|
+
outer.event(device, EVENT_TYPE_LONG_PRESS, binary_state)
|
|
473
|
+
break
|
|
474
|
+
else:
|
|
475
|
+
LOG.warning(
|
|
476
|
+
"Received event for unregistered device %s", sender_ip
|
|
477
|
+
)
|
|
478
|
+
action = self.headers.get("SOAPACTION", "")
|
|
479
|
+
response = SOAP_ACTION_RESPONSE.get(
|
|
480
|
+
action, ERROR_SOAP_ACTION_RESPONSE
|
|
481
|
+
)
|
|
482
|
+
self._send_response(
|
|
483
|
+
200, response, content_type='text/xml; charset="utf-8"'
|
|
484
|
+
)
|
|
485
|
+
else:
|
|
486
|
+
self._send_response(404, RESPONSE_NOT_FOUND)
|
|
487
|
+
|
|
488
|
+
def do_SUBSCRIBE(self) -> None: # pylint: disable=invalid-name
|
|
489
|
+
"""Handle SUBSCRIBE requests for a Virtual WeMo device."""
|
|
490
|
+
if self.path.endswith("/upnp/event/basicevent1"):
|
|
491
|
+
self.send_response(200)
|
|
492
|
+
self.send_header("CONTENT-LENGTH", "0")
|
|
493
|
+
self.send_header("TIMEOUT", "Second-1801")
|
|
494
|
+
# Using a randomly generated valid UUID (uuid.uuid4()).
|
|
495
|
+
self.send_header(
|
|
496
|
+
"SID", "uuid:a74b23d5-34b9-4f71-9f87-bed24353f304"
|
|
497
|
+
)
|
|
498
|
+
self.send_header("Connection", "close")
|
|
499
|
+
self.end_headers()
|
|
500
|
+
else:
|
|
501
|
+
self._send_response(404, RESPONSE_NOT_FOUND)
|
|
502
|
+
|
|
503
|
+
def do_UNSUBSCRIBE(self) -> None: # pylint: disable=invalid-name
|
|
504
|
+
"""Handle UNSUBSCRIBE requests for a Virtual WeMo device."""
|
|
505
|
+
if self.path.endswith("/upnp/event/basicevent1"):
|
|
506
|
+
self.send_response(200)
|
|
507
|
+
self.send_header("CONTENT-LENGTH", "0")
|
|
508
|
+
self.send_header("Connection", "close")
|
|
509
|
+
self.end_headers()
|
|
510
|
+
else:
|
|
511
|
+
self._send_response(404, RESPONSE_NOT_FOUND)
|
|
512
|
+
|
|
513
|
+
def _send_response(
|
|
514
|
+
self, code: int, body: str, *, content_type: str = "text/html"
|
|
515
|
+
) -> None:
|
|
516
|
+
self.send_response(code)
|
|
517
|
+
self.send_header("Content-Type", content_type)
|
|
518
|
+
self.send_header("Content-Length", str(len(body)))
|
|
519
|
+
self.send_header("Connection", "close")
|
|
520
|
+
self.end_headers()
|
|
521
|
+
if body:
|
|
522
|
+
self.wfile.write(body.encode("UTF-8"))
|
|
523
|
+
|
|
524
|
+
def _get_xml_from_http_body(self) -> et._Element:
|
|
525
|
+
"""Build the element tree root from the body of the http request."""
|
|
526
|
+
content_len = int(self.headers.get("content-length", 0))
|
|
527
|
+
data = self.rfile.read(content_len)
|
|
528
|
+
# trim garbage from end, if any
|
|
529
|
+
data = data.strip()
|
|
530
|
+
return et.fromstring(data, parser=et.XMLParser(resolve_entities=False))
|
|
531
|
+
|
|
532
|
+
# pylint: disable=redefined-builtin
|
|
533
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
534
|
+
"""Disable error logging."""
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
SubscriberCallback = Callable[[Device, str, str], Any]
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class SubscriptionRegistry: # pylint: disable=too-many-instance-attributes
|
|
542
|
+
"""Holds device subscriptions and callbacks for wemo events."""
|
|
543
|
+
|
|
544
|
+
subscription_service_names: Iterable[str] = (
|
|
545
|
+
"basicevent",
|
|
546
|
+
"bridge",
|
|
547
|
+
"insight",
|
|
548
|
+
)
|
|
549
|
+
"""Potential service endpoints for subscriptions.
|
|
550
|
+
A Subscription will be created for each entry as long as the service is
|
|
551
|
+
supported by the device.
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
def __init__(self, requested_port: int | None = None) -> None:
|
|
555
|
+
"""Create the subscription registry object."""
|
|
556
|
+
self._callbacks: dict[
|
|
557
|
+
Device, list[tuple[str | None, SubscriberCallback]]
|
|
558
|
+
] = collections.defaultdict(list)
|
|
559
|
+
self._exiting = False
|
|
560
|
+
|
|
561
|
+
self._event_thread: threading.Thread | None = None
|
|
562
|
+
self._event_thread_cond = threading.Condition()
|
|
563
|
+
self._subscriptions: dict[Device, list[Subscription]] = {}
|
|
564
|
+
self._subscription_paths: dict[str, Subscription] = {}
|
|
565
|
+
|
|
566
|
+
def sleep(secs: float) -> None:
|
|
567
|
+
with self._event_thread_cond:
|
|
568
|
+
self._event_thread_cond.wait(secs)
|
|
569
|
+
|
|
570
|
+
self._sched = sched.scheduler(time.time, sleep)
|
|
571
|
+
|
|
572
|
+
self._http_thread: threading.Thread | None = None
|
|
573
|
+
self._httpd: HTTPServer | None = None
|
|
574
|
+
self._requested_port: int | None = requested_port
|
|
575
|
+
|
|
576
|
+
@property
|
|
577
|
+
def port(self) -> int:
|
|
578
|
+
"""Return the port that the http server is listening on."""
|
|
579
|
+
assert self._httpd
|
|
580
|
+
return self._httpd.server_address[1]
|
|
581
|
+
|
|
582
|
+
def register(self, device: Device) -> None:
|
|
583
|
+
"""Register a device for subscription updates."""
|
|
584
|
+
if not device:
|
|
585
|
+
LOG.error("Called with an invalid device: %r", device)
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
LOG.info("Subscribing to events from %r", device)
|
|
589
|
+
with self._event_thread_cond:
|
|
590
|
+
subscriptions = self._subscriptions[device] = []
|
|
591
|
+
for service in self.subscription_service_names:
|
|
592
|
+
if service in device.services:
|
|
593
|
+
subscription = Subscription(device, self.port, service)
|
|
594
|
+
subscriptions.append(subscription)
|
|
595
|
+
self._subscription_paths[subscription.path] = subscription
|
|
596
|
+
self._schedule(0, subscription)
|
|
597
|
+
self._event_thread_cond.notify()
|
|
598
|
+
|
|
599
|
+
def unregister(self, device: Device) -> None:
|
|
600
|
+
"""Unregister a device from subscription updates."""
|
|
601
|
+
if not device:
|
|
602
|
+
LOG.error("Called with an invalid device: %r", device)
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
LOG.info("Unsubscribing to events from %r", device)
|
|
606
|
+
|
|
607
|
+
with self._event_thread_cond:
|
|
608
|
+
# Remove any events, callbacks, and the device itself
|
|
609
|
+
self._callbacks.pop(device, None)
|
|
610
|
+
subscriptions = self._subscriptions.pop(device, [])
|
|
611
|
+
_cancel_events(self._sched, subscriptions)
|
|
612
|
+
for subscription in subscriptions:
|
|
613
|
+
del self._subscription_paths[subscription.path]
|
|
614
|
+
self._event_thread_cond.notify()
|
|
615
|
+
|
|
616
|
+
def _resubscribe(self, subscription: Subscription, retry: int = 0) -> None:
|
|
617
|
+
LOG.info("Resubscribe for %r", subscription)
|
|
618
|
+
try:
|
|
619
|
+
timeout = subscription.maintain()
|
|
620
|
+
with self._event_thread_cond:
|
|
621
|
+
self._schedule(int(timeout * 0.75), subscription)
|
|
622
|
+
except requests.RequestException as exc:
|
|
623
|
+
LOG.warning(
|
|
624
|
+
"Resubscribe error for %r (%s), will retry in %ss",
|
|
625
|
+
subscription,
|
|
626
|
+
exc,
|
|
627
|
+
SUBSCRIPTION_RETRY,
|
|
628
|
+
)
|
|
629
|
+
retry += 1
|
|
630
|
+
if retry > 1:
|
|
631
|
+
# If this wasn't a one-off, try rediscovery
|
|
632
|
+
# in case the device has changed.
|
|
633
|
+
subscription.device.reconnect_with_device()
|
|
634
|
+
with self._event_thread_cond:
|
|
635
|
+
self._schedule(SUBSCRIPTION_RETRY, subscription, retry=retry)
|
|
636
|
+
|
|
637
|
+
def _schedule(
|
|
638
|
+
self, delay: int, subscription: Subscription, **kwargs: Any
|
|
639
|
+
) -> None:
|
|
640
|
+
"""Schedule a subscription.
|
|
641
|
+
|
|
642
|
+
It is expected that the caller will hold the `_event_thread_cond` lock
|
|
643
|
+
before calling this method.
|
|
644
|
+
|
|
645
|
+
This method will not schedule a subscription when the
|
|
646
|
+
`subscription.scheduler_active` property is False. This is done to
|
|
647
|
+
avoid a race condition with the `unregister` method. Once `unregister`
|
|
648
|
+
removes the subscription, it should not be scheduled again.
|
|
649
|
+
"""
|
|
650
|
+
if subscription.scheduler_active:
|
|
651
|
+
subscription.scheduler_event = self._sched.enter(
|
|
652
|
+
delay,
|
|
653
|
+
0, # priority
|
|
654
|
+
self._resubscribe,
|
|
655
|
+
argument=(subscription,),
|
|
656
|
+
kwargs=kwargs,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
def event(
|
|
660
|
+
self, device: Device, type_: str, value: str, path: str | None = None
|
|
661
|
+
) -> None:
|
|
662
|
+
"""Execute the callback for a received event."""
|
|
663
|
+
LOG.debug(
|
|
664
|
+
"Received %s event from %s(%s) - %s %s",
|
|
665
|
+
path or "an",
|
|
666
|
+
device,
|
|
667
|
+
device.host,
|
|
668
|
+
type_,
|
|
669
|
+
value,
|
|
670
|
+
)
|
|
671
|
+
if path:
|
|
672
|
+
# Update the event_received property for the subscription.
|
|
673
|
+
if (
|
|
674
|
+
subscription := self._subscription_paths.get(path)
|
|
675
|
+
) is not None:
|
|
676
|
+
subscription.event_received = True
|
|
677
|
+
else:
|
|
678
|
+
LOG.warning(
|
|
679
|
+
"Received unexpected subscription path (%s) for device %s",
|
|
680
|
+
path,
|
|
681
|
+
device,
|
|
682
|
+
)
|
|
683
|
+
for type_filter, callback in self._callbacks.get(device, ()):
|
|
684
|
+
if type_filter is None or type_ == type_filter:
|
|
685
|
+
callback(device, type_, value)
|
|
686
|
+
|
|
687
|
+
def on( # pylint: disable=invalid-name
|
|
688
|
+
self,
|
|
689
|
+
device: Device,
|
|
690
|
+
type_filter: str | None,
|
|
691
|
+
callback: SubscriberCallback,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Add an event callback for a device."""
|
|
694
|
+
self._callbacks[device].append((type_filter, callback))
|
|
695
|
+
|
|
696
|
+
def is_subscribed(self, device: Device) -> bool:
|
|
697
|
+
"""Return True if all of the device's subscriptions are active."""
|
|
698
|
+
if isinstance(device, Insight) and device.get_state() == 0:
|
|
699
|
+
# Special case: When the Insight device is off, it stops reporting
|
|
700
|
+
# Insight subscription updates. This causes problems for the
|
|
701
|
+
# "today" energy properties on the device, which should reset at
|
|
702
|
+
# midnight but don't because subscription updates have stopped.
|
|
703
|
+
return False
|
|
704
|
+
if isinstance(device, DimmerV2) and device.get_state() == 1:
|
|
705
|
+
# Special case: The V2 (RTOS) Dimmers do not send subscription
|
|
706
|
+
# updates for brightness changes. Return False so clients know
|
|
707
|
+
# polling is required to update the device brightness.
|
|
708
|
+
return False
|
|
709
|
+
subscriptions = self._subscriptions.get(device, [])
|
|
710
|
+
return len(subscriptions) > 0 and all(
|
|
711
|
+
s.is_subscribed for s in subscriptions
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def start(self) -> None:
|
|
715
|
+
"""Start the subscription registry."""
|
|
716
|
+
self._httpd = _start_server(self._requested_port)
|
|
717
|
+
if self._httpd is None:
|
|
718
|
+
raise SubscriptionRegistryFailed(
|
|
719
|
+
"Unable to bind a port for listening"
|
|
720
|
+
)
|
|
721
|
+
self._http_thread = threading.Thread(
|
|
722
|
+
target=self._run_http_server, name="Wemo HTTP Thread"
|
|
723
|
+
)
|
|
724
|
+
self._http_thread.daemon = True
|
|
725
|
+
self._http_thread.start()
|
|
726
|
+
|
|
727
|
+
self._event_thread = threading.Thread(
|
|
728
|
+
target=self._run_event_loop, name="Wemo Events Thread"
|
|
729
|
+
)
|
|
730
|
+
self._event_thread.daemon = True
|
|
731
|
+
self._event_thread.start()
|
|
732
|
+
|
|
733
|
+
def stop(self) -> None:
|
|
734
|
+
"""Shutdown the HTTP server."""
|
|
735
|
+
assert self._httpd
|
|
736
|
+
self._httpd.shutdown()
|
|
737
|
+
|
|
738
|
+
with self._event_thread_cond:
|
|
739
|
+
self._exiting = True
|
|
740
|
+
|
|
741
|
+
# Remove any pending events
|
|
742
|
+
for device_subscriptions in self._subscriptions.values():
|
|
743
|
+
_cancel_events(self._sched, device_subscriptions)
|
|
744
|
+
|
|
745
|
+
# Wake up event thread if its sleeping
|
|
746
|
+
self._event_thread_cond.notify()
|
|
747
|
+
self.join()
|
|
748
|
+
LOG.info("Terminated threads")
|
|
749
|
+
|
|
750
|
+
def join(self) -> None:
|
|
751
|
+
"""Block until the HTTP server and event threads have terminated."""
|
|
752
|
+
assert self._http_thread and self._event_thread
|
|
753
|
+
self._http_thread.join()
|
|
754
|
+
self._event_thread.join()
|
|
755
|
+
|
|
756
|
+
def _run_http_server(self) -> None:
|
|
757
|
+
"""Start the HTTP server."""
|
|
758
|
+
assert self._httpd
|
|
759
|
+
self._httpd.allow_reuse_address = True
|
|
760
|
+
self._httpd.outer = self
|
|
761
|
+
LOG.info("Listening on port %d", self.port)
|
|
762
|
+
self._httpd.serve_forever()
|
|
763
|
+
self._httpd.server_close()
|
|
764
|
+
|
|
765
|
+
def _run_event_loop(self) -> None:
|
|
766
|
+
"""Run the event thread loop."""
|
|
767
|
+
while not self._exiting:
|
|
768
|
+
with self._event_thread_cond:
|
|
769
|
+
while not self._exiting and self._sched.empty():
|
|
770
|
+
self._event_thread_cond.wait(10)
|
|
771
|
+
self._sched.run()
|
|
772
|
+
|
|
773
|
+
@property
|
|
774
|
+
def devices(self) -> dict[str, Device]:
|
|
775
|
+
"""Deprecated mapping of IP address to device."""
|
|
776
|
+
warnings.warn(
|
|
777
|
+
"The devices dict is deprecated "
|
|
778
|
+
"and will be removed in a future release.",
|
|
779
|
+
DeprecationWarning,
|
|
780
|
+
stacklevel=1,
|
|
781
|
+
)
|
|
782
|
+
return {device.host: device for device in self._subscriptions}
|