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.
- SAARubetekAPI/__init__.py +3 -0
- SAARubetekAPI/exceptions.py +34 -0
- SAARubetekAPI/logger.py +44 -0
- SAARubetekAPI/models.py +120 -0
- SAARubetekAPI/wrapper.py +378 -0
- saarubetek_api-0.0.2b0.dist-info/METADATA +48 -0
- saarubetek_api-0.0.2b0.dist-info/RECORD +9 -0
- saarubetek_api-0.0.2b0.dist-info/WHEEL +4 -0
- saarubetek_api-0.0.2b0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
""""""
|
SAARubetekAPI/logger.py
ADDED
|
@@ -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)
|
SAARubetekAPI/models.py
ADDED
|
@@ -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
|
SAARubetekAPI/wrapper.py
ADDED
|
@@ -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
|
+
[](https://www.python.org)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
[](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,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.
|