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.
Files changed (40) hide show
  1. pywemo/README.md +69 -0
  2. pywemo/__init__.py +33 -0
  3. pywemo/color.py +79 -0
  4. pywemo/discovery.py +194 -0
  5. pywemo/exceptions.py +94 -0
  6. pywemo/ouimeaux_device/LICENSE +12 -0
  7. pywemo/ouimeaux_device/__init__.py +679 -0
  8. pywemo/ouimeaux_device/api/__init__.py +1 -0
  9. pywemo/ouimeaux_device/api/attributes.py +131 -0
  10. pywemo/ouimeaux_device/api/db_orm.py +197 -0
  11. pywemo/ouimeaux_device/api/long_press.py +168 -0
  12. pywemo/ouimeaux_device/api/rules_db.py +467 -0
  13. pywemo/ouimeaux_device/api/service.py +363 -0
  14. pywemo/ouimeaux_device/api/wemo_services.py +25 -0
  15. pywemo/ouimeaux_device/api/wemo_services.pyi +241 -0
  16. pywemo/ouimeaux_device/api/xsd/__init__.py +1 -0
  17. pywemo/ouimeaux_device/api/xsd/device.py +3888 -0
  18. pywemo/ouimeaux_device/api/xsd/device.xsd +95 -0
  19. pywemo/ouimeaux_device/api/xsd/service.py +3872 -0
  20. pywemo/ouimeaux_device/api/xsd/service.xsd +93 -0
  21. pywemo/ouimeaux_device/api/xsd_types.py +222 -0
  22. pywemo/ouimeaux_device/bridge.py +506 -0
  23. pywemo/ouimeaux_device/coffeemaker.py +92 -0
  24. pywemo/ouimeaux_device/crockpot.py +157 -0
  25. pywemo/ouimeaux_device/dimmer.py +70 -0
  26. pywemo/ouimeaux_device/humidifier.py +223 -0
  27. pywemo/ouimeaux_device/insight.py +191 -0
  28. pywemo/ouimeaux_device/lightswitch.py +11 -0
  29. pywemo/ouimeaux_device/maker.py +54 -0
  30. pywemo/ouimeaux_device/motion.py +6 -0
  31. pywemo/ouimeaux_device/outdoor_plug.py +6 -0
  32. pywemo/ouimeaux_device/switch.py +32 -0
  33. pywemo/py.typed +0 -0
  34. pywemo/ssdp.py +372 -0
  35. pywemo/subscribe.py +782 -0
  36. pywemo/util.py +139 -0
  37. pywemo-1.4.0.dist-info/LICENSE +54 -0
  38. pywemo-1.4.0.dist-info/METADATA +192 -0
  39. pywemo-1.4.0.dist-info/RECORD +40 -0
  40. 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}