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,131 @@
1
+ """Attribute device helpers."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any, get_type_hints
6
+
7
+ from lxml import etree as et
8
+
9
+ from ..switch import Switch
10
+ from .service import RequiredService
11
+ from .xsd_types import quote_xml
12
+
13
+ LOG = logging.getLogger(__name__)
14
+
15
+
16
+ class AttributeDevice(Switch):
17
+ """Handles all parsing/getting/setting of attribute lists.
18
+
19
+ This is intended to be used as the base class for all devices that support
20
+ the deviceevent.GetAttributes() method.
21
+
22
+ Subclasses can use the _attributes property to fetch the string values of
23
+ all attributes. Subclasses must provide the name of the property to use
24
+ for self._state in a property named _state_property. Subclasses must also
25
+ define a TypedDict to hold the attributes and add the TypedDict subclass as
26
+ a type hint for the _attributes property of the class.
27
+ """
28
+
29
+ EVENT_TYPE_ATTRIBUTE_LIST = "attributeList"
30
+
31
+ _state_property: str
32
+
33
+ _attr_name = "_attributes"
34
+ """Name of the TypedDict attribute that holds values for this device."""
35
+
36
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
37
+ """Create a Attributes device."""
38
+ assert isinstance(self._state_property, str)
39
+ setattr(self, self._attr_name, {})
40
+ class_hints = get_type_hints(type(self))
41
+ assert (attr_type := class_hints.get(self._attr_name)) is not None
42
+ self._attribute_type_hints = get_type_hints(attr_type)
43
+ super().__init__(*args, **kwargs)
44
+ self.update_attributes()
45
+
46
+ @property
47
+ def _required_services(self) -> list[RequiredService]:
48
+ return super()._required_services + [
49
+ RequiredService(
50
+ name="deviceevent", actions=["GetAttributes", "SetAttributes"]
51
+ ),
52
+ ]
53
+
54
+ def _update_attributes_dict(self, xml_blob: str) -> None:
55
+ xml_blob = "<attributes>" + xml_blob + "</attributes>"
56
+ xml_blob = xml_blob.replace("&gt;", ">")
57
+ xml_blob = xml_blob.replace("&lt;", "<")
58
+
59
+ for attribute in et.fromstring(
60
+ xml_blob, parser=et.XMLParser(resolve_entities=False)
61
+ ):
62
+ if len(attribute) < 2:
63
+ raise ValueError(
64
+ f"Too few elements: {et.tostring(attribute).decode()}"
65
+ )
66
+ if (key := attribute[0].text) is None:
67
+ raise ValueError(
68
+ f"Key is not present: {et.tostring(attribute[0]).decode()}"
69
+ )
70
+ if (value := attribute[1].text) is None:
71
+ raise ValueError(
72
+ "Value is not present: "
73
+ f"{et.tostring(attribute[1]).decode()}"
74
+ )
75
+
76
+ if (constructor := self._attribute_type_hints.get(key)) is None:
77
+ continue # Ignore unexpected attributes
78
+ try:
79
+ getattr(self, self._attr_name)[key] = constructor(value)
80
+ except (TypeError, ValueError) as err:
81
+ raise ValueError(
82
+ f"Unexpected value for {key}: {value}"
83
+ ) from err
84
+
85
+ state: int | None = getattr(self, self._state_property)
86
+ self._state = state
87
+
88
+ def update_attributes(self) -> None:
89
+ """Request state from device."""
90
+ resp = self.deviceevent.GetAttributes().get(
91
+ self.EVENT_TYPE_ATTRIBUTE_LIST
92
+ )
93
+ assert resp is not None
94
+ self._update_attributes_dict(resp)
95
+
96
+ def subscription_update(self, _type: str, _params: str) -> bool:
97
+ """Handle subscription push-events from device."""
98
+ if _type == self.EVENT_TYPE_ATTRIBUTE_LIST:
99
+ try:
100
+ self._update_attributes_dict(_params)
101
+ except (et.XMLSyntaxError, ValueError) as err:
102
+ LOG.error(
103
+ "Unexpected %s value `%s` for device %s: %s",
104
+ self.EVENT_TYPE_ATTRIBUTE_LIST,
105
+ _params,
106
+ self.name,
107
+ repr(err),
108
+ )
109
+ return True
110
+
111
+ return super().subscription_update(_type, _params)
112
+
113
+ def get_state(self, force_update: bool = False) -> int:
114
+ """Return 0 if off and 1 if on."""
115
+ if force_update or self._state is None:
116
+ self.update_attributes()
117
+
118
+ assert self._state is not None
119
+ return self._state
120
+
121
+ def _set_attributes(self, *args: tuple[str, str | int | float]) -> None:
122
+ """Set the specified attributes on the device."""
123
+ attribute_xml = "</attribute><attribute>".join(
124
+ f"<name>{name}</name><value>{value}</value>"
125
+ for name, value in args
126
+ )
127
+ attribute_xml = f"<attribute>{attribute_xml}</attribute>"
128
+ self.deviceevent.SetAttributes(attributeList=quote_xml(attribute_xml))
129
+
130
+ # Refresh the device state
131
+ self.get_state(True)
@@ -0,0 +1,197 @@
1
+ """Light-weight mapping between sqlite3 and python data structures."""
2
+ import logging
3
+ import sqlite3
4
+ from typing import Any, Callable, Dict
5
+
6
+ LOG = logging.getLogger(__name__)
7
+
8
+
9
+ class DatabaseRow:
10
+ """Base class for sqlite Row schemas."""
11
+
12
+ TABLE_NAME: str
13
+ FIELDS: Dict[str, Callable[[Any], Any]]
14
+
15
+ def __init__(self, **kwargs):
16
+ """Initialize a row with the supplied values."""
17
+ for key, value in kwargs.items():
18
+ if key not in self.FIELDS:
19
+ raise AttributeError(
20
+ f"{key} is not a valid attribute of {type(self).__name__}"
21
+ )
22
+ setattr(self, key, value)
23
+ self._modified = False
24
+
25
+ def __setattr__(self, name, value):
26
+ """Update one of the attributes of the Row."""
27
+ if name in self.FIELDS and value is not None:
28
+ super().__setattr__(name, self.FIELDS[name](value))
29
+ else:
30
+ super().__setattr__(name, value)
31
+ if name != "_modified":
32
+ super().__setattr__("_modified", True)
33
+
34
+ def __repr__(self):
35
+ """Return a string representation of the Row."""
36
+ values = []
37
+ for name in self.FIELDS.keys():
38
+ if hasattr(self, name):
39
+ values.append(f"{name}={repr(getattr(self, name))}")
40
+ class_name = self.__class__.__name__
41
+ values_str = ", ".join(values)
42
+ return f"{class_name}({values_str})"
43
+
44
+ def __eq__(self, other):
45
+ """Test for equality between two instances."""
46
+ return isinstance(other, self.__class__) and repr(self) == repr(other)
47
+
48
+ @property
49
+ def modified(self):
50
+ """Return True if any fields in the Row have been modified."""
51
+ return self._modified
52
+
53
+ @classmethod
54
+ def from_sqlite_row(cls, row):
55
+ """Initialize a Row from a sqllite cursor row."""
56
+ kwargs = {}
57
+ for key in row.keys():
58
+ kwargs[key] = row[key]
59
+ return cls(**kwargs)
60
+
61
+ @classmethod
62
+ def select_all(cls, cursor):
63
+ """Select all Row entries from the underlying sqlite table."""
64
+ names = ",".join(cls.FIELDS.keys())
65
+ cursor.execute(f"SELECT {names} FROM {cls.TABLE_NAME}")
66
+ for row in cursor.fetchall():
67
+ yield cls.from_sqlite_row(row)
68
+
69
+ @classmethod
70
+ def create_sqlite_table_from_row_schema(cls, cursor):
71
+ """Create a sqlite table based on the schema for this Row class."""
72
+ fields = []
73
+ for name, value in cls.FIELDS.items():
74
+ if isinstance(value, SQLType):
75
+ fields.append(f"{name} {value.sql_type}")
76
+ else:
77
+ value = SQLType.TYPE_MAP[value]
78
+ fields.append(f"{name} {value}")
79
+ fields_str = ", ".join(fields)
80
+ sql = f"CREATE TABLE {cls.TABLE_NAME}({fields_str})"
81
+ try:
82
+ cursor.execute(sql)
83
+ except sqlite3.Error:
84
+ LOG.exception("Query failed: %s", sql)
85
+ raise
86
+
87
+ def primary_key_name(self):
88
+ """Return the primary key for this Row."""
89
+ for name, value in self.FIELDS.items():
90
+ if isinstance(value, PrimaryKey):
91
+ return name
92
+ raise RuntimeError(f"No primary key for table {self.TABLE_NAME}")
93
+
94
+ def primary_key_value(self):
95
+ """Return the value of the primary key for this Row."""
96
+ return getattr(self, self.primary_key_name())
97
+
98
+ def update_db(self, cursor):
99
+ """Update the sqlite database to reflect any changes to this Row."""
100
+ column_list = []
101
+ values = []
102
+ for name in self.FIELDS.keys():
103
+ if hasattr(self, name):
104
+ column_list.append(name)
105
+ values.append(getattr(self, name))
106
+ column_list_str = ", ".join(column_list)
107
+ value_placeholders = ", ".join(["?"] * len(values))
108
+ sql = (
109
+ f"INSERT OR REPLACE INTO {self.TABLE_NAME} ({column_list_str}) "
110
+ f"VALUES ({value_placeholders})"
111
+ )
112
+ try:
113
+ cursor.execute(sql, values)
114
+ except sqlite3.Error:
115
+ LOG.exception("Query failed: %s %s", sql, values)
116
+ raise
117
+ try:
118
+ pk_name = self.primary_key_name()
119
+ except RuntimeError:
120
+ pass
121
+ else:
122
+ pk_type = self.FIELDS[pk_name]
123
+ if pk_type.auto_increment and not hasattr(self, pk_name):
124
+ setattr(self, pk_name, cursor.lastrowid)
125
+
126
+ def remove_from_db(self, cursor):
127
+ """Remove the Row from the sqlite database."""
128
+ pk_name = self.primary_key_name()
129
+ pk_value = self.primary_key_value()
130
+ if pk_value is None:
131
+ sql = f"DELETE FROM {self.TABLE_NAME} WHERE {pk_name} IS NULL"
132
+ values = ()
133
+ else:
134
+ sql = f"DELETE FROM {self.TABLE_NAME} WHERE {pk_name}=?"
135
+ values = (pk_value,)
136
+
137
+ try:
138
+ cursor.execute(sql, values)
139
+ except sqlite3.Error:
140
+ LOG.exception("Query failed: %s %s", sql, pk_value)
141
+ raise
142
+
143
+
144
+ class SQLType:
145
+ """Base class for custom sqlite schema types."""
146
+
147
+ TYPE_MAP = {
148
+ int: "INTEGER",
149
+ float: "REAL",
150
+ str: "TEXT",
151
+ }
152
+
153
+ def __init__(self, type_constructor, *, sql_type=None, not_null=False):
154
+ """Create a SQLType.
155
+
156
+ Args:
157
+ type_constructor: Callable(value) that will receive the value from
158
+ the database cursor and convert it to a Python type.
159
+ sql_type: The sqlite field type.
160
+ not_null: Whether or not the sqlite field can be null.
161
+ """
162
+ self.type_constructor = type_constructor
163
+ self._sql_type = (
164
+ sql_type
165
+ if sql_type is not None
166
+ else SQLType.TYPE_MAP[type_constructor]
167
+ )
168
+ self.not_null = not_null
169
+
170
+ def __call__(self, value):
171
+ """Convert the sqlite row value to a Python type."""
172
+ return self.type_constructor(value)
173
+
174
+ @property
175
+ def sql_type(self):
176
+ """Return the sqlite type name for this type."""
177
+ if self.not_null:
178
+ return f"{self._sql_type} NOT NULL".lstrip()
179
+ return self._sql_type.lstrip()
180
+
181
+
182
+ class PrimaryKey(SQLType): # pylint: disable=too-few-public-methods
183
+ """Class used to indicate the primary key field for a Row."""
184
+
185
+ def __init__(self, *args, auto_increment=False, **kwargs):
186
+ """Create a PrimaryKey instance."""
187
+ super().__init__(*args, **kwargs)
188
+ self.auto_increment = auto_increment
189
+
190
+ @property
191
+ def sql_type(self):
192
+ """Return the sqlite type name for this type."""
193
+ value = super().sql_type
194
+ value = f"{value} PRIMARY KEY"
195
+ if self.auto_increment:
196
+ value = f"{value} AUTOINCREMENT"
197
+ return value.lstrip()
@@ -0,0 +1,168 @@
1
+ """Methods to make changes to the long press rules for a device.
2
+
3
+ Wemo devices store a database of rules that configure actions for the device. A
4
+ long press rule is activated when the button on the device is pressed for 2
5
+ seconds. A person can press the button for 2 seconds and, based on the rules
6
+ configured for the device, it will turn on/off/toggle other Wemo devices on the
7
+ network. The methods in this mixin allow editing of the devices that are
8
+ controlled by a long press.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from enum import Enum
14
+ from typing import Iterable, no_type_check
15
+
16
+ from .rules_db import RuleDevicesRow, RulesDb, RulesRow, rules_db_from_device
17
+ from .service import RequiredService, RequiredServicesMixin
18
+
19
+ LOG = logging.getLogger(__name__)
20
+
21
+ # RulesRow.Type values.
22
+ RULE_TYPE_LONG_PRESS = "Long Press"
23
+
24
+ VIRTUAL_DEVICE_UDN = "uuid:Socket-1_0-PyWemoVirtualDevice"
25
+
26
+
27
+ class ActionType(Enum):
28
+ """Action to perform when a long press rule is triggered."""
29
+
30
+ TOGGLE = 2.0
31
+ ON = 1.0
32
+ OFF = 0.0
33
+
34
+
35
+ @no_type_check
36
+ def ensure_long_press_rule_exists(
37
+ rules_db: RulesDb, device_name: str, device_udn: str
38
+ ) -> RulesRow:
39
+ """Ensure that a long press rule exists and is enabled for the device.
40
+
41
+ Returns the long press rule.
42
+ """
43
+ current_rules = rules_db.rules_for_device(rule_type=RULE_TYPE_LONG_PRESS)
44
+ for rule, _ in current_rules:
45
+ if rule.State != "1":
46
+ LOG.info("Enabling long press rule for device %s", device_name)
47
+ rule.State = "1"
48
+ rule.update_db(rules_db.cursor())
49
+ return rule
50
+
51
+ LOG.info("Adding long press rule for device %s", device_name)
52
+ rule_orders = (
53
+ r.RuleOrder for r in rules_db.rules.values() if r.RuleOrder is not None
54
+ )
55
+ new_rule = RulesRow(
56
+ Name=f"{device_name} Long Press Rule",
57
+ Type=RULE_TYPE_LONG_PRESS,
58
+ RuleOrder=max(rule_orders, default=-1) + 1,
59
+ StartDate="12201982",
60
+ EndDate="07301982",
61
+ State="1",
62
+ Sync="NOSYNC",
63
+ )
64
+ rules_db.add_rule(new_rule)
65
+ rules_db.add_rule_devices(
66
+ RuleDevicesRow(
67
+ RuleID=new_rule.RuleID, # pylint: disable=no-member
68
+ DeviceID=device_udn,
69
+ GroupID=0,
70
+ DayID=-1,
71
+ StartTime=60,
72
+ RuleDuration=86340,
73
+ StartAction=ActionType.TOGGLE.value,
74
+ EndAction=-1.0,
75
+ SensorDuration=-1,
76
+ Type=-1,
77
+ Value=-1,
78
+ Level=-1,
79
+ ZBCapabilityStart="",
80
+ ZBCapabilityEnd="",
81
+ OnModeOffset=-1,
82
+ OffModeOffset=-1,
83
+ CountdownTime=-1,
84
+ EndTime=86400,
85
+ )
86
+ )
87
+ return new_rule
88
+
89
+
90
+ class LongPressMixin(RequiredServicesMixin):
91
+ """Methods to make changes to the long press rules for a device."""
92
+
93
+ EVENT_TYPE_LONG_PRESS = "LongPress"
94
+
95
+ @property
96
+ def _required_services(self) -> list[RequiredService]:
97
+ return super()._required_services + [
98
+ RequiredService(name="rules", actions=["FetchRules", "StoreRules"])
99
+ ]
100
+
101
+ @no_type_check
102
+ def list_long_press_udns(self) -> frozenset[str]:
103
+ """Return a list of device UDNs that are configured for long press."""
104
+ devices = []
105
+ with rules_db_from_device(self) as rules_db:
106
+ for rule, _ in rules_db.rules_for_device(
107
+ rule_type=RULE_TYPE_LONG_PRESS
108
+ ):
109
+ devices.extend(rules_db.get_target_devices_for_rule(rule))
110
+ return frozenset(devices)
111
+
112
+ @no_type_check
113
+ def add_long_press_udns(self, device_udns: Iterable[str]) -> None:
114
+ """Add a list of device UDNs to be configured for long press."""
115
+ with rules_db_from_device(self) as rules_db:
116
+ rule = ensure_long_press_rule_exists(rules_db, self.name, self.udn)
117
+ for udn in device_udns:
118
+ if not udn:
119
+ continue
120
+ if udn not in rules_db.get_target_devices_for_rule(rule):
121
+ rules_db.add_target_device_to_rule(rule, udn)
122
+
123
+ @no_type_check
124
+ def remove_long_press_udns(self, device_udns: Iterable[str]) -> None:
125
+ """Remove a list of device UDNs from the long press configuration."""
126
+ with rules_db_from_device(self) as rules_db:
127
+ for rule, _ in rules_db.rules_for_device(
128
+ rule_type=RULE_TYPE_LONG_PRESS
129
+ ):
130
+ for udn in device_udns:
131
+ if udn in rules_db.get_target_devices_for_rule(rule):
132
+ rules_db.remove_target_device_from_rule(rule, udn)
133
+
134
+ @no_type_check
135
+ def get_long_press_action(self) -> ActionType | None:
136
+ """Fetch the ActionType for the long press rule.
137
+
138
+ Will return None if no long press rule is configured for the device.
139
+ """
140
+ with rules_db_from_device(self) as rules_db:
141
+ for _, device in rules_db.rules_for_device(
142
+ rule_type=RULE_TYPE_LONG_PRESS
143
+ ):
144
+ return ActionType(device.StartAction)
145
+ return None
146
+
147
+ @no_type_check
148
+ def set_long_press_action(self, action: ActionType) -> None:
149
+ """Set the ActionType for the long press rule."""
150
+ with rules_db_from_device(self) as rules_db:
151
+ ensure_long_press_rule_exists(rules_db, self.name, self.udn)
152
+ for _, device in rules_db.rules_for_device(
153
+ rule_type=RULE_TYPE_LONG_PRESS
154
+ ):
155
+ device.StartAction = action.value
156
+
157
+ def ensure_long_press_virtual_device(self) -> None:
158
+ """Configure the device to notify pywemo when a long-press happens.
159
+
160
+ The ensure_long_press_virtual_device method ensures that the pywemo
161
+ virtual device is configured in the rules database for when a long
162
+ press rule is triggered.
163
+ """
164
+ self.add_long_press_udns([VIRTUAL_DEVICE_UDN])
165
+
166
+ def remove_long_press_virtual_device(self) -> None:
167
+ """Remove the pywemo virtual device from the long press."""
168
+ self.remove_long_press_udns([VIRTUAL_DEVICE_UDN])