python-aidot 0.3.54b3__tar.gz → 0.3.55__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.
Files changed (34) hide show
  1. {python_aidot-0.3.54b3/python_aidot.egg-info → python_aidot-0.3.55}/PKG-INFO +1 -1
  2. python_aidot-0.3.55/aidot/api/__init__.py +0 -0
  3. python_aidot-0.3.55/aidot/api/cloud_api.py +151 -0
  4. python_aidot-0.3.55/aidot/client.py +209 -0
  5. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/aidot/const.py +14 -0
  6. python_aidot-0.3.55/aidot/device_client.py +478 -0
  7. python_aidot-0.3.55/aidot/discover.py +138 -0
  8. python_aidot-0.3.55/aidot/models/__init__.py +1 -0
  9. python_aidot-0.3.55/aidot/models/auth_model.py +170 -0
  10. python_aidot-0.3.55/aidot/models/base_model.py +22 -0
  11. python_aidot-0.3.55/aidot/models/device_client_model.py +209 -0
  12. python_aidot-0.3.55/aidot/models/device_model.py +170 -0
  13. python_aidot-0.3.55/aidot/models/discover_model.py +103 -0
  14. python_aidot-0.3.55/aidot/utils/__init__.py +12 -0
  15. python_aidot-0.3.55/aidot/utils/async_timer.py +142 -0
  16. python_aidot-0.3.55/aidot/utils/crypto.py +91 -0
  17. {python_aidot-0.3.54b3 → python_aidot-0.3.55/python_aidot.egg-info}/PKG-INFO +1 -1
  18. python_aidot-0.3.55/python_aidot.egg-info/SOURCES.txt +27 -0
  19. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/setup.py +2 -2
  20. python_aidot-0.3.55/tests/test_cloud_api.py +143 -0
  21. python_aidot-0.3.54b3/aidot/aes_utils.py +0 -46
  22. python_aidot-0.3.54b3/aidot/client.py +0 -314
  23. python_aidot-0.3.54b3/aidot/device_client.py +0 -415
  24. python_aidot-0.3.54b3/aidot/discover.py +0 -133
  25. python_aidot-0.3.54b3/aidot/login_const.py +0 -16
  26. python_aidot-0.3.54b3/python_aidot.egg-info/SOURCES.txt +0 -17
  27. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/LICENSE +0 -0
  28. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/README.md +0 -0
  29. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/aidot/__init__.py +0 -0
  30. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/aidot/exceptions.py +0 -0
  31. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/python_aidot.egg-info/dependency_links.txt +0 -0
  32. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/python_aidot.egg-info/requires.txt +0 -0
  33. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/python_aidot.egg-info/top_level.txt +0 -0
  34. {python_aidot-0.3.54b3 → python_aidot-0.3.55}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-aidot
3
- Version: 0.3.54b3
3
+ Version: 0.3.55
4
4
  Summary: aidot control wifi lights
5
5
  Home-page: https://github.com/Aidot-Development-Team/python-aidot
6
6
  Author: aidotdev2024
