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
@@ -0,0 +1,54 @@
1
+ """Representation of a WeMo Maker device."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TypedDict
5
+
6
+ from .api.attributes import AttributeDevice
7
+ from .api.service import RequiredService
8
+
9
+
10
+ class _Attributes(TypedDict, total=False):
11
+ Switch: int
12
+ Sensor: int
13
+ SwitchMode: int
14
+ SensorPresent: int
15
+
16
+
17
+ class Maker(AttributeDevice):
18
+ """Representation of a WeMo Maker device."""
19
+
20
+ _state_property = "switch_state" # Required by AttributeDevice.
21
+ _attributes: _Attributes # Required by AttributeDevice.
22
+
23
+ @property
24
+ def _required_services(self) -> list[RequiredService]:
25
+ return super()._required_services + [
26
+ RequiredService(name="basicevent", actions=["SetBinaryState"]),
27
+ ]
28
+
29
+ def set_state(self, state: int) -> None:
30
+ """Set the state of this device to on or off."""
31
+ # The Maker has a momentary mode - so it's not safe to assume
32
+ # the state is what you just set, so re-read it from the device
33
+ self.basicevent.SetBinaryState(BinaryState=int(state))
34
+ self.get_state(True)
35
+
36
+ @property
37
+ def switch_state(self) -> int:
38
+ """Return the state of the switch."""
39
+ return self._attributes["Switch"]
40
+
41
+ @property
42
+ def sensor_state(self) -> int:
43
+ """Return the state of the sensor."""
44
+ return self._attributes["Sensor"]
45
+
46
+ @property
47
+ def switch_mode(self) -> int:
48
+ """Return the switch mode of the sensor."""
49
+ return self._attributes["SwitchMode"]
50
+
51
+ @property
52
+ def has_sensor(self) -> int:
53
+ """Return whether the device has a sensor."""
54
+ return self._attributes["SensorPresent"]
@@ -0,0 +1,6 @@
1
+ """Representation of a WeMo Motion device."""
2
+ from . import Device
3
+
4
+
5
+ class Motion(Device):
6
+ """Representation of a WeMo Motion device."""
@@ -0,0 +1,6 @@
1
+ """Representation of a WeMo OutdoorPlug device."""
2
+ from .switch import Switch
3
+
4
+
5
+ class OutdoorPlug(Switch):
6
+ """Representation of a WeMo Motion device."""
@@ -0,0 +1,32 @@
1
+ """Representation of a WeMo Switch device."""
2
+ from __future__ import annotations
3
+
4
+ from . import Device
5
+ from .api.service import RequiredService
6
+
7
+
8
+ class Switch(Device):
9
+ """Representation of a WeMo Switch device."""
10
+
11
+ @property
12
+ def _required_services(self) -> list[RequiredService]:
13
+ return super()._required_services + [
14
+ RequiredService(name="basicevent", actions=["SetBinaryState"]),
15
+ ]
16
+
17
+ def set_state(self, state: int) -> None:
18
+ """Set the state of this device to on or off."""
19
+ self.basicevent.SetBinaryState(BinaryState=int(state))
20
+ self._state = int(state)
21
+
22
+ def off(self) -> None:
23
+ """Turn this device off. If already off, will return "Error"."""
24
+ self.set_state(0)
25
+
26
+ def on(self) -> None: # pylint: disable=invalid-name
27
+ """Turn this device on. If already on, will return "Error"."""
28
+ self.set_state(1)
29
+
30
+ def toggle(self) -> None:
31
+ """Toggle the switch's state."""
32
+ self.set_state(not self.get_state())
pywemo/py.typed ADDED
File without changes
pywemo/ssdp.py ADDED
@@ -0,0 +1,372 @@
1
+ """Module that implements SSDP protocol."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import select
7
+ import socket
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from datetime import datetime, timedelta
12
+ from wsgiref.handlers import format_date_time
13
+
14
+ from .ouimeaux_device.api.long_press import VIRTUAL_DEVICE_UDN
15
+ from .util import get_callback_address, interface_addresses
16
+
17
+ DISCOVER_TIMEOUT = 5
18
+
19
+ LOG = logging.getLogger(__name__)
20
+
21
+ RESPONSE_REGEX = re.compile(r"\n(.*)\: (.*)\r")
22
+
23
+ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=59)
24
+
25
+ MULTICAST_GROUP = "239.255.255.250"
26
+ MULTICAST_PORT = 1900
27
+
28
+ # Wemo specific urn:
29
+ ST = "urn:Belkin:service:basicevent:1"
30
+ VIRTUAL_DEVICE_USN = f"{VIRTUAL_DEVICE_UDN}::{ST}"
31
+ MAX_AGE = 86400
32
+
33
+ SSDP_REPLY = f"""HTTP/1.1 200 OK
34
+ CACHE-CONTROL: max-age={MAX_AGE}
35
+ DATE: %(date)s
36
+ EXT:
37
+ LOCATION: http://%(callback)s/setup.xml
38
+ OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01
39
+ 01-NLS: %(nls)s
40
+ SERVER: Unspecified, UPnP/1.0, Unspecified
41
+ X-User-Agent: pywemo
42
+ ST: {ST}
43
+ USN: {VIRTUAL_DEVICE_USN}
44
+
45
+ """ # Newline characters at the the end of SSDP_REPLY are intentional.
46
+ SSDP_REPLY = SSDP_REPLY.replace("\n", "\r\n")
47
+
48
+ SSDP_NOTIFY = f"""NOTIFY * HTTP/1.1
49
+ HOST: {MULTICAST_GROUP}:{MULTICAST_PORT}
50
+ CACHE-CONTROL: max-age={MAX_AGE}
51
+ LOCATION: http://%(callback)s/setup.xml
52
+ OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01
53
+ 01-NLS: %(nls)s
54
+ NT: {ST}
55
+ NTS: %(nts)s
56
+ SERVER: Unspecified, UPnP/1.0, Unspecified
57
+ X-User-Agent: pywemo
58
+ USN: {VIRTUAL_DEVICE_USN}
59
+
60
+ """ # Newline characters at the the end of SSDP_NOTIFY are intentional.
61
+ SSDP_NOTIFY = SSDP_NOTIFY.replace("\n", "\r\n")
62
+
63
+ EXPECTED_ST_HEADER = ("ST: " + ST).encode("UTF-8")
64
+ EXPECTED_MAN_HEADER = b'MAN: "ssdp:discover"'
65
+
66
+
67
+ class UPNPEntry:
68
+ """Found uPnP entry."""
69
+
70
+ def __init__(self, values: dict[str, str]) -> None:
71
+ """Create a UPNPEntry object."""
72
+ self.values = values
73
+ self._created = datetime.now()
74
+ self._expires: datetime | None = None
75
+
76
+ if "cache-control" in self.values:
77
+ cache_seconds = int(self.values["cache-control"].split("=")[1])
78
+
79
+ self._expires = self._created + timedelta(seconds=cache_seconds)
80
+
81
+ @property
82
+ def st(self) -> str | None: # pylint: disable=invalid-name
83
+ """Return ST value."""
84
+ return self.values.get("st")
85
+
86
+ @property
87
+ def location(self) -> str | None:
88
+ """Return location value."""
89
+ return self.values.get("location")
90
+
91
+ @property
92
+ def usn(self) -> str | None:
93
+ """Return unique service name."""
94
+ return self.values.get("usn")
95
+
96
+ @property
97
+ def udn(self) -> str:
98
+ """Return unique device name."""
99
+ usn = self.usn or ""
100
+ return usn.split("::")[0]
101
+
102
+ @classmethod
103
+ def from_response(cls, response: str) -> UPNPEntry:
104
+ """Create a uPnP entry from a response."""
105
+ return UPNPEntry(
106
+ {
107
+ key.lower(): item
108
+ for key, item in RESPONSE_REGEX.findall(response)
109
+ }
110
+ )
111
+
112
+ @property
113
+ def _key(self) -> tuple[str, str | None]:
114
+ """Tuple of values that uniquely identify the UPNPEntry instance."""
115
+ return (self.udn, self.location)
116
+
117
+ def __eq__(self, other: object) -> bool:
118
+ """Equality operator."""
119
+ return isinstance(other, type(self)) and self._key == other._key
120
+
121
+ def __hash__(self) -> int:
122
+ """Generate hash of instance."""
123
+ return hash(("UPNPEntry", self._key))
124
+
125
+ def __repr__(self) -> str:
126
+ """Return the string representation of the object."""
127
+ st = self.st or "" # pylint: disable=invalid-name
128
+ location = self.location or ""
129
+ udn = self.udn or ""
130
+ return f"<UPNPEntry {st} - {location} - {udn}>"
131
+
132
+
133
+ def build_ssdp_request(ssdp_st: str, ssdp_mx: int) -> bytes:
134
+ """Build the standard request to send during SSDP discovery."""
135
+ return "\r\n".join(
136
+ [
137
+ "M-SEARCH * HTTP/1.1",
138
+ f"ST: {ssdp_st}",
139
+ f"MX: {ssdp_mx}",
140
+ 'MAN: "ssdp:discover"',
141
+ f"HOST: {MULTICAST_GROUP}:{MULTICAST_PORT}",
142
+ "",
143
+ "",
144
+ ]
145
+ ).encode("ascii")
146
+
147
+
148
+ def scan( # pylint: disable=too-many-branches,too-many-locals
149
+ st: str = ST, # pylint: disable=invalid-name
150
+ timeout: float = DISCOVER_TIMEOUT,
151
+ max_entries: int | None = None,
152
+ match_udn: str | None = None,
153
+ ) -> list[UPNPEntry]:
154
+ """
155
+ Send a message over the network to discover upnp devices.
156
+
157
+ Inspired by Crimsdings ChromeCast code
158
+ https://github.com/crimsdings/ [ChromeCast repository since removed]
159
+ """
160
+ # pylint: disable=too-many-nested-blocks
161
+ ssdp_target = (MULTICAST_GROUP, MULTICAST_PORT)
162
+
163
+ entries: list[UPNPEntry] = []
164
+
165
+ calc_now = datetime.now
166
+
167
+ ssdp_request = build_ssdp_request(st, ssdp_mx=1)
168
+ sockets = []
169
+ try:
170
+ for addr in interface_addresses():
171
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
172
+ try:
173
+ sock.bind((addr, 0))
174
+ sock.sendto(ssdp_request, ssdp_target)
175
+ sockets.append(sock)
176
+ except OSError:
177
+ pass
178
+ finally:
179
+ if sock not in sockets:
180
+ sock.close()
181
+
182
+ start = calc_now()
183
+ while sockets:
184
+ time_diff = calc_now() - start
185
+
186
+ seconds_left = max(timeout - time_diff.seconds, 0)
187
+
188
+ ready = select.select(sockets, [], [], min(1, seconds_left))[0]
189
+ if not ready:
190
+ # Only check for timeout when there are no more results. Exit
191
+ # if the time has expired, or probe again if there is more
192
+ # time remaining.
193
+ if seconds_left <= 0:
194
+ return entries
195
+ for sock in sockets:
196
+ sock.sendto(ssdp_request, ssdp_target)
197
+
198
+ for sock in ready:
199
+ response = sock.recv(1024).decode("UTF-8", "replace")
200
+
201
+ entry = UPNPEntry.from_response(response)
202
+ if entry.usn == VIRTUAL_DEVICE_USN:
203
+ continue # Don't return the virtual device.
204
+
205
+ # Search for devices
206
+ if entry not in entries:
207
+ if match_udn is None:
208
+ entries.append(entry)
209
+ elif match_udn == entry.udn:
210
+ entries.append(entry)
211
+
212
+ # Return if we've found the max number of devices
213
+ if max_entries and len(entries) == max_entries:
214
+ return entries
215
+ except OSError:
216
+ LOG.exception("Socket error while discovering SSDP devices")
217
+ finally:
218
+ for sock in sockets:
219
+ sock.close()
220
+
221
+ return entries
222
+
223
+
224
+ class DiscoveryResponder:
225
+ """Inform Wemo devices of the pywemo virtual Wemo device.
226
+
227
+ The DiscoveryResponder informs Wemo devices of the /setup.xml URL for the
228
+ pywemo virtual Wemo device. The virtual device is used for receiving long
229
+ press actions from Wemo devices and is integrated into the
230
+ SubscriptionRegistry HTTP server.
231
+
232
+ Wemo devices are informed of the pywemo virtual Wemo device in two ways:
233
+
234
+ 1. Wemo devices periodically send UPnP M-SEARCH discovery requests for the
235
+ to locate other devices on the network. DiscoveryResponder responds to
236
+ these requests with the URL for the virtual device.
237
+
238
+ 2. A UPnP NOTIFY message is periodically multicasted by DiscoveryResponder
239
+ to inform Wemo devices on the network of the URL for the virtual device.
240
+ """
241
+
242
+ def __init__(self, callback_port: int) -> None:
243
+ """Create a server that will respond to WeMo discovery requests.
244
+
245
+ Args:
246
+ callback_port: The port for the SubscriptionRegistry HTTP server.
247
+ """
248
+ self.callback_port = callback_port
249
+ self._thread: threading.Thread | None = None
250
+ self._exit = threading.Event()
251
+ self._thread_exception: Exception | None = None
252
+ self._notify_enabled = True # Only ever set to False in tests.
253
+ self._nls_uuid = str(uuid.uuid4())
254
+
255
+ def send_notify(self, nts: str) -> None:
256
+ """Send a UPnP NOTIFY message containing the virtual device URL."""
257
+ ssdp_target = (MULTICAST_GROUP, MULTICAST_PORT)
258
+ for addr in interface_addresses(): # Send on all interfaces.
259
+ params = {
260
+ "callback": get_callback_address(addr, self.callback_port),
261
+ "nls": self._nls_uuid,
262
+ "nts": nts,
263
+ }
264
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
265
+ try:
266
+ sock.bind((addr, 0))
267
+ sock.sendto(
268
+ (SSDP_NOTIFY % params).encode("UTF-8"), ssdp_target
269
+ )
270
+ except OSError:
271
+ pass
272
+ finally:
273
+ sock.close()
274
+
275
+ def respond_to_discovery(self) -> None:
276
+ """Respond to a WeMo discovery request with the virtual device URL."""
277
+ recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
278
+ send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
279
+ try:
280
+ recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
281
+
282
+ # Join the multicast group on all interfaces.
283
+ group = socket.inet_aton(MULTICAST_GROUP)
284
+ for addr in interface_addresses():
285
+ try:
286
+ local = socket.inet_aton(addr)
287
+ recv_sock.setsockopt(
288
+ socket.IPPROTO_IP,
289
+ socket.IP_ADD_MEMBERSHIP,
290
+ group + local,
291
+ )
292
+ except OSError as err:
293
+ LOG.error(
294
+ "Failed join multicast group on %s: %s", addr, err
295
+ )
296
+
297
+ recv_sock.bind((MULTICAST_GROUP, MULTICAST_PORT))
298
+
299
+ next_notify = datetime.min
300
+ while not self._exit.is_set():
301
+ # Periodically send NOTIFY messages.
302
+ now = datetime.now()
303
+ if now > next_notify and self._notify_enabled:
304
+ next_notify = now + timedelta(seconds=(MAX_AGE / 2) - 30)
305
+ self.send_notify("ssdp:alive")
306
+
307
+ # Check for new discovery requests.
308
+ if not select.select([recv_sock], [], [], 1)[0]:
309
+ continue # Timeout, no data. Loop again and check for exit
310
+ msg, sock_addr = recv_sock.recvfrom(1024)
311
+ lines = msg.splitlines()
312
+ if len(lines) < 3 or not lines[0].startswith(
313
+ b"M-SEARCH * HTTP"
314
+ ):
315
+ continue
316
+ if (
317
+ EXPECTED_ST_HEADER not in lines
318
+ or EXPECTED_MAN_HEADER not in lines
319
+ ):
320
+ continue
321
+ params = {
322
+ "callback": get_callback_address(
323
+ sock_addr[0], self.callback_port
324
+ ),
325
+ "date": format_date_time(time.time()),
326
+ "nls": self._nls_uuid,
327
+ }
328
+ try:
329
+ send_sock.sendto(
330
+ (SSDP_REPLY % params).encode("UTF-8"), sock_addr
331
+ )
332
+ except OSError as err:
333
+ LOG.error(
334
+ "Failed to send SSDP reply to %r: %s", sock_addr, err
335
+ )
336
+
337
+ if self._notify_enabled:
338
+ self.send_notify("ssdp:byebye")
339
+ except Exception as exp:
340
+ self._thread_exception = exp # Used in the stop() method.
341
+ raise
342
+ finally:
343
+ recv_sock.close()
344
+ send_sock.close()
345
+
346
+ def start(self) -> None:
347
+ """Start the server."""
348
+ self._exit.clear()
349
+ self._thread_exception = None
350
+ self._thread = threading.Thread(
351
+ target=self.respond_to_discovery,
352
+ name="Wemo DiscoveryResponder Thread",
353
+ )
354
+ self._thread.start()
355
+
356
+ def stop(self) -> None:
357
+ """Stop the server."""
358
+ if self._thread:
359
+ self._exit.set()
360
+ self._thread.join()
361
+ self._thread = None
362
+ # Improve visibility of any exceptions that occurred on the thread.
363
+ if self._thread_exception is not None:
364
+ # pylint: disable=raising-bad-type
365
+ raise self._thread_exception
366
+
367
+
368
+ if __name__ == "__main__":
369
+ from pprint import pprint
370
+
371
+ pprint("Scanning UPNP..")
372
+ pprint(scan())