switchbot-api 2.3.0__tar.gz → 2.4.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,8 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: switchbot_api
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: An asynchronous library to use Switchbot API
5
- Home-page: https://github.com/SeraphicCorp/py-switchbot-api
6
5
  License: MIT
7
6
  Author: Ravaka Razafimanantsoa
8
7
  Author-email: contact@ravaka.dev
@@ -13,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: aiohttp (>=3.8.4,<4.0.0)
15
+ Requires-Dist: aiohttp (>=3.0.0)
17
16
  Project-URL: Repository, https://github.com/SeraphicCorp/py-switchbot-api
18
17
  Description-Content-Type: text/markdown
19
18
 
@@ -46,3 +45,4 @@ poetry install
46
45
  poetry run black switchbot_api/
47
46
  poetry run mypy switchbot_api/
48
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.4.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"
@@ -1,16 +1,26 @@
1
1
  """Tools to query the SwitchBot API."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import base64
6
+ from dataclasses import dataclass
7
+ from enum import Enum, StrEnum
4
8
  import hashlib
5
9
  import hmac
6
10
  import logging
11
+ import socket
7
12
  import time
13
+ from typing import Any, Self, TypeVar
8
14
  import uuid
9
- from dataclasses import dataclass
10
- from enum import Enum
11
- from typing import TypeVar
12
15
 
13
- from aiohttp import ClientSession
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
+ )
14
24
 
15
25
  _API_HOST = "https://api.switch-bot.com"
16
26
 
@@ -18,18 +28,6 @@ _LOGGER = logging.getLogger(__name__)
18
28
  NON_OBSERVED_REMOTE_TYPES = ["Others"]
19
29
 
20
30
 
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
31
  @dataclass
34
32
  class Device:
35
33
  """Device."""
@@ -39,7 +37,7 @@ class Device:
39
37
  device_type: str
40
38
  hub_device_id: str
41
39
 
42
- def __init__(self, **kwargs) -> None:
40
+ def __init__(self, **kwargs: Any) -> None:
43
41
  """Initialize."""
44
42
  self.device_id = kwargs["deviceId"]
45
43
  self.device_name = kwargs["deviceName"]
@@ -56,7 +54,7 @@ class Remote:
56
54
  device_type: str
57
55
  hub_device_id: str
58
56
 
59
- def __init__(self, **kwargs) -> None:
57
+ def __init__(self, **kwargs: Any) -> None:
60
58
  """Initialize."""
61
59
  self.device_id = kwargs["deviceId"]
62
60
  self.device_name = kwargs["deviceName"]
@@ -72,7 +70,7 @@ class PowerState(Enum):
72
70
 
73
71
 
74
72
  class Commands(Enum):
75
- pass
73
+ """Base command class."""
76
74
 
77
75
 
78
76
  class CommonCommands(Commands):
@@ -97,7 +95,11 @@ class AirConditionerCommands(Commands):
97
95
  class HumidifierCommands(Commands):
98
96
  """Humidifier commands."""
99
97
 
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%
98
+ # parameter: auto, set to Auto Mode
99
+ # 101, set atomization efficiency to 34%
100
+ # 102, set atomization efficiency to 67%
101
+ # 103, set atomization efficiency to 100%
102
+ SET_MODE = "setMode"
101
103
 
102
104
 
103
105
  class TVCommands(Commands):
@@ -140,6 +142,14 @@ class FanCommands(Commands):
140
142
  HIGH_SPEED = "highSpeed"
141
143
 
142
144
 
145
+ class BatteryCirculatorFanCommands(Commands):
146
+ """Command types for [Battery Circulator Fan] API."""
147
+
148
+ SET_WIND_SPEED = "setWindSpeed"
149
+ SET_WIND_MODE = "setWindMode"
150
+ SET_NIGHT_LIGHT_MODE = "setNightLightMode"
151
+
152
+
143
153
  class LightCommands(Commands):
144
154
  """Light commands."""
145
155
 
@@ -148,7 +158,7 @@ class LightCommands(Commands):
148
158
 
149
159
 
150
160
  class LockCommands(Commands):
151
- """Lock commands"""
161
+ """Lock commands."""
152
162
 
153
163
  LOCK = "lock"
154
164
  UNLOCK = "unlock"
@@ -178,25 +188,43 @@ class BotCommands(Commands):
178
188
  PRESS = "press"
179
189
 
180
190
 
191
+ class BatteryCirculatorFanMode(StrEnum):
192
+ """Fan mode types [Battery Circulator Fan] API."""
193
+
194
+ DIRECT = "direct"
195
+ NATURAL = "natural"
196
+ SLEEP = "sleep"
197
+ BABY = "baby"
198
+
199
+
181
200
  T = TypeVar("T", bound=CommonCommands)
182
201
 
183
202
 
184
203
  class SwitchBotAPI:
185
204
  """SwitchBot API."""
186
205
 
187
- def __init__(self, token: str, secret: str) -> None:
206
+ _close_session: bool = False
207
+
208
+ def __init__(
209
+ self, token: str, secret: str, session: ClientSession | None = None
210
+ ) -> None:
188
211
  """Initialize."""
189
212
  self.token = token
190
213
  self.secret = secret
214
+ self.session = session
191
215
 
192
- def make_headers(self, token: str, secret: str):
216
+ def make_headers(self, token: str, secret: str) -> dict[str, Any]:
193
217
  """Make headers."""
194
218
  nonce = uuid.uuid4()
195
219
  timestamp = int(round(time.time() * 1000))
196
220
  string_to_sign = bytes(f"{token}{timestamp}{nonce}", "utf-8")
197
221
  secret_bytes = bytes(secret, "utf-8")
198
222
 
199
- sign = base64.b64encode(hmac.new(secret_bytes, msg=string_to_sign, digestmod=hashlib.sha256).digest())
223
+ sign = base64.b64encode(
224
+ hmac.new(
225
+ secret_bytes, msg=string_to_sign, digestmod=hashlib.sha256
226
+ ).digest()
227
+ )
200
228
 
201
229
  return {
202
230
  "Authorization": token,
@@ -207,80 +235,95 @@ class SwitchBotAPI:
207
235
  "nonce": str(nonce),
208
236
  }
209
237
 
210
- async def _request(self, path: str = "", callback: str = "get", json=None):
211
- async with ClientSession() as session:
212
- async with getattr(session, callback)(
238
+ async def _request(
239
+ self, method: str, path: str, json: dict[str, Any] | None = None
240
+ ) -> dict[str, Any]:
241
+ if not self.session:
242
+ self.session = ClientSession()
243
+ self._close_session = True
244
+ try:
245
+ response = await self.session.request(
246
+ method,
213
247
  f"{_API_HOST}/v1.1/{path}",
214
248
  headers=self.make_headers(self.token, self.secret),
215
249
  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
- raise CannotConnect()
224
-
225
- match body.get("statusCode"):
226
- case 100:
227
- return body.get("body")
228
- case 161 | 171:
229
- # SwitchBot docs claim that 161 is the code for device
230
- # being offline, and 171 for a _hub_ being offline.
231
- # In testing, the Plug Mini (JP) return 171 when not
232
- # online too.
233
- raise DeviceOffline()
234
- case _:
235
- _LOGGER.error("Error %s: %s", response.status, body)
236
- raise CannotConnect()
250
+ )
251
+ except (
252
+ ClientError,
253
+ ClientResponseError,
254
+ socket.gaierror,
255
+ ) as exception:
256
+ msg = "Error occurred while communicating with the SwitchBot API"
257
+ raise SwitchBotConnectionError(msg) from exception
258
+ if response.status == 403:
259
+ raise SwitchBotAuthenticationError
260
+
261
+ body = await response.json()
262
+
263
+ if response.status >= 400:
264
+ _LOGGER.error("Error %d: %s", response.status, body)
265
+ raise SwitchBotConnectionError
266
+
267
+ match body.get("statusCode"):
268
+ case 100:
269
+ return body["body"] # type: ignore[no-any-return]
270
+ case 161 | 171:
271
+ # SwitchBot docs claim that 161 is the code for device
272
+ # being offline, and 171 for a _hub_ being offline.
273
+ # In testing, the Plug Mini (JP) return 171 when not
274
+ # online too.
275
+ raise SwitchBotDeviceOfflineError
276
+ case _:
277
+ _LOGGER.error("Error %d: %s", response.status, body)
278
+ raise SwitchBotConnectionError
237
279
 
238
280
  async def list_devices(self) -> list[Device | Remote]:
239
281
  """List devices."""
240
- body = await self._request("devices")
282
+ body = await self._request(METH_GET, "devices")
241
283
  _LOGGER.debug("Devices: %s", body)
242
- devices = [Device(**device) for device in body.get("deviceList")]
284
+ devices = [Device(**device) for device in body.get("deviceList")] # type: ignore[union-attr]
243
285
  remotes = [
244
286
  Remote(**remote)
245
- for remote in body.get("infraredRemoteList")
287
+ for remote in body.get("infraredRemoteList") # type: ignore[union-attr]
246
288
  if remote.get("remoteType") not in NON_OBSERVED_REMOTE_TYPES
247
289
  ]
248
290
  return [*devices, *remotes]
249
291
 
250
- async def get_status(self, device_id: str):
292
+ async def get_status(self, device_id: str) -> dict[str, Any]:
251
293
  """No status for IR devices."""
252
- body = await self._request(f"devices/{device_id}/status")
253
- return body
294
+ return await self._request(METH_GET, f"devices/{device_id}/status")
254
295
 
255
- async def get_webook_configuration(self):
296
+ async def get_webook_configuration(self) -> dict[str, Any]:
256
297
  """List webhooks."""
257
298
  json = {"action": "queryUrl"}
258
- return await self._request("webhook/queryWebhook", callback="post", json=json)
299
+ return await self._request(METH_POST, "webhook/queryWebhook", json=json)
259
300
 
260
- async def setup_webhook(self, url: str):
261
- """Setup webhook to receive device status updates."""
301
+ async def setup_webhook(self, url: str) -> None:
302
+ """Set up webhook to receive device status updates."""
262
303
  json = {"deviceList": "ALL", "action": "setupWebhook", "url": url}
263
- await self._request("webhook/setupWebhook", callback="post", json=json)
304
+ await self._request(METH_POST, "webhook/setupWebhook", json=json)
264
305
 
265
- async def delete_webhook(self, url: str):
306
+ async def delete_webhook(self, url: str) -> None:
266
307
  """Delete webhook."""
267
308
  json = {"action": "deleteWebhook", "url": url}
268
- await self._request("webhook/deleteWebhook", callback="post", json=json)
309
+ await self._request(METH_POST, "webhook/deleteWebhook", json=json)
269
310
 
270
311
  async def send_command(
271
312
  self,
272
313
  device_id: str,
273
314
  command: T | str,
274
315
  command_type: str = "command",
275
- parameters: dict | str = "default",
276
- ):
316
+ parameters: dict | str = "default", # type: ignore[type-arg]
317
+ ) -> None:
277
318
  """Send command to device.
