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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: switchbot_api
3
- Version: 2.3.1
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.8.4,<4.0.0)
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
+
@@ -26,4 +26,4 @@ poetry install
26
26
  ```bash
27
27
  poetry run black switchbot_api/
28
28
  poetry run mypy switchbot_api/
29
- ```
29
+ ```
@@ -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