PySwitchbot 0.55.4__tar.gz → 0.56.1__tar.gz

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 (62) hide show
  1. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PKG-INFO +11 -2
  2. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PySwitchbot.egg-info/PKG-INFO +11 -2
  3. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PySwitchbot.egg-info/SOURCES.txt +5 -1
  4. pyswitchbot-0.56.1/pyproject.toml +59 -0
  5. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/setup.py +7 -2
  6. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/__init__.py +15 -16
  7. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parser.py +15 -2
  8. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/blind_tilt.py +0 -1
  9. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/lock.py +1 -1
  10. pyswitchbot-0.56.1/switchbot/adv_parsers/remote.py +23 -0
  11. pyswitchbot-0.55.4/switchbot/const.py → pyswitchbot-0.56.1/switchbot/const/__init__.py +11 -16
  12. pyswitchbot-0.56.1/switchbot/const/lock.py +13 -0
  13. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/base_cover.py +2 -3
  14. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/base_light.py +2 -2
  15. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/curtain.py +0 -1
  16. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/device.py +69 -9
  17. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/lock.py +2 -58
  18. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/relay_switch.py +0 -55
  19. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/discovery.py +0 -1
  20. pyswitchbot-0.56.1/switchbot/helpers.py +17 -0
  21. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/tests/test_adv_parser.py +79 -1
  22. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/tests/test_base_cover.py +0 -1
  23. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/tests/test_blind_tilt.py +0 -1
  24. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/tests/test_curtain.py +0 -1
  25. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/tests/test_relay_switch.py +0 -1
  26. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/LICENSE +0 -0
  27. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/MANIFEST.in +0 -0
  28. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PySwitchbot.egg-info/dependency_links.txt +0 -0
  29. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PySwitchbot.egg-info/requires.txt +0 -0
  30. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/PySwitchbot.egg-info/top_level.txt +0 -0
  31. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/README.md +0 -0
  32. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/setup.cfg +0 -0
  33. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/__init__.py +0 -0
  34. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/bot.py +0 -0
  35. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/bulb.py +0 -0
  36. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
  37. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/contact.py +0 -0
  38. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/curtain.py +0 -0
  39. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/hub2.py +0 -0
  40. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/humidifier.py +0 -0
  41. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/keypad.py +0 -0
  42. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/leak.py +0 -0
  43. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/light_strip.py +0 -0
  44. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/meter.py +0 -0
  45. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/motion.py +0 -0
  46. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/plug.py +0 -0
  47. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/adv_parsers/relay_switch.py +0 -0
  48. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/api_config.py +0 -0
  49. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/__init__.py +0 -0
  50. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/blind_tilt.py +0 -0
  51. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/bot.py +0 -0
  52. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/bulb.py +0 -0
  53. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/ceiling_light.py +0 -0
  54. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/contact.py +0 -0
  55. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/humidifier.py +0 -0
  56. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/keypad.py +0 -0
  57. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/light_strip.py +0 -0
  58. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/meter.py +0 -0
  59. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/motion.py +0 -0
  60. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/devices/plug.py +0 -0
  61. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/enum.py +0 -0
  62. {pyswitchbot-0.55.4 → pyswitchbot-0.56.1}/switchbot/models.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: PySwitchbot
3
- Version: 0.55.4
3
+ Version: 0.56.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -20,6 +20,15 @@ Requires-Dist: bleak>=0.19.0
20
20
  Requires-Dist: bleak-retry-connector>=3.4.0
21
21
  Requires-Dist: cryptography>=39.0.0
22
22
  Requires-Dist: pyOpenSSL>=23.0.0
23
+ Dynamic: author
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: license
29
+ Dynamic: requires-dist
30
+ Dynamic: requires-python
31
+ Dynamic: summary
23
32
 
24
33
  # pySwitchbot [![Build Status](https://travis-ci.org/sblibs/pySwitchbot.svg?branch=master)](https://travis-ci.org/sblibs/pySwitchbot)
25
34
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: PySwitchbot
3
- Version: 0.55.4
3
+ Version: 0.56.1
4
4
  Summary: A library to communicate with Switchbot
5
5
  Home-page: https://github.com/sblibs/pySwitchbot/
6
6
  Author: Daniel Hjelseth Hoyer
@@ -20,6 +20,15 @@ Requires-Dist: bleak>=0.19.0
20
20
  Requires-Dist: bleak-retry-connector>=3.4.0
21
21
  Requires-Dist: cryptography>=39.0.0
22
22
  Requires-Dist: pyOpenSSL>=23.0.0
23
+ Dynamic: author
24
+ Dynamic: classifier
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: license
29
+ Dynamic: requires-dist
30
+ Dynamic: requires-python
31
+ Dynamic: summary
23
32
 
24
33
  # pySwitchbot [![Build Status](https://travis-ci.org/sblibs/pySwitchbot.svg?branch=master)](https://travis-ci.org/sblibs/pySwitchbot)