278
319
 
279
320
  Args:
280
321
  device_id (str): The ID of the device.
281
322
  command (string | extends CommonCommands): The command to be sent.
282
- command_type (str, optional): The type of the command. Defaults to "command".
283
- parameters (dict | str, optional): The parameters for the command. Defaults to "default".
323
+ command_type (str, optional): The type of the command.
324
+ Defaults to "command".
325
+ parameters (dict | str, optional): The parameters for the command.
326
+ Defaults to "default".
284
327
 
285
328
  Example JSON:
286
329
  {
@@ -288,10 +331,36 @@ class SwitchBotAPI:
288
331
  "command": "ボタン", // the name of the customized button
289
332
  "parameter": "default"
290
333
  }
334
+
291
335
  """
292
336
  json = {
293
337
  "commandType": command_type,
294
338
  "command": command.value if isinstance(command, Commands) else command,
295
339
  "parameter": parameters,
296
340
  }
297
- await self._request(f"devices/{device_id}/commands", callback="post", json=json)
341
+ await self._request(METH_POST, f"devices/{device_id}/commands", json=json)
342
+
343
+ async def close(self) -> None:
344
+ """Close the client session."""
345
+ if self.session and self._close_session:
346
+ await self.session.close()
347
+
348
+ async def __aenter__(self) -> Self:
349
+ """Async enter.
350
+
351
+ Returns
352
+ -------
353
+ The SwitchBotAPI object.
354
+
355
+ """
356
+ return self
357
+
358
+ async def __aexit__(self, *_exc_info: object) -> None:
359
+ """Async exit.
360
+
361
+ Args:
362
+ ----
363
+ _exc_info: Exec type.
364
+
365
+ """
366
+ 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.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.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"
File without changes