Skip to content

Commit 408dc1c

Browse files
authored
Merge pull request #80 from kzosabe/develop
Release v0.4.0
2 parents 85004b6 + 6b73655 commit 408dc1c

File tree

13 files changed

+329
-149
lines changed

13 files changed

+329
-149
lines changed

.github/workflows/main.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ jobs:
1717
python-version: ["3.7", "3.8", "3.9", "3.10"]
1818
steps:
1919
- name: Checkout
20-
uses: actions/checkout@v2
20+
uses: actions/checkout@v3
2121

2222
- name: Set up Python ${{ matrix.python-version }}
23-
uses: actions/setup-python@v2
23+
uses: actions/setup-python@v3
2424
with:
2525
python-version: ${{ matrix.python-version }}
2626

@@ -32,7 +32,7 @@ jobs:
3232
3333
- name: Load cached venv
3434
id: cached-poetry-dependencies
35-
uses: actions/cache@v2
35+
uses: actions/cache@v3.0.2
3636
with:
3737
path: .venv
3838
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
0.4.0, 2022-05-24
2+
-------------------------
3+
4+
- Add webhook support(#77, #78)
5+
- You can create, get, set, and delete webhook configurations via SwitchBotClient or SwitchBotAPIClient
6+
- Add pseudo status for AirConditioner(#59)
7+
- Fix Humidifier behavior when lackWater column is missing(#58)
8+
19
0.3.2, 2022-02-03
210
-------------------------
311

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,22 @@ SwitchBotCommandResult(status_code=100, message='success', response_body={})
164164
```
165165
The specified scene can be executed immediately.
166166

167+
### Webhooks
168+
169+
```python
170+
from switchbot_client import SwitchBotClient
171+
172+
client = SwitchBotClient()
173+
client.set_webhook(url="https://example.com/foo", enable=True)
174+
print(client.webhooks())
175+
```
176+
177+
```
178+
[SwitchBotWebhook(url='https://example.com/foo', enable=True, device_list='ALL', create_time=datetime.datetime(2022, 1, 1, 12, 0, 0, 123456), last_update_time=datetime.datetime(2022, 1, 1, 12, 0, 0, 123456))]
179+
```
180+
181+
You can handle [webhook](https://github.com/OpenWonderLabs/SwitchBotAPI#webhook) configurations via SwitchBotClient.
182+
167183
### Raw API interface
168184

169185
Devices and scenes also can be manipulated via the low-level raw API client.

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
project = "switchbot-client"
66
copyright = "2021, kzosabe"
77
author = "kzosabe"
8-
release = "0.3.2"
8+
release = "0.4.0"
99
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"]
1010
templates_path = ["_templates"]
1111
exclude_patterns = ["_build", ".DS_Store"]

poetry.lock

Lines changed: 141 additions & 118 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "switchbot-client"
3-
version = "0.3.2"
3+
version = "0.4.0"
44
description = "A Python client library for SwitchBot API."
55
license = "Apache-2.0 or MIT"
66
authors = [
@@ -29,12 +29,12 @@ typing-extensions = ">=3.10,<5.0"
2929
black = ">=20.8b1"
3030
flake8 = "^4.0.1"
3131
isort = "^5.9.3"
32-
mypy = "^0.931"
33-
pylint = "^2.12.2"
34-
pytest = "^6.2.5"
32+
mypy = "^0.950"
33+
pylint = "^2.13.8"
34+
pytest = "^7.1.2"
3535
pytest-cov = "^3.0.0"
3636
pytest-mock = "^3.7.0"
37-
tox = "^3.24.5"
37+
tox = "^3.25.0"
3838
Sphinx = "^4.3.2"
3939

4040
[build-system]

switchbot_client/api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import os
33
from dataclasses import dataclass
4+
from typing import List
45

56
import requests
67
import yaml
@@ -101,6 +102,62 @@ def scenes_execute(self, scene_id: str) -> SwitchBotAPIResponse:
101102
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
102103
return formatted_response
103104

105+
def webhook_setup(self, url: str) -> SwitchBotAPIResponse:
106+
payload = {
107+
"action": "setupWebhook",
108+
"url": url,
109+
"deviceList": "ALL",
110+
}
111+
response: requests.Response = requests.post(
112+
self._uri("v1.0/webhook/setupWebhook"),
113+
headers=self._headers(),
114+
data=json.dumps(payload),
115+
)
116+
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
117+
return formatted_response
118+
119+
def webhook_query_url(self) -> SwitchBotAPIResponse:
120+
payload = {
121+
"action": "queryUrl",
122+
}
123+
response: requests.Response = requests.post(
124+
self._uri("v1.0/webhook/queryWebhook"),
125+
headers=self._headers(),
126+
data=json.dumps(payload),
127+
)
128+
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
129+
return formatted_response
130+
131+
def webhook_query_details(self, urls: List[str]) -> SwitchBotAPIResponse:
132+
payload = {"action": "queryDetails", "urls": urls}
133+
response: requests.Response = requests.post(
134+
self._uri("v1.0/webhook/queryWebhook"),
135+
headers=self._headers(),
136+
data=json.dumps(payload),
137+
)
138+
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
139+
return formatted_response
140+
141+
def webhook_update(self, config: dict) -> SwitchBotAPIResponse:
142+
payload = {"action": "updateWebhook", "config": config}
143+
response: requests.Response = requests.post(
144+
self._uri("v1.0/webhook/updateWebhook"),
145+
headers=self._headers(),
146+
data=json.dumps(payload),
147+
)
148+
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
149+
return formatted_response
150+
151+
def webhook_delete(self, url: str) -> SwitchBotAPIResponse:
152+
payload = {"action": "deleteWebhook", "url": url}
153+
response: requests.Response = requests.post(
154+
self._uri("v1.0/webhook/deleteWebhook"),
155+
headers=self._headers(),
156+
data=json.dumps(payload),
157+
)
158+
formatted_response: SwitchBotAPIResponse = self._check_api_response(response)
159+
return formatted_response
160+
104161
def config_file_path(self):
105162
if self.__config_file_path is None:
106163
return os.path.expanduser(SwitchBotAPIClient.DEFAULT_CONFIG_FILE_PATH)

switchbot_client/client.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from datetime import datetime
12
from typing import List, Optional
23

3-
from switchbot_client.api import SwitchBotAPIClient
4+
from switchbot_client.api import SwitchBotAPIClient, SwitchBotAPIResponse
45
from switchbot_client.devices.base import SwitchBotDevice
56
from switchbot_client.devices.factory import SwitchBotDeviceFactory
67
from switchbot_client.scenes import SwitchBotScene
8+
from switchbot_client.webhooks.base import SwitchBotWebhook
79

810

911
class SwitchBotClient:
@@ -42,3 +44,30 @@ def scene(self, scene_id: str) -> Optional[SwitchBotScene]:
4244
if len(filtered) == 0:
4345
return None
4446
return filtered[0]
47+
48+
def webhooks(self) -> List[SwitchBotWebhook]:
49+
response_urls = self.api_client.webhook_query_url().body["urls"]
50+
response = self.api_client.webhook_query_details(response_urls).body
51+
return [
52+
SwitchBotWebhook(
53+
r["url"],
54+
r["enable"],
55+
r["deviceList"],
56+
datetime.fromtimestamp(r["createTime"] / 1000),
57+
datetime.fromtimestamp(r["lastUpdateTime"] / 1000),
58+
)
59+
for r in response
60+
]
61+
62+
def create_webhook(self, url: str) -> SwitchBotAPIResponse:
63+
return self.api_client.webhook_setup(url)
64+
65+
def set_webhook(self, url: str, enable: bool) -> SwitchBotAPIResponse:
66+
config = {
67+
"url": url,
68+
"enable": enable,
69+
}
70+
return self.api_client.webhook_update(config)
71+
72+
def delete_webhook(self, url: str) -> SwitchBotAPIResponse:
73+
return self.api_client.webhook_delete(url)

switchbot_client/devices/physical.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ def status(self) -> HumidifierDeviceStatus:
621621
is_auto=status.raw_data["auto"],
622622
is_child_lock=status.raw_data["childLock"],
623623
is_muted=not status.raw_data["sound"],
624-
is_lack_water=status.raw_data["lackWater"],
624+
is_lack_water=status.raw_data["lackWater"] if "lackWater" in status.raw_data else False,
625625
)
626626

627627
def power(self) -> str:

switchbot_client/devices/remote.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, Generic, Optional, TypeVar
44

55
from switchbot_client.enums import ControlCommand, RemoteType
66
from switchbot_client.types import APIRemoteDeviceObject
77

88
from .base import SwitchBotCommandResult, SwitchBotDevice
9-
from .status import PseudoRemoteDeviceStatus
9+
from .status import PseudoAirConditionerStatus, PseudoRemoteDeviceStatus
1010

1111
if TYPE_CHECKING:
1212
from switchbot_client import SwitchBotClient
1313

14+
AnyRemoteDeviceStatus = TypeVar("AnyRemoteDeviceStatus", bound=PseudoRemoteDeviceStatus)
1415

15-
class SwitchBotRemoteDevice(SwitchBotDevice):
16+
17+
class SwitchBotRemoteDevice(SwitchBotDevice, Generic[AnyRemoteDeviceStatus]):
1618
def __init__(
1719
self,
1820
client: SwitchBotClient,
1921
device: APIRemoteDeviceObject,
20-
pseudo_status: PseudoRemoteDeviceStatus,
22+
pseudo_status: AnyRemoteDeviceStatus,
2123
):
2224
super().__init__(
2325
client,
@@ -41,7 +43,7 @@ def turn_off(self) -> SwitchBotCommandResult:
4143
self.pseudo_status.set_power("off")
4244
return response
4345

44-
def status(self) -> PseudoRemoteDeviceStatus:
46+
def status(self) -> AnyRemoteDeviceStatus:
4547
return self.pseudo_status
4648

4749
@staticmethod
@@ -116,7 +118,7 @@ def _validate_pseudo_status(self):
116118
)
117119

118120

119-
class AirConditioner(SwitchBotRemoteDevice):
121+
class AirConditioner(SwitchBotRemoteDevice[PseudoAirConditionerStatus]):
120122
class Parameters:
121123
MODE_AUTO = 1
122124
MODE_COOL = 2
@@ -131,40 +133,46 @@ class Parameters:
131133
POWER_OFF = "off"
132134

133135
def __init__(self, client: SwitchBotClient, device: APIRemoteDeviceObject):
134-
pseudo_status = PseudoRemoteDeviceStatus(
136+
pseudo_status = PseudoAirConditionerStatus(
135137
device_id=device["deviceId"],
136138
device_type=device["remoteType"],
137139
device_name=device["deviceName"],
138140
hub_device_id=device["hubDeviceId"],
139141
power=None,
140142
raw_data={},
143+
temperature=25.0,
144+
mode=AirConditioner.Parameters.MODE_AUTO,
145+
fan_speed=AirConditioner.Parameters.FAN_SPEED_AUTO,
141146
)
142147
super().__init__(client, device, pseudo_status)
143148
self._check_remote_type(RemoteType.AIR_CONDITIONER)
144149

145-
# remote devices don't have status fetch commands
146-
# we can only memorize recently applied parameter and use it
147-
self.temperature_memory = 25.0
148-
self.mode_memory = AirConditioner.Parameters.MODE_AUTO
149-
self.fan_speed_memory = AirConditioner.Parameters.FAN_SPEED_AUTO
150-
151150
@staticmethod
152151
def create_by_id(client: SwitchBotClient, device_id: str) -> AirConditioner:
153152
device = SwitchBotRemoteDevice.get_device_by_id(client, device_id)
154153
return AirConditioner(client, device)
155154

156155
def set_all(
157-
self, temperature: float, mode: int, fan_speed: int, power: str
156+
self,
157+
temperature: Optional[float],
158+
mode: Optional[int],
159+
fan_speed: Optional[int],
160+
power: Optional[str],
158161
) -> SwitchBotCommandResult:
159162
"""
160163
temperature: temperature in celsius
161164
mode(Parameters.MODE_XXX): 1(auto), 2(cool), 3(dry), 4(fan), 5(heat)
162165
fan_speed(Parameters.FAN_SPEED_XXX): 1(auto), 2(low), 3(medium), 4(high)
163166
power(Parameters.POWER_XXX): on, off
164167
"""
165-
self.temperature_memory = temperature
166-
self.mode_memory = mode
167-
self.fan_speed_memory = fan_speed
168+
if temperature is not None:
169+
self.pseudo_status.set_temperature(temperature)
170+
if mode is not None:
171+
self.pseudo_status.set_mode(mode)
172+
if fan_speed is not None:
173+
self.pseudo_status.set_fan_speed(fan_speed)
174+
if power is not None:
175+
self.pseudo_status.set_power(power)
168176
return self.command(
169177
ControlCommand.VirtualInfrared.SET_ALL,
170178
parameter=f"{temperature},{mode},{fan_speed},{power}",
@@ -182,7 +190,9 @@ def set_temperature(self, temperature: float) -> SwitchBotCommandResult:
182190
It is recommended to turn on AirConditioner with the set_all
183191
with all values specified before use this function.
184192
"""
185-
return self.set_all(temperature, self.mode_memory, self.fan_speed_memory, "on")
193+
return self.set_all(
194+
temperature, self.pseudo_status.mode, self.pseudo_status.fan_speed, "on"
195+
)
186196

187197
def set_mode(self, mode: int) -> SwitchBotCommandResult:
188198
"""
@@ -196,7 +206,9 @@ def set_mode(self, mode: int) -> SwitchBotCommandResult:
196206
It is recommended to turn on AirConditioner with the set_all
197207
with all values specified before use this function.
198208
"""
199-
return self.set_all(self.temperature_memory, mode, self.fan_speed_memory, "on")
209+
return self.set_all(
210+
self.pseudo_status.temperature, mode, self.pseudo_status.fan_speed, "on"
211+
)
200212

201213
def set_fan_speed(self, fan_speed: int) -> SwitchBotCommandResult:
202214
"""
@@ -210,7 +222,9 @@ def set_fan_speed(self, fan_speed: int) -> SwitchBotCommandResult:
210222
It is recommended to turn on AirConditioner with the set_all
211223
with all values specified before use this function.
212224
"""
213-
return self.set_all(self.temperature_memory, self.mode_memory, fan_speed, "on")
225+
return self.set_all(
226+
self.pseudo_status.temperature, self.pseudo_status.mode, fan_speed, "on"
227+
)
214228

215229

216230
class TV(SwitchBotRemoteDevice):

0 commit comments

Comments
 (0)