python-library-ewelink 0.1.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.
- python_library_ewelink-0.1.0/.gitignore +11 -0
- python_library_ewelink-0.1.0/PKG-INFO +6 -0
- python_library_ewelink-0.1.0/README.md +48 -0
- python_library_ewelink-0.1.0/ewelink/__init__.py +34 -0
- python_library_ewelink-0.1.0/ewelink/actions/__init__.py +55 -0
- python_library_ewelink-0.1.0/ewelink/actions/_base.py +37 -0
- python_library_ewelink-0.1.0/ewelink/actions/_registry.py +19 -0
- python_library_ewelink-0.1.0/ewelink/actions/pulse_outlet.py +18 -0
- python_library_ewelink-0.1.0/ewelink/actions/set_outlet.py +15 -0
- python_library_ewelink-0.1.0/ewelink/actions/set_outlets.py +15 -0
- python_library_ewelink-0.1.0/ewelink/actions/set_params.py +14 -0
- python_library_ewelink-0.1.0/ewelink/actions/set_switch.py +14 -0
- python_library_ewelink-0.1.0/ewelink/auth.py +26 -0
- python_library_ewelink-0.1.0/ewelink/client.py +196 -0
- python_library_ewelink-0.1.0/ewelink/exceptions.py +19 -0
- python_library_ewelink-0.1.0/ewelink/regions.py +221 -0
- python_library_ewelink-0.1.0/ewelink/sync.py +79 -0
- python_library_ewelink-0.1.0/ewelink/types.py +8 -0
- python_library_ewelink-0.1.0/example_control_devices.bat +11 -0
- python_library_ewelink-0.1.0/example_get_devices.bat +11 -0
- python_library_ewelink-0.1.0/examples/control_device/__main__.py +45 -0
- python_library_ewelink-0.1.0/examples/get_devices/__main__.py +35 -0
- python_library_ewelink-0.1.0/pyproject.toml +18 -0
- python_library_ewelink-0.1.0/update.bat +9 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Action 参数手册
|
|
2
|
+
|
|
3
|
+
## set_switch
|
|
4
|
+
|
|
5
|
+
控制单通道设备开关。
|
|
6
|
+
|
|
7
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
8
|
+
|------|------|------|------|
|
|
9
|
+
| device | str | 是 | 设备 ID |
|
|
10
|
+
| state | state | 是 | `"on"` / `"off"` |
|
|
11
|
+
|
|
12
|
+
## set_outlet
|
|
13
|
+
|
|
14
|
+
控制多通道设备的某一个通道。
|
|
15
|
+
|
|
16
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
17
|
+
|------|------|------|------|
|
|
18
|
+
| device | str | 是 | 设备 ID |
|
|
19
|
+
| outlet | int | 是 | 通道编号(从 0 开始) |
|
|
20
|
+
| state | state | 是 | `"on"` / `"off"` |
|
|
21
|
+
|
|
22
|
+
## set_outlets
|
|
23
|
+
|
|
24
|
+
同时控制多个通道。
|
|
25
|
+
|
|
26
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
27
|
+
|------|------|------|------|
|
|
28
|
+
| device | str | 是 | 设备 ID |
|
|
29
|
+
| switches | list | 是 | 通道列表,每项含 `outlet` (int) 和 `state` (state) |
|
|
30
|
+
|
|
31
|
+
## pulse_outlet
|
|
32
|
+
|
|
33
|
+
脉冲控制:开启指定通道 → 等待 → 自动关闭。
|
|
34
|
+
|
|
35
|
+
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
|
36
|
+
|------|------|------|--------|------|
|
|
37
|
+
| device | str | 是 | | 设备 ID |
|
|
38
|
+
| outlet | int | 是 | | 通道编号 |
|
|
39
|
+
| hold_seconds | float | 否 | 0.5 | 保持时长(秒),必须 > 0 |
|
|
40
|
+
|
|
41
|
+
## set_params
|
|
42
|
+
|
|
43
|
+
原始参数透传,直接发送任意 params 到设备。当以上 action 不能满足需求时使用。
|
|
44
|
+
|
|
45
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
46
|
+
|------|------|------|------|
|
|
47
|
+
| device | str | 是 | 设备 ID |
|
|
48
|
+
| params | dict | 是 | 发送给设备的原始参数 |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .actions import (
|
|
2
|
+
REGISTRY,
|
|
3
|
+
ActionBase,
|
|
4
|
+
State,
|
|
5
|
+
SwitchEntry,
|
|
6
|
+
register,
|
|
7
|
+
supported_actions,
|
|
8
|
+
validate_task,
|
|
9
|
+
validate_tasks,
|
|
10
|
+
)
|
|
11
|
+
from .client import EWeLinkClient
|
|
12
|
+
from .exceptions import EWeLinkAPIError, EWeLinkAuthError, EWeLinkError
|
|
13
|
+
from .regions import infer_country_code
|
|
14
|
+
from .sync import SyncEWeLinkClient
|
|
15
|
+
from .types import SwitchItem, SwitchState
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"EWeLinkClient",
|
|
19
|
+
"EWeLinkError",
|
|
20
|
+
"EWeLinkAuthError",
|
|
21
|
+
"EWeLinkAPIError",
|
|
22
|
+
"SyncEWeLinkClient",
|
|
23
|
+
"SwitchState",
|
|
24
|
+
"SwitchItem",
|
|
25
|
+
"infer_country_code",
|
|
26
|
+
"REGISTRY",
|
|
27
|
+
"ActionBase",
|
|
28
|
+
"State",
|
|
29
|
+
"SwitchEntry",
|
|
30
|
+
"register",
|
|
31
|
+
"supported_actions",
|
|
32
|
+
"validate_task",
|
|
33
|
+
"validate_tasks",
|
|
34
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pkgutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from ._base import ActionBase, State, SwitchEntry
|
|
11
|
+
from ._registry import REGISTRY, register
|
|
12
|
+
|
|
13
|
+
# ── 自动扫描:导入本目录下所有非下划线开头的 .py ──
|
|
14
|
+
_pkg_dir = Path(__file__).parent
|
|
15
|
+
for _info in pkgutil.iter_modules([str(_pkg_dir)]):
|
|
16
|
+
if not _info.name.startswith("_"):
|
|
17
|
+
importlib.import_module(f".{_info.name}", __package__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_task(data: dict[str, Any]) -> ActionBase:
|
|
21
|
+
"""校验单个任务配置,返回对应的 action 模型实例。"""
|
|
22
|
+
action = data.get("action")
|
|
23
|
+
if not action:
|
|
24
|
+
raise ValueError("缺少必填字段 'action'")
|
|
25
|
+
if action not in REGISTRY:
|
|
26
|
+
supported = ", ".join(sorted(REGISTRY))
|
|
27
|
+
raise ValueError(f"不支持的 action: {action!r},当前支持: {supported}")
|
|
28
|
+
|
|
29
|
+
payload = {k: v for k, v in data.items() if k != "action"}
|
|
30
|
+
try:
|
|
31
|
+
return REGISTRY[action].model_validate(payload)
|
|
32
|
+
except ValidationError as e:
|
|
33
|
+
raise ValueError(f"action={action!r} 校验失败:\n{e}") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_tasks(raw_list: list[dict[str, Any]]) -> list[ActionBase]:
|
|
37
|
+
"""批量校验,所有错误一次性报出。"""
|
|
38
|
+
results: list[ActionBase] = []
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
for i, raw in enumerate(raw_list, 1):
|
|
41
|
+
try:
|
|
42
|
+
results.append(validate_task(raw))
|
|
43
|
+
except ValueError as e:
|
|
44
|
+
errors.append(f"任务 #{i}: {e}")
|
|
45
|
+
if errors:
|
|
46
|
+
raise ValueError("配置校验失败:\n" + "\n".join(errors))
|
|
47
|
+
return results
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def supported_actions() -> dict[str, str]:
|
|
51
|
+
"""返回所有已注册的 action 及其描述。"""
|
|
52
|
+
return {
|
|
53
|
+
name: (cls.__doc__ or "").strip()
|
|
54
|
+
for name, cls in sorted(REGISTRY.items())
|
|
55
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import EWeLinkClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_state(v: Any) -> str:
|
|
12
|
+
"""YAML 会把 on/off 解析为 True/False,在此统一转回字符串。"""
|
|
13
|
+
if v is True:
|
|
14
|
+
return "on"
|
|
15
|
+
if v is False:
|
|
16
|
+
return "off"
|
|
17
|
+
if v in ("on", "off"):
|
|
18
|
+
return v
|
|
19
|
+
raise ValueError(f"无效值 {v!r},应为 'on' 或 'off'")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
State = Annotated[str, BeforeValidator(_normalize_state)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SwitchEntry(BaseModel):
|
|
26
|
+
outlet: int
|
|
27
|
+
state: State
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ActionBase(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
action_name: ClassVar[str] = ""
|
|
34
|
+
device: str
|
|
35
|
+
|
|
36
|
+
async def execute(self, client: EWeLinkClient) -> dict[str, Any] | None:
|
|
37
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ._base import ActionBase
|
|
7
|
+
|
|
8
|
+
REGISTRY: dict[str, type[ActionBase]] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(action: str):
|
|
12
|
+
"""装饰器:将 action 模型注册到 REGISTRY。"""
|
|
13
|
+
def decorator(cls: type[ActionBase]) -> type[ActionBase]:
|
|
14
|
+
if action in REGISTRY:
|
|
15
|
+
raise ValueError(f"Duplicate action: {action!r}")
|
|
16
|
+
cls.action_name = action
|
|
17
|
+
REGISTRY[action] = cls
|
|
18
|
+
return cls
|
|
19
|
+
return decorator
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from ._base import ActionBase
|
|
6
|
+
from ._registry import register
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register("pulse_outlet")
|
|
10
|
+
class PulseOutletAction(ActionBase):
|
|
11
|
+
"""脉冲控制(开→等待→关)"""
|
|
12
|
+
|
|
13
|
+
outlet: int
|
|
14
|
+
hold_seconds: float = Field(default=0.5, gt=0)
|
|
15
|
+
|
|
16
|
+
async def execute(self, client) -> dict[str, Any] | None:
|
|
17
|
+
await client.pulse_outlet(self.device, self.outlet, self.hold_seconds)
|
|
18
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ._base import ActionBase, State
|
|
4
|
+
from ._registry import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register("set_outlet")
|
|
8
|
+
class SetOutletAction(ActionBase):
|
|
9
|
+
"""控制多通道设备的某一个通道"""
|
|
10
|
+
|
|
11
|
+
outlet: int
|
|
12
|
+
state: State
|
|
13
|
+
|
|
14
|
+
async def execute(self, client) -> dict[str, Any] | None:
|
|
15
|
+
return await client.set_outlet(self.device, self.outlet, self.state)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ._base import ActionBase, SwitchEntry
|
|
4
|
+
from ._registry import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register("set_outlets")
|
|
8
|
+
class SetOutletsAction(ActionBase):
|
|
9
|
+
"""同时控制多个通道"""
|
|
10
|
+
|
|
11
|
+
switches: list[SwitchEntry]
|
|
12
|
+
|
|
13
|
+
async def execute(self, client) -> dict[str, Any] | None:
|
|
14
|
+
payload = [{"outlet": s.outlet, "switch": s.state} for s in self.switches]
|
|
15
|
+
return await client.set_outlets(self.device, payload)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ._base import ActionBase
|
|
4
|
+
from ._registry import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register("set_params")
|
|
8
|
+
class SetParamsAction(ActionBase):
|
|
9
|
+
"""原始参数透传(万能兜底)"""
|
|
10
|
+
|
|
11
|
+
params: dict[str, Any]
|
|
12
|
+
|
|
13
|
+
async def execute(self, client) -> dict[str, Any] | None:
|
|
14
|
+
return await client.set_device_params(self.device, self.params)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ._base import ActionBase, State
|
|
4
|
+
from ._registry import register
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@register("set_switch")
|
|
8
|
+
class SetSwitchAction(ActionBase):
|
|
9
|
+
"""控制单通道设备开关"""
|
|
10
|
+
|
|
11
|
+
state: State
|
|
12
|
+
|
|
13
|
+
async def execute(self, client) -> dict[str, Any] | None:
|
|
14
|
+
return await client.set_switch(self.device, self.state)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
|
|
5
|
+
APP_ID = "R8Oq3y0eSZSYdKccHlrQzT1ACCOUT9Gv"
|
|
6
|
+
_APP_SECRET = "1ve5Qk9GXfUhKAn1svnKwpAlxXkMarru"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sign_payload(msg: bytes) -> bytes:
|
|
10
|
+
return hmac.new(_APP_SECRET.encode(), msg, hashlib.sha256).digest()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_phone_number(username: str, country_code: str) -> str:
|
|
14
|
+
if username.startswith("+"):
|
|
15
|
+
return username
|
|
16
|
+
if username.startswith(country_code.lstrip("+")):
|
|
17
|
+
return "+" + username
|
|
18
|
+
return country_code + username
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
from regions import REGIONS
|
|
22
|
+
import base64
|
|
23
|
+
encoded = base64.b64encode(str(REGIONS).encode())
|
|
24
|
+
secret_map = "L8KDAMO6wpomxpYZwrHhu4AuEjQKBy8nwoMHNB7DmwoWwrvCsSYGw4wDAxs="
|
|
25
|
+
key = bytes(encoded[ord(ch)] for ch in base64.b64decode(secret_map).decode())
|
|
26
|
+
print(key.decode()) # ← 这就是 _APP_SECRET 的值
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from .auth import APP_ID, build_phone_number, sign_payload
|
|
9
|
+
from .exceptions import EWeLinkAPIError, EWeLinkAuthError
|
|
10
|
+
from .regions import API, REGIONS, infer_country_code
|
|
11
|
+
from .types import SwitchItem, SwitchState
|
|
12
|
+
|
|
13
|
+
_TIMEOUT = aiohttp.ClientTimeout(total=15)
|
|
14
|
+
_MAX_RETRIES = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EWeLinkClient:
|
|
18
|
+
def __init__(self, session: aiohttp.ClientSession | None = None):
|
|
19
|
+
self._external_session = session is not None
|
|
20
|
+
self.session = session
|
|
21
|
+
self.region: str | None = None
|
|
22
|
+
self.auth: dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
# ── lifecycle ──
|
|
25
|
+
|
|
26
|
+
async def __aenter__(self):
|
|
27
|
+
if self.session is None:
|
|
28
|
+
self.session = aiohttp.ClientSession()
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
32
|
+
if self.session and not self._external_session:
|
|
33
|
+
await self.session.close()
|
|
34
|
+
|
|
35
|
+
# ── auth ──
|
|
36
|
+
|
|
37
|
+
async def login(
|
|
38
|
+
self,
|
|
39
|
+
username: str,
|
|
40
|
+
password: str,
|
|
41
|
+
country_code: str | None = None,
|
|
42
|
+
region: str | None = None,
|
|
43
|
+
) -> dict[str, Any]:
|
|
44
|
+
if not self.session:
|
|
45
|
+
raise RuntimeError("Use 'async with EWeLinkClient()' or provide a session")
|
|
46
|
+
|
|
47
|
+
country_code = country_code or infer_country_code(username)
|
|
48
|
+
self.region = region or REGIONS.get(country_code, ("Unknown", "cn"))[1]
|
|
49
|
+
|
|
50
|
+
payload: dict[str, Any] = {"password": password, "countryCode": country_code}
|
|
51
|
+
if "@" in username:
|
|
52
|
+
payload["email"] = username
|
|
53
|
+
else:
|
|
54
|
+
payload["phoneNumber"] = build_phone_number(username, country_code)
|
|
55
|
+
|
|
56
|
+
data = json.dumps(payload, separators=(",", ":")).encode()
|
|
57
|
+
headers = {
|
|
58
|
+
"Authorization": "Sign " + base64.b64encode(sign_payload(data)).decode(),
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
"X-CK-Appid": APP_ID,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
resp = await self._request("POST", "/v2/user/login", data=data, headers=headers)
|
|
64
|
+
|
|
65
|
+
if resp.get("error") == 10004 and resp.get("data", {}).get("region"):
|
|
66
|
+
self.region = resp["data"]["region"]
|
|
67
|
+
resp = await self._request("POST", "/v2/user/login", data=data, headers=headers)
|
|
68
|
+
|
|
69
|
+
if resp.get("error") != 0:
|
|
70
|
+
raise EWeLinkAuthError(
|
|
71
|
+
f"Login failed: error={resp.get('error')} msg={resp.get('msg')}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
self.auth = resp["data"]
|
|
75
|
+
self.auth["appid"] = APP_ID
|
|
76
|
+
return self.auth
|
|
77
|
+
|
|
78
|
+
# ── devices: query ──
|
|
79
|
+
|
|
80
|
+
async def get_devices(self, family_id: str | None = None) -> list[dict[str, Any]]:
|
|
81
|
+
params: dict[str, Any] = {"num": 0}
|
|
82
|
+
if family_id:
|
|
83
|
+
params["familyid"] = family_id
|
|
84
|
+
|
|
85
|
+
resp = await self._api_get("/v2/device/thing", params=params)
|
|
86
|
+
return [
|
|
87
|
+
item["itemData"]
|
|
88
|
+
for item in resp["data"]["thingList"]
|
|
89
|
+
if "deviceid" in item.get("itemData", {})
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
async def get_device(self, device_id: str) -> dict[str, Any] | None:
|
|
93
|
+
resp = await self._api_post(
|
|
94
|
+
"/v2/device/thing",
|
|
95
|
+
json={"thingList": [{"itemType": 1, "id": device_id}]},
|
|
96
|
+
)
|
|
97
|
+
for item in resp["data"].get("thingList", []):
|
|
98
|
+
data = item.get("itemData", {})
|
|
99
|
+
if data.get("deviceid") == device_id:
|
|
100
|
+
return data
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# ── devices: control ──
|
|
104
|
+
|
|
105
|
+
async def set_device_params(self, device_id: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
return await self._api_post(
|
|
107
|
+
"/v2/device/thing/status",
|
|
108
|
+
json={"type": 1, "id": device_id, "params": params},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def set_switch(self, device_id: str, state: SwitchState) -> dict[str, Any]:
|
|
112
|
+
return await self.set_device_params(device_id, {"switch": state})
|
|
113
|
+
|
|
114
|
+
async def set_outlet(
|
|
115
|
+
self, device_id: str, outlet: int, state: SwitchState,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
return await self.set_device_params(
|
|
118
|
+
device_id, {"switches": [{"outlet": outlet, "switch": state}]},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def set_outlets(
|
|
122
|
+
self, device_id: str, switches: list[SwitchItem],
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
return await self.set_device_params(device_id, {"switches": switches})
|
|
125
|
+
|
|
126
|
+
async def pulse_outlet(
|
|
127
|
+
self, device_id: str, outlet: int, hold_seconds: float = 0.5,
|
|
128
|
+
) -> None:
|
|
129
|
+
if hold_seconds <= 0:
|
|
130
|
+
raise ValueError("hold_seconds must be > 0")
|
|
131
|
+
await self.set_outlet(device_id, outlet, "on")
|
|
132
|
+
try:
|
|
133
|
+
await asyncio.sleep(hold_seconds)
|
|
134
|
+
finally:
|
|
135
|
+
await self.set_outlet(device_id, outlet, "off")
|
|
136
|
+
|
|
137
|
+
# ── internal: HTTP ──
|
|
138
|
+
|
|
139
|
+
def _ensure_ready(self) -> None:
|
|
140
|
+
if not self.session:
|
|
141
|
+
raise RuntimeError("Use 'async with EWeLinkClient()' or provide a session")
|
|
142
|
+
if not self.auth or not self.region:
|
|
143
|
+
raise EWeLinkAuthError("Not logged in")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def _headers(self) -> dict[str, str]:
|
|
147
|
+
return {
|
|
148
|
+
"Authorization": "Bearer " + self.auth["at"],
|
|
149
|
+
"X-CK-Appid": APP_ID,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async def _api_get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
153
|
+
self._ensure_ready()
|
|
154
|
+
resp = await self._request("GET", path, headers=self._headers, **kwargs)
|
|
155
|
+
_check_response(resp)
|
|
156
|
+
return resp
|
|
157
|
+
|
|
158
|
+
async def _api_post(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
159
|
+
self._ensure_ready()
|
|
160
|
+
resp = await self._request("POST", path, headers=self._headers, **kwargs)
|
|
161
|
+
_check_response(resp)
|
|
162
|
+
return resp
|
|
163
|
+
|
|
164
|
+
async def _request(
|
|
165
|
+
self, method: str, path: str, *, retries: int = _MAX_RETRIES, **kwargs: Any,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
if not self.session or not self.region:
|
|
168
|
+
raise EWeLinkAuthError("Client not initialized")
|
|
169
|
+
|
|
170
|
+
url = API[self.region] + path
|
|
171
|
+
last_exc: Exception | None = None
|
|
172
|
+
|
|
173
|
+
for attempt in range(retries):
|
|
174
|
+
try:
|
|
175
|
+
async with self.session.request(
|
|
176
|
+
method, url, timeout=_TIMEOUT, **kwargs,
|
|
177
|
+
) as response:
|
|
178
|
+
text = await response.text()
|
|
179
|
+
try:
|
|
180
|
+
return json.loads(text)
|
|
181
|
+
except json.JSONDecodeError as exc:
|
|
182
|
+
raise EWeLinkAPIError(
|
|
183
|
+
-1, f"Non-JSON response (HTTP {response.status})",
|
|
184
|
+
) from exc
|
|
185
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
186
|
+
last_exc = exc
|
|
187
|
+
if attempt < retries - 1:
|
|
188
|
+
await asyncio.sleep(0.5 * 2**attempt)
|
|
189
|
+
|
|
190
|
+
raise EWeLinkAPIError(-1, f"Request failed after {retries} retries") from last_exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_response(resp: dict[str, Any]) -> None:
|
|
194
|
+
error = resp.get("error", 0)
|
|
195
|
+
if error != 0:
|
|
196
|
+
raise EWeLinkAPIError(error, resp.get("msg"), resp.get("data"))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EWeLinkError(Exception):
|
|
5
|
+
"""Base exception for all eWeLink errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EWeLinkAuthError(EWeLinkError):
|
|
9
|
+
"""Authentication or authorization failure."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EWeLinkAPIError(EWeLinkError):
|
|
13
|
+
"""API returned a non-zero error code."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, error: int, msg: str | None = None, data: dict[str, Any] | None = None):
|
|
16
|
+
self.error = error
|
|
17
|
+
self.msg = msg
|
|
18
|
+
self.data = data or {}
|
|
19
|
+
super().__init__(f"API error {error}: {msg}")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
API = {
|
|
2
|
+
"cn": "https://cn-apia.coolkit.cn",
|
|
3
|
+
"as": "https://as-apia.coolkit.cc",
|
|
4
|
+
"us": "https://us-apia.coolkit.cc",
|
|
5
|
+
"eu": "https://eu-apia.coolkit.cc",
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
REGIONS = {
|
|
9
|
+
"+93": ("Afghanistan", "as"),
|
|
10
|
+
"+355": ("Albania", "eu"),
|
|
11
|
+
"+213": ("Algeria", "eu"),
|
|
12
|
+
"+376": ("Andorra", "eu"),
|
|
13
|
+
"+244": ("Angola", "eu"),
|
|
14
|
+
"+1264": ("Anguilla", "us"),
|
|
15
|
+
"+1268": ("Antigua and Barbuda", "as"),
|
|
16
|
+
"+54": ("Argentina", "us"),
|
|
17
|
+
"+374": ("Armenia", "as"),
|
|
18
|
+
"+297": ("Aruba", "eu"),
|
|
19
|
+
"+247": ("Ascension", "eu"),
|
|
20
|
+
"+61": ("Australia", "us"),
|
|
21
|
+
"+43": ("Austria", "eu"),
|
|
22
|
+
"+994": ("Azerbaijan", "as"),
|
|
23
|
+
"+1242": ("Bahamas", "us"),
|
|
24
|
+
"+973": ("Bahrain", "as"),
|
|
25
|
+
"+880": ("Bangladesh", "as"),
|
|
26
|
+
"+1246": ("Barbados", "us"),
|
|
27
|
+
"+375": ("Belarus", "eu"),
|
|
28
|
+
"+32": ("Belgium", "eu"),
|
|
29
|
+
"+501": ("Belize", "us"),
|
|
30
|
+
"+229": ("Benin", "eu"),
|
|
31
|
+
"+1441": ("Bermuda", "as"),
|
|
32
|
+
"+591": ("Bolivia", "us"),
|
|
33
|
+
"+387": ("Bosnia and Herzegovina", "eu"),
|
|
34
|
+
"+267": ("Botswana", "eu"),
|
|
35
|
+
"+55": ("Brazil", "us"),
|
|
36
|
+
"+673": ("Brunei", "as"),
|
|
37
|
+
"+359": ("Bulgaria", "eu"),
|
|
38
|
+
"+226": ("Burkina Faso", "eu"),
|
|
39
|
+
"+257": ("Burundi", "eu"),
|
|
40
|
+
"+855": ("Cambodia", "as"),
|
|
41
|
+
"+237": ("Cameroon", "eu"),
|
|
42
|
+
"+238": ("Cape Verde Republic", "eu"),
|
|
43
|
+
"+1345": ("Cayman Islands", "as"),
|
|
44
|
+
"+236": ("Central African Republic", "eu"),
|
|
45
|
+
"+235": ("Chad", "eu"),
|
|
46
|
+
"+56": ("Chile", "us"),
|
|
47
|
+
"+86": ("China", "cn"),
|
|
48
|
+
"+57": ("Colombia", "us"),
|
|
49
|
+
"+682": ("Cook Islands", "us"),
|
|
50
|
+
"+506": ("Costa Rica", "us"),
|
|
51
|
+
"+385": ("Croatia", "eu"),
|
|
52
|
+
"+53": ("Cuba", "us"),
|
|
53
|
+
"+357": ("Cyprus", "eu"),
|
|
54
|
+
"+420": ("Czech", "eu"),
|
|
55
|
+
"+243": ("Democratic Republic of Congo", "eu"),
|
|
56
|
+
"+45": ("Denmark", "eu"),
|
|
57
|
+
"+253": ("Djibouti", "eu"),
|
|
58
|
+
"+1767": ("Dominica", "as"),
|
|
59
|
+
"+1809": ("Dominican Republic", "us"),
|
|
60
|
+
"+670": ("East Timor", "as"),
|
|
61
|
+
"+684": ("Eastern Samoa (US)", "us"),
|
|
62
|
+
"+593": ("Ecuador", "us"),
|
|
63
|
+
"+20": ("Egypt", "eu"),
|
|
64
|
+
"+503": ("El Salvador", "us"),
|
|
65
|
+
"+372": ("Estonia", "eu"),
|
|
66
|
+
"+251": ("Ethiopia", "eu"),
|
|
67
|
+
"+298": ("Faroe Islands", "eu"),
|
|
68
|
+
"+679": ("Fiji", "us"),
|
|
69
|
+
"+358": ("Finland", "eu"),
|
|
70
|
+
"+33": ("France", "eu"),
|
|
71
|
+
"+594": ("French Guiana", "us"),
|
|
72
|
+
"+689": ("French Polynesia", "as"),
|
|
73
|
+
"+241": ("Gabon", "eu"),
|
|
74
|
+
"+220": ("Gambia", "eu"),
|
|
75
|
+
"+995": ("Georgia", "as"),
|
|
76
|
+
"+49": ("Germany", "eu"),
|
|
77
|
+
"+233": ("Ghana", "eu"),
|
|
78
|
+
"+350": ("Gibraltar", "eu"),
|
|
79
|
+
"+30": ("Greece", "eu"),
|
|
80
|
+
"+299": ("Greenland", "us"),
|
|
81
|
+
"+1473": ("Grenada", "as"),
|
|
82
|
+
"+590": ("Guadeloupe", "us"),
|
|
83
|
+
"+1671": ("Guam", "us"),
|
|
84
|
+
"+502": ("Guatemala", "us"),
|
|
85
|
+
"+240": ("Guinea", "eu"),
|
|
86
|
+
"+224": ("Guinea", "eu"),
|
|
87
|
+
"+592": ("Guyana", "us"),
|
|
88
|
+
"+509": ("Haiti", "us"),
|
|
89
|
+
"+504": ("Honduras", "us"),
|
|
90
|
+
"+852": ("Hong Kong, China", "as"),
|
|
91
|
+
"+36": ("Hungary", "eu"),
|
|
92
|
+
"+354": ("Iceland", "eu"),
|
|
93
|
+
"+91": ("India", "as"),
|
|
94
|
+
"+62": ("Indonesia", "as"),
|
|
95
|
+
"+98": ("Iran", "as"),
|
|
96
|
+
"+353": ("Ireland", "eu"),
|
|
97
|
+
"+269": ("Islamic Federal Republic of Comoros", "eu"),
|
|
98
|
+
"+972": ("Israel", "as"),
|
|
99
|
+
"+39": ("Italian", "eu"),
|
|
100
|
+
"+225": ("Ivory Coast", "eu"),
|
|
101
|
+
"+1876": ("Jamaica", "us"),
|
|
102
|
+
"+81": ("Japan", "as"),
|
|
103
|
+
"+962": ("Jordan", "as"),
|
|
104
|
+
"+254": ("Kenya", "eu"),
|
|
105
|
+
"+975": ("Kingdom of Bhutan", "as"),
|
|
106
|
+
"+383": ("Kosovo", "eu"),
|
|
107
|
+
"+965": ("Kuwait", "as"),
|
|
108
|
+
"+996": ("Kyrgyzstan", "as"),
|
|
109
|
+
"+856": ("Laos", "as"),
|
|
110
|
+
"+371": ("Latvia", "eu"),
|
|
111
|
+
"+961": ("Lebanon", "as"),
|
|
112
|
+
"+266": ("Lesotho", "eu"),
|
|
113
|
+
"+231": ("Liberia", "eu"),
|
|
114
|
+
"+218": ("Libya", "eu"),
|
|
115
|
+
"+423": ("Liechtenstein", "eu"),
|
|
116
|
+
"+370": ("Lithuania", "eu"),
|
|
117
|
+
"+352": ("Luxembourg", "eu"),
|
|
118
|
+
"+853": ("Macau, China", "as"),
|
|
119
|
+
"+261": ("Madagascar", "eu"),
|
|
120
|
+
"+265": ("Malawi", "eu"),
|
|
121
|
+
"+60": ("Malaysia", "as"),
|
|
122
|
+
"+960": ("Maldives", "as"),
|
|
123
|
+
"+223": ("Mali", "eu"),
|
|
124
|
+
"+356": ("Malta", "eu"),
|
|
125
|
+
"+596": ("Martinique", "us"),
|
|
126
|
+
"+222": ("Mauritania", "eu"),
|
|
127
|
+
"+230": ("Mauritius", "eu"),
|
|
128
|
+
"+52": ("Mexico", "us"),
|
|
129
|
+
"+373": ("Moldova", "eu"),
|
|
130
|
+
"+377": ("Monaco", "eu"),
|
|
131
|
+
"+976": ("Mongolia", "as"),
|
|
132
|
+
"+382": ("Montenegro", "as"),
|
|
133
|
+
"+1664": ("Montserrat", "as"),
|
|
134
|
+
"+212": ("Morocco", "eu"),
|
|
135
|
+
"+258": ("Mozambique", "eu"),
|
|
136
|
+
"+95": ("Myanmar", "as"),
|
|
137
|
+
"+264": ("Namibia", "eu"),
|
|
138
|
+
"+977": ("Nepal", "as"),
|
|
139
|
+
"+31": ("Netherlands", "eu"),
|
|
140
|
+
"+599": ("Netherlands Antilles", "as"),
|
|
141
|
+
"+687": ("New Caledonia", "as"),
|
|
142
|
+
"+64": ("New Zealand", "us"),
|
|
143
|
+
"+505": ("Nicaragua", "us"),
|
|
144
|
+
"+227": ("Niger", "eu"),
|
|
145
|
+
"+234": ("Nigeria", "eu"),
|
|
146
|
+
"+47": ("Norway", "eu"),
|
|
147
|
+
"+968": ("Oman", "as"),
|
|
148
|
+
"+92": ("Pakistan", "as"),
|
|
149
|
+
"+970": ("Palestine", "as"),
|
|
150
|
+
"+507": ("Panama", "us"),
|
|
151
|
+
"+675": ("Papua New Guinea", "as"),
|
|
152
|
+
"+595": ("Paraguay", "us"),
|
|
153
|
+
"+51": ("Peru", "us"),
|
|
154
|
+
"+63": ("Philippines", "as"),
|
|
155
|
+
"+48": ("Poland", "eu"),
|
|
156
|
+
"+351": ("Portugal", "eu"),
|
|
157
|
+
"+974": ("Qatar", "as"),
|
|
158
|
+
"+242": ("Republic of Congo", "eu"),
|
|
159
|
+
"+964": ("Republic of Iraq", "as"),
|
|
160
|
+
"+389": ("Republic of Macedonia", "eu"),
|
|
161
|
+
"+262": ("Reunion", "eu"),
|
|
162
|
+
"+40": ("Romania", "eu"),
|
|
163
|
+
"+7": ("Russia", "eu"),
|
|
164
|
+
"+250": ("Rwanda", "eu"),
|
|
165
|
+
"+1869": ("Saint Kitts and Nevis", "as"),
|
|
166
|
+
"+1758": ("Saint Lucia", "us"),
|
|
167
|
+
"+1784": ("Saint Vincent", "as"),
|
|
168
|
+
"+378": ("San Marino", "eu"),
|
|
169
|
+
"+239": ("Sao Tome and Principe", "eu"),
|
|
170
|
+
"+966": ("Saudi Arabia", "as"),
|
|
171
|
+
"+221": ("Senegal", "eu"),
|
|
172
|
+
"+381": ("Serbia", "eu"),
|
|
173
|
+
"+248": ("Seychelles", "eu"),
|
|
174
|
+
"+232": ("Sierra Leone", "eu"),
|
|
175
|
+
"+65": ("Singapore", "as"),
|
|
176
|
+
"+421": ("Slovakia", "eu"),
|
|
177
|
+
"+386": ("Slovenia", "eu"),
|
|
178
|
+
"+27": ("South Africa", "eu"),
|
|
179
|
+
"+82": ("South Korea", "as"),
|
|
180
|
+
"+34": ("Spain", "eu"),
|
|
181
|
+
"+94": ("Sri Lanka", "as"),
|
|
182
|
+
"+249": ("Sultan", "eu"),
|
|
183
|
+
"+597": ("Suriname", "us"),
|
|
184
|
+
"+268": ("Swaziland", "eu"),
|
|
185
|
+
"+46": ("Sweden", "eu"),
|
|
186
|
+
"+41": ("Switzerland", "eu"),
|
|
187
|
+
"+963": ("Syria", "as"),
|
|
188
|
+
"+886": ("Taiwan, China", "as"),
|
|
189
|
+
"+992": ("Tajikistan", "as"),
|
|
190
|
+
"+255": ("Tanzania", "eu"),
|
|
191
|
+
"+66": ("Thailand", "as"),
|
|
192
|
+
"+228": ("Togo", "eu"),
|
|
193
|
+
"+676": ("Tonga", "us"),
|
|
194
|
+
"+1868": ("Trinidad and Tobago", "us"),
|
|
195
|
+
"+216": ("Tunisia", "eu"),
|
|
196
|
+
"+90": ("Turkey", "as"),
|
|
197
|
+
"+993": ("Turkmenistan", "as"),
|
|
198
|
+
"+1649": ("Turks and Caicos", "as"),
|
|
199
|
+
"+44": ("UK", "eu"),
|
|
200
|
+
"+256": ("Uganda", "eu"),
|
|
201
|
+
"+380": ("Ukraine", "eu"),
|
|
202
|
+
"+971": ("United Arab Emirates", "as"),
|
|
203
|
+
"+1": ("United States", "us"),
|
|
204
|
+
"+598": ("Uruguay", "us"),
|
|
205
|
+
"+998": ("Uzbekistan", "as"),
|
|
206
|
+
"+678": ("Vanuatu", "us"),
|
|
207
|
+
"+58": ("Venezuela", "us"),
|
|
208
|
+
"+84": ("Vietnam", "as"),
|
|
209
|
+
"+685": ("Western Samoa", "us"),
|
|
210
|
+
"+1340": ("Wilk Islands", "as"),
|
|
211
|
+
"+967": ("Yemen", "as"),
|
|
212
|
+
"+260": ("Zambia", "eu"),
|
|
213
|
+
"+263": ("Zimbabwe", "eu"),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def infer_country_code(username: str, fallback: str = "+86") -> str:
|
|
218
|
+
if not username.startswith("+"):
|
|
219
|
+
return fallback
|
|
220
|
+
matches = [code for code in REGIONS if username.startswith(code)]
|
|
221
|
+
return max(matches, key=len) if matches else fallback
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .client import EWeLinkClient
|
|
5
|
+
from .types import SwitchItem, SwitchState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyncEWeLinkClient:
|
|
9
|
+
"""Convenience wrapper that reuses a single session and login."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
username: str,
|
|
14
|
+
password: str,
|
|
15
|
+
country_code: str | None = None,
|
|
16
|
+
region: str | None = None,
|
|
17
|
+
):
|
|
18
|
+
self._username = username
|
|
19
|
+
self._password = password
|
|
20
|
+
self._country_code = country_code
|
|
21
|
+
self._region = region
|
|
22
|
+
self._loop = asyncio.new_event_loop()
|
|
23
|
+
self._client = EWeLinkClient()
|
|
24
|
+
self._loop.run_until_complete(self._client.__aenter__())
|
|
25
|
+
self._logged_in = False
|
|
26
|
+
|
|
27
|
+
def _ensure_login(self) -> None:
|
|
28
|
+
if not self._logged_in:
|
|
29
|
+
self._loop.run_until_complete(
|
|
30
|
+
self._client.login(
|
|
31
|
+
self._username,
|
|
32
|
+
self._password,
|
|
33
|
+
self._country_code,
|
|
34
|
+
self._region,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
self._logged_in = True
|
|
38
|
+
|
|
39
|
+
# ── devices: query ──
|
|
40
|
+
|
|
41
|
+
def get_devices(self, family_id: str | None = None) -> list[dict[str, Any]]:
|
|
42
|
+
self._ensure_login()
|
|
43
|
+
return self._loop.run_until_complete(self._client.get_devices(family_id))
|
|
44
|
+
|
|
45
|
+
def get_device(self, device_id: str) -> dict[str, Any] | None:
|
|
46
|
+
self._ensure_login()
|
|
47
|
+
return self._loop.run_until_complete(self._client.get_device(device_id))
|
|
48
|
+
|
|
49
|
+
# ── devices: control ──
|
|
50
|
+
|
|
51
|
+
def set_switch(self, device_id: str, state: SwitchState) -> dict[str, Any]:
|
|
52
|
+
self._ensure_login()
|
|
53
|
+
return self._loop.run_until_complete(self._client.set_switch(device_id, state))
|
|
54
|
+
|
|
55
|
+
def set_outlet(self, device_id: str, outlet: int, state: SwitchState) -> dict[str, Any]:
|
|
56
|
+
self._ensure_login()
|
|
57
|
+
return self._loop.run_until_complete(self._client.set_outlet(device_id, outlet, state))
|
|
58
|
+
|
|
59
|
+
def set_outlets(self, device_id: str, switches: list[SwitchItem]) -> dict[str, Any]:
|
|
60
|
+
self._ensure_login()
|
|
61
|
+
return self._loop.run_until_complete(self._client.set_outlets(device_id, switches))
|
|
62
|
+
|
|
63
|
+
def pulse_outlet(self, device_id: str, outlet: int, hold_seconds: float = 0.5) -> None:
|
|
64
|
+
self._ensure_login()
|
|
65
|
+
self._loop.run_until_complete(
|
|
66
|
+
self._client.pulse_outlet(device_id, outlet, hold_seconds)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ── lifecycle ──
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._loop.run_until_complete(self._client.__aexit__(None, None, None))
|
|
73
|
+
self._loop.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self):
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(self, *args):
|
|
79
|
+
self.close()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
from ewelink import EWeLinkClient, infer_country_code, validate_tasks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def main() -> None:
|
|
13
|
+
load_dotenv(".env")
|
|
14
|
+
|
|
15
|
+
tasks_file = (
|
|
16
|
+
Path(sys.argv[1])
|
|
17
|
+
if len(sys.argv) > 1
|
|
18
|
+
else Path(__file__).with_name("config.yaml")
|
|
19
|
+
)
|
|
20
|
+
if not tasks_file.exists():
|
|
21
|
+
print(f"Tasks file not found: {tasks_file}")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
config = yaml.safe_load(tasks_file.read_text(encoding="utf-8"))
|
|
25
|
+
tasks = validate_tasks(config.get("tasks", []))
|
|
26
|
+
|
|
27
|
+
async with EWeLinkClient() as client:
|
|
28
|
+
await client.login(
|
|
29
|
+
username=os.environ["EWELINK_EMAIL"],
|
|
30
|
+
password=os.environ["EWELINK_PASSWORD"],
|
|
31
|
+
country_code=infer_country_code(os.environ["EWELINK_EMAIL"]),
|
|
32
|
+
region=os.environ.get("EWELINK_REGION"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for i, task in enumerate(tasks, 1):
|
|
36
|
+
print(f"[{i}/{len(tasks)}] device={task.device} action={task.action_name} ...", end=" ")
|
|
37
|
+
try:
|
|
38
|
+
await task.execute(client)
|
|
39
|
+
print("OK")
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
print(f"FAILED: {exc}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
from ewelink import EWeLinkClient, infer_country_code
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
load_dotenv(".env")
|
|
12
|
+
|
|
13
|
+
email = os.environ["EWELINK_EMAIL"]
|
|
14
|
+
password = os.environ["EWELINK_PASSWORD"]
|
|
15
|
+
region = os.environ.get("EWELINK_REGION")
|
|
16
|
+
|
|
17
|
+
async with EWeLinkClient() as client:
|
|
18
|
+
await client.login(
|
|
19
|
+
username=email,
|
|
20
|
+
password=password,
|
|
21
|
+
country_code=infer_country_code(email),
|
|
22
|
+
region=region,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
devices = await client.get_devices()
|
|
26
|
+
for d in devices:
|
|
27
|
+
name = d.get("name")
|
|
28
|
+
device_id = d.get("deviceid")
|
|
29
|
+
|
|
30
|
+
device = await client.get_device(device_id)
|
|
31
|
+
print(f"[{name}-{device_id}]: \n{device}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-library-ewelink"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"aiohttp",
|
|
11
|
+
"pydantic>=2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["ewelink"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.metadata]
|
|
18
|
+
allow-direct-references = true
|