25
34
 
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  MANIFEST.in
3
3
  README.md
4
+ pyproject.toml
4
5
  setup.py
5
6
  PySwitchbot.egg-info/PKG-INFO
6
7
  PySwitchbot.egg-info/SOURCES.txt
@@ -10,9 +11,9 @@ PySwitchbot.egg-info/top_level.txt
10
11
  switchbot/__init__.py
11
12
  switchbot/adv_parser.py
12
13
  switchbot/api_config.py
13
- switchbot/const.py
14
14
  switchbot/discovery.py
15
15
  switchbot/enum.py
16
+ switchbot/helpers.py
16
17
  switchbot/models.py
17
18
  switchbot/adv_parsers/__init__.py
18
19
  switchbot/adv_parsers/blind_tilt.py
@@ -31,6 +32,9 @@ switchbot/adv_parsers/meter.py
31
32
  switchbot/adv_parsers/motion.py
32
33
  switchbot/adv_parsers/plug.py
33
34
  switchbot/adv_parsers/relay_switch.py
35
+ switchbot/adv_parsers/remote.py
36
+ switchbot/const/__init__.py
37
+ switchbot/const/lock.py
34
38
  switchbot/devices/__init__.py
35
39
  switchbot/devices/base_cover.py
36
40
  switchbot/devices/base_light.py
@@ -0,0 +1,59 @@
1
+ [tool.ruff]
2
+ target-version = "py311"
3
+ line-length = 88
4
+
5
+ [tool.ruff.lint]
6
+ ignore = [
7
+ "S101", # use of assert
8
+ "D203", # 1 blank line required before class docstring
9
+ "D212", # Multi-line docstring summary should start at the first line
10
+ "D100", # Missing docstring in public module
11
+ "D101", # Missing docstring in public module
12
+ "D102", # Missing docstring in public method
13
+ "D103", # Missing docstring in public module
14
+ "D104", # Missing docstring in public package
15
+ "D105", # Missing docstring in magic method
16
+ "D107", # Missing docstring in `__init__`
17
+ "D400", # First line should end with a period
18
+ "D401", # First line of docstring should be in imperative mood
19
+ "D205", # 1 blank line required between summary line and description
20
+ "D415", # First line should end with a period, question mark, or exclamation point
21
+ "D417", # Missing argument descriptions in the docstring
22
+ "E501", # Line too long
23
+ "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
24
+ "B008", # Do not perform function call
25
+ "S110", # `try`-`except`-`pass` detected, consider logging the exception
26
+ "D106", # Missing docstring in public nested class
27
+ "UP007", # typer needs Optional syntax
28
+ "UP038", # Use `X | Y` in `isinstance` is slower
29
+ "S603", # check for execution of untrusted input
30
+ "S105", # possible hard coded creds
31
+ ]
32
+ select = [
33
+ "B", # flake8-bugbear
34
+ "D", # flake8-docstrings
35
+ "C4", # flake8-comprehensions
36
+ "S", # flake8-bandit
37
+ "F", # pyflake
38
+ "E", # pycodestyle
39
+ "W", # pycodestyle
40
+ "UP", # pyupgrade
41
+ "I", # isort
42
+ "RUF", # ruff specific
43
+ ]
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ "tests/**/*" = [
47
+ "D100",
48
+ "D101",
49
+ "D102",
50
+ "D103",
51
+ "D104",
52
+ "S101",
53
+ ]
54
+ "setup.py" = ["D100"]
55
+ "conftest.py" = ["D100"]
56
+ "docs/conf.py" = ["D100"]
57
+
58
+ [tool.ruff.lint.isort]
59
+ known-first-party = ["pySwitchbot", "tests"]
@@ -7,7 +7,12 @@ long_description = (this_directory / "README.md").read_text()
7
7
 