File without changes
@@ -0,0 +1,151 @@
1
+ """Cloud API for AiDot HTTP requests."""
2
+
3
+ from collections.abc import Callable
4
+ import logging
5
+ from typing import Any
6
+
7
+ import aiohttp
8
+ from aiohttp import ClientSession
9
+
10
+ from ..const import (
11
+ API_URL_TEMPLATE,
12
+ CONF_ACCESS_TOKEN,
13
+ CONF_CODE,
14
+ CONF_REFRESH_TOKEN,
15
+ ServerErrorCode,
16
+ )
17
+ from ..exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
18
+ from ..models.auth_model import RequestHeaders, UserInformation
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ class CloudApi:
24
+ """Cloud API client scoped to one AiDot account."""
25
+
26
+ def __init__(
27
+ self,
28
+ session: ClientSession,
29
+ user_info: UserInformation,
30
+ auth_failed_callback: Callable[[], None] | None = None,
31
+ token_refreshed_callback: Callable[[], None] | None = None,
32
+ ) -> None:
33
+ """Initialize an account-scoped Cloud API client."""
34
+ self._session = session
35
+ self._user_info = user_info
36
+ self._base_url = API_URL_TEMPLATE.format(region=user_info.region)
37
+ self._auth_failed_callback = auth_failed_callback
38
+ self._token_refreshed_callback = token_refreshed_callback
39
+
40
+ def _get_headers(self) -> dict[str, str]:
41
+ """Build request headers with access token."""
42
+ return RequestHeaders.create(self._user_info.accessToken)
43
+
44
+ async def login(self, data: dict[str, Any]) -> dict[str, Any]:
45
+ """Login and return tokens."""
46
+ url = f"{self._base_url}/users/loginWithFreeVerification"
47
+ headers = self._get_headers()
48
+
49
+ response_data = {}
50
+ try:
51
+ _LOGGER.info("POST %s body=%s", url, data)
52
+ response = await self._session.post(url, headers=headers, json=data)
53
+ response_data = await response.json()
54
+ response.raise_for_status()
55
+ _LOGGER.info("POST %s → %s resp=%s", url, response.status, response_data)
56
+ return response_data
57
+ except aiohttp.ClientError as e:
58
+ _LOGGER.error(f"login failed: {e} resp={response_data}")
59
+ if response_data.get(CONF_CODE) == ServerErrorCode.USER_PWD_INCORRECT:
60
+ raise AidotUserOrPassIncorrect
61
+ raise Exception
62
+
63
+ async def refresh_token(self) -> dict[str, Any]:
64
+ """Refresh access token."""
65
+ url = f"{self._base_url}/users/refreshToken"
66
+ headers = self._get_headers()
67
+ data = {
68
+ CONF_REFRESH_TOKEN: self._user_info.refreshToken,
69
+ }
70
+
71
+ response_data = {}
72
+ try:
73
+ _LOGGER.info("POST %s body=%s", url, data)
74
+ response = await self._session.post(url, headers=headers, json=data)
75
+ response_data = await response.json()
76
+ response.raise_for_status()
77
+
78
+ # Update tokens and notify
79
+ self._user_info.accessToken = response_data.get(CONF_ACCESS_TOKEN, "")
80
+ if response_data.get(CONF_REFRESH_TOKEN) is not None:
81
+ self._user_info.refreshToken = response_data[CONF_REFRESH_TOKEN]
82
+
83
+ # Notify callback
84
+ if self._token_refreshed_callback:
85
+ self._token_refreshed_callback()
86
+
87
+ _LOGGER.info("Token refreshed: %s", response_data)
88
+ return response_data
89
+ except aiohttp.ClientError as e:
90
+ _LOGGER.error(f"refresh_token failed: {e} resp={response_data}")
91
+ code = response_data.get(CONF_CODE)
92
+ if code == ServerErrorCode.LOGIN_INVALID or code in (21027, 21041):
93
+ raise AidotAuthFailed
94
+ return None
95
+
96
+ async def get(self, params: str) -> dict[str, Any]:
97
+ """GET request with auto token refresh on 401."""
98
+ url = f"{self._base_url}{params}"
99
+ headers = self._get_headers()
100
+
101
+ response_data = {}
102
+ try:
103
+ _LOGGER.info("GET %s", url)
104
+ response = await self._session.get(url, headers=headers)
105
+ response_data = await response.json()
106
+ response.raise_for_status()
107
+ _LOGGER.info("GET %s → %s", url, response.status)
108
+ return response_data
109
+ except aiohttp.ClientError as e:
110
+ _LOGGER.error(f"GET {params} failed: {e} resp={response_data}")
111
+ code = response_data.get(CONF_CODE)
112
+ if code == ServerErrorCode.TOKEN_EXPIRED:
113
+ try:
114
+ refresh_data = await self.refresh_token()
115
+ if refresh_data:
116
+ _LOGGER.info("Retrying GET %s", url)
117
+ headers = self._get_headers()
118
+ response = await self._session.get(url, headers=headers)
119
+ response_data = await response.json()
120
+ response.raise_for_status()
121
+ _LOGGER.info("GET %s → %s retry=ok", url, response.status)
122
+ return response_data
123
+ except AidotAuthFailed:
124
+ if self._auth_failed_callback:
125
+ self._auth_failed_callback()
126
+ raise
127
+ except Exception as refresh_error:
128
+ _LOGGER.error(f"Token refresh failed: {refresh_error}")
129
+ if self._auth_failed_callback:
130
+ self._auth_failed_callback()
131
+ raise AidotAuthFailed
132
+ elif code in (ServerErrorCode.LOGIN_INVALID, 21027, 21041):
133
+ if self._auth_failed_callback:
134
+ self._auth_failed_callback()
135
+ raise AidotAuthFailed
136
+ raise
137
+
138
+ async def get_products(self, product_ids: str) -> list[dict[str, Any]]:
139
+ """Get products by comma-separated IDs."""
140
+ params = f"/products/{product_ids}"
141
+ return await self.get(params)
142
+
143
+ async def get_devices(self, house_id: str) -> list[dict[str, Any]]:
144
+ """Get devices in a house."""
145
+ params = f"/devices?houseId={house_id}"
146
+ return await self.get(params)
147
+
148
+ async def get_houses(self) -> list[dict[str, Any]]:
149
+ """Get all houses for the user."""
150
+ params = "/houses"
151
+ return await self.get(params)
@@ -0,0 +1,209 @@
1
+ """The aidot integration."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from aiohttp import ClientSession
6
+ from typing import Any, Optional
7
+ import uuid
8
+ from pathlib import Path
9
+ import hashlib
10
+
11
+ from .utils import rsa_encrypt
12
+ from .device_client import DeviceClient
13
+ from .models.auth_model import UserInformation, LoginRequest
14
+ from .models.device_model import DeviceModel
15
+ from .api.cloud_api import CloudApi
16
+ from .discover import Discover
17
+ from .const import PUBLIC_KEY_PEM
18
+ from .const import (
19
+ CONF_ID,
20
+ CONF_IPADDRESS,
21
+ CONF_LOGIN_INFO,
22
+ CONF_DEVICE_LIST,
23
+ CONF_PRODUCT,
24
+ CONF_PRODUCT_ID,
25
+ CONF_IS_OWNER,
26
+ SUPPORTED_COUNTRYS,
27
+ CONF_TYPE,
28
+ CONF_AES_KEY,
29
+ )
30
+
31
+ _LOGGER = logging.getLogger(__name__)
32
+
33
+
34
+ class AidotClient:
35
+ """AiDot client for managing devices and authentication."""
36
+
37
+ _device_clients: dict[str, DeviceClient]
38
+ user_info: UserInformation = None
39
+ _token_fresh_cb: Optional[callable] = None
40
+ _products: dict[str, dict[str, Any]] = {}
41
+
42
+ def __init__(
43
+ self,
44
+ session: Optional[ClientSession],
45
+ country_code: str | None = None,
46
+ username: str | None = None,
47
+ password: str | None = None,
48
+ token: dict | None = None,
49
+ ) -> None:
50
+ _LOGGER.info("Client Version: v0.3.55")
51
+ self.country_code = country_code
52
+ self._device_clients = {}
53
+ self.user_info = UserInformation(
54
+ username=username, password=password, country_code=country_code
55
+ )
56
+
57
+ # Set region and country from country_code
58
+ for item in SUPPORTED_COUNTRYS:
59
+ if item[CONF_ID] == self.country_code:
60
+ self.user_info.country = item["name"]
61
+ self.user_info.region = item["region"].lower()
62
+ break
63
+
64
+ # Handle token (existing login info)
65
+ if token is not None:
66
+ if token.get(CONF_ID) is None and token.get(CONF_LOGIN_INFO) is not None:
67
+ token = token.get(CONF_LOGIN_INFO)
68
+ self.user_info.update_from_json(token)
69
+
70
+ self._cloud_api = CloudApi(
71
+ session=session,
72
+ user_info=self.user_info,
73
+ auth_failed_callback=self._on_auth_failed,
74
+ token_refreshed_callback=self._on_token_refreshed,
75
+ )
76
+ self.setup_discover()
77
+
78
+ @property
79
+ def login_info(self) -> dict:
80
+ return self.user_info.to_dict()
81
+
82
+ def _on_auth_failed(self) -> None:
83
+ """Handle authentication failed event from CloudApi."""
84
+ _LOGGER.warning("Authentication failed, clearing user info")
85
+ self.user_info.accessToken = ""
86
+
87
+ def _on_token_refreshed(self) -> None:
88
+ """Handle token refreshed event from CloudApi."""
89
+ _LOGGER.debug("Token refreshed successfully")
90
+ if self._token_fresh_cb:
91
+ self._token_fresh_cb()
92
+
93
+ def set_token_fresh_cb(self, callback) -> None:
94
+ """Set callback for token refresh events."""
95
+ self._token_fresh_cb = callback
96
+
97
+ async def get_terminal_id(self) -> str:
98
+ """Get or create terminal ID for device identification."""
99
+ file_path = Path.home() / ".aidot_terminal_id"
100
+
101
+ def _read_or_create() -> str:
102
+ try:
103
+ if file_path.exists():
104
+ return file_path.read_text().strip()
105
+ node = uuid.getnode()
106
+ is_random = (node >> 40) & 1
107
+ raw_id = str(uuid.uuid4()) if is_random else format(node, "x")
108
+ file_path.write_text(raw_id)
109
+ return raw_id
110
+ except OSError:
111
+ return "gvz3gjae10l4zii00t7y0"
112
+
113
+ raw_id = await asyncio.to_thread(_read_or_create)
114
+ return hashlib.md5(raw_id.encode()).hexdigest()
115
+
116
+ async def async_post_login(self) -> dict[str, Any]:
117
+ """Login the user input allows us to connect."""
118
+ terminal_id = await self.get_terminal_id()
119
+
120
+ login_request = LoginRequest.create(
121
+ username=self.user_info.username,
122
+ encrypted_password=rsa_encrypt(self.user_info.password, PUBLIC_KEY_PEM),
123
+ country_name=self.user_info.country,
124
+ terminal_id=terminal_id,
125
+ )
126
+
127
+ response_data = await self._cloud_api.login(login_request.to_dict())
128
+ self.user_info.update_from_json(response_data)
129
+ self.setup_discover()
130
+ return self.user_info.to_dict()
131
+
132
+ async def async_get_all_device(self) -> dict[str, Any]:
133
+ """Get all devices for the user."""
134
+ filter_devices: dict[str, Any] = {}
135
+ filter_product_ids: set[str] = set()
136
+ try:
137
+ houses = await self._cloud_api.get_houses() or []
138
+ for house in houses:
139
+ if house.get(CONF_IS_OWNER) is False:
140
+ continue
141
+ device_list = await self._cloud_api.get_devices(house[CONF_ID]) or []
142
+ for device in device_list:
143
+ filter_device: dict[str, Any] = None
144
+ if (
145
+ device.get(CONF_TYPE) == "light"
146
+ and device.get(CONF_AES_KEY, [None])[0] is not None
147
+ ):
148
+ filter_device = device
149
+ if filter_device is not None:
150
+ filter_devices[device[CONF_ID]] = device
151
+ filter_product_ids.add(device[CONF_PRODUCT_ID])
152
+
153
+ # Get product info and merge into devices
154
+ if filter_product_ids:
155
+ product_ids = ",".join(filter_product_ids)
156
+ product_list = await self._cloud_api.get_products(product_ids) or []
157
+ product_map = {p[CONF_ID]: p for p in product_list}
158
+ for device in filter_devices.values():
159
+ device[CONF_PRODUCT] = product_map.get(device[CONF_PRODUCT_ID])
160
+
161
+ except Exception as e:
162
+ raise e
163
+ return filter_devices
164
+
165
+ def get_device_client(self, device: dict[str, Any]) -> DeviceClient:
166
+ """Get or create device client for a device."""
167
+ _device: DeviceModel = DeviceModel.from_json(data=device)
168
+ device_client: DeviceClient = self._device_clients.get(_device.id)
169
+ if device_client is None:
170
+ device_client = DeviceClient(_device, self.user_info)
171
+ self._device_clients[_device.id] = device_client
172
+
173
+ ip = Discover.DISCOVERED_DEVICE.get(_device.id)
174
+ device_client.update_ip_address(ip)
175
+ return device_client
176
+
177
+ async def remove_device_client(self, dev_id: str) -> None:
178
+ """Remove and close device client."""
179
+ device_client: DeviceClient = self._device_clients.get(dev_id)
180
+ if device_client is not None:
181
+ await device_client.close()
182
+ del self._device_clients[dev_id]
183
+
184
+ def setup_discover(self) -> None:
185
+ """Initialize device discovery after login."""
186
+ if not self.user_info.id:
187
+ return
188
+
189
+ _LOGGER.warning("setup_discover")
190
+
191
+ def _discover_callback(dev_id: str, event: dict[str, str]) -> None:
192
+ device_ip = event[CONF_IPADDRESS]
193
+ device_client: DeviceClient = self._device_clients.get(dev_id)
194
+ if device_client is not None:
195
+ device_client.update_ip_address(device_ip)
196
+
197
+ Discover.set_call_back(_discover_callback)
198
+ Discover.set_user_info(self.user_info)
199
+
200
+ async def async_close(self) -> None:
201
+ """Close client and cleanup resources."""
202
+ for client in self._device_clients.values():
203
+ await client.close()
204
+ self._device_clients.clear()
205
+
206
+ async def async_cleanup(self) -> None:
207
+ """Cleanup all resources."""
208
+ _LOGGER.debug("async_cleanup")
209
+ await self.async_close()
@@ -230,3 +230,17 @@ class ServerErrorCode(IntEnum):
230
230
  TOKEN_EXPIRED = 21026
231
231
  LOGIN_INVALID = 21025
232
232
  USER_PWD_INCORRECT = 560080
233
+
234
+
235
+ # Login / API constants
236
+ APP_ID = "1383974540041977857"
237
+ API_URL_TEMPLATE = "https://prod-{region}-api.arnoo.com/v17"
238
+ DEFAULT_REGION = "us"
239
+ PUBLIC_KEY_PEM = b"""
240
+ -----BEGIN PUBLIC KEY-----
241
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCtQAnPCi8ksPnS1Du6z96PsKfN
242
+ p2Gp/f/bHwlrAdplbX3p7/TnGpnbJGkLq8uRxf6cw+vOthTsZjkPCF7CatRvRnTj
243
+ c9fcy7yE0oXa5TloYyXD6GkxgftBbN/movkJJGQCc7gFavuYoAdTRBOyQoXBtm0m
244
+ kXMSjXOldI/290b9BQIDAQAB
245
+ -----END PUBLIC KEY-----
246
+ """