python-siseli 0.1.2__py3-none-any.whl → 0.2.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.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-siseli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Python SDK for Siseli Cloud
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: httpx>=0.27
9
+ Requires-Dist: cryptography>=42
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: pytest>=8; extra == "dev"
11
12
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -1,15 +1,16 @@
1
- siseli/__init__.py,sha256=_sHO0M0medXnJz_ZTtHKyy8DP6e40wuf5ubEGLC1kBs,1035
1
+ siseli/__init__.py,sha256=F4eW-gk3BGfcBhVgDCyqotYgCcibac6iSEgyp_px8Ug,1099
2
2
  siseli/_utils.py,sha256=QT-ooemKKAIEHICNI2QErPvRkFIkUGo6ncUFCJM1kGc,727
3
3
  siseli/alarms.py,sha256=Mc_CV8kUBFTXu8c48NstX8Lc6Q0bPTnhjwehx9sbCkQ,4853
4
4
  siseli/auth.py,sha256=I8WwJgy1go1MGxjowkVx-lvCtneh5rRtbs9DV9yO4T8,6470
5
5
  siseli/client.py,sha256=Eap7MTrnctdskt6kOzbns7SqUt27ZDjMlIKVshfcfN8,14200
6
6
  siseli/config.py,sha256=J8raq7Yf4gCyjO9ISNoTrk_EYNEGrESV56O-aHfDvBU,3278
7
- siseli/const.py,sha256=M8bBrc8cH0OyEGb9158_emux_dfknvikGQqm09iQ8CA,174
7
+ siseli/const.py,sha256=9qk2YEaDELKmn7xRsvxlnHh9nUGFymN1DWnulfQG4w4,364
8
8
  siseli/dashboard.py,sha256=JQHfi8orKs5uWsGGfoTvA14A9M_R9yIAYuGCxb4zTsM,3325
9
9
  siseli/device.py,sha256=crix14pYakrDaFmomPC3Uwgdq-1boZ-ZaEg2HgzXibs,2762
10
10
  siseli/dictionary.py,sha256=VSO83uXPaGLPzKGRmpROuWQKivXN7DNOmXN8QQJ-Y-w,1120
11
11
  siseli/exceptions.py,sha256=y9ws9c2vr5Q3bZxLwkXybUqQooq57YEEXAN0wO3_fxk,1420
12
12
  siseli/history.py,sha256=CgBvDfZBIrrewu7PeRv08-Zr4boEVOY8Xevruu-ge-I,3869
13
+ siseli/open_auth.py,sha256=t64FrrzS-edsQlLr-tY0MHVvHP0nfNJEd1LcZPw_yXQ,5857
13
14
  siseli/state.py,sha256=d7Exlf7FzF8O_CcF-NbAt6kq8qaR4fFhjGW8028kbrM,5800
14
15
  siseli/station.py,sha256=e1OK4OIry9WUjT8KggQI5b4Acypm9D31mWAMTjDGFew,5531
15
16
  siseli/models/__init__.py,sha256=WSIXRM_JRKNhB_hDwxrpeQgdqKUHdbT3KiFvdrLrrzY,1242
@@ -23,7 +24,7 @@ siseli/models/dictionary.py,sha256=d6KCu77UMlPcIDON8G3sjuytkSfbI5go1y3I_jAGTX8,4
23
24
  siseli/models/history.py,sha256=dx26VWLJosucMvwL36vojvAguvkMiv5GEKTllUtZV0Y,791
24
25
  siseli/models/state.py,sha256=VK5CNqMLRkY1w9HrcUhvaO_6J-rjqP7VwvbppCFpucI,2393
25
26
  siseli/models/station.py,sha256=02OLINAVblnKkF73ZwKxYvPtUFROsu4-g544oDg8ApA,1783
26
- python_siseli-0.1.2.dist-info/METADATA,sha256=xnZJLpywI8nzDQEqwUgUsN9Pbx5iL7bnSqV3v326MFs,1407
27
- python_siseli-0.1.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
28
- python_siseli-0.1.2.dist-info/top_level.txt,sha256=Dofr9c7hGvRRV6ij6aoPuAZqxEjkgtsBeN4KtFHbGgo,7
29
- python_siseli-0.1.2.dist-info/RECORD,,
27
+ python_siseli-0.2.0.dist-info/METADATA,sha256=qHA8H1BcZ_7_LZKWG7mBP5sw61JOUuWXSXHzF58hRFg,1439
28
+ python_siseli-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
29
+ python_siseli-0.2.0.dist-info/top_level.txt,sha256=Dofr9c7hGvRRV6ij6aoPuAZqxEjkgtsBeN4KtFHbGgo,7
30
+ python_siseli-0.2.0.dist-info/RECORD,,
siseli/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """python-siseli — Python SDK for Siseli Cloud."""
2
2
 