8
8
  setup(
9
9
  name="PySwitchbot",
10
- packages=["switchbot", "switchbot.devices", "switchbot.adv_parsers"],
10
+ packages=[
11
+ "switchbot",
12
+ "switchbot.devices",
13
+ "switchbot.const",
14
+ "switchbot.adv_parsers",
15
+ ],
11
16
  install_requires=[
12
17
  "aiohttp>=3.9.5",
13
18
  "bleak>=0.19.0",
@@ -15,7 +20,7 @@ setup(
15
20
  "cryptography>=39.0.0",
16
21
  "pyOpenSSL>=23.0.0",
17
22
  ],
18
- version="0.55.4",
23
+ version="0.56.1",
19
24
  description="A library to communicate with Switchbot",
20
25
  long_description=long_description,
21
26
  long_description_content_type="text/markdown",
@@ -16,14 +16,13 @@ from .const import (
16
16
  SwitchbotAuthenticationError,
17
17
  SwitchbotModel,
18
18
  )
19
- from .devices.device import SwitchbotEncryptedDevice
20
19
  from .devices.base_light import SwitchbotBaseLight
21
20
  from .devices.blind_tilt import SwitchbotBlindTilt
22
21
  from .devices.bot import Switchbot
23
22
  from .devices.bulb import SwitchbotBulb
24
23
  from .devices.ceiling_light import SwitchbotCeilingLight
25
24
  from .devices.curtain import SwitchbotCurtain
26
- from .devices.device import ColorMode, SwitchbotDevice
25
+ from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
27
26
  from .devices.humidifier import SwitchbotHumidifier
28
27
  from .devices.light_strip import SwitchbotLightStrip
29
28
  from .devices.lock import SwitchbotLock
@@ -33,30 +32,30 @@ from .discovery import GetSwitchbotDevices
33
32
  from .models import SwitchBotAdvertisement
34
33
 
35
34
  __all__ = [
36
- "get_device",
37
- "close_stale_connections",
38
- "close_stale_connections_by_address",
39
- "parse_advertisement_data",
35
+ "ColorMode",
40
36
  "GetSwitchbotDevices",
37
+ "LockStatus",
41
38
  "SwitchBotAdvertisement",
39
+ "Switchbot",
42
40
  "SwitchbotAccountConnectionError",
43
41
  "SwitchbotApiError",
44
42
  "SwitchbotAuthenticationError",
45
- "SwitchbotEncryptedDevice",
46
- "ColorMode",
47
- "LockStatus",
48
43
  "SwitchbotBaseLight",
44
+ "SwitchbotBlindTilt",
49
45
  "SwitchbotBulb",
50
46
  "SwitchbotCeilingLight",
51
- "SwitchbotDevice",
52
47
  "SwitchbotCurtain",
53
- "SwitchbotLightStrip",
48
+ "SwitchbotDevice",
49
+ "SwitchbotEncryptedDevice",
54
50
  "SwitchbotHumidifier",
55
- "Switchbot",
56
- "SwitchbotPlugMini",
57
- "SwitchbotSupportedType",
58
- "SwitchbotModel",
51
+ "SwitchbotLightStrip",
59
52
  "SwitchbotLock",
60
- "SwitchbotBlindTilt",
53
+ "SwitchbotModel",
54
+ "SwitchbotPlugMini",
61
55
  "SwitchbotRelaySwitch",
56
+ "SwitchbotSupportedType",
57
+ "close_stale_connections",
58
+ "close_stale_connections_by_address",
59
+ "get_device",
60
+ "parse_advertisement_data",
62
61
  ]
@@ -29,6 +29,7 @@ from .adv_parsers.relay_switch import (
29
29
  process_worelay_switch_1,
30
30
  process_worelay_switch_1pm,
31
31
  )
32
+ from .adv_parsers.remote import process_woremote
32
33
  from .const import SwitchbotModel
33
34
  from .models import SwitchBotAdvertisement
34
35
 
@@ -40,6 +41,8 @@ SERVICE_DATA_ORDER = (
40
41
  )
41
42
  MFR_DATA_ORDER = (2409, 741, 89)
42
43
 
44
+ APPLE_MANUFACTURER_ID = 76
45
+
43
46
 
44
47
  class SwitchbotSupportedType(TypedDict):
45
48
  """Supported type of Switchbot."""
@@ -203,6 +206,12 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
203
206
  "func": process_worelay_switch_1,
204
207
  "manufacturer_id": 2409,
205
208
  },
209
+ "b": {
210
+ "modelName": SwitchbotModel.REMOTE,
211
+ "modelFriendlyName": "Remote",
212
+ "func": process_woremote,
213
+ "manufacturer_id": 89,
214
+ },
206
215
  }
207
216
 
