python-siseli 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.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-siseli
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Siseli Cloud
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx>=0.27
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
12
+ Requires-Dist: respx>=0.21; extra == "dev"
13
+
14
+ # python-siseli
15
+
16
+ Python SDK for [Siseli Cloud](https://solar.siseli.com).
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install python-siseli
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ import asyncio
28
+ from siseli import SiseliClient
29
+
30
+ async def main():
31
+ async with SiseliClient("user@example.com", "secret", timezone="Europe/Warsaw") as client:
32
+ # Discover devices
33
+ devices = await client.get_devices()
34
+ device = devices[0]
35
+ print(f"Device: {device.name} ({device.id})")
36
+
37
+ # Latest telemetry
38
+ state = await client.get_device_state(device.id)
39
+ grid = state.get("gridVoltage")
40
+ if grid:
41
+ print(f"Grid voltage: {grid.value_display} {grid.unit}")
42
+
43
+ # Energy flow diagram
44
+ flow = await client.get_energy_flow(device.id)
45
+ if flow.battery:
46
+ print(f"Battery: {flow.battery.value.value_display} {flow.battery.value.unit}")
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ ## Development plan
52
+
53
+ See [`roadmap.md`](roadmap.md) for the full development plan.
54
+
55
+ ## API documentation
56
+
57
+ See [`docs/api.md`](docs/api.md).
@@ -0,0 +1,29 @@
1
+ siseli/__init__.py,sha256=_sHO0M0medXnJz_ZTtHKyy8DP6e40wuf5ubEGLC1kBs,1035
2
+ siseli/_utils.py,sha256=QT-ooemKKAIEHICNI2QErPvRkFIkUGo6ncUFCJM1kGc,727
3
+ siseli/alarms.py,sha256=Mc_CV8kUBFTXu8c48NstX8Lc6Q0bPTnhjwehx9sbCkQ,4853
4
+ siseli/auth.py,sha256=L-aT_R9CQDrnwx6uiGR4aYaEdb3ky2DFQgqkW0x5cEM,3356
5
+ siseli/client.py,sha256=Eap7MTrnctdskt6kOzbns7SqUt27ZDjMlIKVshfcfN8,14200
6
+ siseli/config.py,sha256=J8raq7Yf4gCyjO9ISNoTrk_EYNEGrESV56O-aHfDvBU,3278
7
+ siseli/const.py,sha256=M8bBrc8cH0OyEGb9158_emux_dfknvikGQqm09iQ8CA,174
8
+ siseli/dashboard.py,sha256=JQHfi8orKs5uWsGGfoTvA14A9M_R9yIAYuGCxb4zTsM,3325
9
+ siseli/device.py,sha256=crix14pYakrDaFmomPC3Uwgdq-1boZ-ZaEg2HgzXibs,2762
10
+ siseli/dictionary.py,sha256=VSO83uXPaGLPzKGRmpROuWQKivXN7DNOmXN8QQJ-Y-w,1120
11
+ siseli/exceptions.py,sha256=Y34NlvH8Lx1Hf1tQY8rlmbtEElR9TCq-RmH5zrj9b3A,757
12
+ siseli/history.py,sha256=CgBvDfZBIrrewu7PeRv08-Zr4boEVOY8Xevruu-ge-I,3869
13
+ siseli/state.py,sha256=d7Exlf7FzF8O_CcF-NbAt6kq8qaR4fFhjGW8028kbrM,5800
14
+ siseli/station.py,sha256=e1OK4OIry9WUjT8KggQI5b4Acypm9D31mWAMTjDGFew,5531
15
+ siseli/models/__init__.py,sha256=WSIXRM_JRKNhB_hDwxrpeQgdqKUHdbT3KiFvdrLrrzY,1242
16
+ siseli/models/alarm.py,sha256=Id8OyN2HQ-EeU-E9AleUHmhFuOyTH27r1G5pT3g8nzM,887
17
+ siseli/models/auth.py,sha256=u9mpkhJ-1lhBQWHkgTKCdSdvVufufWhwyjs_jR0TKd8,381
18
+ siseli/models/common.py,sha256=zOEz8W-NGaNW7H-jUYOup7D9-cidWpvpRjeTRTe3WiY,960
19
+ siseli/models/config.py,sha256=VXlzXYij6ZhY0zmdaTjVxiA86H46mczBwcMI9L-xMf0,680
20
+ siseli/models/dashboard.py,sha256=P86feNI8Yn6ADkwDGFQJFUESrKLt6VttGqsEiNzGnZA,1125
21
+ siseli/models/device.py,sha256=u57-csypaF-c5hAimqb8GIMeZqYZMKMhYFBTVuBRSfQ,657
22
+ siseli/models/dictionary.py,sha256=d6KCu77UMlPcIDON8G3sjuytkSfbI5go1y3I_jAGTX8,422
23
+ siseli/models/history.py,sha256=dx26VWLJosucMvwL36vojvAguvkMiv5GEKTllUtZV0Y,791
24
+ siseli/models/state.py,sha256=VK5CNqMLRkY1w9HrcUhvaO_6J-rjqP7VwvbppCFpucI,2393
25
+ siseli/models/station.py,sha256=02OLINAVblnKkF73ZwKxYvPtUFROsu4-g544oDg8ApA,1783
26
+ python_siseli-0.1.0.dist-info/METADATA,sha256=6ie6JBHG1D8xEGY9afztyw-LaAVSM9Zn2y0LK8BZPRg,1407
27
+ python_siseli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
28
+ python_siseli-0.1.0.dist-info/top_level.txt,sha256=Dofr9c7hGvRRV6ij6aoPuAZqxEjkgtsBeN4KtFHbGgo,7
29
+ python_siseli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ siseli
siseli/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """python-siseli — Python SDK for Siseli Cloud."""
2
+
3
+ from .client import SiseliClient
4
+ from .exceptions import ApiError, AuthenticationError, NetworkError, SiseliError, TokenExpiredError
5
+ from .models import (
6
+ Alarm,
7
+ AlarmReport,
8
+ AttributeGroup,
9
+ AttributeGroupSet,
10
+ AttributeMetadata,
11
+ ConfigBatchRead,
12
+ DashboardSummary,
13
+ Device,
14
+ DeviceState,
15
+ DictionaryData,
16
+ EnergyFlow,
17
+ FlowNode,
18
+ HistorySeries,
19
+ PagedResult,
20
+ StateAttribute,
21
+ Station,
22
+ StationEnergyFlow,
23
+ TokenInfo,
24
+ )
25
+
26
+ __all__ = [
27
+ "SiseliClient",
28
+ "SiseliError",
29
+ "AuthenticationError",
30
+ "TokenExpiredError",
31
+ "ApiError",
32
+ "NetworkError",
33
+ "Alarm",
34
+ "AlarmReport",
35
+ "AttributeGroup",
36
+ "AttributeGroupSet",
37
+ "AttributeMetadata",
38
+ "ConfigBatchRead",
39
+ "DashboardSummary",
40
+ "Device",
41
+ "DeviceState",
42
+ "DictionaryData",
43
+ "EnergyFlow",
44
+ "FlowNode",
45
+ "HistorySeries",
46
+ "PagedResult",
47
+ "StateAttribute",
48
+ "Station",
49
+ "StationEnergyFlow",
50
+ "TokenInfo",
51
+ ]
siseli/_utils.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared parsing helpers for Siseli API payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+
9
+ def parse_datetime(value: str | None) -> datetime | None:
10
+ if not value:
11
+ return None
12
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
13
+
14
+
15
+ def serialize_datetime(value: datetime | str | None) -> str | None:
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, str):
19
+ return value
20
+ return value.isoformat().replace("+00:00", "Z")
21
+
22
+
23
+ def extract_value(raw: Any) -> Any:
24
+ if not isinstance(raw, dict):
25
+ return raw
26
+ for key in ("vd", "valueDisplay", "value"):
27
+ if key in raw:
28
+ return raw[key]
29
+ return raw
siseli/alarms.py ADDED
@@ -0,0 +1,159 @@
1
+ """Alarm retrieval helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable
6
+
7
+ from ._utils import parse_datetime, serialize_datetime
8
+ from .const import DEFAULT_PAGE_SIZE
9
+ from .models.alarm import Alarm, AlarmReport
10
+ from .models.common import PagedResult
11
+
12
+ RequestFunc = Callable[..., Awaitable[Any]]
13
+
14
+
15
+
16
+ def _parse_alarm(raw: dict) -> Alarm:
17
+ return Alarm(
18
+ id=raw.get("id", ""),
19
+ device_id=raw.get("deviceId"),
20
+ device_serial_number=raw.get("deviceSerialNumber"),
21
+ device_name=raw.get("deviceName"),
22
+ station_id=raw.get("stationId"),
23
+ station_name=raw.get("stationName"),
24
+ level=raw.get("level"),
25
+ state=raw.get("state"),
26
+ category=raw.get("category"),
27
+ title=raw.get("title") or raw.get("name") or raw.get("alarmName"),
28
+ content=raw.get("content") or raw.get("message") or raw.get("remark"),
29
+ created_at=parse_datetime(raw.get("createdAt")),
30
+ processed_at=parse_datetime(raw.get("processedAt")),
31
+ raw=raw,
32
+ )
33
+
34
+
35
+
36
+ def _parse_alarm_report(raw: dict) -> AlarmReport:
37
+ return AlarmReport(
38
+ id=raw.get("id", ""),
39
+ remark=raw.get("remark"),
40
+ report_from_time=parse_datetime(raw.get("reportFromTime")),
41
+ report_to_time=parse_datetime(raw.get("reportToTime") or raw.get("reportToFime")),
42
+ progress=raw.get("progress"),
43
+ state=raw.get("state"),
44
+ download_resid=raw.get("downloadResid"),
45
+ created_at=parse_datetime(raw.get("createdAt")),
46
+ raw=raw,
47
+ )
48
+
49
+
50
+ async def fetch_latest_alarm(
51
+ request: RequestFunc,
52
+ *,
53
+ certificate_dtu_id: str = "",
54
+ device_serial_number: str = "",
55
+ page: int = 1,
56
+ count: int = 1,
57
+ ) -> Alarm | None:
58
+ """Return the latest alarm matching filters."""
59
+ data = await request(
60
+ "POST",
61
+ "/apis/alarm/getLatestAlarm",
62
+ json={
63
+ "certificateDtuID": certificate_dtu_id,
64
+ "deviceSerialNumber": device_serial_number,
65
+ "page": page,
66
+ "count": count,
67
+ },
68
+ )
69
+ if not data:
70
+ return None
71
+ return _parse_alarm(data)
72
+
73
+
74
+ async def fetch_alarm_list(
75
+ request: RequestFunc,
76
+ *,
77
+ page: int = 1,
78
+ count: int = DEFAULT_PAGE_SIZE,
79
+ certificate_dtu_id: str = "",
80
+ device_serial_number: str = "",
81
+ from_time: Any = None,
82
+ to_time: Any = None,
83
+ is_processed: int | None = None,
84
+ level: int | None = None,
85
+ order_by_created_time_desc: bool = True,
86
+ ) -> PagedResult[Alarm]:
87
+ """Return paginated alarm history."""
88
+ data = await request(
89
+ "POST",
90
+ "/apis/alarm/query/list",
91
+ json={
92
+ "page": page,
93
+ "count": count,
94
+ "certificateDtuID": certificate_dtu_id,
95
+ "deviceSerialNumber": device_serial_number,
96
+ "fromTime": serialize_datetime(from_time),
97
+ "toTime": serialize_datetime(to_time),
98
+ "isProcessed": is_processed,
99
+ "level": level,
100
+ "orderByCreatedTimeDesc": order_by_created_time_desc,
101
+ },
102
+ )
103
+ items = [_parse_alarm(item) for item in data.get("list", [])]
104
+ return PagedResult(
105
+ page=data.get("page", page),
106
+ count=data.get("count", count),
107
+ total=data.get("total", len(items)),
108
+ items=items,
109
+ raw=data,
110
+ )
111
+
112
+
113
+ async def fetch_alarm_report_headers(request: RequestFunc) -> list[dict[str, Any]]:
114
+ """Return alarm report column definitions."""
115
+ return await request("GET", "/apis/alarm/report/alarmList/headers")
116
+
117
+
118
+ async def fetch_alarm_report_details(request: RequestFunc, record_id: str) -> dict[str, Any]:
119
+ """Return details for one alarm report record."""
120
+ return await request(
121
+ "GET",
122
+ "/apis/alarm/report/record/details",
123
+ params={"id": record_id},
124
+ )
125
+
126
+
127
+ async def fetch_alarm_reports(
128
+ request: RequestFunc,
129
+ *,
130
+ page: int = 1,
131
+ count: int = DEFAULT_PAGE_SIZE,
132
+ dtu_id: str = "",
133
+ state: int | None = None,
134
+ created_from_time: Any = None,
135
+ created_to_time: Any = None,
136
+ order_by_created_at_asc: bool = False,
137
+ ) -> PagedResult[AlarmReport]:
138
+ """Return alarm report/export history."""
139
+ data = await request(
140
+ "POST",
141
+ "/apis/alarm/report/record/list",
142
+ json={
143
+ "page": page,
144
+ "count": count,
145
+ "dtuID": dtu_id,
146
+ "state": state,
147
+ "createdFromTime": serialize_datetime(created_from_time),
148
+ "createdToTime": serialize_datetime(created_to_time),
149
+ "orderByCreatedAtAsc": order_by_created_at_asc,
150
+ },
151
+ )
152
+ items = [_parse_alarm_report(item) for item in data.get("list", [])]
153
+ return PagedResult(
154
+ page=data.get("page", page),
155
+ count=data.get("count", count),
156
+ total=data.get("total", len(items)),
157
+ items=items,
158
+ raw=data,
159
+ )
siseli/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ """Authentication: login and token lifecycle management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import UTC, datetime
7
+
8
+ import httpx
9
+
10
+ from .exceptions import AuthenticationError
11
+ from .models.auth import TokenInfo
12
+
13
+
14
+ def _md5(text: str) -> str:
15
+ # The Siseli Cloud API requires the MD5 hash of the plaintext password.
16
+ # This is a protocol constraint imposed by the server, not a local
17
+ # password-storage decision. The hash is sent over HTTPS and is never
18
+ # stored on disk.
19
+ return hashlib.md5(text.encode()).hexdigest() # noqa: S324
20
+
21
+
22
+ def _parse_dt(value: str | None) -> datetime | None:
23
+ if not value:
24
+ return None
25
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
26
+
27
+
28
+ class Auth:
29
+ """Manages credentials and the current access token.
30
+
31
+ The password is hashed with MD5 before being sent to the API, which
32
+ matches the behaviour observed in the browser client.
33
+ """
34
+
35
+ def __init__(self, account: str, password: str) -> None:
36
+ self._account = account
37
+ # The web client sends the MD5 hash of the plaintext password.
38
+ self._password_hash = _md5(password)
39
+ self._token_info: TokenInfo | None = None
40
+
41
+ async def login(self, http: httpx.AsyncClient) -> TokenInfo:
42
+ """Authenticate and store the returned tokens.
43
+
44
+ Raises :exc:`~siseli.exceptions.AuthenticationError` on failure.
45
+ """
46
+ try:
47
+ response = await http.post(
48
+ "/apis/login/account",
49
+ json={"account": self._account, "password": self._password_hash},
50
+ )
51
+ response.raise_for_status()
52
+ except httpx.HTTPError as exc:
53
+ raise AuthenticationError(f"Login request failed: {exc}") from exc
54
+
55
+ body = response.json()
56
+ if body.get("code") != 0:
57
+ raise AuthenticationError(
58
+ f"Login failed (code {body.get('code')}): {body.get('message')}"
59
+ )
60
+
61
+ data = body["data"]
62
+ self._token_info = TokenInfo(
63
+ access_token=data["accessToken"],
64
+ refresh_token=data["refreshToken"],
65
+ access_token_expires_at=_parse_dt(data.get("accessTokenWillExpiredAt")),
66
+ refresh_token_expires_at=_parse_dt(data.get("refreshTokenWillExpiredAt")),
67
+ auth_id=data.get("authId", ""),
68
+ account=data.get("account", self._account),
69
+ user_id=data.get("userId", ""),
70
+ )
71
+ return self._token_info
72
+
73
+ @property
74
+ def access_token(self) -> str:
75
+ """Return the current access token.
76
+
77
+ Raises :exc:`~siseli.exceptions.AuthenticationError` if not yet
78
+ authenticated.
79
+ """
80
+ if self._token_info is None:
81
+ raise AuthenticationError("Not authenticated — call authenticate() first")
82
+ return self._token_info.access_token
83
+
84
+ @property
85
+ def token_info(self) -> TokenInfo | None:
86
+ """Return token metadata, or *None* if not authenticated."""
87
+ return self._token_info
88
+
89
+ def is_authenticated(self) -> bool:
90
+ """Return *True* when there is a valid, non-expired access token."""
91
+ if self._token_info is None:
92
+ return False
93
+ expires_at = self._token_info.access_token_expires_at
94
+ if expires_at is None:
95
+ return True
96
+ return datetime.now(UTC) < expires_at