3
3
  from .client import SiseliClient
4
+ from .open_auth import attach_open_auth
4
5
  from .exceptions import ApiError, AuthenticationError, NetworkError, SiseliError, TokenExpiredError
5
6
  from .models import (
6
7
  Alarm,
@@ -25,6 +26,7 @@ from .models import (
25
26
 
26
27
  __all__ = [
27
28
  "SiseliClient",
29
+ "attach_open_auth",
28
30
  "SiseliError",
29
31
  "AuthenticationError",
30
32
  "TokenExpiredError",
siseli/const.py CHANGED
@@ -3,3 +3,7 @@ DEFAULT_TIMEOUT = 30.0
3
3
  DEFAULT_PAGE_SIZE = 20
4
4
  # dataSource=1 observed in all telemetry requests in HAR captures
5
5
  DEFAULT_DATA_SOURCE = 1
6
+
7
+ # Production Siseli Open API application credentials from the official web client.
8
+ SISELI_APP_ID = "rBrTRfAPXz"
9
+ SISELI_APP_SECRET_ENCRYPTED = "I4D0KRr2339z3pQ/at91V9BpFAOe54DaTafwSm6suIQ="
siseli/open_auth.py ADDED
@@ -0,0 +1,177 @@
1
+ """Siseli Open API request signing helpers.
2
+
3
+ Every API request must carry IOT-Open-* headers that prove the caller holds a
4
+ valid application credential. The signing algorithm mirrors the official web
5
+ client (CryptoJS-based) exactly:
6
+
7
+ 1. Decrypt the base64-encoded app secret with AES-128-CBC.
8
+ Key and IV are derived from MD5(app_id) as ASCII hex chars.
9
+ 2. Compute a body hash: empty string for GET; SHA-256 hex for everything else.
10
+ 3. Collect query params, inject the three open-auth fields, sort by key,
11
+ serialise without URL-encoding, hex-encode the UTF-8 bytes.
12
+ 4. Sign: HMAC-SHA256(hex_string, secret) → MD5 → lowercase hex.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import uuid
20
+ from typing import TYPE_CHECKING, Any
21
+ from urllib.parse import parse_qs
22
+
23
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
24
+
25
+ from .const import SISELI_APP_ID, SISELI_APP_SECRET_ENCRYPTED
26
+
27
+ if TYPE_CHECKING:
28
+ import httpx
29
+
30
+
31
+ def decrypt_open_secret(app_id: str, encrypted_secret_b64: str) -> str:
32
+ """Decrypt the base64-encoded app secret using AES-128-CBC.
33
+
34
+ The AES key and IV are derived from MD5(app_id).lower():
35
+ - first 16 hex chars → 16 ASCII bytes → AES key
36
+ - last 16 hex chars → 16 ASCII bytes → IV
37
+
38
+ ZeroPadding is assumed: trailing null bytes are stripped from the output.
39
+ """
40
+ import base64
41
+
42
+ md5_hex = hashlib.md5(app_id.encode()).hexdigest() # noqa: S324
43
+ key = md5_hex[:16].encode("ascii")
44
+ iv = md5_hex[16:].encode("ascii")
45
+
46
+ encrypted_bytes = base64.b64decode(encrypted_secret_b64)
47
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
48
+ decryptor = cipher.decryptor()
49
+ plaintext = decryptor.update(encrypted_bytes) + decryptor.finalize()
50
+ return plaintext.rstrip(b"\x00").decode("utf-8")
51
+
52
+
53
+ def _sha256_hex(data: bytes) -> str:
54
+ """Return the lowercase hex SHA-256 digest of *data*."""
55
+ return hashlib.sha256(data).hexdigest()
56
+
57
+
58
+ def build_open_headers(
59
+ *,
60
+ method: str,
61
+ query: str,
62
+ body: bytes | None,
63
+ app_id: str,
64
+ app_secret: str,
65
+ timezone: str,
66
+ nonce: str,
67
+ ) -> dict[str, str]:
68
+ """Build the IOT-Open-* headers (and IOT-Time-Zone) for a single request.
69
+
70
+ Parameters
71
+ ----------
72
+ method:
73
+ HTTP method ("GET", "POST", …).
74
+ query:
75
+ The raw query string portion of the URL (without the leading "?").
76
+ body:
77
+ Raw request body bytes, or *None* / empty for GET requests.
78
+ app_id:
79
+ The application ID (``IOT-Open-AppID``).
80
+ app_secret:
81
+ The *decrypted* application secret used as the HMAC key.
82
+ timezone:
83
+ The client timezone string, e.g. "UTC" or "Europe/Moscow".
84
+ nonce:
85
+ A unique string per request (UUID hex recommended).
86
+ """
87
+ # --- body hash -----------------------------------------------------------
88
+ if method.upper() == "GET":
89
+ body_hash = ""
90
+ else:
91
+ body_hash = _sha256_hex(body if body else b"")
92
+
93
+ # --- params for the sign input -------------------------------------------
94
+ params: dict[str, str] = {}
95
+ if query:
96
+ for key, values in parse_qs(query, keep_blank_values=True).items():
97
+ params[key] = values[0]
98
+
99
+ # Remove any stale open-auth fields so they cannot influence the new sign.
100
+ for field in ("IOT-Open-AppID", "IOT-Open-Nonce", "IOT-Open-Sign", "IOT-Open-Body-Hash"):
101
+ params.pop(field, None)
102
+
103
+ params["IOT-Open-AppID"] = app_id
104
+ params["IOT-Open-Nonce"] = nonce
105
+ params["IOT-Open-Body-Hash"] = body_hash
106
+
107
+ # Sort alphabetically (case-sensitive, matching JS default sort order).
108
+ query_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
109
+
110
+ # --- sign ----------------------------------------------------------------
111
+ # CryptoJS: Utf8.parse(query_str) → Hex.stringify → hex_string
112
+ # Then: HmacSHA256(hex_string, secret) → MD5 → hex
113
+ hex_str = query_str.encode("utf-8").hex()
114
+ hmac_digest = hmac.new(
115
+ app_secret.encode("utf-8"),
116
+ hex_str.encode("utf-8"),
117
+ hashlib.sha256,
118
+ ).digest()
119
+ sign = hashlib.md5(hmac_digest).hexdigest() # noqa: S324
120
+
121
+ return {
122
+ "IOT-Time-Zone": timezone,
123
+ "IOT-Open-AppID": app_id,
124
+ "IOT-Open-Nonce": nonce,
125
+ "IOT-Open-Body-Hash": body_hash,
126
+ "IOT-Open-Sign": sign,
127
+ }
128
+
129
+
130
+ def attach_open_auth(
131
+ client: Any,
132
+ *,
133
+ app_id: str = SISELI_APP_ID,
134
+ encrypted_secret: str = SISELI_APP_SECRET_ENCRYPTED,
135
+ ) -> None:
136
+ """Attach an httpx request hook that adds Open API signing headers.
137
+
138
+ The hook is appended to ``client._http.event_hooks["request"]`` and runs
139
+ before every outgoing request, injecting:
140
+
141
+ - ``IOT-Time-Zone``
142
+ - ``IOT-Open-AppID``
143
+ - ``IOT-Open-Nonce``
144
+ - ``IOT-Open-Body-Hash``
145
+ - ``IOT-Open-Sign``
146
+
147
+ Parameters
148
+ ----------
149
+ client:
150
+ A :class:`~siseli.SiseliClient` instance (or any object with
151
+ ``._http.event_hooks`` and ``._timezone``).
152
+ app_id:
153
+ Override the default production App ID.
154
+ encrypted_secret:
155
+ Override the default production encrypted secret.
156
+ """
157
+ secret = decrypt_open_secret(app_id, encrypted_secret)
158
+ timezone = getattr(client, "_timezone", "UTC")
159
+
160
+ async def _sign_request(request: "httpx.Request") -> None:
161
+ nonce = uuid.uuid4().hex
162
+ query = request.url.query
163
+ if isinstance(query, bytes):
164
+ query = query.decode("utf-8")
165
+ body = request.content if request.method.upper() != "GET" else None
166
+ headers = build_open_headers(
167
+ method=request.method,
168
+ query=query,
169
+ body=body,
170
+ app_id=app_id,
171
+ app_secret=secret,
172
+ timezone=timezone,
173
+ nonce=nonce,
174
+ )
175
+ request.headers.update(headers)
176
+
177
+ client._http.event_hooks["request"].append(_sign_request)