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,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."""