switchbot-api 2.3.1__tar.gz → 2.5.0__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.
- {switchbot_api-2.3.1 → switchbot_api-2.5.0}/PKG-INFO +3 -2
- {switchbot_api-2.3.1 → switchbot_api-2.5.0}/README.md +1 -1
- switchbot_api-2.5.0/pyproject.toml +106 -0
- switchbot_api-2.5.0/switchbot_api/__init__.py +483 -0
- switchbot_api-2.5.0/switchbot_api/exceptions.py +17 -0
- switchbot_api-2.3.1/pyproject.toml +0 -51
- switchbot_api-2.3.1/switchbot_api/__init__.py +0 -298
- {switchbot_api-2.3.1 → switchbot_api-2.5.0}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: switchbot_api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: An asynchronous library to use Switchbot API
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Ravaka Razafimanantsoa
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
Requires-Dist: aiohttp (>=3.
|
|
15
|
+
Requires-Dist: aiohttp (>=3.0.0)
|
|
16
16
|
Project-URL: Repository, https://github.com/SeraphicCorp/py-switchbot-api
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
@@ -45,3 +45,4 @@ poetry install
|
|
|
45
45
|
poetry run black switchbot_api/
|
|
46
46
|
poetry run mypy switchbot_api/
|
|
47
47
|
```
|
|
48
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "switchbot_api"
|
|
3
|
+
version = "2.5.0"
|
|
4
|
+
description = "An asynchronous library to use Switchbot API"
|
|
5
|
+
authors = ["Ravaka Razafimanantsoa <contact@ravaka.dev>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{include = "switchbot_api"}]
|
|
9
|
+
repository = "https://github.com/SeraphicCorp/py-switchbot-api"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.10"
|
|
13
|
+
aiohttp = ">=3.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
coverage = {version = "7.6.10", extras = ["toml"]}
|
|
18
|
+
mypy = "^1.4.1"
|
|
19
|
+
pytest-asyncio = "^0.25.2"
|
|
20
|
+
pytest-freezer = "^0.4.9"
|
|
21
|
+
pytest = "^8.3.4"
|
|
22
|
+
syrupy = "^4.8.1"
|
|
23
|
+
aioresponses = "^0.7.7"
|
|
24
|
+
ruff = "^0.9.2"
|
|
25
|
+
pytest-cov = "^6.0.0"
|
|
26
|
+
covdefaults = "^2.3.0"
|
|
27
|
+
codespell = "^2.3.0"
|
|
28
|
+
pre-commit = "^4.0.1"
|
|
29
|
+
pre-commit-hooks = "^5.0.0"
|
|
30
|
+
yamllint = "^1.35.1"
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
# Specify the target platform details in config, so your developers are
|
|
34
|
+
# free to run mypy on Windows, Linux, or macOS and get consistent
|
|
35
|
+
# results.
|
|
36
|
+
platform = "linux"
|
|
37
|
+
python_version = "3.11"
|
|
38
|
+
|
|
39
|
+
# show error messages from unrelated files
|
|
40
|
+
follow_imports = "normal"
|
|
41
|
+
|
|
42
|
+
# suppress errors about unsatisfied imports
|
|
43
|
+
ignore_missing_imports = true
|
|
44
|
+
|
|
45
|
+
# be strict
|
|
46
|
+
check_untyped_defs = true
|
|
47
|
+
disallow_any_generics = true
|
|
48
|
+
disallow_incomplete_defs = true
|
|
49
|
+
disallow_subclassing_any = true
|
|
50
|
+
disallow_untyped_calls = true
|
|
51
|
+
disallow_untyped_decorators = true
|
|
52
|
+
disallow_untyped_defs = true
|
|
53
|
+
no_implicit_optional = true
|
|
54
|
+
strict_optional = true
|
|
55
|
+
warn_incomplete_stub = true
|
|
56
|
+
warn_no_return = true
|
|
57
|
+
warn_redundant_casts = true
|
|
58
|
+
warn_return_any = true
|
|
59
|
+
warn_unused_configs = true
|
|
60
|
+
warn_unused_ignores = true
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
addopts = "--cov"
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
pythonpath = ["switchbot_api"]
|
|
67
|
+
|
|
68
|
+
[tool.coverage.report]
|
|
69
|
+
skip_empty = true
|
|
70
|
+
show_missing = true
|
|
71
|
+
fail_under = 50
|
|
72
|
+
|
|
73
|
+
[tool.coverage.run]
|
|
74
|
+
plugins = ["covdefaults"]
|
|
75
|
+
source = ["switchbot_api"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint]
|
|
79
|
+
ignore = [
|
|
80
|
+
"ANN401", # Opinioated warning on disallowing dynamically typed expressions
|
|
81
|
+
"COM812", # Conflicts with other rules
|
|
82
|
+
"D203", # Conflicts with other rules
|
|
83
|
+
"D213", # Conflicts with other rules
|
|
84
|
+
"D417", # False positives in some occasions
|
|
85
|
+
"ISC001", # Conflicts with other rules
|
|
86
|
+
"PLR2004", # Just annoying, not really useful
|
|
87
|
+
]
|
|
88
|
+
select = ["ALL"]
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint.flake8-pytest-style]
|
|
91
|
+
fixture-parentheses = false
|
|
92
|
+
mark-parentheses = false
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.isort]
|
|
95
|
+
known-first-party = ["switchbot_api"]
|
|
96
|
+
force-sort-within-sections = true
|
|
97
|
+
split-on-trailing-comma = false
|
|
98
|
+
combine-as-imports = true
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.mccabe]
|
|
101
|
+
max-complexity = 25
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
[build-system]
|
|
105
|
+
requires = ["poetry-core"]
|
|
106
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Tools to query the SwitchBot API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum, StrEnum
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import logging
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Self, TypeVar
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from aiohttp import ClientError, ClientResponseError, ClientSession
|
|
17
|
+
from aiohttp.hdrs import METH_GET, METH_POST
|
|
18
|
+
|
|
19
|
+
from switchbot_api.exceptions import (
|
|
20
|
+
SwitchBotAuthenticationError,
|
|
21
|
+
SwitchBotConnectionError,
|
|
22
|
+
SwitchBotDeviceOfflineError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_API_HOST = "https://api.switch-bot.com"
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
NON_OBSERVED_REMOTE_TYPES = ["Others"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Device:
|
|
33
|
+
"""Device."""
|
|
34
|
+
|
|
35
|
+
device_id: str
|
|
36
|
+
device_name: str
|
|
37
|
+
device_type: str
|
|
38
|
+
hub_device_id: str
|
|
39
|
+
|
|
40
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
41
|
+
"""Initialize."""
|
|
42
|
+
self.device_id = kwargs["deviceId"]
|
|
43
|
+
self.device_name = kwargs["deviceName"]
|
|
44
|
+
self.device_type = kwargs.get("deviceType", "-")
|
|
45
|
+
self.hub_device_id = kwargs["hubDeviceId"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Remote:
|
|
50
|
+
"""Remote device."""
|
|
51
|
+
|
|
52
|
+
device_id: str
|
|
53
|
+
device_name: str
|
|
54
|
+
device_type: str
|
|
55
|
+
hub_device_id: str
|
|
56
|
+
|
|
57
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
58
|
+
"""Initialize."""
|
|
59
|
+
self.device_id = kwargs["deviceId"]
|
|
60
|
+
self.device_name = kwargs["deviceName"]
|
|
61
|
+
self.device_type = kwargs.get("remoteType", "-")
|
|
62
|
+
self.hub_device_id = kwargs["hubDeviceId"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PowerState(Enum):
|
|
66
|
+
"""Power state."""
|
|
67
|
+
|
|
68
|
+
ON = "on"
|
|
69
|
+
OFF = "off"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Commands(Enum):
|
|
73
|
+
"""Base command class."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CommonCommands(Commands):
|
|
77
|
+
"""Common commands."""
|
|
78
|
+
|
|
79
|
+
ON = "turnOn"
|
|
80
|
+
OFF = "turnOff"
|
|
81
|
+
TOGGLE = "toggle"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OthersCommands(Commands):
|
|
85
|
+
"""Others commands."""
|
|
86
|
+
|
|
87
|
+
CUSTOMIZE = "customize" # Command {user-defined button name}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AirConditionerCommands(Commands):
|
|
91
|
+
"""Air conditioner commands."""
|
|
92
|
+
|
|
93
|
+
SET_ALL = "setAll" # parameter: {temperature},{mode},{fan speed},{power state}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class HumidifierCommands(Commands):
|
|
97
|
+
"""Humidifier commands."""
|
|
98
|
+
|
|
99
|
+
# parameter: auto, set to Auto Mode
|
|
100
|
+
# 101, set atomization efficiency to 34%
|
|
101
|
+
# 102, set atomization efficiency to 67%
|
|
102
|
+
# 103, set atomization efficiency to 100%
|
|
103
|
+
SET_MODE = "setMode"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class HumidifierV2Commands(Commands):
|
|
107
|
+
"""Humidifier 2 commands."""
|
|
108
|
+
|
|
109
|
+
SET_MODE = "setMode"
|
|
110
|
+
SET_CHILD_LOCK = "setChildLock"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AirPurifierCommands(Commands):
|
|
114
|
+
"""Purifier commands."""
|
|
115
|
+
|
|
116
|
+
# Supported Device List:
|
|
117
|
+
# Air Purifier VOC
|
|
118
|
+
# Air Purifier Table VOC
|
|
119
|
+
# Air Purifier PM2.5
|
|
120
|
+
# Air Purifier Table PM2.5
|
|
121
|
+
|
|
122
|
+
SET_MODE = "setMode"
|
|
123
|
+
SET_CHILD_LOCK = "setChildLock"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CurtainCommands(Commands):
|
|
127
|
+
"""Curtain & Curtain3 commands."""
|
|
128
|
+
|
|
129
|
+
SET_POSITION = "setPosition"
|
|
130
|
+
PAUSE = "pause"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SwitchCommands(Commands):
|
|
134
|
+
"""Switch commands."""
|
|
135
|
+
|
|
136
|
+
# Supported Device List:
|
|
137
|
+
# Relay Switch 1
|
|
138
|
+
# Relay Switch 1PM
|
|
139
|
+
SET_MODE = "setMode"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Switch2PMCommands(Commands):
|
|
143
|
+
"""Switch commands."""
|
|
144
|
+
|
|
145
|
+
# Supported Device List:
|
|
146
|
+
# Relay Switch 2PM
|
|
147
|
+
SET_MODE = "setMode"
|
|
148
|
+
SET_POSITION = "setPosition"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RGBWLightCommands(Commands):
|
|
152
|
+
"""RGBWLight commands."""
|
|
153
|
+
|
|
154
|
+
# Supported Device List:
|
|
155
|
+
# Strip Light
|
|
156
|
+
SET_BRIGHTNESS = "setBrightness"
|
|
157
|
+
SET_COLOR = "setColor"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RGBWWLightCommands(Commands):
|
|
161
|
+
"""RGBWwLight commands."""
|
|
162
|
+
|
|
163
|
+
# Supported Device List:
|
|
164
|
+
# Floor Lamp
|
|
165
|
+
# Strip Light 3
|
|
166
|
+
# Color Bulb
|
|
167
|
+
SET_BRIGHTNESS = "setBrightness"
|
|
168
|
+
SET_COLOR = "setColor"
|
|
169
|
+
SET_COLOR_TEMPERATURE = "setColorTemperature"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DoorBellCommands(Commands):
|
|
173
|
+
"""Door Bell commands."""
|
|
174
|
+
|
|
175
|
+
ENABLE = "enableMotionDetection"
|
|
176
|
+
DISABLE = "disableMotionDetection"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class VacuumCleanerV2Commands(Commands):
|
|
180
|
+
"""VacuumCleanerV2 commands."""
|
|
181
|
+
|
|
182
|
+
# Supported Device List:
|
|
183
|
+
# K20+ Pro
|
|
184
|
+
# Robot Vacuum Cleaner K10+ Pro Combo
|
|
185
|
+
START_CLEAN = "startClean"
|
|
186
|
+
PAUSE = "pause"
|
|
187
|
+
DOCK = "dock"
|
|
188
|
+
SET_VOLUME = "setVolume"
|
|
189
|
+
CHANGE_PARAM = "changeParam"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class VacuumCleanerV3Commands(Commands):
|
|
193
|
+
"""VacuumCleanerV3 commands."""
|
|
194
|
+
|
|
195
|
+
# Supported Device List:
|
|
196
|
+
# Floor Cleaning Robot S10
|
|
197
|
+
# S20
|
|
198
|
+
START_CLEAN = "startClean"
|
|
199
|
+
PAUSE = "pause"
|
|
200
|
+
DOCK = "dock"
|
|
201
|
+
SET_VOLUME = "setVolume"
|
|
202
|
+
CHANGE_PARAM = "changeParam"
|
|
203
|
+
ADD_WATER_FOR_HUMI = "addWaterForHumi"
|
|
204
|
+
SELF_CLEAN = "selfClean"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class BlindTiltCommands(Commands):
|
|
208
|
+
"""Blind Tilt commands."""
|
|
209
|
+
|
|
210
|
+
SET_POSITION = "setPosition"
|
|
211
|
+
FULLY_OPEN = "fullyOpen"
|
|
212
|
+
CLOSE_UP = "closeUp"
|
|
213
|
+
CLOSE_DOWN = "closeDown"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RollerShadeCommands(Commands):
|
|
217
|
+
"""Roller Shade commands."""
|
|
218
|
+
|
|
219
|
+
SET_POSITION = "setPosition"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TVCommands(Commands):
|
|
223
|
+
"""TV commands."""
|
|
224
|
+
|
|
225
|
+
SET_CHANNEL = "SetChannel"
|
|
226
|
+
VOLUME_ADD = "volumeAdd"
|
|
227
|
+
VOLUME_SUB = "volumeSub"
|
|
228
|
+
CHANNEL_ADD = "channelAdd"
|
|
229
|
+
CHANNEL_SUB = "channelSub"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class DVDCommands(Commands):
|
|
233
|
+
"""DVD commands."""
|
|
234
|
+
|
|
235
|
+
SET_MUTE = "setMute"
|
|
236
|
+
FAST_FORWARD = "FastForward"
|
|
237
|
+
REWIND = "Rewind"
|
|
238
|
+
NEXT = "Next"
|
|
239
|
+
PREVIOUS = "Previous"
|
|
240
|
+
PAUSE = "Pause"
|
|
241
|
+
PLAY = "Play"
|
|
242
|
+
STOP = "Stop"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class SpeakerCommands(Commands):
|
|
246
|
+
"""Speaker commands."""
|
|
247
|
+
|
|
248
|
+
VOLUME_ADD = "volumeAdd"
|
|
249
|
+
VOLUME_SUB = "volumeSub"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class FanCommands(Commands):
|
|
253
|
+
"""Fan commands."""
|
|
254
|
+
|
|
255
|
+
SWING = "swing"
|
|
256
|
+
TIMER = "timer"
|
|
257
|
+
LOW_SPEED = "lowSpeed"
|
|
258
|
+
MIDDLE_SPEED = "middleSpeed"
|
|
259
|
+
HIGH_SPEED = "highSpeed"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class BatteryCirculatorFanCommands(Commands):
|
|
263
|
+
"""Command types for [Battery Circulator Fan] API."""
|
|
264
|
+
|
|
265
|
+
SET_WIND_SPEED = "setWindSpeed"
|
|
266
|
+
SET_WIND_MODE = "setWindMode"
|
|
267
|
+
SET_NIGHT_LIGHT_MODE = "setNightLightMode"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class LightCommands(Commands):
|
|
271
|
+
"""Light commands."""
|
|
272
|
+
|
|
273
|
+
BRIGHTNESS_UP = "brightnessUp"
|
|
274
|
+
BRIGHTNESS_DOWN = "brightnessDown"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class LockCommands(Commands):
|
|
278
|
+
"""Lock commands."""
|
|
279
|
+
|
|
280
|
+
LOCK = "lock"
|
|
281
|
+
UNLOCK = "unlock"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class CeilingLightCommands(Commands):
|
|
285
|
+
"""Ceiling light commands."""
|
|
286
|
+
|
|
287
|
+
# 1-100
|
|
288
|
+
SET_BRIGHTNESS = "setBrightness"
|
|
289
|
+
# 2700-6500
|
|
290
|
+
SET_COLOR_TEMPERATURE = "setColorTemperature"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class VacuumCommands(Commands):
|
|
294
|
+
"""Vacuum commands."""
|
|
295
|
+
|
|
296
|
+
START = "start"
|
|
297
|
+
STOP = "stop"
|
|
298
|
+
DOCK = "dock"
|
|
299
|
+
POW_LEVEL = "PowLevel"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class BotCommands(Commands):
|
|
303
|
+
"""Bot commands."""
|
|
304
|
+
|
|
305
|
+
PRESS = "press"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class BatteryCirculatorFanMode(StrEnum):
|
|
309
|
+
"""Fan mode types [Battery Circulator Fan] API."""
|
|
310
|
+
|
|
311
|
+
DIRECT = "direct"
|
|
312
|
+
NATURAL = "natural"
|
|
313
|
+
SLEEP = "sleep"
|
|
314
|
+
BABY = "baby"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
T = TypeVar("T", bound=CommonCommands)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class SwitchBotAPI:
|
|
321
|
+
"""SwitchBot API."""
|
|
322
|
+
|
|
323
|
+
_close_session: bool = False
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self, token: str, secret: str, session: ClientSession | None = None
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Initialize."""
|
|
329
|
+
self.token = token
|
|
330
|
+
self.secret = secret
|
|
331
|
+
self.session = session
|
|
332
|
+
|
|
333
|
+
def make_headers(self, token: str, secret: str) -> dict[str, Any]:
|
|
334
|
+
"""Make headers."""
|
|
335
|
+
nonce = uuid.uuid4()
|
|
336
|
+
timestamp = int(round(time.time() * 1000))
|
|
337
|
+
string_to_sign = bytes(f"{token}{timestamp}{nonce}", "utf-8")
|
|
338
|
+
secret_bytes = bytes(secret, "utf-8")
|
|
339
|
+
|
|
340
|
+
sign = base64.b64encode(
|
|
341
|
+
hmac.new(
|
|
342
|
+
secret_bytes, msg=string_to_sign, digestmod=hashlib.sha256
|
|
343
|
+
).digest()
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"Authorization": token,
|
|
348
|
+
"Content-Type": "application/json",
|
|
349
|
+
"charset": "utf8",
|
|
350
|
+
"t": str(timestamp),
|
|
351
|
+
"sign": str(sign, "utf-8"),
|
|
352
|
+
"nonce": str(nonce),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async def _request(
|
|
356
|
+
self, method: str, path: str, json: dict[str, Any] | None = None
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
if not self.session:
|
|
359
|
+
self.session = ClientSession()
|
|
360
|
+
self._close_session = True
|
|
361
|
+
try:
|
|
362
|
+
response = await self.session.request(
|
|
363
|
+
method,
|
|
364
|
+
f"{_API_HOST}/v1.1/{path}",
|
|
365
|
+
headers=self.make_headers(self.token, self.secret),
|
|
366
|
+
json=json,
|
|
367
|
+
)
|
|
368
|
+
except (
|
|
369
|
+
ClientError,
|
|
370
|
+
ClientResponseError,
|
|
371
|
+
socket.gaierror,
|
|
372
|
+
) as exception:
|
|
373
|
+
msg = "Error occurred while communicating with the SwitchBot API"
|
|
374
|
+
raise SwitchBotConnectionError(msg) from exception
|
|
375
|
+
if response.status == 403:
|
|
376
|
+
raise SwitchBotAuthenticationError
|
|
377
|
+
|
|
378
|
+
body = await response.json()
|
|
379
|
+
|
|
380
|
+
if response.status >= 400:
|
|
381
|
+
_LOGGER.error("Error %d: %s", response.status, body)
|
|
382
|
+
raise SwitchBotConnectionError
|
|
383
|
+
|
|
384
|
+
match body.get("statusCode"):
|
|
385
|
+
case 100:
|
|
386
|
+
return body["body"] # type: ignore[no-any-return]
|
|
387
|
+
case 161 | 171:
|
|
388
|
+
# SwitchBot docs claim that 161 is the code for device
|
|
389
|
+
# being offline, and 171 for a _hub_ being offline.
|
|
390
|
+
# In testing, the Plug Mini (JP) return 171 when not
|
|
391
|
+
# online too.
|
|
392
|
+
raise SwitchBotDeviceOfflineError
|
|
393
|
+
case _:
|
|
394
|
+
_LOGGER.error("Error %d: %s", response.status, body)
|
|
395
|
+
raise SwitchBotConnectionError
|
|
396
|
+
|
|
397
|
+
async def list_devices(self) -> list[Device | Remote]:
|
|
398
|
+
"""List devices."""
|
|
399
|
+
body = await self._request(METH_GET, "devices")
|
|
400
|
+
_LOGGER.debug("Devices: %s", body)
|
|
401
|
+
devices = [Device(**device) for device in body.get("deviceList")] # type: ignore[union-attr]
|
|
402
|
+
remotes = [
|
|
403
|
+
Remote(**remote)
|
|
404
|
+
for remote in body.get("infraredRemoteList") # type: ignore[union-attr]
|
|
405
|
+
if remote.get("remoteType") not in NON_OBSERVED_REMOTE_TYPES
|
|
406
|
+
]
|
|
407
|
+
return [*devices, *remotes]
|
|
408
|
+
|
|
409
|
+
async def get_status(self, device_id: str) -> dict[str, Any]:
|
|
410
|
+
"""No status for IR devices."""
|
|
411
|
+
return await self._request(METH_GET, f"devices/{device_id}/status")
|
|
412
|
+
|
|
413
|
+
async def get_webook_configuration(self) -> dict[str, Any]:
|
|
414
|
+
"""List webhooks."""
|
|
415
|
+
json = {"action": "queryUrl"}
|
|
416
|
+
return await self._request(METH_POST, "webhook/queryWebhook", json=json)
|
|
417
|
+
|
|
418
|
+
async def setup_webhook(self, url: str) -> None:
|
|
419
|
+
"""Set up webhook to receive device status updates."""
|
|
420
|
+
json = {"deviceList": "ALL", "action": "setupWebhook", "url": url}
|
|
421
|
+
await self._request(METH_POST, "webhook/setupWebhook", json=json)
|
|
422
|
+
|
|
423
|
+
async def delete_webhook(self, url: str) -> None:
|
|
424
|
+
"""Delete webhook."""
|
|
425
|
+
json = {"action": "deleteWebhook", "url": url}
|
|
426
|
+
await self._request(METH_POST, "webhook/deleteWebhook", json=json)
|
|
427
|
+
|
|
428
|
+
async def send_command(
|
|
429
|
+
self,
|
|
430
|
+
device_id: str,
|
|
431
|
+
command: T | str,
|
|
432
|
+
command_type: str = "command",
|
|
433
|
+
parameters: dict | str = "default", # type: ignore[type-arg]
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Send command to device.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
device_id (str): The ID of the device.
|
|
439
|
+
command (string | extends CommonCommands): The command to be sent.
|
|
440
|
+
command_type (str, optional): The type of the command.
|
|
441
|
+
Defaults to "command".
|
|
442
|
+
parameters (dict | str, optional): The parameters for the command.
|
|
443
|
+
Defaults to "default".
|
|
444
|
+
|
|
445
|
+
Example JSON:
|
|
446
|
+
{
|
|
447
|
+
"commandType": "customize",
|
|
448
|
+
"command": "ボタン", // the name of the customized button
|
|
449
|
+
"parameter": "default"
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
"""
|
|
453
|
+
json = {
|
|
454
|
+
"commandType": command_type,
|
|
455
|
+
"command": command.value if isinstance(command, Commands) else command,
|
|
456
|
+
"parameter": parameters,
|
|
457
|
+
}
|
|
458
|
+
await self._request(METH_POST, f"devices/{device_id}/commands", json=json)
|
|
459
|
+
|
|
460
|
+
async def close(self) -> None:
|
|
461
|
+
"""Close the client session."""
|
|
462
|
+
if self.session and self._close_session:
|
|
463
|
+
await self.session.close()
|
|
464
|
+
|
|
465
|
+
async def __aenter__(self) -> Self:
|
|
466
|
+
"""Async enter.
|
|
467
|
+
|
|
468
|
+
Returns
|
|
469
|
+
-------
|
|
470
|
+
The SwitchBotAPI object.
|
|
471
|
+
|
|
472
|
+
"""
|
|
473
|
+
return self
|
|
474
|
+
|
|
475
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
476
|
+
"""Async exit.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
----
|
|
480
|
+
_exc_info: Exec type.
|
|
481
|
+
|
|
482
|
+
"""
|
|
483
|
+
await self.close()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions for the SwitchBot API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SwitchBotError(Exception):
|
|
5
|
+
"""General SwitchBot error occurred."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SwitchBotConnectionError(SwitchBotError):
|
|
9
|
+
"""Cannot connect to the SwitchBot API."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SwitchBotAuthenticationError(SwitchBotError):
|
|
13
|
+
"""Invalid auth for the SwitchBot API."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SwitchBotDeviceOfflineError(SwitchBotError):
|
|
17
|
+
"""Device currently offline."""
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "switchbot_api"
|
|
3
|
-
version = "2.3.1"
|
|
4
|
-
description = "An asynchronous library to use Switchbot API"
|
|
5
|
-
authors = ["Ravaka Razafimanantsoa <contact@ravaka.dev>"]
|
|
6
|
-
license = "MIT"
|
|
7
|
-
readme = "README.md"
|
|
8
|
-
packages = [{include = "switchbot_api"}]
|
|
9
|
-
repository = "https://github.com/SeraphicCorp/py-switchbot-api"
|
|
10
|
-
|
|
11
|
-
[tool.poetry.dependencies]
|
|
12
|
-
python = "^3.10"
|
|
13
|
-
aiohttp = "^3.8.4"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[tool.poetry.group.dev.dependencies]
|
|
17
|
-
pytest = "^7.2.2"
|
|
18
|
-
flake8 = "^6.0.0"
|
|
19
|
-
black = ">=23.1,<25.0"
|
|
20
|
-
coverage = "^7.2.1"
|
|
21
|
-
parameterized = "^0.8.1"
|
|
22
|
-
isort = "^5.12.0"
|
|
23
|
-
mypy = "^1.4.1"
|
|
24
|
-
|
|
25
|
-
[tool.black]
|
|
26
|
-
line-length = 120
|
|
27
|
-
|
|
28
|
-
[tool.isort]
|
|
29
|
-
profile = "black"
|
|
30
|
-
|
|
31
|
-
[tool.mypy]
|
|
32
|
-
check_untyped_defs = true
|
|
33
|
-
|
|
34
|
-
[tool.pytest.ini_options]
|
|
35
|
-
pythonpath = ["switchbot_api"]
|
|
36
|
-
|
|
37
|
-
[tool.coverage.run]
|
|
38
|
-
branch = true
|
|
39
|
-
omit = [
|
|
40
|
-
"*/test_*.py",
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
[tool.coverage.report]
|
|
44
|
-
skip_empty = true
|
|
45
|
-
show_missing = true
|
|
46
|
-
fail_under = 0
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[build-system]
|
|
50
|
-
requires = ["poetry-core"]
|
|
51
|
-
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
"""Tools to query the SwitchBot API."""
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import hashlib
|
|
5
|
-
import hmac
|
|
6
|
-
import logging
|
|
7
|
-
import time
|
|
8
|
-
import uuid
|
|
9
|
-
from dataclasses import dataclass
|
|
10
|
-
from enum import Enum
|
|
11
|
-
from typing import TypeVar
|
|
12
|
-
|
|
13
|
-
from aiohttp import ClientSession
|
|
14
|
-
|
|
15
|
-
_API_HOST = "https://api.switch-bot.com"
|
|
16
|
-
|
|
17
|
-
_LOGGER = logging.getLogger(__name__)
|
|
18
|
-
NON_OBSERVED_REMOTE_TYPES = ["Others"]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class CannotConnect(Exception):
|
|
22
|
-
"""Cannot connect to the SwitchBot API."""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class InvalidAuth(Exception):
|
|
26
|
-
"""Invalid auth for the SwitchBot API."""
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class DeviceOffline(Exception):
|
|
30
|
-
"""Device currently offline."""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass
|
|
34
|
-
class Device:
|
|
35
|
-
"""Device."""
|
|
36
|
-
|
|
37
|
-
device_id: str
|
|
38
|
-
device_name: str
|
|
39
|
-
device_type: str
|
|
40
|
-
hub_device_id: str
|
|
41
|
-
|
|
42
|
-
def __init__(self, **kwargs) -> None:
|
|
43
|
-
"""Initialize."""
|
|
44
|
-
self.device_id = kwargs["deviceId"]
|
|
45
|
-
self.device_name = kwargs["deviceName"]
|
|
46
|
-
self.device_type = kwargs.get("deviceType", "-")
|
|
47
|
-
self.hub_device_id = kwargs["hubDeviceId"]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@dataclass
|
|
51
|
-
class Remote:
|
|
52
|
-
"""Remote device."""
|
|
53
|
-
|
|
54
|
-
device_id: str
|
|
55
|
-
device_name: str
|
|
56
|
-
device_type: str
|
|
57
|
-
hub_device_id: str
|
|
58
|
-
|
|
59
|
-
def __init__(self, **kwargs) -> None:
|
|
60
|
-
"""Initialize."""
|
|
61
|
-
self.device_id = kwargs["deviceId"]
|
|
62
|
-
self.device_name = kwargs["deviceName"]
|
|
63
|
-
self.device_type = kwargs.get("remoteType", "-")
|
|
64
|
-
self.hub_device_id = kwargs["hubDeviceId"]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class PowerState(Enum):
|
|
68
|
-
"""Power state."""
|
|
69
|
-
|
|
70
|
-
ON = "on"
|
|
71
|
-
OFF = "off"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class Commands(Enum):
|
|
75
|
-
pass
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class CommonCommands(Commands):
|
|
79
|
-
"""Common commands."""
|
|
80
|
-
|
|
81
|
-
ON = "turnOn"
|
|
82
|
-
OFF = "turnOff"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
class OthersCommands(Commands):
|
|
86
|
-
"""Others commands."""
|
|
87
|
-
|
|
88
|
-
CUSTOMIZE = "customize" # Command {user-defined button name}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
class AirConditionerCommands(Commands):
|
|
92
|
-
"""Air conditioner commands."""
|
|
93
|
-
|
|
94
|
-
SET_ALL = "setAll" # parameter: {temperature},{mode},{fan speed},{power state}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
class HumidifierCommands(Commands):
|
|
98
|
-
"""Humidifier commands."""
|
|
99
|
-
|
|
100
|
-
SET_MODE = "setMode" # parameter: auto, set to Auto Mode, 101, set atomization efficiency to 34%,102, set atomization efficiency to 67%, 103, set atomization efficiency to 100%
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class TVCommands(Commands):
|
|
104
|
-
"""TV commands."""
|
|
105
|
-
|
|
106
|
-
SET_CHANNEL = "SetChannel"
|
|
107
|
-
VOLUME_ADD = "volumeAdd"
|
|
108
|
-
VOLUME_SUB = "volumeSub"
|
|
109
|
-
CHANNEL_ADD = "channelAdd"
|
|
110
|
-
CHANNEL_SUB = "channelSub"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
class DVDCommands(Commands):
|
|
114
|
-
"""DVD commands."""
|
|
115
|
-
|
|
116
|
-
SET_MUTE = "setMute"
|
|
117
|
-
FAST_FORWARD = "FastForward"
|
|
118
|
-
REWIND = "Rewind"
|
|
119
|
-
NEXT = "Next"
|
|
120
|
-
PREVIOUS = "Previous"
|
|
121
|
-
PAUSE = "Pause"
|
|
122
|
-
PLAY = "Play"
|
|
123
|
-
STOP = "Stop"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class SpeakerCommands(Commands):
|
|
127
|
-
"""Speaker commands."""
|
|
128
|
-
|
|
129
|
-
VOLUME_ADD = "volumeAdd"
|
|
130
|
-
VOLUME_SUB = "volumeSub"
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class FanCommands(Commands):
|
|
134
|
-
"""Fan commands."""
|
|
135
|
-
|
|
136
|
-
SWING = "swing"
|
|
137
|
-
TIMER = "timer"
|
|
138
|
-
LOW_SPEED = "lowSpeed"
|
|
139
|
-
MIDDLE_SPEED = "middleSpeed"
|
|
140
|
-
HIGH_SPEED = "highSpeed"
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
class LightCommands(Commands):
|
|
144
|
-
"""Light commands."""
|
|
145
|
-
|
|
146
|
-
BRIGHTNESS_UP = "brightnessUp"
|
|
147
|
-
BRIGHTNESS_DOWN = "brightnessDown"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
class LockCommands(Commands):
|
|
151
|
-
"""Lock commands"""
|
|
152
|
-
|
|
153
|
-
LOCK = "lock"
|
|
154
|
-
UNLOCK = "unlock"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
class CeilingLightCommands(Commands):
|
|
158
|
-
"""Ceiling light commands."""
|
|
159
|
-
|
|
160
|
-
# 1-100
|
|
161
|
-
SET_BRIGHTNESS = "setBrightness"
|
|
162
|
-
# 2700-6500
|
|
163
|
-
SET_COLOR_TEMPERATURE = "setColorTemperature"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
class VacuumCommands(Commands):
|
|
167
|
-
"""Vacuum commands."""
|
|
168
|
-
|
|
169
|
-
START = "start"
|
|
170
|
-
STOP = "stop"
|
|
171
|
-
DOCK = "dock"
|
|
172
|
-
POW_LEVEL = "PowLevel"
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class BotCommands(Commands):
|
|
176
|
-
"""Bot commands."""
|
|
177
|
-
|
|
178
|
-
PRESS = "press"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
T = TypeVar("T", bound=CommonCommands)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
class SwitchBotAPI:
|
|
185
|
-
"""SwitchBot API."""
|
|
186
|
-
|
|
187
|
-
def __init__(self, token: str, secret: str) -> None:
|
|
188
|
-
"""Initialize."""
|
|
189
|
-
self.token = token
|
|
190
|
-
self.secret = secret
|
|
191
|
-
|
|
192
|
-
def make_headers(self, token: str, secret: str):
|
|
193
|
-
"""Make headers."""
|
|
194
|
-
nonce = uuid.uuid4()
|
|
195
|
-
timestamp = int(round(time.time() * 1000))
|
|
196
|
-
string_to_sign = bytes(f"{token}{timestamp}{nonce}", "utf-8")
|
|
197
|
-
secret_bytes = bytes(secret, "utf-8")
|
|
198
|
-
|
|
199
|
-
sign = base64.b64encode(hmac.new(secret_bytes, msg=string_to_sign, digestmod=hashlib.sha256).digest())
|
|
200
|
-
|
|
201
|
-
return {
|
|
202
|
-
"Authorization": token,
|
|
203
|
-
"Content-Type": "application/json",
|
|
204
|
-
"charset": "utf8",
|
|
205
|
-
"t": str(timestamp),
|
|
206
|
-
"sign": str(sign, "utf-8"),
|
|
207
|
-
"nonce": str(nonce),
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async def _request(self, path: str = "", callback: str = "get", json=None):
|
|
211
|
-
async with ClientSession() as session:
|
|
212
|
-
async with getattr(session, callback)(
|
|
213
|
-
f"{_API_HOST}/v1.1/{path}",
|
|
214
|
-
headers=self.make_headers(self.token, self.secret),
|
|
215
|
-
json=json,
|
|
216
|
-
) as response:
|
|
217
|
-
if response.status == 403:
|
|
218
|
-
raise InvalidAuth()
|
|
219
|
-
|
|
220
|
-
body = await response.json()
|
|
221
|
-
|
|
222
|
-
if response.status >= 400:
|
|
223
|
-
_LOGGER.error("Error %d: %s", response.status, body)
|
|
224
|
-
raise CannotConnect()
|
|
225
|
-
|
|
226
|
-
match body.get("statusCode"):
|
|
227
|
-
case 100:
|
|
228
|
-
return body.get("body")
|
|
229
|
-
case 161 | 171:
|
|
230
|
-
# SwitchBot docs claim that 161 is the code for device
|
|
231
|
-
# being offline, and 171 for a _hub_ being offline.
|
|
232
|
-
# In testing, the Plug Mini (JP) return 171 when not
|
|
233
|
-
# online too.
|
|
234
|
-
raise DeviceOffline()
|
|
235
|
-
case _:
|
|
236
|
-
_LOGGER.error("Error %d: %s", response.status, body)
|
|
237
|
-
raise CannotConnect()
|
|
238
|
-
|
|
239
|
-
async def list_devices(self) -> list[Device | Remote]:
|
|
240
|
-
"""List devices."""
|
|
241
|
-
body = await self._request("devices")
|
|
242
|
-
_LOGGER.debug("Devices: %s", body)
|
|
243
|
-
devices = [Device(**device) for device in body.get("deviceList")]
|
|
244
|
-
remotes = [
|
|
245
|
-
Remote(**remote)
|
|
246
|
-
for remote in body.get("infraredRemoteList")
|
|
247
|
-
if remote.get("remoteType") not in NON_OBSERVED_REMOTE_TYPES
|
|
248
|
-
]
|
|
249
|
-
return [*devices, *remotes]
|
|
250
|
-
|
|
251
|
-
async def get_status(self, device_id: str):
|
|
252
|
-
"""No status for IR devices."""
|
|
253
|
-
body = await self._request(f"devices/{device_id}/status")
|
|
254
|
-
return body
|
|
255
|
-
|
|
256
|
-
async def get_webook_configuration(self):
|
|
257
|
-
"""List webhooks."""
|
|
258
|
-
json = {"action": "queryUrl"}
|
|
259
|
-
return await self._request("webhook/queryWebhook", callback="post", json=json)
|
|
260
|
-
|
|
261
|
-
async def setup_webhook(self, url: str):
|
|
262
|
-
"""Setup webhook to receive device status updates."""
|
|
263
|
-
json = {"deviceList": "ALL", "action": "setupWebhook", "url": url}
|
|
264
|
-
await self._request("webhook/setupWebhook", callback="post", json=json)
|
|
265
|
-
|
|
266
|
-
async def delete_webhook(self, url: str):
|
|
267
|
-
"""Delete webhook."""
|
|
268
|
-
json = {"action": "deleteWebhook", "url": url}
|
|
269
|
-
await self._request("webhook/deleteWebhook", callback="post", json=json)
|
|
270
|
-
|
|
271
|
-
async def send_command(
|
|
272
|
-
self,
|
|
273
|
-
device_id: str,
|
|
274
|
-
command: T | str,
|
|
275
|
-
command_type: str = "command",
|
|
276
|
-
parameters: dict | str = "default",
|
|
277
|
-
):
|
|
278
|
-
"""Send command to device.
|
|
279
|
-
|
|
280
|
-
Args:
|
|
281
|
-
device_id (str): The ID of the device.
|
|
282
|
-
command (string | extends CommonCommands): The command to be sent.
|
|
283
|
-
command_type (str, optional): The type of the command. Defaults to "command".
|
|
284
|
-
parameters (dict | str, optional): The parameters for the command. Defaults to "default".
|
|
285
|
-
|
|
286
|
-
Example JSON:
|
|
287
|
-
{
|
|
288
|
-
"commandType": "customize",
|
|
289
|
-
"command": "ボタン", // the name of the customized button
|
|
290
|
-
"parameter": "default"
|
|
291
|
-
}
|
|
292
|
-
"""
|
|
293
|
-
json = {
|
|
294
|
-
"commandType": command_type,
|
|
295
|
-
"command": command.value if isinstance(command, Commands) else command,
|
|
296
|
-
"parameter": parameters,
|
|
297
|
-
}
|
|
298
|
-
await self._request(f"devices/{device_id}/commands", callback="post", json=json)
|
|
File without changes
|