switchbot-api 2.3.1__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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: switchbot_api
3
- Version: 2.3.1
3
+ Version: 2.4.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.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,81 +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
- _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()
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
238
279
 
239
280
  async def list_devices(self) -> list[Device | Remote]:
240
281
  """List devices."""
241
- body = await self._request("devices")
282
+ body = await self._request(METH_GET, "devices")
242
283
  _LOGGER.debug("Devices: %s", body)
243
- devices = [Device(**device) for device in body.get("deviceList")]
284
+ devices = [Device(**device) for device in body.get("deviceList")] # type: ignore[union-attr]
244
285
  remotes = [
245
286
  Remote(**remote)
246
- for remote in body.get("infraredRemoteList")
287
+ for remote in body.get("infraredRemoteList") # type: ignore[union-attr]
247
288
  if remote.get("remoteType") not in NON_OBSERVED_REMOTE_TYPES
248
289
  ]
249
290
  return [*devices, *remotes]
250
291
 
251
- async def get_status(self, device_id: str):
292
+ async def get_status(self, device_id: str) -> dict[str, Any]:
252
293
  """No status for IR devices."""
253
- body = await self._request(f"devices/{device_id}/status")
254
- return body
294
+ return await self._request(METH_GET, f"devices/{device_id}/status")
255
295
 
256
- async def get_webook_configuration(self):
296
+ async def get_webook_configuration(self) -> dict[str, Any]:
257
297
  """List webhooks."""
258
298
  json = {"action": "queryUrl"}
259
- return await self._request("webhook/queryWebhook", callback="post", json=json)
299
+ return await self._request(METH_POST, "webhook/queryWebhook", json=json)
260
300
 
261
- async def setup_webhook(self, url: str):
262
- """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."""
263
303
  json = {"deviceList": "ALL", "action": "setupWebhook", "url": url}
264
- await self._request("webhook/setupWebhook", callback="post", json=json)
304
+ await self._request(METH_POST, "webhook/setupWebhook", json=json)
265
305
 
266
- async def delete_webhook(self, url: str):
306
+ async def delete_webhook(self, url: str) -> None:
267
307
  """Delete webhook."""
268
308
  json = {"action": "deleteWebhook", "url": url}
269
- await self._request("webhook/deleteWebhook", callback="post", json=json)
309
+ await self._request(METH_POST, "webhook/deleteWebhook", json=json)
270
310
 
271
311
  async def send_command(
272
312
  self,
273
313
  device_id: str,
274
314
  command: T | str,
275
315
  command_type: str = "command",
276
- parameters: dict | str = "default",
277
- ):
316
+ parameters: dict | str = "default", # type: ignore[type-arg]
317
+ ) -> None:
278
318
  """Send command to device.
279
319
 
280
320
  Args:
281
321
  device_id (str): The ID of the device.
282
322
  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".
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".
285
327
 
286
328
  Example JSON:
287
329
  {
@@ -289,10 +331,36 @@ class SwitchBotAPI:
289
331
  "command": "ボタン", // the name of the customized button
290
332
  "parameter": "default"
291
333
  }
334
+
292
335
  """
293
336
  json = {
294
337
  "commandType": command_type,
295
338
  "command": command.value if isinstance(command, Commands) else command,
296
339
  "parameter": parameters,
297
340
  }
298
- 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.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"
File without changes