saarubetek-api 0.0.2b0__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.
@@ -0,0 +1,3 @@
1
+ from .wrapper import SAARubetekAPI
2
+
3
+ __all__ = ["SAARubetekAPI"]
@@ -0,0 +1,34 @@
1
+ class RubetekAPIError(Exception):
2
+ """"""
3
+
4
+
5
+ class AuthorizationRequiredRubetekAPIError(RubetekAPIError):
6
+ def __init__(self, message):
7
+ super().__init__("\n--------------------------------------------------------------------------\n"
8
+ f"{message}"
9
+ "rubetek_api = SAARubetekAPI()\n"
10
+ "phone = input('Введите номер телефона (c +7): ')"
11
+ "await rubetek_api.authentication(phone=phone)"
12
+ "code = input('Введите последние 4 цифры номера звонившего телефона: ')"
13
+ "await rubetek_api.authorization(phone=phone, code=code)"
14
+ "\n--------------------------------------------------------------------------\n")
15
+
16
+
17
+ class ClientConnectorRubetekAPIError(RubetekAPIError):
18
+ """"""
19
+
20
+
21
+ class UnauthorizedRubetekAPIError(RubetekAPIError):
22
+ """"""
23
+
24
+
25
+ class DDosRubetekAPIError(RubetekAPIError):
26
+ """"""
27
+
28
+
29
+ class TimeoutRubetekAPIError(RubetekAPIError):
30
+ """"""
31
+
32
+
33
+ class UnknownRubetekAPIError(RubetekAPIError):
34
+ """"""
@@ -0,0 +1,44 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+
5
+ class SafeLogger(logging.Logger):
6
+ SENSITIVE_KEYS = {
7
+ 'access_token', 'refresh_token', 'code', 'phone', 'email', 'authorization'
8
+ }
9
+
10
+ def __init__(self, name: str):
11
+ super().__init__(name)
12
+ if not self.handlers:
13
+ handler = logging.StreamHandler()
14
+ formatter = logging.Formatter(fmt='%(asctime)s %(levelname)s: %(name)s %(message)s', style='%')
15
+ handler.setFormatter(formatter)
16
+ self.addHandler(handler)
17
+ self.propagate = False
18
+
19
+ def mask_sensitive(self, obj: Any) -> Any:
20
+ if isinstance(obj, dict):
21
+ return {
22
+ k: (f"{str(v)[:3]}***" if k.lower() in self.SENSITIVE_KEYS and v is not None else self.mask_sensitive(v))
23
+ for k, v in obj.items()
24
+ }
25
+ if isinstance(obj, list):
26
+ return [self.mask_sensitive(v) for v in obj]
27
+ return obj
28
+
29
+ def safe(self, level: int, msg: str, *args: Any, **kwargs: Any):
30
+ safe_args = tuple(self.mask_sensitive(arg) for arg in args)
31
+ safe_kwargs = {k: self.mask_sensitive(v) for k, v in kwargs.items()}
32
+ super().log(level, msg, *safe_args, **safe_kwargs)
33
+
34
+ def info(self, msg: str, *args: Any, **kwargs: Any):
35
+ self.safe(logging.INFO, msg, *args, **kwargs)
36
+
37
+ def debug(self, msg: str, *args: Any, **kwargs: Any):
38
+ self.safe(logging.DEBUG, msg, *args, **kwargs)
39
+
40
+ def warning(self, msg: str, *args: Any, **kwargs: Any):
41
+ self.safe(logging.WARNING, msg, *args, **kwargs)
42
+
43
+ def error(self, msg: str, *args: Any, **kwargs: Any):
44
+ self.safe(logging.ERROR, msg, *args, **kwargs)
@@ -0,0 +1,120 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, model_validator
4
+ from typing import Any, Optional
5
+
6
+ class User(BaseModel):
7
+ id: str
8
+ _id: int
9
+ phone: str
10
+ last_name: str
11
+ first_name: str
12
+ middle_name: str
13
+
14
+ class House(BaseModel):
15
+ id: str
16
+ name: str
17
+ iot_locked_id: Optional[str] = None
18
+ address: Optional[str] = None
19
+
20
+ @model_validator(mode="before")
21
+ @classmethod
22
+ def parse_location(cls, data: Any) -> Any:
23
+ if isinstance(data, dict) and data.get("location"):
24
+ addr = data["location"].get("address", {})
25
+ parts = [addr.get("city"), addr.get("street"), addr.get("house")]
26
+ data["address"] = ", ".join(filter(None, parts))
27
+ return data
28
+
29
+ class Intercom(BaseModel):
30
+ id: int
31
+ intercom_id: int
32
+ ble_allowed: bool
33
+ is_ble: bool
34
+ live_snapshot_url: Optional[str] = None
35
+ name: str
36
+ status: str
37
+ webrtc_supported: bool
38
+ sip_account_ex_user: Optional[int] = None
39
+ sip_account_proxy: Optional[str] = None
40
+ real_name: Optional[str] = None
41
+
42
+ @model_validator(mode="before")
43
+ @classmethod
44
+ def parse_visible_name(cls, data: Any) -> Any:
45
+ if isinstance(data, dict) and data.get("user_settings"):
46
+ name = data["user_settings"].get("custom_name", None)
47
+ if name is not None:
48
+ data["real_name"] = data["name"]
49
+ data["name"] = name
50
+ return data
51
+
52
+ class DeviceState(BaseModel):
53
+ status: str
54
+ current_value: str
55
+ units: str
56
+ battery: Optional[float] = None
57
+ error_code: Optional[str] = None
58
+
59
+ class Device(BaseModel):
60
+ id: str
61
+ natural_id: str
62
+ type: str
63
+ updated_at: Optional[datetime] = None
64
+ name: Optional[str] = None
65
+ room: Optional[str] = None
66
+ last_activity: Optional[datetime] = None
67
+ state: DeviceState
68
+
69
+ @model_validator(mode="before")
70
+ @classmethod
71
+ def parse_last_activity(cls, data: Any) -> Any:
72
+ if isinstance(data, dict) and data.get("state"):
73
+ last_activity = data["state"].get("last_activity", None)
74
+ if last_activity is not None:
75
+ data["last_activity"] = last_activity
76
+ return data
77
+
78
+ @model_validator(mode="before")
79
+ @classmethod
80
+ def parse_parameters(cls, data: Any) -> Any:
81
+ if isinstance(data, dict) and data.get("parameters"):
82
+ name = data["parameters"].get("name", None)
83
+ room = data["parameters"].get("room", None)
84
+ if name is not None:
85
+ data["name"] = name
86
+ if room is not None:
87
+ data["room"] = room
88
+ return data
89
+
90
+ class Camera(BaseModel):
91
+ id: str
92
+ natural_id: int
93
+ live_snapshot_url: Optional[str] = None
94
+ rtsp_url: Optional[str] = None
95
+ status: Optional[str] = None
96
+ type: str
97
+ name: Optional[str] = None
98
+
99
+ @model_validator(mode="before")
100
+ @classmethod
101
+ def parse_state(cls, data: Any) -> Any:
102
+ if isinstance(data, dict) and data.get("state"):
103
+ live_snapshot_url = data["state"].get("live_snapshot_url", None)
104
+ rtsp = data["state"].get("rtsp_url", None)
105
+ status = data["state"].get("status", "offline")
106
+ if live_snapshot_url is not None:
107
+ data["live_snapshot_url"] = live_snapshot_url
108
+ if rtsp is not None:
109
+ data["rtsp_url"] = rtsp
110
+ data["status"] = status
111
+ return data
112
+
113
+ @model_validator(mode="before")
114
+ @classmethod
115
+ def parse_name(cls, data: Any) -> Any:
116
+ if isinstance(data, dict) and data.get("parameters"):
117
+ name = data["parameters"].get("name", None)
118
+ if name is not None:
119
+ data["name"] = name
120
+ return data
@@ -0,0 +1,378 @@
1
+ from urllib.parse import urljoin
2
+ import asyncio
3
+ import logging
4
+ import ssl
5
+ import secrets
6
+ import string
7
+
8
+ from types import SimpleNamespace
9
+
10
+ from .logger import SafeLogger
11
+ from json.decoder import JSONDecodeError
12
+ from pathlib import Path
13
+
14
+ from uuid import uuid4
15
+ from typing import (Any,
16
+ Dict,
17
+ Optional,
18
+ Union)
19
+
20
+ import certifi
21
+ from aiohttp import (ClientSession,
22
+ ClientTimeout,
23
+ TCPConnector)
24
+ from aiohttp.client_exceptions import (ClientConnectorError,
25
+ ContentTypeError)
26
+
27
+ from .exceptions import (AuthorizationRequiredRubetekAPIError,
28
+ ClientConnectorRubetekAPIError,
29
+ TimeoutRubetekAPIError,
30
+ UnauthorizedRubetekAPIError,
31
+ DDosRubetekAPIError,
32
+ UnknownRubetekAPIError)
33
+
34
+ from contextlib import suppress
35
+
36
+ from .models import User, House, Intercom, Device, Camera
37
+
38
+
39
+ class SAARubetekAPI:
40
+ __iot_url: str = "https://iot.rubetek.com/"
41
+ __base_url: str = "https://ccc.rubetek.com/"
42
+ __client_id: str = "ckvfvkClm2IdPrkSlvWSe3KiEWJOAbyKOQR5giCYYAo" # Harcode
43
+ __cl_sec: str = "_TiXiy8xkVmVEpTBoYndqvyYbldXFs00wBtgLNmSOCE" # Hardcode
44
+ __logger = SafeLogger('RubetekAPI')
45
+ refresh_token: Optional[str] = None
46
+ iot_access_token: Optional[str] = None
47
+ access_token: Optional[str] = None
48
+ house_id: Optional[str] = None
49
+ personal_id: Optional[str] = None
50
+ device_id: Optional[str] = None
51
+
52
+ __initial_cookies: str = '_iot_rubetek_com_session=IRsv98v2DswIJJ1i2VKdWcVzOyv8%2BVlPAldUGbFqUe3eKwLT8VK%2BfuWvHFo1JJKapEAqoQOfMTBhfuQ14xgkV7TBQy2hllQTtu2R8J2oo8sgCtPQWRO9aInCxeJB4wQ7UX%2F%2FXiZafoIAjT%2BKrHWAueo6HaH232cje1h2vT4HX0vQarHhLk75SQipMrOuIhefdOQW7fzKamFavxtyquxtrBV9uEhOdQULVbbxQt3AjqGAbAY%2BsHzjgI%2FEIfw0qI8XJcjry1aD3yFex316kDABTGU4PCDJdOm1telhF8rAjpCKe2CMISv3g8okyvx9oc3ELAlbvREG%2BxVonOH2--4WBiJmIgZZT825rb--45HBPHoCrH8aa9ly%2B6cVyA%3D%3D; locale=ru'
53
+
54
+
55
+ def __init__(self, refresh_token: Optional[str] = None,
56
+ refresh_token_file: Optional[Path] = Path("./rr_token"),
57
+ save_token_file: Optional[bool] = True,
58
+ enabledCaptcha: Optional[bool] = True,
59
+ insecure: Optional[bool] = False,
60
+ timeout: Optional[int] = 30,
61
+ retry_count: Optional[int] = 5,
62
+ retry_timeout_ms: Optional[int] = 1000,
63
+ logging_level: Optional[str] = "INFO",
64
+ device_id: Optional[str] = None):
65
+
66
+ if logging_level.upper() == "INFO":
67
+ self.__logger.setLevel(logging.INFO)
68
+ elif logging_level.upper() == "WARNING":
69
+ self.__logger.setLevel(logging.WARNING)
70
+ elif logging_level.upper() == "ERROR":
71
+ self.__logger.setLevel(logging.ERROR)
72
+ elif logging_level.upper() == "DEBUG":
73
+ self.__logger.setLevel(logging.DEBUG)
74
+ else:
75
+ # Default INFO
76
+ self.__logger.setLevel(logging.INFO)
77
+
78
+ self.refresh_token: Optional[str] = refresh_token
79
+ self.refresh_token_file: Optional[Path] = refresh_token_file
80
+ self.save_token_file: Optional[bool] = save_token_file
81
+ self.enabledCaptcha: Optional[bool] = enabledCaptcha
82
+ self.insecure: Optional[bool] = insecure
83
+ self.timeout: Optional[int] = timeout
84
+ self.retry_count: Optional[int] = retry_count
85
+ self.retry_timeout_ms: Optional[int] = retry_timeout_ms
86
+ self.device_id: Optional[str] = device_id or self.generate_device_uid()
87
+
88
+ self.session = None
89
+
90
+ self.__logger.debug(f"Initialization done. Items=({', '.join(f'{k}={v}' for k, v in vars(self).items())})")
91
+
92
+ async def _ensure_session(self) -> ClientSession:
93
+ if self.session and not self.session.closed:
94
+ return self.session
95
+ if not self.insecure:
96
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
97
+ self.session = ClientSession(connector=TCPConnector(ssl=ssl_context), timeout=ClientTimeout(total=self.timeout))
98
+ else:
99
+ connector = TCPConnector(ssl=False)
100
+ self.session = ClientSession(connector=connector, timeout=ClientTimeout(total=self.timeout))
101
+
102
+ return self.session
103
+
104
+ async def __aenter__(self):
105
+ await self._ensure_session()
106
+ return self
107
+
108
+ async def __aexit__(self, exc_type, exc, tb):
109
+ await self.close()
110
+
111
+ async def send_request(self, url: str,
112
+ method: str = "GET",
113
+ headers: Dict[str, Any] = None,
114
+ params: Dict[str, Any] = None,
115
+ json_data: Dict[str, Any] = None,
116
+ name: str = "UNKNOWN",
117
+ request_uid: Optional[str] = None,
118
+ refresh_on_unauthorized: bool = True):
119
+
120
+ headers = headers or {}
121
+ params = params or {}
122
+ json_data = json_data or {}
123
+
124
+ if request_uid is None:
125
+ request_uid = uuid4().hex
126
+
127
+ attempt = 0
128
+ last_error: Optional[Exception] = None
129
+
130
+ while attempt < self.retry_count:
131
+ _headers = self.default_headers(headers)
132
+ self.__logger.info('[%s] Request=%s method=%s url=%s params=%s json=%s headers=%s',
133
+ name, request_uid, method, url, params, json_data, _headers)
134
+ try:
135
+ session = await self._ensure_session()
136
+ async with session.request(method, url, params=params, json=json_data, headers=_headers) as response:
137
+ try:
138
+ json_response = await response.json() if response.status == 200 else {}
139
+ except (JSONDecodeError, ContentTypeError) as e:
140
+ raw_response = await response.text()
141
+ self.__logger.error('[%s] Response=%s unsuccessful request status=%s reason=%s raw=%s error=%s',
142
+ name, request_uid, response.status, response.reason, raw_response, e)
143
+ raise UnknownRubetekAPIError(f'Unknown error: {response.status} {response.reason}')
144
+ if response.status == 401:
145
+ self.__logger.error("[%s] Response=%s Status=%s. Body=%s", name, request_uid, response.status, str(await response.text()))
146
+ raise UnauthorizedRubetekAPIError(json_response)
147
+ if response.status == 429:
148
+ self.__logger.error("[%s] Response=%s Status=%s. Body=%s", name, request_uid, response.status, str(await response.text()))
149
+ raise DDosRubetekAPIError("429 DDoS")
150
+ if response.status not in (200, 201, 204):
151
+ self.__logger.error('[%s] Response=%s unsuccessful request json_response=%s Status=%s Reason=%s',
152
+ name, request_uid, json_response, response.status, response.reason)
153
+ raise UnknownRubetekAPIError(json_response.get('error_description') or json_response.get('error') or json_response)
154
+
155
+ return json_response
156
+
157
+ except asyncio.exceptions.TimeoutError:
158
+ self.__logger.error('[%s] Response=%s TimeoutRubetekAPIError', name, request_uid)
159
+ raise TimeoutRubetekAPIError('Timeout error')
160
+ except ClientConnectorError:
161
+
162
+ self.__logger.error('[%s] Response=%s ClientConnectorRubetekAPIError', name, request_uid)
163
+ raise ClientConnectorRubetekAPIError('Client connector error')
164
+ except DDosRubetekAPIError:
165
+ last_error = DDosRubetekAPIError("429 DDoS")
166
+ await asyncio.sleep((self.retry_timeout_ms / 1000) * attempt)
167
+ except UnauthorizedRubetekAPIError as error:
168
+ if not refresh_on_unauthorized:
169
+ raise
170
+ last_error = error
171
+ self.__logger.warning("[%s] The token may have expired. Get a new one")
172
+ await self.refresh_tokens(request_uid = request_uid)
173
+ await asyncio.sleep((self.retry_timeout_ms / 1000) * attempt)
174
+ finally:
175
+ attempt += 1
176
+
177
+ if last_error is not None:
178
+ raise last_error
179
+ raise UnknownRubetekAPIError("Request failed after all retry attempts")
180
+
181
+ @staticmethod
182
+ def generate_device_uid() -> str:
183
+ charset = string.ascii_letters + string.digits
184
+ device_id = ''.join(secrets.choice(charset) for _ in range(16))
185
+ return device_id
186
+
187
+ def default_headers(self, new_headers: dict | None = None) -> dict:
188
+ headers = {
189
+ "Content-Type": "application/json; charset=UTF-8",
190
+ "Device-Client-UID": self.device_id,
191
+ "Device-Client-App": "Rubetek",
192
+ "Accept-Language": "ru",
193
+ "Device-Client-OS": "Android",
194
+ "User-Agent": "okhttp/5.3.2"
195
+ }
196
+ if new_headers is not None:
197
+ headers.update(new_headers)
198
+ return headers
199
+
200
+ async def authentication(self, email: str = None, phone: str = None, method: str = "flash_call"):
201
+ length_code = 4 if method == "flash_call" and email is None else 6
202
+
203
+ if not ((email is not None) ^ (phone is not None)):
204
+ raise ValueError('Either "email" or "phone" must be provided.')
205
+
206
+ url = urljoin(self.__iot_url, 'api/v1/code_requests')
207
+ payload = {
208
+ 'code_request': {
209
+ 'length': length_code
210
+ }
211
+ }
212
+ if email is not None:
213
+ payload['code_request'].update({'email': email, 'method': 'email'})
214
+ elif method == "flash_call":
215
+ payload['code_request'].update({'phone': phone, 'method': 'flash_call'})
216
+ else:
217
+ payload['code_request'].update({'phone': phone, 'method': 'sms'})
218
+ headers = {
219
+ 'Cookie': self.__initial_cookies
220
+ } if self.__initial_cookies else {}
221
+
222
+ self.__logger.info("Sending a confirmation request method=%s target=%s...", method, email if email else phone)
223
+ await self.send_request(url=url, method='POST', json_data=payload, headers=headers, name="AUTHENTICATION")
224
+
225
+ async def authorization(self, code: Union[int, str], email: Optional[str] = None, phone: Optional[str] = None):
226
+ if not ((email is not None) ^ (phone is not None)):
227
+ raise ValueError('Either "email" or "phone" must be provided.')
228
+
229
+ url = urljoin(self.__iot_url, 'oauth/token')
230
+ params = {
231
+ 'client_id': self.__client_id,
232
+ 'client_secret': self.__cl_sec,
233
+ 'grant_type': 'password',
234
+ 'code': str(code),
235
+ }
236
+ if email is not None:
237
+ params['email'] = email
238
+ else:
239
+ params['phone'] = phone
240
+
241
+ self.__logger.debug("Get iot token...")
242
+ headers = {
243
+ 'Content-Type': 'application/json; charset=UTF-8',
244
+ 'User-Agent': 'okhttp/4.12.0'
245
+ }
246
+ response = await self.send_request(url=url, method='POST', headers=headers, params=params, name="AUTHORIZATION.STEP_1", refresh_on_unauthorized=False)
247
+ token = SimpleNamespace(**response)
248
+ self.__logger.debug("IOT: %s", token)
249
+
250
+ self.iot_access_token = getattr(token, 'access_token', None)
251
+
252
+ url = urljoin(self.__base_url, 'v5/oauth/iot')
253
+ payload = {
254
+ 'client_id': 'rubetek_android',
255
+ 'token': f'Bearer {self.iot_access_token}'
256
+ }
257
+
258
+ self.__logger.debug("Get refresh token and access token...")
259
+ response = await self.send_request(url=url, method='POST', json_data=payload, name="AUTHORIZATION.STEP_2", refresh_on_unauthorized=False)
260
+ token = SimpleNamespace(**response)
261
+ self.access_token = getattr(token, 'access_token', None)
262
+ self.save_refresh_token(refresh_token=getattr(token, 'refresh_token', None))
263
+ return self.refresh_token, self.access_token, self.iot_access_token
264
+
265
+
266
+ def save_refresh_token(self, refresh_token):
267
+ if self.refresh_token_file and self.save_token_file:
268
+ with open(self.refresh_token_file, 'w', encoding='utf-8') as f:
269
+ f.write(refresh_token)
270
+ self.__logger.info('New refresh token saved to file %s', self.refresh_token_file)
271
+ else:
272
+ self.__logger.warning('We do not save the token to a file')
273
+ self.refresh_token = refresh_token
274
+
275
+ async def refresh_tokens(self, request_uid: str=None):
276
+ if not self.refresh_token and self.refresh_token_file and self.save_token_file:
277
+ with suppress(FileNotFoundError):
278
+ with open(self.refresh_token_file, encoding='utf-8') as f:
279
+ self.refresh_token = f.read()
280
+ if not self.refresh_token:
281
+ self.__logger.error('Response=%s AuthorizationRequiredRubetekAPIError', request_uid)
282
+ raise AuthorizationRequiredRubetekAPIError("Not found refresh token. Need to authorize")
283
+ await self.get_access_token(request_uid=request_uid)
284
+ await self.get_iot_access_token(request_uid=request_uid)
285
+ return self.refresh_token, self.access_token, self.iot_access_token
286
+
287
+ async def get_access_token(self, request_uid: str = None) -> str:
288
+ url = urljoin(self.__base_url, 'v5/oauth/access_token')
289
+ payload = {
290
+ 'client_id': 'rubetek_android',
291
+ 'grant_type': 'refresh_token',
292
+ 'refresh_token': self.refresh_token
293
+ }
294
+ response = await self.send_request(url=url, method='POST', json_data=payload, name="REFRESH_ACCESS_TOKEN", request_uid=request_uid, refresh_on_unauthorized=False)
295
+ token = SimpleNamespace(**response)
296
+ self.__logger.debug("Access token updated")
297
+ self.access_token = getattr(token, 'access_token', None)
298
+
299
+ return self.access_token
300
+
301
+ async def get_iot_access_token(self, request_uid: str = None) -> str:
302
+ url = urljoin(self.__base_url, 'v5/iot/auth_token')
303
+ headers = {
304
+ "Authorization": f"Bearer {self.access_token}",
305
+ "Accept": "application/json"
306
+ }
307
+ payload = {
308
+ 'client_id': self.__client_id
309
+ }
310
+ response = await self.send_request(url=url, method='POST', json_data=payload, headers=headers, name="REFRESH_IOT_ACCESS_TOKEN", request_uid=request_uid, refresh_on_unauthorized=False)
311
+ token = SimpleNamespace(**response)
312
+ self.__logger.debug("IoT Access token updated")
313
+ self.iot_access_token = getattr(token, 'access_token', None)
314
+ return self.iot_access_token
315
+
316
+ async def get_user(self) -> User:
317
+ url = urljoin(self.__base_url, 'v5/user')
318
+ headers = {
319
+ "Authorization": f"Bearer {self.access_token}"
320
+ }
321
+ response = await self.send_request(url=url, headers=headers, name="GET_USER")
322
+ return User(**response)
323
+
324
+ async def get_homes(self):
325
+ url = urljoin(self.__base_url, 'v6/houses')
326
+ headers = {
327
+ "Authorization": f"Bearer {self.access_token}"
328
+ }
329
+ response = await self.send_request(url=url, headers=headers, name="GET_HOUSES")
330
+ return [House(**h) for h in response]
331
+
332
+
333
+ async def get_devices(self):
334
+ if self.house_id is None:
335
+ raise ValueError("House ID not set")
336
+ url = urljoin(self.__iot_url, f'api/personal/v1/homes/{self.house_id}/devices')
337
+ params = {'page': 1, 'per_page': 500, 'include_deleted': 'true', 'q[model_type_not_eq]': 'Cameras::Device'}
338
+ headers = {
339
+ "Authorization": f"Bearer {self.iot_access_token}"
340
+ }
341
+ response = await self.send_request(url=url, params=params, headers=headers, name="GET_DEVICES")
342
+ return [Device(**d) for d in response]
343
+
344
+ async def get_device(self, device_id: str) -> Optional[Device]:
345
+ devices = await self.get_devices()
346
+ device = next((d for d in devices if d.id == device_id), None)
347
+ return device
348
+
349
+ async def get_cameras(self):
350
+ if self.house_id is None:
351
+ raise ValueError("House ID not set")
352
+ url = urljoin(self.__iot_url, f'api/personal/v1/homes/{self.house_id}/devices')
353
+ params = {'page': 1, 'per_page': 500, 'include_deleted': 'true', 'q[model_type_eq]': 'Cameras::Device'}
354
+ headers = {
355
+ "Authorization": f"Bearer {self.iot_access_token}"
356
+ }
357
+ response = await self.send_request(url=url, params=params, headers=headers, name="GET_CAMERAS")
358
+ return [Camera(**c) for c in response]
359
+
360
+ async def get_camera(self, camera_id: str) -> Optional[Camera]:
361
+ cameras = await self.get_cameras()
362
+ camera = next((d for d in cameras if d.id == camera_id), None)
363
+ return camera
364
+
365
+ async def get_intercoms(self):
366
+ if self.house_id is None:
367
+ raise ValueError("House ID not set")
368
+ url = urljoin(self.__iot_url, f'api/personal/v1/homes/{self.house_id}/relays')
369
+ headers = {
370
+ "Authorization": f"Bearer {self.iot_access_token}"
371
+ }
372
+ response = await self.send_request(method='GET', url=url, headers=headers, name="GET_INTERCOMS")
373
+ return [Intercom(**c) for c in response]
374
+
375
+ async def close(self):
376
+ if self.session and not self.session.closed:
377
+ await self.session.close()
378
+ self.session = None
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: saarubetek-api
3
+ Version: 0.0.2b0
4
+ Summary: A Python library for the Rubetek API – control smart home devices, manage scenes, get device status, and automate your Rubetek ecosystem
5
+ Project-URL: Homepage, https://github.com/RuFiHome/RubetekAPI
6
+ Project-URL: Repository, https://github.com/RuFiHome/RubetekAPI
7
+ Project-URL: Issues, https://github.com/RuFiHome/RubetekAPI/issues
8
+ Author-email: AlexVot <alexey.shamray@yandex.ru>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: aiohttp>=3.8.0
13
+ Requires-Dist: certifi>=2023.7.22
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Rubetek API Library
18
+
19
+ [![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org)
20
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
21
+ [![HACS](https://img.shields.io/badge/HACS-custom-orange.svg)](https://hacs.xyz)
22
+
23
+ > Python-библиотека для работы с экосистемой умного дома Rubetek с расширенным функционалом
24
+
25
+ ## 📋 Описание
26
+
27
+ Библиотека предоставляет удобный интерфейс для подключения к облачной платформе Rubetek, получения данных об устройствах, камерах и мониторинга показаний счётчиков водоснабжения. Основана на [RubetekSocketApi](https://github.com/regenara/rubetek_socket_api)
28
+
29
+ ## ✨ Возможности
30
+
31
+ - 🔐 Авторизация и управление сессией подключения к Rubetek Cloud
32
+ - 📡 Получение списка устройств и их текущего состояния
33
+ - 🎥 Работа с камерами: статус, поток, события
34
+ - 💧 Мониторинг показаний счётчиков ХВС и ГВС с историей данных
35
+ - 🔔 Подписка на события в реальном времени (WebSocket)
36
+ - 🔄 Автоматическое переподключение при обрыве связи
37
+ - 🪵 Логирование с настраиваемым уровнем детализации
38
+
39
+ ## 🚀 Установка
40
+
41
+ ```bash
42
+ # Установка из PyPI (когда будет опубликовано)
43
+ pip install ...
44
+
45
+ # Или установка из исходников
46
+ pip install git+https://github.com/RuFiHome/RubetekAPI.git
47
+
48
+ НЕ ИМЕЕТ ОТНОШЕНИЯ К RUBETEK!!!
@@ -0,0 +1,9 @@
1
+ SAARubetekAPI/__init__.py,sha256=BqgNa0moEXeZjtDrRZ31EaYoZ-b95u3zS53QROP81D4,64
2
+ SAARubetekAPI/exceptions.py,sha256=IF2KVd4tVgcGs2pztyO-36OGt4prQASN4OHEbyPjMh8,1156
3
+ SAARubetekAPI/logger.py,sha256=czXvDHF3FdZ3AQXqF4NGUXuQABFEV4QcAnHL03rHWhc,1674
4
+ SAARubetekAPI/models.py,sha256=AGl5BbhAmxCaNndx_ybju1y4hcWUA6xIGTDD2JI8Y5A,3725
5
+ SAARubetekAPI/wrapper.py,sha256=WzB_07zj4UGzc685KiQpL8BqOXXeYXC7VXSzFNkPvSY,17465
6
+ saarubetek_api-0.0.2b0.dist-info/METADATA,sha256=RgG0f9QdGi7Fv-HTFSED3QO6kFomadRZOYA65WYaPlw,2545
7
+ saarubetek_api-0.0.2b0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ saarubetek_api-0.0.2b0.dist-info/licenses/LICENSE,sha256=9JnvwqkPZIr54e5L2NGQxrwn2OazW6FeuisPmyqkS_M,1064
9
+ saarubetek_api-0.0.2b0.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
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexVot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.