208
217
  _SWITCHBOT_MODEL_TO_CHAR = {
@@ -235,10 +244,14 @@ def parse_advertisement_data(
235
244
 
236
245
  _mfr_data = None
237
246
  _mfr_id = None
247
+ manufacturer_data = advertisement_data.manufacturer_data
248
+ if APPLE_MANUFACTURER_ID in manufacturer_data:
249
+ return None
250
+
238
251
  for mfr_id in MFR_DATA_ORDER:
239
- if mfr_id in advertisement_data.manufacturer_data:
252
+ if mfr_id in manufacturer_data:
240
253
  _mfr_id = mfr_id
241
- _mfr_data = advertisement_data.manufacturer_data[mfr_id]
254
+ _mfr_data = manufacturer_data[mfr_id]
242
255
  break
243
256
 
244
257
  if _mfr_data is None and _service_data is None:
@@ -7,7 +7,6 @@ def process_woblindtilt(
7
7
  data: bytes | None, mfr_data: bytes | None, reverse: bool = False
8
8
  ) -> dict[str, bool | int]:
9
9
  """Process woBlindTilt services data."""
10
-
11
10
  if mfr_data is None:
12
11
  return {}
13
12
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
 
7
- from ..const import LockStatus
7
+ from ..const.lock import LockStatus
8
8
 
9
9
  _LOGGER = logging.getLogger(__name__)
10
10
 
@@ -0,0 +1,23 @@
1
+ """Remote adv parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ _LOGGER = logging.getLogger(__name__)
8
+
9
+
10
+ def process_woremote(
11
+ data: bytes | None, mfr_data: bytes | None
12
+ ) -> dict[str, int | None]:
13
+ """Process WoRemote adv data."""
14
+ if data is None:
15
+ return {
16
+ "battery": None,
17
+ }
18
+
19
+ _LOGGER.debug("data: %s", data.hex())
20
+
21
+ return {
22
+ "battery": data[2] & 0b01111111,
23
+ }
@@ -1,10 +1,11 @@
1
- """Library to handle connection with Switchbot."""
1
+ """Switchbot Device Consts Library."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from enum import Enum
5
+ from ..enum import StrEnum
6
6
 
7
- from .enum import StrEnum
7
+ # Preserve old LockStatus export for backwards compatibility
8
+ from .lock import LockStatus as LockStatus
8
9
 
9
10
  DEFAULT_RETRY_COUNT = 3
10
11
  DEFAULT_RETRY_TIMEOUT = 1
@@ -12,7 +13,8 @@ DEFAULT_SCAN_TIMEOUT = 5
12
13
 
13
14
 
14
15
  class SwitchbotApiError(RuntimeError):
15
- """Raised when API call fails.
16
+ """
17
+ Raised when API call fails.
16
18
 
17
19
  This exception inherits from RuntimeError to avoid breaking existing code
18
20
  but will be changed to Exception in a future release.
@@ -20,7 +22,8 @@ class SwitchbotApiError(RuntimeError):
20
22
 
21
23
 
22
24
  class SwitchbotAuthenticationError(RuntimeError):
23
- """Raised when authentication fails.
25
+ """
26
+ Raised when authentication fails.
24
27
 
25
28
  This exception inherits from RuntimeError to avoid breaking existing code
26
29
  but will be changed to Exception in a future release.
@@ -28,7 +31,8 @@ class SwitchbotAuthenticationError(RuntimeError):
28
31
 
29
32
 
30
33
  class SwitchbotAccountConnectionError(RuntimeError):
31
- """Raised when connection to Switchbot account fails.
34
+ """
35
+ Raised when connection to Switchbot account fails.
32
36
 
33
37
  This exception inherits from RuntimeError to avoid breaking existing code
34
38
  but will be changed to Exception in a future release.
@@ -57,13 +61,4 @@ class SwitchbotModel(StrEnum):
57
61
  KEYPAD = "WoKeypad"
58
62
  RELAY_SWITCH_1PM = "Relay Switch 1PM"
59
63
  RELAY_SWITCH_1 = "Relay Switch 1"
60
-
61
-
62
- class LockStatus(Enum):
63
- LOCKED = 0
64
- UNLOCKED = 1
65
- LOCKING = 2
66
- UNLOCKING = 3
67
- LOCKING_STOP = 4 # LOCKING_BLOCKED
68
- UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED
69
- NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type
64
+ REMOTE = "WoRemote"
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class LockStatus(Enum):
7
+ LOCKED = 0
8
+ UNLOCKED = 1
9
+ LOCKING = 2
10
+ UNLOCKING = 3
11
+ LOCKING_STOP = 4 # LOCKING_BLOCKED
12
+ UNLOCKING_STOP = 5 # UNLOCKING_BLOCKED
13
+ NOT_FULLY_LOCKED = 6 # LATCH_LOCKED - Only EU lock type
@@ -33,7 +33,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
33
33
 
34
34
  def __init__(self, reverse: bool, *args: Any, **kwargs: Any) -> None:
35
35
  """Switchbot Cover device constructor."""
36
-
37
36
  super().__init__(*args, **kwargs)
38
37
  self._reverse = reverse
39
38
  self._settings: dict[str, Any] = {}
@@ -43,7 +42,8 @@ class SwitchbotBaseCover(SwitchbotDevice):
43
42
  self._is_closing: bool = False
44
43
 
45
44
  async def _send_multiple_commands(self, keys: list[str]) -> bool:
46
- """Send multiple commands to device.
45
+ """
46
+ Send multiple commands to device.
47
47
 
48
48
  Since we current have no way to tell which command the device
49
49
  needs we send both.
@@ -84,7 +84,6 @@ class SwitchbotBaseCover(SwitchbotDevice):
84
84
 
85
85
  async def get_extended_info_adv(self) -> dict[str, Any] | None:
86
86
  """Get advance page info for device chain."""
87
-
88
87
  _data = await self._send_command(key=COVER_EXT_ADV_KEY)
89
88
  if not _data:
90
89
  _LOGGER.error("%s: Unsuccessful, no result from device", self.name)
@@ -1,11 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import logging
5
4
  import time
6
5
  from abc import abstractmethod
7
6
  from typing import Any
8
7
 
8
+ from ..helpers import create_background_task
9
9
  from ..models import SwitchBotAdvertisement
10
10
  from .device import ColorMode, SwitchbotDevice
11
11
 
@@ -106,4 +106,4 @@ class SwitchbotSequenceBaseLight(SwitchbotBaseLight):
106
106
  new_state,
107
107
  )
108
108
  if current_state != new_state:
109
- asyncio.ensure_future(self.update())
109
+ create_background_task(self.update())
@@ -38,7 +38,6 @@ class SwitchbotCurtain(SwitchbotBaseCover):
38
38
 
39
39
  def __init__(self, *args: Any, **kwargs: Any) -> None:
40
40
  """Switchbot Curtain/WoCurtain constructor."""
41
-
42
41
  # The position of the curtain is saved returned with 0 = open and 100 = closed.
43
42
  # This is independent of the calibration of the curtain bot (Open left to right/
44
43
  # Open right to left/Open from the middle).
@@ -6,10 +6,10 @@ import asyncio
6
6
  import binascii
7
7
  import logging
8
8
  import time
9
+ from collections.abc import Callable
9
10
  from dataclasses import replace
10
11
  from enum import Enum
11
12
  from typing import Any, TypeVar, cast
12
- from collections.abc import Callable
13
13
  from uuid import UUID
14
14
 
15
15
  import aiohttp
@@ -23,6 +23,7 @@ from bleak_retry_connector import (
23
23
  ble_device_has_changed,
24
24
  establish_connection,
25
25
  )
26
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
26
27
 
27
28
  from ..api_config import SWITCHBOT_APP_API_BASE_URL, SWITCHBOT_APP_CLIENT_ID
28
29
  from ..const import (
@@ -34,6 +35,7 @@ from ..const import (
34
35
  SwitchbotModel,
35
36
  )
36
37
  from ..discovery import GetSwitchbotDevices
38
+ from ..helpers import create_background_task
37
39
  from ..models import SwitchBotAdvertisement
38
40
 
39
41
  _LOGGER = logging.getLogger(__name__)
@@ -45,6 +47,7 @@ REQ_HEADER = "570f"
45
47
  DEVICE_GET_BASIC_SETTINGS_KEY = "5702"
46
48
  DEVICE_SET_MODE_KEY = "5703"
47
49
  DEVICE_SET_EXTENDED_KEY = REQ_HEADER
50
+ COMMAND_GET_CK_IV = f"{REQ_HEADER}2103"
48
51
 
49
52
  # Base key when encryption is set
50
53
  KEY_PASSWORD_PREFIX = "571"
@@ -81,7 +84,6 @@ class SwitchbotOperationError(Exception):
81
84
 
82
85
  def _sb_uuid(comms_type: str = "service") -> UUID | str:
83
86
  """Return Switchbot UUID."""
84
-
85
87
  _uuid = {"tx": "002", "rx": "003", "service": "d00"}
86
88
 
87
89
  if comms_type in _uuid:
@@ -167,8 +169,8 @@ class SwitchbotBaseDevice:
167
169
  session: aiohttp.ClientSession,
168
170
  subdomain: str,
169
171
  path: str,
170
- data: dict = None,
171
- headers: dict = None,
172
+ data: dict | None = None,
173
+ headers: dict | None = None,
172
174
  ) -> dict:
173
175
  url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
174
176
  async with session.post(
@@ -637,7 +639,8 @@ class SwitchbotBaseDevice:
637
639
  return result[index] in values
638
640
 
639
641
  def _update_parsed_data(self, new_data: dict[str, Any]) -> bool:
640
- """Update data.
642
+ """
643
+ Update data.
641
644
 
642
645
  Returns true if data has changed and False if not.
643
646
  """
@@ -691,7 +694,8 @@ class SwitchbotBaseDevice:
691
694
 
692
695
 
693
696
  class SwitchbotDevice(SwitchbotBaseDevice):
694
- """Base Representation of a Switchbot Device.
697
+ """
698
+ Base Representation of a Switchbot Device.
695
699
 
696
700
  This base class consumes the advertisement data during connection. If the device
697
701
  sends stale advertisement data while connected, use
@@ -827,9 +831,64 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
827
831
 
828
832
  return info is not None
829
833
 
834
+ async def _send_command(
835
+ self, key: str, retry: int | None = None, encrypt: bool = True
836
+ ) -> bytes | None:
837
+ if not encrypt:
838
+ return await super()._send_command(key[:2] + "000000" + key[2:], retry)
839
+
840
+ result = await self._ensure_encryption_initialized()
841
+ if not result:
842
+ _LOGGER.error("Failed to initialize encryption")
843
+ return None
844
+
845
+ encrypted = (
846
+ key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
847
+ )
848
+ result = await super()._send_command(encrypted, retry)
849
+ return result[:1] + self._decrypt(result[4:])
850
+
851
+ async def _ensure_encryption_initialized(self) -> bool:
852
+ if self._iv is not None:
853
+ return True
854
+
855
+ result = await self._send_command(
856
+ COMMAND_GET_CK_IV + self._key_id, encrypt=False
857
+ )
858
+ ok = self._check_command_result(result, 0, {1})
859
+ if ok:
860
+ self._iv = result[4:]
861
+
862
+ return ok
863
+
864
+ async def _execute_disconnect(self) -> None:
865
+ await super()._execute_disconnect()
866
+ self._iv = None
867
+ self._cipher = None
868
+
869
+ def _get_cipher(self) -> Cipher:
870
+ if self._cipher is None:
871
+ self._cipher = Cipher(
872
+ algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
873
+ )
874
+ return self._cipher
875
+
876
+ def _encrypt(self, data: str) -> str:
877
+ if len(data) == 0:
878
+ return ""
879
+ encryptor = self._get_cipher().encryptor()
880
+ return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex()
881
+
882
+ def _decrypt(self, data: bytearray) -> bytes:
883
+ if len(data) == 0:
884
+ return b""
885
+ decryptor = self._get_cipher().decryptor()
886
+ return decryptor.update(data) + decryptor.finalize()
887
+
830
888
 
831
889
  class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
832
- """Base Representation of a Switchbot Device.
890
+ """
891
+ Base Representation of a Switchbot Device.
833
892
 
834
893
  This base class ignores the advertisement data during connection and uses the
835
894
  data from the device instead.
@@ -847,7 +906,8 @@ class SwitchbotDeviceOverrideStateDuringConnection(SwitchbotBaseDevice):
847
906
 
848
907
 
849
908
  class SwitchbotSequenceDevice(SwitchbotDevice):
850
- """A Switchbot sequence device.
909
+ """
910
+ A Switchbot sequence device.
851
911
 
852
912
  This class must not use SwitchbotDeviceOverrideStateDuringConnection because
853
913
  it needs to know when the sequence_number has changed.
@@ -866,4 +926,4 @@ class SwitchbotSequenceDevice(SwitchbotDevice):
866
926
  new_state,
867
927
  )
868
928
  if current_state != new_state:
869
- asyncio.ensure_future(self.update())
929
+ create_background_task(self.update())
@@ -7,13 +7,12 @@ import time
7
7
  from typing import Any
8
8
 
9
9
  from bleak.backends.device import BLEDevice
10
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
11
10
 
12
- from ..const import LockStatus, SwitchbotModel
11
+ from ..const import SwitchbotModel
12
+ from ..const.lock import LockStatus
13
13
  from .device import SwitchbotEncryptedDevice
14
14
 
15
15
  COMMAND_HEADER = "57"
16
- COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
17
16
  COMMAND_LOCK_INFO = {
18
17
  SwitchbotModel.LOCK: f"{COMMAND_HEADER}0f4f8101",
19
18
  SwitchbotModel.LOCK_PRO: f"{COMMAND_HEADER}0f4f8102",
@@ -220,58 +219,3 @@ class SwitchbotLock(SwitchbotEncryptedDevice):
220
219
  "unclosed_alarm": bool(data[1] & 0b00100000),
221
220
  "unlocked_alarm": bool(data[1] & 0b00010000),
222
221
  }
223
-
224
- async def _send_command(
225
- self, key: str, retry: int | None = None, encrypt: bool = True
226
- ) -> bytes | None:
227
- if not encrypt:
228
- return await super()._send_command(key[:2] + "000000" + key[2:], retry)
229
-
230
- result = await self._ensure_encryption_initialized()
231
- if not result:
232
- _LOGGER.error("Failed to initialize encryption")
233
- return None
234
-
235
- encrypted = (
236
- key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
237
- )
238
- result = await super()._send_command(encrypted, retry)
239
- return result[:1] + self._decrypt(result[4:])
240
-
241
- async def _ensure_encryption_initialized(self) -> bool:
242
- if self._iv is not None:
243
- return True
244
-
245
- result = await self._send_command(
246
- COMMAND_GET_CK_IV + self._key_id, encrypt=False
247
- )
248
- ok = self._check_command_result(result, 0, COMMAND_RESULT_EXPECTED_VALUES)
249
- if ok:
250
- self._iv = result[4:]
251
-
252
- return ok
253
-
254
- async def _execute_disconnect(self) -> None:
255
- await super()._execute_disconnect()
256
- self._iv = None
257
- self._cipher = None
258
- self._notifications_enabled = False
259
-
260
- def _get_cipher(self) -> Cipher:
261
- if self._cipher is None:
262
- self._cipher = Cipher(
263
- algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
264
- )
265
- return self._cipher
266
-
267
- def _encrypt(self, data: str) -> str:
268
- if len(data) == 0:
269
- return ""
270
- encryptor = self._get_cipher().encryptor()
271
- return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex()
272
-
273
- def _decrypt(self, data: bytearray) -> bytes:
274
- if len(data) == 0:
275
- return b""
276
- decryptor = self._get_cipher().decryptor()
277
- return decryptor.update(data) + decryptor.finalize()
@@ -3,7 +3,6 @@ import time
3
3
  from typing import Any
4
4
 
5
5
  from bleak.backends.device import BLEDevice
6
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
7
6
 
8
7
  from ..const import SwitchbotModel
9
8
  from ..models import SwitchBotAdvertisement
@@ -12,7 +11,6 @@ from .device import SwitchbotEncryptedDevice
12
11
  _LOGGER = logging.getLogger(__name__)
13
12
 
14
13
  COMMAND_HEADER = "57"
15
- COMMAND_GET_CK_IV = f"{COMMAND_HEADER}0f2103"
16
14
  COMMAND_TURN_OFF = f"{COMMAND_HEADER}0f70010000"
17
15
  COMMAND_TURN_ON = f"{COMMAND_HEADER}0f70010100"
18
16
  COMMAND_TOGGLE = f"{COMMAND_HEADER}0f70010200"
@@ -139,56 +137,3 @@ class SwitchbotRelaySwitch(SwitchbotEncryptedDevice):
139
137
  def is_on(self) -> bool | None:
140
138
  """Return switch state from cache."""
141
139
  return self._get_adv_value("isOn")
142
-
143
- async def _send_command(
144
- self, key: str, retry: int | None = None, encrypt: bool = True
145
- ) -> bytes | None:
146
- if not encrypt:
147
- return await super()._send_command(key[:2] + "000000" + key[2:], retry)
148
-
149
- result = await self._ensure_encryption_initialized()
150
- if not result:
151
- return None
152
-
153
- encrypted = (
154
- key[:2] + self._key_id + self._iv[0:2].hex() + self._encrypt(key[2:])
155
- )
156
- result = await super()._send_command(encrypted, retry)
157
- return result[:1] + self._decrypt(result[4:])
158
-
159
- async def _ensure_encryption_initialized(self) -> bool:
160
- if self._iv is not None:
161
- return True
162
-
163
- result = await self._send_command(
164
- COMMAND_GET_CK_IV + self._key_id, encrypt=False
165
- )
166
- ok = self._check_command_result(result, 0, {1})
167
- if ok:
168
- self._iv = result[4:]
169
-
170
- return ok
171
-
172
- async def _execute_disconnect(self) -> None:
173
- await super()._execute_disconnect()
174
- self._iv = None
175
- self._cipher = None
176
-
177
- def _get_cipher(self) -> Cipher:
178
- if self._cipher is None:
179
- self._cipher = Cipher(
180
- algorithms.AES128(self._encryption_key), modes.CTR(self._iv)
181
- )
182
- return self._cipher
183
-
184
- def _encrypt(self, data: str) -> str:
185
- if len(data) == 0:
186
- return ""
187
- encryptor = self._get_cipher().encryptor()
188
- return (encryptor.update(bytearray.fromhex(data)) + encryptor.finalize()).hex()
189
-
190
- def _decrypt(self, data: bytearray) -> bytes:
191
- if len(data) == 0:
192
- return b""
193
- decryptor = self._get_cipher().decryptor()
194
- return decryptor.update(data) + decryptor.finalize()
@@ -39,7 +39,6 @@ class GetSwitchbotDevices:
39
39
  self, retry: int = DEFAULT_RETRY_COUNT, scan_timeout: int = DEFAULT_SCAN_TIMEOUT
40
40
  ) -> dict:
41
41
  """Find switchbot devices and their advertisement data."""
42
-
43
42
  devices = None
44
43
  devices = bleak.BleakScanner(
45
44
  detection_callback=self.detection_callback,
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Coroutine
5
+ from typing import Any, TypeVar
6
+
7
+ _R = TypeVar("_R")
8
+
9
+ _BACKGROUND_TASKS: set[asyncio.Task[Any]] = set()
10
+
11
+
12
+ def create_background_task(target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]:
13
+ """Create a background task."""
14
+ task = asyncio.create_task(target)
15
+ _BACKGROUND_TASKS.add(task)
16
+ task.add_done_callback(_BACKGROUND_TASKS.remove)
17
+ return task
@@ -5,8 +5,9 @@ from typing import Any
5
5
  from bleak.backends.device import BLEDevice
6
6
  from bleak.backends.scanner import AdvertisementData
7
7
 
8
- from switchbot import LockStatus, SwitchbotModel
8
+ from switchbot import SwitchbotModel
9
9
  from switchbot.adv_parser import parse_advertisement_data
10
+ from switchbot.const.lock import LockStatus
10
11
  from switchbot.models import SwitchBotAdvertisement
11
12
 
12
13
  ADVERTISEMENT_DATA_DEFAULTS = {
@@ -1917,3 +1918,80 @@ def test_leak_real_data_from_ha():
1917
1918
  rssi=-73,
1918
1919
  active=True,
1919
1920
  )
1921
+
1922
+
1923
+ def test_remote_active() -> None:
1924
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1925
+ adv_data = generate_advertisement_data(
1926
+ manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"},
1927
+ service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"},
1928
+ service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
1929
+ rssi=-95,
1930
+ )
1931
+ result = parse_advertisement_data(ble_device, adv_data)
1932
+ assert result == SwitchBotAdvertisement(
1933
+ address="aa:bb:cc:dd:ee:ff",
1934
+ data={
1935
+ "data": {
1936
+ "battery": 86,
1937
+ },
1938
+ "isEncrypted": False,
1939
+ "model": "b",
1940
+ "modelFriendlyName": "Remote",
1941
+ "modelName": SwitchbotModel.REMOTE,
1942
+ "rawAdvData": b"b V\x00",
1943
+ },
1944
+ device=ble_device,
1945
+ rssi=-95,
1946
+ active=True,
1947
+ )
1948
+
1949
+
1950
+ def test_remote_passive() -> None:
1951
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1952
+ adv_data = generate_advertisement_data(
1953
+ manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"},
1954
+ rssi=-97,
1955
+ )
1956
+ result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.REMOTE)
1957
+ assert result == SwitchBotAdvertisement(
1958
+ address="aa:bb:cc:dd:ee:ff",
1959
+ data={
1960
+ "data": {
1961
+ "battery": None,
1962
+ },
1963
+ "isEncrypted": False,
1964
+ "model": "b",
1965
+ "modelFriendlyName": "Remote",
1966
+ "modelName": SwitchbotModel.REMOTE,
1967
+ "rawAdvData": None,
1968
+ },
1969
+ device=ble_device,
1970
+ rssi=-97,
1971
+ active=False,
1972
+ )
1973
+
1974
+
1975
+ def test_parse_advertisement_ignores_devices_with_apple_manufacturer_id():
1976
+ """Test parse_advertisement_data ignores devices with apple manufacturer id."""
1977
+ ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
1978
+ adv_data = generate_advertisement_data(
1979
+ local_name="WoCurtain",
1980
+ manufacturer_data={
1981
+ 89: b"\xcc\xf4\xc4\xf9\xacl",
1982
+ 2409: b"\xcc\xf4\xc4\xf9\xacl\xe2\x0f\x00\x12\x04",
1983
+ 76: b"\x10",
1984
+ },
1985
+ service_data={
1986
+ "00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Yd\x11\x04",
1987
+ "0000fd3d-0000-1000-8000-00805f9b34fb": b"c\xc0d\x00\x12\x04",
1988
+ },
1989
+ service_uuids=[
1990
+ "00001800-0000-1000-8000-00805f9b34fb",
1991
+ "00001801-0000-1000-8000-00805f9b34fb",
1992
+ "cba20d00-224d-11e6-9fb8-0002a5d5c51b",
1993
+ ],
1994
+ rssi=-2,
1995
+ )
1996
+ result = parse_advertisement_data(ble_device, adv_data)
1997
+ assert result is None
@@ -24,7 +24,6 @@ def make_advertisement_data(
24
24
  ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
25
25
  ):
26
26
  """Set advertisement data with defaults."""
27
-
28
27
  return SwitchBotAdvertisement(
29
28
  address="aa:bb:cc:dd:ee:ff",
30
29
  data={
@@ -29,7 +29,6 @@ def make_advertisement_data(
29
29
  ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
30
30
  ):
31
31
  """Set advertisement data with defaults."""
32
-
33
32
  return SwitchBotAdvertisement(
34
33
  address="aa:bb:cc:dd:ee:ff",
35
34
  data={
@@ -25,7 +25,6 @@ def make_advertisement_data(
25
25
  ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
26
26
  ):
27
27
  """Set advertisement data with defaults."""
28
-
29
28
  return SwitchBotAdvertisement(
30
29
  address="aa:bb:cc:dd:ee:ff",
31
30
  data={
@@ -20,7 +20,6 @@ def create_device_for_command_testing(calibration=True, reverse_mode=False):
20
20
 
21
21
  def make_advertisement_data(ble_device: BLEDevice):
22
22
  """Set advertisement data with defaults."""
23
-
24
23
  return SwitchBotAdvertisement(
25
24
  address="aa:bb:cc:dd:ee:ff",
26
25
  data={
File without changes
File without changes
File without changes
File without changes