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
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Representation of a WeMo Bridge (Link) device."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
from html import escape
|
|
8
|
+
from typing import Any, Iterable, TypedDict
|
|
9
|
+
|
|
10
|
+
from lxml import etree as et
|
|
11
|
+
|
|
12
|
+
from ..color import ColorXY, get_profiles, limit_to_gamut
|
|
13
|
+
from ..exceptions import InvalidSchemaError
|
|
14
|
+
from . import Device
|
|
15
|
+
from .api.service import RequiredService
|
|
16
|
+
|
|
17
|
+
CAPABILITY_ID2NAME = dict(
|
|
18
|
+
(
|
|
19
|
+
("10006", "onoff"),
|
|
20
|
+
("10008", "levelcontrol"),
|
|
21
|
+
("30008", "sleepfader"),
|
|
22
|
+
("30009", "levelcontrol_move"),
|
|
23
|
+
("3000A", "levelcontrol_stop"),
|
|
24
|
+
("10300", "colorcontrol"),
|
|
25
|
+
("30301", "colortemperature"),
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
CAPABILITY_NAME2ID = dict(
|
|
29
|
+
(val, cap) for cap, val in CAPABILITY_ID2NAME.items()
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# acceptable values for 'onoff'
|
|
33
|
+
OFF = 0
|
|
34
|
+
ON = 1
|
|
35
|
+
TOGGLE = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _warn_rename(old: str, new: str, stacklevel: int = 3) -> None:
|
|
39
|
+
warnings.warn(
|
|
40
|
+
f"{old} is deprecated and will be removed in a future release. "
|
|
41
|
+
f"Use {new} instead",
|
|
42
|
+
DeprecationWarning,
|
|
43
|
+
stacklevel=stacklevel,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def limit(value: int, min_val: int, max_val: int) -> int:
|
|
48
|
+
"""Return a value clipped to the range [min_val, max_val]."""
|
|
49
|
+
return max(min_val, min(value, max_val))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Bridge(Device):
|
|
53
|
+
"""Representation of a WeMo Bridge (Link) device."""
|
|
54
|
+
|
|
55
|
+
lights: dict[str, Light]
|
|
56
|
+
groups: dict[str, Group]
|
|
57
|
+
|
|
58
|
+
EVENT_TYPE_STATUS_CHANGE = "StatusChange"
|
|
59
|
+
|
|
60
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
61
|
+
"""Create a WeMo Bridge (Link) device."""
|
|
62
|
+
super().__init__(*args, **kwargs)
|
|
63
|
+
self.lights = {}
|
|
64
|
+
self.groups = {}
|
|
65
|
+
self.bridge_update()
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
"""Return a string representation of the device."""
|
|
69
|
+
return (
|
|
70
|
+
'<WeMo Bridge "{name}", Lights: {lights}, ' + "Groups: {groups}>"
|
|
71
|
+
).format(
|
|
72
|
+
name=self.name, lights=len(self.lights), groups=len(self.groups)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def _required_services(self) -> list[RequiredService]:
|
|
77
|
+
return super()._required_services + [
|
|
78
|
+
RequiredService(name="basicevent", actions=["GetMacAddr"]),
|
|
79
|
+
RequiredService(
|
|
80
|
+
name="bridge",
|
|
81
|
+
actions=["GetDeviceStatus", "SetDeviceStatus"],
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def Lights(self) -> dict[str, Light]: # pylint: disable=invalid-name
|
|
87
|
+
"""Deprecated method for accessing .lights."""
|
|
88
|
+
_warn_rename("Lights", "lights")
|
|
89
|
+
return self.lights
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def Groups(self) -> dict[str, Group]: # pylint: disable=invalid-name
|
|
93
|
+
"""Deprecated method for accessing .groups."""
|
|
94
|
+
_warn_rename("Groups", "groups")
|
|
95
|
+
return self.groups
|
|
96
|
+
|
|
97
|
+
def bridge_update(
|
|
98
|
+
self, force_update: bool = True
|
|
99
|
+
) -> tuple[dict[str, Light], dict[str, Group]]:
|
|
100
|
+
"""Get updated status information for the bridge and its lights."""
|
|
101
|
+
if force_update or self.lights is None or self.groups is None:
|
|
102
|
+
plugin_udn = self.basicevent.GetMacAddr().get("PluginUDN")
|
|
103
|
+
|
|
104
|
+
if hasattr(self.bridge, "GetEndDevicesWithStatus"):
|
|
105
|
+
end_devices = self.bridge.GetEndDevicesWithStatus(
|
|
106
|
+
DevUDN=plugin_udn, ReqListType="PAIRED_LIST"
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
end_devices = self.bridge.GetEndDevices(
|
|
110
|
+
DevUDN=plugin_udn, ReqListType="PAIRED_LIST"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not (end_devices_xml := end_devices.get("DeviceLists")):
|
|
114
|
+
return self.lights, self.groups
|
|
115
|
+
|
|
116
|
+
end_device_list = et.fromstring(
|
|
117
|
+
end_devices_xml.encode("utf-8"),
|
|
118
|
+
parser=et.XMLParser(resolve_entities=False),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for light in end_device_list.iter("DeviceInfo"):
|
|
122
|
+
if not (device_id := light.findtext("DeviceID")):
|
|
123
|
+
raise InvalidSchemaError(
|
|
124
|
+
f"DeviceID missing: {et.tostring(light).decode()}"
|
|
125
|
+
)
|
|
126
|
+
if device_id in self.lights:
|
|
127
|
+
self.lights[device_id].update_state(light)
|
|
128
|
+
else:
|
|
129
|
+
self.lights[device_id] = Light(self, light)
|
|
130
|
+
|
|
131
|
+
for group in end_device_list.iter("GroupInfo"):
|
|
132
|
+
if not (group_id := group.findtext("GroupID")):
|
|
133
|
+
raise InvalidSchemaError(
|
|
134
|
+
f"GroupID missing: {et.tostring(group).decode()}"
|
|
135
|
+
)
|
|
136
|
+
if group_id in self.groups:
|
|
137
|
+
self.groups[group_id].update_state(group)
|
|
138
|
+
else:
|
|
139
|
+
self.groups[group_id] = Group(self, group)
|
|
140
|
+
|
|
141
|
+
return self.lights, self.groups
|
|
142
|
+
|
|
143
|
+
def get_state(self, force_update: bool = False) -> int:
|
|
144
|
+
"""Update the state of the Bridge device."""
|
|
145
|
+
state = super().get_state(force_update)
|
|
146
|
+
self.bridge_update(force_update)
|
|
147
|
+
return state
|
|
148
|
+
|
|
149
|
+
def subscription_update(self, _type: str, _param: str) -> bool:
|
|
150
|
+
"""Update the bridge attributes due to a subscription update event."""
|
|
151
|
+
if _type == self.EVENT_TYPE_STATUS_CHANGE and _param:
|
|
152
|
+
try:
|
|
153
|
+
state_event = et.fromstring(
|
|
154
|
+
_param.encode("utf8"),
|
|
155
|
+
parser=et.XMLParser(resolve_entities=False),
|
|
156
|
+
)
|
|
157
|
+
except et.XMLSyntaxError:
|
|
158
|
+
return False
|
|
159
|
+
if not (key := state_event.findtext("DeviceID")):
|
|
160
|
+
return False
|
|
161
|
+
if key in self.lights:
|
|
162
|
+
return self.lights[key].subscription_update(state_event)
|
|
163
|
+
|
|
164
|
+
if key in self.groups:
|
|
165
|
+
return self.groups[key].subscription_update(state_event)
|
|
166
|
+
|
|
167
|
+
return False
|
|
168
|
+
return super().subscription_update(_type, _param)
|
|
169
|
+
|
|
170
|
+
def bridge_getdevicestatus(self, deviceid: str) -> et._Element | None:
|
|
171
|
+
"""Return the list of device statuses for the bridge's lights."""
|
|
172
|
+
status_list = self.bridge.GetDeviceStatus(DeviceIDs=deviceid)
|
|
173
|
+
device_status_list_xml = status_list.get("DeviceStatusList")
|
|
174
|
+
if not device_status_list_xml:
|
|
175
|
+
return None
|
|
176
|
+
device_status_list = et.fromstring(
|
|
177
|
+
device_status_list_xml.encode("utf-8"),
|
|
178
|
+
parser=et.XMLParser(resolve_entities=False),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return device_status_list.find("DeviceStatus")
|
|
182
|
+
|
|
183
|
+
def bridge_setdevicestatus(
|
|
184
|
+
self, isgroup: str, deviceid: str, capids: list[str], values: list[str]
|
|
185
|
+
) -> dict[str, str]:
|
|
186
|
+
"""Set the status of the bridge's lights."""
|
|
187
|
+
req = et.Element("DeviceStatus")
|
|
188
|
+
et.SubElement(req, "IsGroupAction").text = isgroup
|
|
189
|
+
et.SubElement(req, "DeviceID", available="YES").text = deviceid
|
|
190
|
+
et.SubElement(req, "CapabilityID").text = ",".join(capids)
|
|
191
|
+
et.SubElement(req, "CapabilityValue").text = ",".join(values)
|
|
192
|
+
|
|
193
|
+
buf = io.BytesIO()
|
|
194
|
+
et.ElementTree(req).write(buf, encoding="UTF-8", xml_declaration=True)
|
|
195
|
+
send_state = escape(buf.getvalue().decode(), quote=True)
|
|
196
|
+
|
|
197
|
+
return self.bridge.SetDeviceStatus(DeviceStatusList=send_state)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class DeviceState(TypedDict, total=False):
|
|
201
|
+
"""LinkedDevice state dictionary type."""
|
|
202
|
+
|
|
203
|
+
available: bool
|
|
204
|
+
onoff: int
|
|
205
|
+
level: int
|
|
206
|
+
temperature_mireds: int
|
|
207
|
+
temperature_kelvin: int
|
|
208
|
+
color_xy: ColorXY
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class LinkedDevice: # pylint: disable=too-many-instance-attributes
|
|
212
|
+
"""Representation of a device connected to the bridge."""
|
|
213
|
+
|
|
214
|
+
_NAME_TAG: str
|
|
215
|
+
_CAPABILITIES_TAGS: tuple[str, ...]
|
|
216
|
+
_VALUES_TAGS: tuple[str, ...]
|
|
217
|
+
|
|
218
|
+
def __init__(self, bridge: Bridge, info: et._Element) -> None:
|
|
219
|
+
"""Create a Linked Device."""
|
|
220
|
+
self.bridge: Bridge = bridge
|
|
221
|
+
self.host: str = self.bridge.host
|
|
222
|
+
self.port: int = self.bridge.port
|
|
223
|
+
self.name: str = ""
|
|
224
|
+
self.state: DeviceState = {}
|
|
225
|
+
self.capabilities: Iterable[str] = tuple()
|
|
226
|
+
self.update_state(info)
|
|
227
|
+
self._last_err: dict[str, str] = {}
|
|
228
|
+
self.mac: str = self.bridge.mac
|
|
229
|
+
self.serial_number: str = self.bridge.serial_number
|
|
230
|
+
self.uniqueID: str = "" # pylint: disable=invalid-name
|
|
231
|
+
|
|
232
|
+
def get_state(self, force_update: bool = False) -> DeviceState:
|
|
233
|
+
"""Return the status of the device."""
|
|
234
|
+
if force_update:
|
|
235
|
+
self.bridge.bridge_update()
|
|
236
|
+
return self.state
|
|
237
|
+
|
|
238
|
+
def update_state(self, status: et._Element) -> None:
|
|
239
|
+
"""Fetch the capabilities and values then update the device state."""
|
|
240
|
+
if name := status.findtext(self._NAME_TAG, ""):
|
|
241
|
+
self.name = name
|
|
242
|
+
|
|
243
|
+
def get_first_text(tags: Iterable[str]) -> str | None:
|
|
244
|
+
candidates = (status.findtext(tag) for tag in tags)
|
|
245
|
+
return next(filter(bool, candidates), None)
|
|
246
|
+
|
|
247
|
+
if capabilities := get_first_text(self._CAPABILITIES_TAGS):
|
|
248
|
+
self.capabilities = tuple(
|
|
249
|
+
CAPABILITY_ID2NAME.get(c, c) for c in capabilities.split(",")
|
|
250
|
+
)
|
|
251
|
+
if current_state := get_first_text(self._VALUES_TAGS):
|
|
252
|
+
self._update_values(
|
|
253
|
+
zip(self.capabilities, current_state.split(","))
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _update_values(self, values: Iterable[tuple[str, str]]) -> None:
|
|
257
|
+
"""Set the device state based on capabilities and values."""
|
|
258
|
+
status: dict[str, tuple[int, ...] | None] = {}
|
|
259
|
+
for capability, value in values:
|
|
260
|
+
if capability not in CAPABILITY_NAME2ID:
|
|
261
|
+
continue # Ignore unsupported capabilities.
|
|
262
|
+
if not value:
|
|
263
|
+
status[capability] = None
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
status[capability] = tuple(
|
|
267
|
+
int(round(float(v))) for v in value.split(":")
|
|
268
|
+
)
|
|
269
|
+
except ValueError as err:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"Invalid value for {capability}: {repr(value)}"
|
|
272
|
+
) from err
|
|
273
|
+
|
|
274
|
+
# unreachable devices have empty strings for all capability values
|
|
275
|
+
if (on_off := status.get("onoff", ("Missing",))) is None:
|
|
276
|
+
self.state["available"] = False
|
|
277
|
+
self.state["onoff"] = 0
|
|
278
|
+
elif isinstance(on_off[0], int):
|
|
279
|
+
self.state["available"] = True
|
|
280
|
+
self.state["onoff"] = on_off[0]
|
|
281
|
+
|
|
282
|
+
if (level_control := status.get("levelcontrol")) is not None:
|
|
283
|
+
self.state["level"] = level_control[0]
|
|
284
|
+
|
|
285
|
+
if (color_temperature := status.get("colortemperature")) is not None:
|
|
286
|
+
temperature = color_temperature[0]
|
|
287
|
+
if temperature <= 0:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"Invalid value for color temperature: {temperature}"
|
|
290
|
+
)
|
|
291
|
+
self.state["temperature_mireds"] = temperature
|
|
292
|
+
self.state["temperature_kelvin"] = int(1000000 / temperature)
|
|
293
|
+
|
|
294
|
+
if (color_control := status.get("colorcontrol")) is not None:
|
|
295
|
+
if len(color_control) < 2:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"Too few values for colorcontrol: {repr(color_control)}"
|
|
298
|
+
)
|
|
299
|
+
color_x, color_y = float(color_control[0]), float(color_control[1])
|
|
300
|
+
color_x, color_y = color_x / 65535.0, color_y / 65535.0
|
|
301
|
+
self.state["color_xy"] = color_x, color_y
|
|
302
|
+
|
|
303
|
+
def subscription_update(self, state_event: et._Element) -> bool:
|
|
304
|
+
"""Update the light values due to a subscription update event."""
|
|
305
|
+
if (
|
|
306
|
+
device_id := state_event.find("DeviceID")
|
|
307
|
+
) is None or device_id.get("available", "YES").upper() == "YES":
|
|
308
|
+
capability = state_event.findtext("CapabilityId")
|
|
309
|
+
value = state_event.findtext("Value")
|
|
310
|
+
else:
|
|
311
|
+
capability = CAPABILITY_NAME2ID.get("onoff")
|
|
312
|
+
value = "" # Use an empty string to indicate an unreachable device
|
|
313
|
+
|
|
314
|
+
if capability is None or value is None:
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
name = CAPABILITY_ID2NAME.get(capability, capability)
|
|
318
|
+
if name not in self.capabilities:
|
|
319
|
+
# Should't receive updates for capabilities that were not
|
|
320
|
+
# originally present.
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
self._update_values([(name, value)])
|
|
325
|
+
except ValueError:
|
|
326
|
+
return False
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
def _setdevicestatus(self, **kwargs: Any) -> LinkedDevice:
|
|
330
|
+
"""Ask the bridge to set the device status."""
|
|
331
|
+
isgroup = "YES" if isinstance(self, Group) else "NO"
|
|
332
|
+
|
|
333
|
+
capids = []
|
|
334
|
+
values = []
|
|
335
|
+
for cap, val in kwargs.items():
|
|
336
|
+
capids.append(CAPABILITY_NAME2ID[cap])
|
|
337
|
+
|
|
338
|
+
if not isinstance(val, (list, tuple)):
|
|
339
|
+
val = (val,)
|
|
340
|
+
values.append(":".join(str(v) for v in val))
|
|
341
|
+
|
|
342
|
+
self._last_err = self.bridge.bridge_setdevicestatus(
|
|
343
|
+
isgroup, self.uniqueID, capids, values
|
|
344
|
+
)
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
def turn_on( # pylint: disable=unused-argument
|
|
348
|
+
self,
|
|
349
|
+
level: int | None = None,
|
|
350
|
+
transition: int = 0,
|
|
351
|
+
force_update: bool = False,
|
|
352
|
+
) -> LinkedDevice:
|
|
353
|
+
"""Turn on the device."""
|
|
354
|
+
return self._setdevicestatus(onoff=ON)
|
|
355
|
+
|
|
356
|
+
def turn_off( # pylint: disable=unused-argument
|
|
357
|
+
self, transition: int = 0
|
|
358
|
+
) -> LinkedDevice:
|
|
359
|
+
"""Turn off the device."""
|
|
360
|
+
return self._setdevicestatus(onoff=OFF)
|
|
361
|
+
|
|
362
|
+
def toggle(self) -> LinkedDevice:
|
|
363
|
+
"""Toggle the device from on to off or off to on."""
|
|
364
|
+
return self._setdevicestatus(onoff=TOGGLE)
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def device_type(self) -> str:
|
|
368
|
+
"""Return what kind of WeMo this device is."""
|
|
369
|
+
return type(self).__name__
|
|
370
|
+
|
|
371
|
+
def __repr__(self) -> str:
|
|
372
|
+
"""Return a string representation of the device."""
|
|
373
|
+
return f'<{self.device_type.upper()} "{self.name}">'
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class Light(LinkedDevice): # pylint: disable=too-many-instance-attributes
|
|
377
|
+
"""Representation of a Light connected to the Bridge."""
|
|
378
|
+
|
|
379
|
+
_NAME_TAG = "FriendlyName"
|
|
380
|
+
_CAPABILITIES_TAGS = ("CapabilityIDs", "CapabilityID")
|
|
381
|
+
_VALUES_TAGS = ("CurrentState", "CapabilityValue")
|
|
382
|
+
|
|
383
|
+
def __init__(self, bridge: Bridge, info: et._Element) -> None:
|
|
384
|
+
"""Create a Light device."""
|
|
385
|
+
super().__init__(bridge, info)
|
|
386
|
+
|
|
387
|
+
self.device_index: str = info.findtext("DeviceIndex", "")
|
|
388
|
+
self.uniqueID: str = info.findtext("DeviceID", "")
|
|
389
|
+
self.iconvalue: str = info.findtext("IconVersion", "")
|
|
390
|
+
self.firmware: str = info.findtext("FirmwareVersion", "")
|
|
391
|
+
self.manufacturer: str = info.findtext("Manufacturer", "")
|
|
392
|
+
self.model: str = info.findtext("ModelCode", "")
|
|
393
|
+
self.certified: str = info.findtext("WeMoCertified", "")
|
|
394
|
+
|
|
395
|
+
self.temperature_range, self.gamut = get_profiles(self.model)
|
|
396
|
+
self._pending: dict[str, Any] = {}
|
|
397
|
+
|
|
398
|
+
def _queuedevicestatus(self, queue: bool = False, **kwargs: Any) -> Light:
|
|
399
|
+
"""Queue an update to the device."""
|
|
400
|
+
if kwargs:
|
|
401
|
+
self._pending.update(kwargs)
|
|
402
|
+
if not queue:
|
|
403
|
+
self._setdevicestatus(**self._pending)
|
|
404
|
+
self._pending = {}
|
|
405
|
+
|
|
406
|
+
return self
|
|
407
|
+
|
|
408
|
+
def turn_on(
|
|
409
|
+
self,
|
|
410
|
+
level: int | None = None,
|
|
411
|
+
transition: int = 0,
|
|
412
|
+
force_update: bool = False,
|
|
413
|
+
) -> Light:
|
|
414
|
+
"""Turn on the light."""
|
|
415
|
+
transition_time = limit(int(transition * 10), 0, 65535)
|
|
416
|
+
|
|
417
|
+
if level == 0:
|
|
418
|
+
return self.turn_off(transition)
|
|
419
|
+
if "levelcontrol" in self.capabilities:
|
|
420
|
+
# Work around observed fw bugs.
|
|
421
|
+
# - When we set a new brightness level but the bulb is off, it
|
|
422
|
+
# first turns on at the old brightness and then fades to the new
|
|
423
|
+
# setting. So we have to force the saved brightness to 0 first.
|
|
424
|
+
# - When we turn a bulb on with levelcontrol the onoff state
|
|
425
|
+
# doesn't update.
|
|
426
|
+
# - After turning off a bulb with sleepfader, it fails to turn back
|
|
427
|
+
# on unless the brightness is re-set with levelcontrol.
|
|
428
|
+
self.get_state(force_update=force_update)
|
|
429
|
+
# A freshly power cycled bridge has no record of the bulb
|
|
430
|
+
# brightness, so default to full on if the client didn't request
|
|
431
|
+
# a level and we have no record
|
|
432
|
+
if level is None:
|
|
433
|
+
level = self.state.get("level", 255)
|
|
434
|
+
|
|
435
|
+
if self.state["onoff"] == 0:
|
|
436
|
+
self._setdevicestatus(levelcontrol=(0, 0), onoff=ON)
|
|
437
|
+
|
|
438
|
+
level = limit(int(level), 0, 255)
|
|
439
|
+
return self._queuedevicestatus(
|
|
440
|
+
levelcontrol=(level, transition_time)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return self._queuedevicestatus(onoff=ON)
|
|
444
|
+
|
|
445
|
+
def turn_off(self, transition: int = 0) -> Light:
|
|
446
|
+
"""Turn off the light."""
|
|
447
|
+
if transition and "sleepfader" in self.capabilities:
|
|
448
|
+
# Sleepfader control did not turn off bulb when fadetime was 0
|
|
449
|
+
transition_time = limit(int(transition * 10), 1, 65535)
|
|
450
|
+
reference = int(time.time())
|
|
451
|
+
return self._queuedevicestatus(
|
|
452
|
+
sleepfader=(transition_time, reference)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
return self._queuedevicestatus(onoff=OFF)
|
|
456
|
+
|
|
457
|
+
def set_temperature(
|
|
458
|
+
self,
|
|
459
|
+
kelvin: int = 2700,
|
|
460
|
+
mireds: int | None = None,
|
|
461
|
+
transition: int = 0,
|
|
462
|
+
delay: bool = True,
|
|
463
|
+
) -> Light:
|
|
464
|
+
"""Set the color temperature of the light."""
|
|
465
|
+
transition_time = limit(int(transition * 10), 0, 65535)
|
|
466
|
+
if mireds is None:
|
|
467
|
+
mireds = int(1000000 / kelvin)
|
|
468
|
+
mireds = limit(int(mireds), *self.temperature_range)
|
|
469
|
+
return self._queuedevicestatus(
|
|
470
|
+
colortemperature=(mireds, transition_time), queue=delay
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def set_color(
|
|
474
|
+
self, colorxy: ColorXY, transition: int = 0, delay: bool = True
|
|
475
|
+
) -> Light:
|
|
476
|
+
"""Set the color of the light."""
|
|
477
|
+
transition_time = limit(int(transition * 10), 0, 65535)
|
|
478
|
+
colorxy = limit_to_gamut(colorxy, self.gamut)
|
|
479
|
+
colorx = limit(int(colorxy[0] * 65535), 0, 65535)
|
|
480
|
+
colory = limit(int(colorxy[1] * 65535), 0, 65535)
|
|
481
|
+
return self._queuedevicestatus(
|
|
482
|
+
colorcontrol=(colorx, colory, transition_time), queue=delay
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def start_ramp(self, ramp_up: bool, rate: int) -> Light:
|
|
486
|
+
"""Start ramping the brightness up or down."""
|
|
487
|
+
up_down = "1" if ramp_up else "0"
|
|
488
|
+
rate = limit(int(rate), 0, 255)
|
|
489
|
+
return self._queuedevicestatus(levelcontrol_move=(up_down, rate))
|
|
490
|
+
|
|
491
|
+
def stop_ramp(self) -> LinkedDevice:
|
|
492
|
+
"""Start ramping the brightness up or down."""
|
|
493
|
+
return self._setdevicestatus(levelcontrol_stop="")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class Group(LinkedDevice):
|
|
497
|
+
"""Representation of a Group of lights connected to the Bridge."""
|
|
498
|
+
|
|
499
|
+
_NAME_TAG = "GroupName"
|
|
500
|
+
_CAPABILITIES_TAGS = ("GroupCapabilityIDs", "CapabilityID")
|
|
501
|
+
_VALUES_TAGS = ("GroupCapabilityValues", "CapabilityValue")
|
|
502
|
+
|
|
503
|
+
def __init__(self, bridge: Bridge, info: et._Element) -> None:
|
|
504
|
+
"""Create a Group device."""
|
|
505
|
+
super().__init__(bridge, info)
|
|
506
|
+
self.uniqueID: str = info.findtext("GroupID", "")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Representation of a WeMo CoffeeMaker device."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
from .api.attributes import AttributeDevice
|
|
8
|
+
|
|
9
|
+
_UNKNOWN = -1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# These enums were derived from the
|
|
13
|
+
# CoffeeMaker.deviceevent.GetAttributeList() service call
|
|
14
|
+
# Thus these names/values were not chosen randomly
|
|
15
|
+
# and the numbers have meaning.
|
|
16
|
+
class CoffeeMakerMode(IntEnum):
|
|
17
|
+
"""Enum to map WeMo modes to human-readable strings."""
|
|
18
|
+
|
|
19
|
+
_UNKNOWN = _UNKNOWN
|
|
20
|
+
# Note: The UpperMixedCase (invalid) names are deprecated.
|
|
21
|
+
REFILL = 0 # reservoir empty and carafe not in place
|
|
22
|
+
Refill = 0 # pylint: disable=invalid-name
|
|
23
|
+
PLACE_CARAFE = 1 # reservoir has water but carafe not present
|
|
24
|
+
PlaceCarafe = 1 # pylint: disable=invalid-name
|
|
25
|
+
REFILL_WATER = 2 # carafe present but reservoir is empty
|
|
26
|
+
RefillWater = 2 # pylint: disable=invalid-name
|
|
27
|
+
READY = 3
|
|
28
|
+
Ready = 3 # pylint: disable=invalid-name
|
|
29
|
+
BREWING = 4
|
|
30
|
+
Brewing = 4 # pylint: disable=invalid-name
|
|
31
|
+
BREWED = 5
|
|
32
|
+
Brewed = 5 # pylint: disable=invalid-name
|
|
33
|
+
CLEANING_BREWING = 6
|
|
34
|
+
CleaningBrewing = 6 # pylint: disable=invalid-name
|
|
35
|
+
CLEANING_SOAKING = 7
|
|
36
|
+
CleaningSoaking = 7 # pylint: disable=invalid-name
|
|
37
|
+
BREW_FAILED_CARAFE_REMOVED = 8
|
|
38
|
+
BrewFailCarafeRemoved = 8 # pylint: disable=invalid-name
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def _missing_(cls, value: Any) -> CoffeeMakerMode:
|
|
42
|
+
return cls._UNKNOWN
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
MODE_NAMES = {
|
|
46
|
+
CoffeeMakerMode.REFILL: "Refill",
|
|
47
|
+
CoffeeMakerMode.PLACE_CARAFE: "PlaceCarafe",
|
|
48
|
+
CoffeeMakerMode.REFILL_WATER: "RefillWater",
|
|
49
|
+
CoffeeMakerMode.READY: "Ready",
|
|
50
|
+
CoffeeMakerMode.BREWING: "Brewing",
|
|
51
|
+
CoffeeMakerMode.BREWED: "Brewed",
|
|
52
|
+
CoffeeMakerMode.CLEANING_BREWING: "CleaningBrewing",
|
|
53
|
+
CoffeeMakerMode.CLEANING_SOAKING: "CleaningSoaking",
|
|
54
|
+
CoffeeMakerMode.BREW_FAILED_CARAFE_REMOVED: "BrewFailCarafeRemoved",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Attributes(TypedDict, total=False):
|
|
59
|
+
Mode: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CoffeeMaker(AttributeDevice):
|
|
63
|
+
"""Representation of a WeMo CoffeeMaker device."""
|
|
64
|
+
|
|
65
|
+
_state_property = "mode" # Required by AttributeDevice.
|
|
66
|
+
_attributes: _Attributes # Required by AttributeDevice.
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def mode(self) -> CoffeeMakerMode:
|
|
70
|
+
"""Return the mode of the device."""
|
|
71
|
+
return CoffeeMakerMode(self._attributes.get("Mode", _UNKNOWN))
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def mode_string(self) -> str:
|
|
75
|
+
"""Return the mode of the device as a string."""
|
|
76
|
+
return MODE_NAMES.get(self.mode, "Unknown")
|
|
77
|
+
|
|
78
|
+
def get_state(self, force_update: bool = False) -> int:
|
|
79
|
+
"""Return 0 if off and 1 if on."""
|
|
80
|
+
# The base implementation using GetBinaryState doesn't work for
|
|
81
|
+
# CoffeeMaker (always returns 0), so use mode instead.
|
|
82
|
+
# Consider the Coffee Maker to be "on" if it's currently brewing.
|
|
83
|
+
return int(super().get_state(force_update) == CoffeeMakerMode.BREWING)
|
|
84
|
+
|
|
85
|
+
def set_state(self, state: int) -> None:
|
|
86
|
+
"""Set the state of this device to on or off."""
|
|
87
|
+
# CoffeeMaker cannot be turned off remotely, so ignore the request if
|
|
88
|
+
# state is "falsey"
|
|
89
|
+
if state:
|
|
90
|
+
# Coffee Maker always responds with an error if SetBinaryState is
|
|
91
|
+
# called. Use SetAttributes to change the Mode to "Brewing"
|
|
92
|
+
self._set_attributes(("Mode", CoffeeMakerMode.BREWING))
|