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,467 @@
1
+ """Access and manipulate the on-device rules sqlite database."""
2
+
3
+ import base64
4
+ import contextlib
5
+ import io
6
+ import logging
7
+ import os
8
+ import sqlite3
9
+ import tempfile
10
+ import zipfile
11
+ from types import MappingProxyType
12
+ from typing import FrozenSet, List, Mapping, Optional, Tuple
13
+
14
+ from pywemo.exceptions import HTTPNotOkException, RulesDbQueryError
15
+
16
+ from .db_orm import DatabaseRow, PrimaryKey, SQLType
17
+
18
+ LOG = logging.getLogger(__name__)
19
+
20
+
21
+ class RulesRow(DatabaseRow):
22
+ """Row schema for the RULES table."""
23
+
24
+ TABLE_NAME = "RULES"
25
+ FIELDS = {
26
+ "RuleID": PrimaryKey(int, sql_type=""),
27
+ "Name": SQLType(str, not_null=True),
28
+ "Type": SQLType(str, not_null=True),
29
+ "RuleOrder": int,
30
+ "StartDate": str,
31
+ "EndDate": str,
32
+ "State": str,
33
+ # Sync has type INTEGER in the database, but contains the
34
+ # value 'NOSYNC', it is represented as a string in Python.
35
+ "Sync": SQLType(str, sql_type="INTEGER"),
36
+ }
37
+
38
+
39
+ class RuleDevicesRow(DatabaseRow):
40
+ """Row schema for the RULEDEVICES table."""
41
+
42
+ TABLE_NAME = "RULEDEVICES"
43
+ FIELDS = {
44
+ "RuleDevicePK": PrimaryKey(int, auto_increment=True),
45
+ "RuleID": int,
46
+ "DeviceID": str,
47
+ "GroupID": int,
48
+ "DayID": int,
49
+ "StartTime": int,
50
+ "RuleDuration": int,
51
+ "StartAction": float,
52
+ "EndAction": float,
53
+ "SensorDuration": int,
54
+ "Type": int,
55
+ "Value": int,
56
+ "Level": int,
57
+ "ZBCapabilityStart": str,
58
+ "ZBCapabilityEnd": str,
59
+ "OnModeOffset": int,
60
+ "OffModeOffset": int,
61
+ "CountdownTime": int,
62
+ "EndTime": int,
63
+ }
64
+
65
+
66
+ class TargetDevicesRow(DatabaseRow):
67
+ """Row schema for the TARGETDEVICES table."""
68
+
69
+ TABLE_NAME = "TARGETDEVICES"
70
+ FIELDS = {
71
+ "TargetDevicesPK": PrimaryKey(int, auto_increment=True),
72
+ "RuleID": int,
73
+ "DeviceID": str,
74
+ "DeviceIndex": int,
75
+ }
76
+
77
+
78
+ class DeviceCombinationRow(DatabaseRow):
79
+ """Row schema for the DEVICECOMBINATION table."""
80
+
81
+ TABLE_NAME = "DEVICECOMBINATION"
82
+ FIELDS = {
83
+ "DeviceCombinationPK": PrimaryKey(int, auto_increment=True),
84
+ "RuleID": int,
85
+ "SensorID": str,
86
+ "SensorGroupID": int,
87
+ "DeviceID": str,
88
+ "DeviceGroupID": int,
89
+ }
90
+
91
+
92
+ class GroupDevicesRow(DatabaseRow):
93
+ """Row schema for the GROUPDEVICES table."""
94
+
95
+ TABLE_NAME = "GROUPDEVICES"
96
+ FIELDS = {
97
+ "GroupDevicePK": PrimaryKey(int, auto_increment=True),
98
+ "GroupID": int,
99
+ "DeviceID": str,
100
+ }
101
+
102
+
103
+ class LocationInfoRow(DatabaseRow):
104
+ """Row schema for the LOCATIONINFO table."""
105
+
106
+ TABLE_NAME = "LOCATIONINFO"
107
+ FIELDS = {
108
+ "LocationPk": PrimaryKey(int, auto_increment=True),
109
+ "cityName": str,
110
+ "countryName": str,
111
+ "latitude": str,
112
+ "longitude": str,
113
+ "countryCode": str,
114
+ "region": str,
115
+ }
116
+
117
+
118
+ class BlockedRulesRow(DatabaseRow):
119
+ """Row schema for the BLOCKEDRULES table."""
120
+
121
+ TABLE_NAME = "BLOCKEDRULES"
122
+ FIELDS = {
123
+ "Primarykey": PrimaryKey(int, auto_increment=True),
124
+ "ruleId": str,
125
+ }
126
+
127
+
128
+ class RulesNotifyMessageRow(DatabaseRow):
129
+ """Row schema for the RULESNOTIFYMESSAGE table."""
130
+
131
+ TABLE_NAME = "RULESNOTIFYMESSAGE"
132
+ FIELDS = {
133
+ "RuleID": PrimaryKey(int, auto_increment=True),
134
+ "NotifyRuleID": int,
135
+ "Message": str,
136
+ "Frequency": int,
137
+ }
138
+
139
+
140
+ class SensorNotificationRow(DatabaseRow):
141
+ """Row schema for the SENSORNOTIFICATION table."""
142
+
143
+ TABLE_NAME = "SENSORNOTIFICATION"
144
+ FIELDS = {
145
+ "SensorNotificationPK": PrimaryKey(int, auto_increment=True),
146
+ "RuleID": int,
147
+ "NotifyRuleID": int,
148
+ "NotificationMessage": str,
149
+ "NotificationDuration": int,
150
+ }
151
+
152
+
153
+ ALL_TABLES = [
154
+ RulesRow,
155
+ RuleDevicesRow,
156
+ DeviceCombinationRow,
157
+ GroupDevicesRow,
158
+ LocationInfoRow,
159
+ BlockedRulesRow,
160
+ RulesNotifyMessageRow,
161
+ SensorNotificationRow,
162
+ TargetDevicesRow,
163
+ ]
164
+
165
+
166
+ class RulesDb:
167
+ """Methods to access and manipulate the `rules` sqlite database."""
168
+
169
+ def __init__(
170
+ self, sql_db: sqlite3.Connection, default_udn: str, device_name: str
171
+ ):
172
+ """Preparse tables for device."""
173
+ self._db = sql_db
174
+ self._default_udn = default_udn
175
+ self._device_name = device_name
176
+ self.modified = False
177
+ cursor = sql_db.cursor()
178
+ self._rules = _index_by_primary_key(RulesRow.select_all(cursor))
179
+ self._rule_devices = _index_by_primary_key(
180
+ RuleDevicesRow.select_all(cursor)
181
+ )
182
+ self._target_devices = _index_by_primary_key(
183
+ TargetDevicesRow.select_all(cursor)
184
+ )
185
+
186
+ @property
187
+ def db(self) -> sqlite3.Connection: # pylint: disable=invalid-name
188
+ """Return the sqlite3 connection instance."""
189
+ return self._db
190
+
191
+ def cursor(self) -> sqlite3.Cursor:
192
+ """Return a cursor for the underlying sqlite3 database."""
193
+ return self.db.cursor()
194
+
195
+ @property
196
+ def rules(self) -> Mapping[int, RulesRow]:
197
+ """Contents of the RULES table, keyed by RuleID."""
198
+ return MappingProxyType(self._rules)
199
+
200
+ def add_rule(self, rule: RulesRow) -> RulesRow:
201
+ """Add a new entry to the RULES table."""
202
+ if not hasattr(rule, "RuleID"):
203
+ rule.RuleID = max(self._rules.keys(), default=0) + 1
204
+ rule.update_db(self.cursor())
205
+ self._rules[rule.RuleID] = rule
206
+ self.modified = True
207
+ return rule
208
+
209
+ def remove_rule(self, rule: RulesRow) -> None:
210
+ """Remove an entry to the RULES table."""
211
+ del self._rules[rule.RuleID]
212
+ rule.remove_from_db(self.cursor())
213
+ self.modified = True
214
+
215
+ @property
216
+ def rule_devices(self) -> Mapping[int, RuleDevicesRow]:
217
+ """Contents of the RULEDEVICES table, keyed by RuleDevicePK."""
218
+ return MappingProxyType(self._rule_devices)
219
+
220
+ def add_rule_devices(self, rule_devices: RuleDevicesRow) -> RuleDevicesRow:
221
+ """Add a new entry to the RULEDEVICES table."""
222
+ rule_devices.update_db(self.cursor())
223
+ self._rule_devices[rule_devices.RuleDevicePK] = rule_devices
224
+ self.modified = True
225
+ return rule_devices
226
+
227
+ def remove_rule_devices(self, rule_devices: RuleDevicesRow) -> None:
228
+ """Remove an entry to the RULEDEVICES table."""
229
+ del self._rule_devices[rule_devices.RuleDevicePK]
230
+ rule_devices.remove_from_db(self.cursor())
231
+ self.modified = True
232
+
233
+ @property
234
+ def target_devices(self) -> Mapping[int, TargetDevicesRow]:
235
+ """Contents of the TARGETDEVICES table, keyed by TargetDevicesPK."""
236
+ return MappingProxyType(self._target_devices)
237
+
238
+ def add_target_devices(
239
+ self, target_devices: TargetDevicesRow
240
+ ) -> TargetDevicesRow:
241
+ """Add a new entry to the TARGETDEVICES table."""
242
+ target_devices.update_db(self.cursor())
243
+ self._target_devices[target_devices.TargetDevicesPK] = target_devices
244
+ self.modified = True
245
+ return target_devices
246
+
247
+ def remove_target_devices(self, target_devices: TargetDevicesRow) -> None:
248
+ """Remove an entry to the TARGETDEVICES table."""
249
+ del self._target_devices[target_devices.TargetDevicesPK]
250
+ target_devices.remove_from_db(self.cursor())
251
+ self.modified = True
252
+
253
+ def update_if_modified(self) -> bool:
254
+ """Sync the contents with the sqlite database.
255
+
256
+ Return True if the database was modified.
257
+ """
258
+ modified = self.modified
259
+ cursor = self.cursor()
260
+
261
+ def update(rows):
262
+ nonlocal modified
263
+ for row in rows:
264
+ if row.modified:
265
+ row.update_db(cursor)
266
+ modified = True
267
+
268
+ update(self._rules.values())
269
+ update(self._rule_devices.values())
270
+ update(self._target_devices.values())
271
+ return modified
272
+
273
+ def rules_for_device(
274
+ self,
275
+ *,
276
+ device_udn: Optional[str] = None,
277
+ rule_type: Optional[str] = None,
278
+ ) -> List[Tuple[RulesRow, RuleDevicesRow]]:
279
+ """Fetch the current rules for a particular device."""
280
+ if device_udn is None:
281
+ device_udn = self._default_udn
282
+ values = []
283
+ for device in self.rule_devices.values():
284
+ if device_udn and device.DeviceID != device_udn:
285
+ continue
286
+ rule = self.rules[device.RuleID]
287
+ if rule_type and rule.Type != rule_type:
288
+ continue
289
+ values.append((rule, device))
290
+
291
+ return values
292
+
293
+ def get_target_devices_for_rule(self, rule: RulesRow) -> FrozenSet[str]:
294
+ """Return the target DeviceIDs that are associated with the rule."""
295
+ return frozenset(
296
+ [
297
+ target.DeviceID
298
+ for target in self.target_devices.values()
299
+ if target.RuleID == rule.RuleID
300
+ ]
301
+ )
302
+
303
+ def add_target_device_to_rule(
304
+ self,
305
+ rule: RulesRow,
306
+ device_id: str,
307
+ *,
308
+ device_index: Optional[int] = None,
309
+ ):
310
+ """Add a new target DeviceID to the rule."""
311
+ if device_index is None:
312
+ target_device_index = (
313
+ target.DeviceIndex
314
+ for target in self.target_devices.values()
315
+ if target.RuleID == rule.RuleID
316
+ )
317
+ device_index = max(target_device_index, default=-1) + 1
318
+ self.add_target_devices(
319
+ TargetDevicesRow(
320
+ RuleID=rule.RuleID,
321
+ DeviceID=device_id,
322
+ DeviceIndex=device_index,
323
+ )
324
+ )
325
+
326
+ def remove_target_device_from_rule(self, rule: RulesRow, device_id: str):
327
+ """Remove a target DeviceID from a rule."""
328
+ targets = [
329
+ target
330
+ for target in self.target_devices.values()
331
+ if target.RuleID == rule.RuleID and target.DeviceID == device_id
332
+ ]
333
+ if len(targets) != 1:
334
+ raise NameError(
335
+ f"device {device_id} not found in target devices for rule"
336
+ )
337
+ self.remove_target_devices(targets[0])
338
+
339
+ def clear_all(self) -> None:
340
+ """Clear all data from the database."""
341
+ cursor = self.cursor()
342
+ for table in ALL_TABLES:
343
+ cursor.execute(f"DELETE FROM {table.TABLE_NAME}")
344
+ self.modified = True
345
+ self._rules = _index_by_primary_key(RulesRow.select_all(cursor))
346
+ self._rule_devices = _index_by_primary_key(
347
+ RuleDevicesRow.select_all(cursor)
348
+ )
349
+ self._target_devices = _index_by_primary_key(
350
+ TargetDevicesRow.select_all(cursor)
351
+ )
352
+
353
+
354
+ @contextlib.contextmanager
355
+ def rules_db_from_device(device) -> RulesDb:
356
+ """Yield a RuleDb instance for the rules on a device.
357
+
358
+ Usage:
359
+ with rules_db.rules_db_from_device(device) as rules:
360
+ ...
361
+
362
+ The sqlite3.Connection object can be accessed via the '.db' property in the
363
+ returned RulesDb instance. If the database is modified directly, setting
364
+ the `.modified` attribute to True will cause the database to be sent to the
365
+ WeMo device. Any updates that take place via the RulesDb helper methods
366
+ will also be propagated back to the WeMo device.
367
+ """
368
+ fetch = device.rules.FetchRules()
369
+ version = int(fetch["ruleDbVersion"])
370
+ rule_db_url = fetch["ruleDbPath"]
371
+ try:
372
+ response = device.session.get(rule_db_url)
373
+ except HTTPNotOkException:
374
+ response = None
375
+
376
+ with tempfile.TemporaryDirectory(prefix="wemorules_") as temp_dir:
377
+ local_file_name = os.path.join(temp_dir, "rules.db")
378
+ # Create a new db, or extract the current db.
379
+ if response is None:
380
+ inner_file_name = _create_empty_db(local_file_name)
381
+ else:
382
+ inner_file_name = _unpack_db(response.content, local_file_name)
383
+
384
+ # Open the DB.
385
+ conn = sqlite3.connect(local_file_name)
386
+ try:
387
+ conn.row_factory = sqlite3.Row
388
+ try:
389
+ rules = RulesDb(
390
+ conn, default_udn=device.udn, device_name=device.name
391
+ )
392
+ yield rules
393
+ except sqlite3.Error as err:
394
+ try:
395
+ db_dump = list(conn.iterdump())
396
+ LOG.debug("Rules: %s", repr(db_dump))
397
+ fw_version = device.firmwareupdate.GetFirmwareVersion()
398
+ LOG.debug("Firmware: %s", repr(fw_version))
399
+ except Exception: # pylint: disable=broad-except
400
+ # Ignore any additional errors that occur as a result of
401
+ # outputting this debug information.
402
+ pass
403
+ raise RulesDbQueryError(f"sqlite3 exception {err}") from err
404
+
405
+ if rules.update_if_modified():
406
+ LOG.debug("Rules for %s updated. Storing rules.", device.name)
407
+ conn.commit()
408
+ conn.close()
409
+ conn = None
410
+ body = _pack_db(local_file_name, inner_file_name)
411
+ device.rules.StoreRules(
412
+ ruleDbVersion=version + 1,
413
+ processDb=1,
414
+ ruleDbBody="<![CDATA[" + body + "]]>",
415
+ )
416
+ finally:
417
+ if conn is not None:
418
+ conn.close()
419
+
420
+
421
+ def _unpack_db(content, local_file_name):
422
+ """Unpack the sqlite database from a .zip file content."""
423
+ zip_contents = io.BytesIO(content)
424
+ with zipfile.ZipFile(zip_contents) as zip_file:
425
+ inner_file_name = zip_file.namelist()[0]
426
+ with zip_file.open(inner_file_name) as zipped_db_file:
427
+ with open(local_file_name, "w+b") as db_file:
428
+ db_file.write(zipped_db_file.read())
429
+ return inner_file_name
430
+ raise RuntimeError("Could not find database within zip file")
431
+
432
+
433
+ def _pack_db(local_file_name, inner_file_name):
434
+ """Pack the sqlite database as a base64(zipped(db))."""
435
+ zip_contents = io.BytesIO()
436
+ with zipfile.ZipFile(
437
+ zip_contents, mode="w", compression=zipfile.ZIP_DEFLATED
438
+ ) as zip_file:
439
+ zip_file.write(local_file_name, arcname=inner_file_name)
440
+ return base64.b64encode(zip_contents.getvalue()).decode("utf-8")
441
+
442
+
443
+ def _index_by_primary_key(rows):
444
+ """Return a dict of Rows indexed by the primary key."""
445
+ result = {}
446
+ for row in rows:
447
+ value = row.primary_key_value()
448
+ if value is None:
449
+ LOG.debug("Skipping row with NULL primary key: %s", row)
450
+ continue
451
+ result[value] = row
452
+ return result
453
+
454
+
455
+ def _create_empty_db(file_name):
456
+ """Create an empty sqlite database.
457
+
458
+ Returns the name of the database file that would be inside the zip.
459
+ """
460
+ conn = sqlite3.connect(file_name)
461
+ try:
462
+ for row_class in ALL_TABLES:
463
+ row_class.create_sqlite_table_from_row_schema(conn.cursor())
464
+ conn.commit()
465
+ finally:
466
+ conn.close()
467
+ return "temppluginRules.db"