python-library-ewelink 0.1.0__py3-none-any.whl

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.
ewelink/__init__.py ADDED
@@ -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)
ewelink/auth.py ADDED
@@ -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 的值
ewelink/client.py ADDED
@@ -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"))
ewelink/exceptions.py ADDED
@@ -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}")
ewelink/regions.py ADDED
@@ -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
ewelink/sync.py ADDED
@@ -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()
ewelink/types.py ADDED
@@ -0,0 +1,8 @@
1
+ from typing import Literal, TypedDict
2
+
3
+ SwitchState = Literal["on", "off"]
4
+
5
+
6
+ class SwitchItem(TypedDict):
7
+ outlet: int
8
+ switch: SwitchState
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-library-ewelink
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.10
5
+ Requires-Dist: aiohttp
6
+ Requires-Dist: pydantic>=2
@@ -0,0 +1,18 @@
1
+ ewelink/__init__.py,sha256=E6Kk0t7Gvw8DzmsiMI_s3CXi6i74V9o8RA5CE8eoevY,757
2
+ ewelink/auth.py,sha256=dlfTRIawgDerZiWaJkHFZEO0Vsc84ZZ4ulCR2wNUcs4,869
3
+ ewelink/client.py,sha256=xLs0FbjssDVWH3q8UvG_xwEdEdik0X616YYFx9tde_A,7118
4
+ ewelink/exceptions.py,sha256=mJFyJ62o4MxzJSwaOuwKej_za-aK-OB9oLNGsUVkUUA,530
5
+ ewelink/regions.py,sha256=q6l68nGGTmaVzCw0yZLvg-STcLyrTlbYSl8-lb_I90s,7389
6
+ ewelink/sync.py,sha256=dwsApMT-Xq9ykwL1LLnoCa_mqLUki0vm3x9GvCmSKv0,2718
7
+ ewelink/types.py,sha256=e6VIH5dNql97RNGsQ76YynctyOrmPPXE56oDy7zCglw,151
8
+ ewelink/actions/__init__.py,sha256=QfSuX-2g67nXJopzAmUuUsMVatdZit5brniSq9SF9gY,1906
9
+ ewelink/actions/_base.py,sha256=WtkW3mLAGTr0wxuwyjZ1PlrmxD-y95vxqcxz9ZOf4JA,925
10
+ ewelink/actions/_registry.py,sha256=vdZfVFIZNgtLrIBhsEVSl4YNwwomnMIcaXBou_RcB3I,532
11
+ ewelink/actions/pulse_outlet.py,sha256=vUqec9rgZKwANhOJ0udtt-M8GZ6qznfbb_yMKefxglc,472
12
+ ewelink/actions/set_outlet.py,sha256=nigKyQbPOIAjLlzwkKRBm0DV7wTaKzTaiZ7Wol8v7dk,391
13
+ ewelink/actions/set_outlets.py,sha256=PXwUWYfAx6Nqi1-jnaDS4-q2fe_cai51P49v6oCpQU0,451
14
+ ewelink/actions/set_params.py,sha256=AUllws7o9p2wcOm_YTuOJmhrc0cVJ9oD4d2D2qZBiUw,369
15
+ ewelink/actions/set_switch.py,sha256=FdLGaC4rDtO39g94SDYe16ic8McGqFxNlZ6hSv4jS5k,349
16
+ python_library_ewelink-0.1.0.dist-info/METADATA,sha256=vHJJX1Df9tyetn7WnkVC1Di-uDA0LyrWx5EU686GezA,140
17
+ python_library_ewelink-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
18
+ python_library_ewelink-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any