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,679 @@
|
|
|
1
|
+
"""Base WeMo Device class."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Sequence
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ..exceptions import (
|
|
14
|
+
ActionException,
|
|
15
|
+
APNotFound,
|
|
16
|
+
InvalidSchemaError,
|
|
17
|
+
ResetException,
|
|
18
|
+
SetupException,
|
|
19
|
+
ShortPassword,
|
|
20
|
+
UnknownService,
|
|
21
|
+
)
|
|
22
|
+
from ..util import MetaInfo
|
|
23
|
+
from .api.long_press import LongPressMixin
|
|
24
|
+
from .api.service import (
|
|
25
|
+
REQUESTS_TIMEOUT,
|
|
26
|
+
RequiredService,
|
|
27
|
+
RequiredServicesMixin,
|
|
28
|
+
Service,
|
|
29
|
+
Session,
|
|
30
|
+
)
|
|
31
|
+
from .api.wemo_services import WeMoServiceTypesMixin
|
|
32
|
+
from .api.xsd_types import DeviceDescription
|
|
33
|
+
|
|
34
|
+
LOG = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Start with the most commonly used port
|
|
37
|
+
PROBE_PORTS = (49153, 49152, 49154, 49151, 49155, 49156, 49157, 49158, 49159)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def probe_wemo(
|
|
41
|
+
host: str,
|
|
42
|
+
ports: Sequence[int] = PROBE_PORTS,
|
|
43
|
+
probe_timeout: float = REQUESTS_TIMEOUT,
|
|
44
|
+
match_udn: str | None = None,
|
|
45
|
+
) -> int | None:
|
|
46
|
+
"""Probe a host for the current port.
|
|
47
|
+
|
|
48
|
+
This probes a host for known-to-be-possible ports and
|
|
49
|
+
returns the one currently in use. If no port is discovered
|
|
50
|
+
then it returns None.
|
|
51
|
+
"""
|
|
52
|
+
for port in ports:
|
|
53
|
+
try:
|
|
54
|
+
response = requests.get(
|
|
55
|
+
f"http://{host}:{port}/setup.xml", timeout=probe_timeout
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
device = DeviceDescription.from_xml(response.content)
|
|
59
|
+
except InvalidSchemaError:
|
|
60
|
+
continue
|
|
61
|
+
if match_udn and match_udn != device.udn:
|
|
62
|
+
LOG.error(
|
|
63
|
+
"Reconnected to a different WeMo. "
|
|
64
|
+
"Expected %s / Received %s",
|
|
65
|
+
match_udn,
|
|
66
|
+
device.udn,
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
return port
|
|
70
|
+
except requests.exceptions.ConnectTimeout:
|
|
71
|
+
# If we timed out connecting, then the wemo is gone,
|
|
72
|
+
# no point in trying further.
|
|
73
|
+
LOG.debug(
|
|
74
|
+
"Timed out connecting to %s on port %i, wemo is offline",
|
|
75
|
+
host,
|
|
76
|
+
port,
|
|
77
|
+
)
|
|
78
|
+
break
|
|
79
|
+
except requests.exceptions.Timeout:
|
|
80
|
+
# Apparently sometimes wemos get into a wedged state where
|
|
81
|
+
# they still accept connections on an old port, but do not
|
|
82
|
+
# respond. If that happens, we should keep searching.
|
|
83
|
+
LOG.debug("No response from %s on port %i, continuing", host, port)
|
|
84
|
+
continue
|
|
85
|
+
except requests.exceptions.ConnectionError:
|
|
86
|
+
pass
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def probe_device(device: Device) -> int | None:
|
|
91
|
+
"""Probe a device for available port.
|
|
92
|
+
|
|
93
|
+
This is an extension for probe_wemo, also probing current port.
|
|
94
|
+
"""
|
|
95
|
+
ports = list(PROBE_PORTS)
|
|
96
|
+
if device.port in ports:
|
|
97
|
+
ports.remove(device.port)
|
|
98
|
+
ports.insert(0, device.port)
|
|
99
|
+
|
|
100
|
+
return probe_wemo(device.host, ports, match_udn=device.udn)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Device(DeviceDescription, RequiredServicesMixin, WeMoServiceTypesMixin):
|
|
104
|
+
"""Base object for WeMo devices."""
|
|
105
|
+
|
|
106
|
+
EVENT_TYPE_BINARY_STATE = "BinaryState"
|
|
107
|
+
|
|
108
|
+
def __init__(self, url: str) -> None:
|
|
109
|
+
"""Create a WeMo device."""
|
|
110
|
+
self._state: int | None = None
|
|
111
|
+
self.basic_state_params: dict[str, str] = {}
|
|
112
|
+
self._reconnect_lock = threading.Lock()
|
|
113
|
+
self.session = Session(url)
|
|
114
|
+
xml = self.session.get(url).data
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
super().__init__(**DeviceDescription.dict_from_xml(xml))
|
|
118
|
+
except InvalidSchemaError:
|
|
119
|
+
LOG.debug("Received invalid schema from %s: %r", url, xml)
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
self.services = {}
|
|
123
|
+
for svc in self._services:
|
|
124
|
+
service = Service(self, svc)
|
|
125
|
+
self.services[service.name] = service
|
|
126
|
+
setattr(self, service.name, service)
|
|
127
|
+
self._check_required_services(self.services.values())
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def _required_services(self) -> list[RequiredService]:
|
|
131
|
+
return super()._required_services + [
|
|
132
|
+
RequiredService(name="basicevent", actions=["GetBinaryState"])
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
def _reconnect_with_device_by_discovery(self) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Scan network to find the device again.
|
|
138
|
+
|
|
139
|
+
Wemos tend to change their port number from time to time.
|
|
140
|
+
Whenever requests throws an error, we will try to find the device again
|
|
141
|
+
on the network and update this device.
|
|
142
|
+
"""
|
|
143
|
+
# Put here to avoid circular dependency
|
|
144
|
+
# pylint: disable=import-outside-toplevel
|
|
145
|
+
from ..ssdp import scan
|
|
146
|
+
|
|
147
|
+
LOG.info("Trying to reconnect with %s", self.name)
|
|
148
|
+
|
|
149
|
+
found = scan(max_entries=1, match_udn=self.udn)
|
|
150
|
+
if found and found[0].location:
|
|
151
|
+
LOG.info("Found %s again, updating location", self.name)
|
|
152
|
+
self.session.url = found[0].location
|
|
153
|
+
else:
|
|
154
|
+
LOG.error("Unable to reconnect with %s", self.name)
|
|
155
|
+
|
|
156
|
+
def _reconnect_with_device_by_probing(self) -> bool:
|
|
157
|
+
"""Attempt to reconnect to the device on the existing port."""
|
|
158
|
+
port = probe_device(self)
|
|
159
|
+
|
|
160
|
+
if port is None:
|
|
161
|
+
LOG.error("Unable to re-probe wemo %s at %s", self, self.host)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
LOG.info("Reconnected to wemo %s on port %i", self, port)
|
|
165
|
+
self.session.url = f"http://{self.host}:{port}/setup.xml"
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def reconnect_with_device(self) -> None:
|
|
169
|
+
"""Re-probe & scan network to rediscover a disconnected device."""
|
|
170
|
+
# Avoid retrying from multiple threads
|
|
171
|
+
# pylint: disable=consider-using-with
|
|
172
|
+
if not self._reconnect_lock.acquire(blocking=False):
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
if not self._reconnect_with_device_by_probing():
|
|
176
|
+
self._reconnect_with_device_by_discovery()
|
|
177
|
+
finally:
|
|
178
|
+
self._reconnect_lock.release()
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def parse_basic_state(params: str) -> dict[str, str]:
|
|
182
|
+
"""Parse the basic state response from the device."""
|
|
183
|
+
# The BinaryState `params` could have two different formats:
|
|
184
|
+
# 1|1492338954|0|922|14195|1209600|0|940670|15213709|227088884
|
|
185
|
+
# 1
|
|
186
|
+
# In both formats, the first integer value indicates the state.
|
|
187
|
+
# 0 if off, 1 if on,
|
|
188
|
+
return {"state": params.split("|")[0]}
|
|
189
|
+
|
|
190
|
+
def update_binary_state(self) -> None:
|
|
191
|
+
"""Update the cached copy of the basic state response."""
|
|
192
|
+
self.basic_state_params = self.basicevent.GetBinaryState() or {}
|
|
193
|
+
|
|
194
|
+
def subscription_update(self, _type: str, _params: str) -> bool:
|
|
195
|
+
"""Update device state based on subscription event."""
|
|
196
|
+
LOG.debug("subscription_update %s %s", _type, _params)
|
|
197
|
+
if _type == self.EVENT_TYPE_BINARY_STATE:
|
|
198
|
+
try:
|
|
199
|
+
self._state = int(
|
|
200
|
+
self.parse_basic_state(_params).get("state", "0")
|
|
201
|
+
)
|
|
202
|
+
except ValueError:
|
|
203
|
+
LOG.error(
|
|
204
|
+
"Unexpected BinaryState value `%s` for device %s.",
|
|
205
|
+
_params,
|
|
206
|
+
self.name,
|
|
207
|
+
)
|
|
208
|
+
return True
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def get_state(self, force_update: bool = False) -> int:
|
|
212
|
+
"""Return 0 if off and 1 if on."""
|
|
213
|
+
if force_update or self._state is None:
|
|
214
|
+
self.update_binary_state()
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
self._state = int(
|
|
218
|
+
self.basic_state_params.get("BinaryState", 0)
|
|
219
|
+
)
|
|
220
|
+
except ValueError:
|
|
221
|
+
self._state = 0
|
|
222
|
+
|
|
223
|
+
return self._state
|
|
224
|
+
|
|
225
|
+
def get_service(self, name: str) -> Service:
|
|
226
|
+
"""Get service object by name."""
|
|
227
|
+
try:
|
|
228
|
+
return self.services[name]
|
|
229
|
+
except KeyError as exc:
|
|
230
|
+
raise UnknownService(name) from exc
|
|
231
|
+
|
|
232
|
+
def list_services(self) -> list[str]:
|
|
233
|
+
"""Return list of services."""
|
|
234
|
+
return list(self.services.keys())
|
|
235
|
+
|
|
236
|
+
def explain(self) -> None:
|
|
237
|
+
"""Print information about the device and its actions."""
|
|
238
|
+
for name, svc in self.services.items():
|
|
239
|
+
print(name)
|
|
240
|
+
print("-" * len(name))
|
|
241
|
+
for aname, action in svc.actions.items():
|
|
242
|
+
inputs = ", ".join(str(val) for val in action.args)
|
|
243
|
+
outputs = ", ".join(str(val) for val in action.returns)
|
|
244
|
+
if len(action.returns) > 1:
|
|
245
|
+
outputs = "(" + outputs + ")"
|
|
246
|
+
if outputs:
|
|
247
|
+
outputs = " -> " + outputs
|
|
248
|
+
print(f" {aname}({inputs}){outputs}")
|
|
249
|
+
print()
|
|
250
|
+
|
|
251
|
+
def reset(self, data: bool, wifi: bool) -> str:
|
|
252
|
+
"""Reset Wemo device.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
data (bool):
|
|
256
|
+
Set to True to reset the data ("Clear Personalized Info" in the
|
|
257
|
+
Wemo app), which resets the device name and cleans the icon and
|
|
258
|
+
rules.
|
|
259
|
+
wifi (bool):
|
|
260
|
+
Set to True to clear wifi information ("Change Wi-Fi" in the Wemo
|
|
261
|
+
app), which does not clear the rules, name, etc.
|
|
262
|
+
|
|
263
|
+
Notes
|
|
264
|
+
-----
|
|
265
|
+
Setting both to true is equivalent to a "Factory Restore" from the app.
|
|
266
|
+
|
|
267
|
+
Wemo devices contain a hardware reset procedure as well, so this
|
|
268
|
+
method is mainly for convenience or when physical access is not
|
|
269
|
+
possible.
|
|
270
|
+
|
|
271
|
+
From testing on a handful of devices, the Reset codes used in the
|
|
272
|
+
ReSetup action below were consistent. These could potentially change
|
|
273
|
+
in a future firmware revision or may be different for other untested
|
|
274
|
+
devices.
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
action = self.basicevent.ReSetup
|
|
278
|
+
except AttributeError as exc:
|
|
279
|
+
raise ResetException(
|
|
280
|
+
"Cannot reset device: ReSetup action not found"
|
|
281
|
+
) from exc
|
|
282
|
+
|
|
283
|
+
if data and wifi:
|
|
284
|
+
LOG.info("Clearing data and wifi (factory reset)")
|
|
285
|
+
result = action(Reset=2)
|
|
286
|
+
elif data:
|
|
287
|
+
LOG.info("Clearing data (icon, rules, etc)")
|
|
288
|
+
result = action(Reset=1)
|
|
289
|
+
elif wifi:
|
|
290
|
+
LOG.info("Clearing wifi information")
|
|
291
|
+
result = action(Reset=5)
|
|
292
|
+
else:
|
|
293
|
+
raise ResetException("no action requested")
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
status = result["Reset"].strip().lower()
|
|
297
|
+
except KeyError:
|
|
298
|
+
status = "unknown"
|
|
299
|
+
|
|
300
|
+
if status == "success":
|
|
301
|
+
LOG.info("reset successful")
|
|
302
|
+
else:
|
|
303
|
+
# one test unit always returns "reset_remote" here instead of
|
|
304
|
+
# "success", but it appears to still reset successfully
|
|
305
|
+
LOG.warning("result of reset (may be successful): %s", status)
|
|
306
|
+
|
|
307
|
+
return status
|
|
308
|
+
|
|
309
|
+
def factory_reset(self) -> str:
|
|
310
|
+
"""Perform a full factory reset (convenience method)."""
|
|
311
|
+
return self.reset(data=True, wifi=True)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def encrypt_aes128(
|
|
315
|
+
password: str, wemo_metadata: str, is_rtos: bool
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Encrypt a password using OpenSSL.
|
|
318
|
+
|
|
319
|
+
Function borrows heavily from Vadim Kantorov's "wemosetup" script:
|
|
320
|
+
https://github.com/vadimkantorov/wemosetup
|
|
321
|
+
"""
|
|
322
|
+
if not password:
|
|
323
|
+
raise SetupException("password required for AES")
|
|
324
|
+
|
|
325
|
+
# Wemo uses some meta information for salt and iv
|
|
326
|
+
meta_info = MetaInfo.from_meta_info(wemo_metadata)
|
|
327
|
+
keydata = (
|
|
328
|
+
meta_info.mac[:6] + meta_info.serial_number + meta_info.mac[6:12]
|
|
329
|
+
)
|
|
330
|
+
if is_rtos:
|
|
331
|
+
keydata += "b3{8t;80dIN{ra83eC1s?M70?683@2Yf"
|
|
332
|
+
|
|
333
|
+
salt, initialization_vector = keydata[:8], keydata[:16]
|
|
334
|
+
if len(salt) != 8 or len(initialization_vector) != 16:
|
|
335
|
+
LOG.warning("device meta information may not be supported")
|
|
336
|
+
|
|
337
|
+
# call OpenSSL to encrypt the data
|
|
338
|
+
try:
|
|
339
|
+
openssl = subprocess.run(
|
|
340
|
+
[
|
|
341
|
+
"openssl",
|
|
342
|
+
"enc",
|
|
343
|
+
"-aes-128-cbc",
|
|
344
|
+
"-md",
|
|
345
|
+
"md5",
|
|
346
|
+
"-S",
|
|
347
|
+
salt.encode("utf-8").hex(),
|
|
348
|
+
"-iv",
|
|
349
|
+
initialization_vector.encode("utf-8").hex(),
|
|
350
|
+
"-pass",
|
|
351
|
+
"pass:" + keydata,
|
|
352
|
+
],
|
|
353
|
+
check=True,
|
|
354
|
+
capture_output=True,
|
|
355
|
+
input=password.encode("utf-8"),
|
|
356
|
+
)
|
|
357
|
+
except FileNotFoundError as exc:
|
|
358
|
+
raise SetupException(
|
|
359
|
+
"openssl command failed (openssl not installed / not on path?)"
|
|
360
|
+
) from exc
|
|
361
|
+
except subprocess.CalledProcessError as exc:
|
|
362
|
+
raise SetupException("openssl command failed") from exc
|
|
363
|
+
|
|
364
|
+
output = openssl.stdout
|
|
365
|
+
if output.startswith(b"Salted__"):
|
|
366
|
+
# remove 16byte magic and salt prefix inserted by OpenSSL, which
|
|
367
|
+
# is of the form "Salted__XXXXXXXX" before the actual password
|
|
368
|
+
output = output[16:]
|
|
369
|
+
encrypted_password = base64.b64encode(output).decode()
|
|
370
|
+
|
|
371
|
+
# the last 4 digits that wemo expects is xxyy, where:
|
|
372
|
+
# xx: length of the encrypted password as hexadecimal
|
|
373
|
+
# yy: length of the original password as hexadecimal
|
|
374
|
+
len_encrypted = len(encrypted_password)
|
|
375
|
+
len_original = len(password)
|
|
376
|
+
LOG.debug("password length (before encryption): %s", len_original)
|
|
377
|
+
LOG.debug("password length (after encryption): %s", len_encrypted)
|
|
378
|
+
if len_encrypted > 255 or len_original > 255:
|
|
379
|
+
# untested, but over 255 characters would require >2 hex digits
|
|
380
|
+
raise SetupException(
|
|
381
|
+
"Wemo requires the wifi password (including after encryption) "
|
|
382
|
+
"to be 255 or less characters, but found password of length "
|
|
383
|
+
f"{len_original} (and {len_encrypted} after encryption)."
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if not is_rtos:
|
|
387
|
+
encrypted_password += f"{len_encrypted:#04x}"[2:]
|
|
388
|
+
encrypted_password += f"{len_original:#04x}"[2:]
|
|
389
|
+
return encrypted_password
|
|
390
|
+
|
|
391
|
+
def setup(self, *args: Any, **kwargs: Any) -> tuple[str, str]:
|
|
392
|
+
"""Connect Wemo to wifi network.
|
|
393
|
+
|
|
394
|
+
This function should be used and will capture several potential
|
|
395
|
+
exceptions to indicate when the setup method won't work on a device.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
ssid (str):
|
|
399
|
+
SSID to connect the device to.
|
|
400
|
+
password (str):
|
|
401
|
+
Password for the indicated SSID. This password will be encrypted
|
|
402
|
+
with OpenSSL and then sent to the device. To connect to an open,
|
|
403
|
+
unsecured network, pass anything for the password as it will be
|
|
404
|
+
ignored.
|
|
405
|
+
timeout (float, optional):
|
|
406
|
+
Number of seconds to wait and poll a device to see if it has
|
|
407
|
+
successfully connected to the network. The minimum value allows is
|
|
408
|
+
15 seconds as devices sometimes take 10-15 seconds to connect.
|
|
409
|
+
connection_attempts (int, optional):
|
|
410
|
+
Number of times to try connecting a debice to the network, if it
|
|
411
|
+
has failed to connect within `timeout` seconds.
|
|
412
|
+
status_delay (float, optional):
|
|
413
|
+
Number of seconds to delay between each called to the connection
|
|
414
|
+
status of the device. Generally should prefer this to be as short
|
|
415
|
+
as possible, but not too quick to overload the device with
|
|
416
|
+
requests. It must be less than or equal to half of the `timeout`.
|
|
417
|
+
|
|
418
|
+
Notes
|
|
419
|
+
-----
|
|
420
|
+
The timeout applies to each connection attempt, so the total wait time
|
|
421
|
+
will be approximately `timeout * connection_attempts`.
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
return self._setup(*args, **kwargs)
|
|
425
|
+
except (UnknownService, AttributeError, KeyError) as exc:
|
|
426
|
+
# Exception | Reason to catch it
|
|
427
|
+
# --------------------------------------------------------------
|
|
428
|
+
# UnknownService | some devices or firmwares may not have the
|
|
429
|
+
# | services used
|
|
430
|
+
# --------------------------------------------------------------
|
|
431
|
+
# AttributeError | some devices or firmwares may not have the
|
|
432
|
+
# | actions used
|
|
433
|
+
# --------------------------------------------------------------
|
|
434
|
+
# KeyError | an expected result (return from an action)
|
|
435
|
+
# | does not exist (e.g. ApList)
|
|
436
|
+
# --------------------------------------------------------------
|
|
437
|
+
raise SetupException(f"pywemo cannot setup {self}") from exc
|
|
438
|
+
except ActionException as exc:
|
|
439
|
+
# Exception | Reason to catch it
|
|
440
|
+
# --------------------------------------------------------------
|
|
441
|
+
# ActionException | one of the action calls never returned! The
|
|
442
|
+
# | device was not re-discovered. It may have
|
|
443
|
+
# | lost power (been unplugged).
|
|
444
|
+
# --------------------------------------------------------------
|
|
445
|
+
raise SetupException(
|
|
446
|
+
f"pywemo lost device {self} and was unable to reconnect. "
|
|
447
|
+
"Setup status is uncertain, re-probing and checking is "
|
|
448
|
+
"required."
|
|
449
|
+
) from exc
|
|
450
|
+
|
|
451
|
+
def _setup( # noqa: C901
|
|
452
|
+
# pylint: disable=too-many-arguments,too-many-branches,too-many-locals
|
|
453
|
+
# pylint: disable=too-many-statements
|
|
454
|
+
self,
|
|
455
|
+
ssid: str,
|
|
456
|
+
password: str,
|
|
457
|
+
timeout: float = 20.0,
|
|
458
|
+
connection_attempts: int = 1,
|
|
459
|
+
status_delay: float = 1.0,
|
|
460
|
+
) -> tuple[str, str]:
|
|
461
|
+
"""Connect Wemo to wifi network.
|
|
462
|
+
|
|
463
|
+
See the setup method for details.
|
|
464
|
+
"""
|
|
465
|
+
# a timeout of less than 20 is too short for many devices, so require
|
|
466
|
+
# at least 20 seconds.
|
|
467
|
+
timeout = max(timeout, 20.0)
|
|
468
|
+
status_delay = min(status_delay, timeout / 2.0)
|
|
469
|
+
connection_attempts = int(max(1, connection_attempts))
|
|
470
|
+
|
|
471
|
+
# find all access points that the device can see, and select the one
|
|
472
|
+
# matching the desired SSID
|
|
473
|
+
LOG.info("scanning for AP's...")
|
|
474
|
+
wifisetup = self.get_service("WiFiSetup")
|
|
475
|
+
access_points = wifisetup.GetApList()["ApList"]
|
|
476
|
+
|
|
477
|
+
selected_ap = None
|
|
478
|
+
for access_point in access_points.split("\n")[1:]:
|
|
479
|
+
access_point = access_point.strip().rstrip(",")
|
|
480
|
+
if not access_point.strip() or "|" not in access_point:
|
|
481
|
+
continue
|
|
482
|
+
LOG.debug("found AP: %s", access_point)
|
|
483
|
+
if access_point.startswith(f"{ssid}|"):
|
|
484
|
+
selected_ap = access_point
|
|
485
|
+
LOG.info("selecting AP: %s", selected_ap)
|
|
486
|
+
break
|
|
487
|
+
|
|
488
|
+
if selected_ap is None:
|
|
489
|
+
raise APNotFound(f"AP with SSID {ssid} not found. Try again.")
|
|
490
|
+
|
|
491
|
+
# get some information about the access point
|
|
492
|
+
columns = selected_ap.split("|")
|
|
493
|
+
channel = columns[1].strip()
|
|
494
|
+
auth_mode, encryption_method = columns[-1].strip().split("/")
|
|
495
|
+
LOG.debug("AP channel: %s", channel)
|
|
496
|
+
LOG.debug("AP authorization mode(s): %s", auth_mode)
|
|
497
|
+
LOG.debug("AP encryption method: %s", encryption_method)
|
|
498
|
+
|
|
499
|
+
# check if the encryption type is supported by this script
|
|
500
|
+
supported_encryptions = {"NONE", "AES"}
|
|
501
|
+
if encryption_method not in supported_encryptions:
|
|
502
|
+
raise SetupException(
|
|
503
|
+
f"Encryption {encryption_method} not currently supported. "
|
|
504
|
+
f'Supported encryptions are: {",".join(supported_encryptions)}'
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# try to connect the device to the selected network
|
|
508
|
+
if encryption_method == "NONE":
|
|
509
|
+
LOG.debug("selected network has no encryption (password ignored)")
|
|
510
|
+
auth_mode = "OPEN"
|
|
511
|
+
encrypted_password = ""
|
|
512
|
+
else:
|
|
513
|
+
# get the meta information of the device and encrypt the password
|
|
514
|
+
meta_info = self.get_service("metainfo").GetMetaInfo()["MetaInfo"]
|
|
515
|
+
is_rtos = self._config_any.get("rtos", "0") == "1"
|
|
516
|
+
encrypted_password = self.encrypt_aes128(
|
|
517
|
+
password, meta_info, is_rtos
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# optionally make multiple connection attempts
|
|
521
|
+
start_time = time.time()
|
|
522
|
+
|
|
523
|
+
# status messages:
|
|
524
|
+
# 0: still trying to connect to network
|
|
525
|
+
# 1: successfully connected
|
|
526
|
+
# 2: short password (Wemo requires at least 8 characters)
|
|
527
|
+
# 3: performing handshake? (uncertain, but devices generally
|
|
528
|
+
# go to status 3 for a few moments before switching to
|
|
529
|
+
# successful status 1)
|
|
530
|
+
skip = {"1", "2"}
|
|
531
|
+
|
|
532
|
+
for attempt in range(connection_attempts):
|
|
533
|
+
LOG.info("sending connection request (try %s)", attempt + 1)
|
|
534
|
+
# success rate is much higher if the ConnectHomeNetwork command is
|
|
535
|
+
# sent twice (not sure why!)
|
|
536
|
+
for retry in range(2):
|
|
537
|
+
result = wifisetup.ConnectHomeNetwork(
|
|
538
|
+
ssid=ssid,
|
|
539
|
+
auth=auth_mode,
|
|
540
|
+
password=encrypted_password,
|
|
541
|
+
encrypt=encryption_method,
|
|
542
|
+
channel=channel,
|
|
543
|
+
)
|
|
544
|
+
try:
|
|
545
|
+
status = result["PairingStatus"]
|
|
546
|
+
except KeyError:
|
|
547
|
+
# print entire dictionary if PairingStatus doesn't exist
|
|
548
|
+
status = repr(result)
|
|
549
|
+
LOG.debug("pairing status (send %s): %s", retry + 1, status)
|
|
550
|
+
if retry == 0:
|
|
551
|
+
# only delay on the first call
|
|
552
|
+
time.sleep(0.10)
|
|
553
|
+
|
|
554
|
+
timeout_start = time.time()
|
|
555
|
+
LOG.info("starting status checks (%s second timeout)", timeout)
|
|
556
|
+
status = ""
|
|
557
|
+
|
|
558
|
+
# Make an initial, quicker check
|
|
559
|
+
time.sleep(min(0.50, status_delay / 3.0))
|
|
560
|
+
status = wifisetup.GetNetworkStatus()["NetworkStatus"]
|
|
561
|
+
LOG.debug("initial status check: %s", status)
|
|
562
|
+
|
|
563
|
+
while time.time() - timeout_start < timeout and status not in skip:
|
|
564
|
+
time.sleep(status_delay)
|
|
565
|
+
status = wifisetup.GetNetworkStatus()["NetworkStatus"]
|
|
566
|
+
LOG.debug(
|
|
567
|
+
"network status after %.2f seconds: %s",
|
|
568
|
+
time.time() - timeout_start,
|
|
569
|
+
status,
|
|
570
|
+
)
|
|
571
|
+
if status in skip:
|
|
572
|
+
# skip any further attempts
|
|
573
|
+
break
|
|
574
|
+
|
|
575
|
+
# status 3 usually (always?) occurs shortly before it switches to
|
|
576
|
+
# status 1, so if the status is 3 here, then delay a few more seconds
|
|
577
|
+
# to see if it switches to status 1.
|
|
578
|
+
if status == "3":
|
|
579
|
+
LOG.debug("delaying a little longer (status 3)...")
|
|
580
|
+
loops = 3 # 3 seconds with default status_delay
|
|
581
|
+
while loops > 0 and status not in skip:
|
|
582
|
+
time.sleep(status_delay)
|
|
583
|
+
status = wifisetup.GetNetworkStatus()["NetworkStatus"]
|
|
584
|
+
loops -= 1
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
result = wifisetup.CloseSetup()
|
|
588
|
+
except AttributeError:
|
|
589
|
+
# if CloseSetup doesn't exist, it may still work
|
|
590
|
+
result = {"status": "CloseSetup action not available"}
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
close_status = result["status"]
|
|
594
|
+
except KeyError:
|
|
595
|
+
# print entire dictionary if status doesn't exist
|
|
596
|
+
close_status = repr(result)
|
|
597
|
+
LOG.debug("network status: %s", status)
|
|
598
|
+
LOG.debug("close status: %s", close_status)
|
|
599
|
+
|
|
600
|
+
if status == "2":
|
|
601
|
+
# we could check the password length way earlier (start of the
|
|
602
|
+
# function), but perhaps Wemo will change this requirement some
|
|
603
|
+
# day to make it longer, so instead just use the status '2' return
|
|
604
|
+
# code.
|
|
605
|
+
raise ShortPassword(
|
|
606
|
+
"Password is too short (Wemo requires at least 8 characters)."
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if status == "1" and close_status == "success":
|
|
610
|
+
try:
|
|
611
|
+
self.basicevent.SetSetupDoneStatus()
|
|
612
|
+
except AttributeError:
|
|
613
|
+
LOG.debug(
|
|
614
|
+
"SetSetupDoneStatus not available (some devices do not "
|
|
615
|
+
"have this method)"
|
|
616
|
+
)
|
|
617
|
+
LOG.info(
|
|
618
|
+
'Wemo device connected to "%s" in %.2f seconds (%s connection '
|
|
619
|
+
"attempts(s))",
|
|
620
|
+
ssid,
|
|
621
|
+
time.time() - start_time,
|
|
622
|
+
attempt + 1,
|
|
623
|
+
)
|
|
624
|
+
elif status == "1":
|
|
625
|
+
LOG.warning(
|
|
626
|
+
'Wemo device likely connected to "%s", but should be verified '
|
|
627
|
+
'(CloseSetup returned "%s").',
|
|
628
|
+
ssid,
|
|
629
|
+
close_status,
|
|
630
|
+
)
|
|
631
|
+
elif status == "3":
|
|
632
|
+
raise SetupException(
|
|
633
|
+
f'Wemo device failed to connect to "{ssid}", but has status=3,'
|
|
634
|
+
"which usually precedes a successful connection. Thus it may "
|
|
635
|
+
"still connect to the network shortly. Otherwise, please try "
|
|
636
|
+
"again."
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
raise SetupException(
|
|
640
|
+
f'Wemo device failed to connect to "{ssid}". It could be a '
|
|
641
|
+
"wrong password or Wemo device/firmware issue. Please try "
|
|
642
|
+
"again."
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
return status, close_status
|
|
646
|
+
|
|
647
|
+
@classmethod
|
|
648
|
+
def supports_long_press(cls) -> bool:
|
|
649
|
+
"""Return True of the device supports long press events."""
|
|
650
|
+
return issubclass(cls, LongPressMixin)
|
|
651
|
+
|
|
652
|
+
@property
|
|
653
|
+
def host(self) -> str:
|
|
654
|
+
"""Host name of the device's UPnP web server."""
|
|
655
|
+
return self.session.host
|
|
656
|
+
|
|
657
|
+
@property
|
|
658
|
+
def port(self) -> int:
|
|
659
|
+
"""TCP port for the device's UPnP web server."""
|
|
660
|
+
return self.session.port
|
|
661
|
+
|
|
662
|
+
@property
|
|
663
|
+
def device_type(self) -> str:
|
|
664
|
+
"""Return what kind of WeMo this device is."""
|
|
665
|
+
return type(self).__name__
|
|
666
|
+
|
|
667
|
+
def __repr__(self) -> str:
|
|
668
|
+
"""Return a string representation of the device."""
|
|
669
|
+
return f'<WeMo {self.device_type} "{self.name}">'
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class UnsupportedDevice(Device):
|
|
673
|
+
"""Representation of a WeMo device without a definition in pywemo.
|
|
674
|
+
|
|
675
|
+
This class is used if an apparent WeMo device is found on the network via
|
|
676
|
+
upnp discovery, but the device does not yet exist in pywemo. This will
|
|
677
|
+
allow a user to see that something is discovered and manually interact with
|
|
678
|
+
it as well as aide in creating a permenant class for the new product.
|
|
679
|
+
"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WeMo device API."""
|