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.
siseli/client.py ADDED
@@ -0,0 +1,474 @@
1
+ """SiseliClient — the main public entry point for the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Iterable
7
+
8
+ import httpx
9
+
10
+ from .alarms import (
11
+ fetch_alarm_list,
12
+ fetch_alarm_report_details,
13
+ fetch_alarm_report_headers,
14
+ fetch_alarm_reports,
15
+ fetch_latest_alarm,
16
+ )
17
+ from .auth import Auth
18
+ from .config import (
19
+ fetch_cached_device_configs,
20
+ fetch_device_config,
21
+ fetch_device_config_batch_details,
22
+ fetch_device_configs,
23
+ write_device_config,
24
+ )
25
+ from .const import BASE_URL, DEFAULT_DATA_SOURCE, DEFAULT_PAGE_SIZE, DEFAULT_TIMEOUT
26
+ from .dashboard import (
27
+ fetch_dashboard_daily_generation_time_rank,
28
+ fetch_dashboard_monthly_generated_energy,
29
+ fetch_dashboard_station_distribution,
30
+ fetch_dashboard_summary,
31
+ )
32
+ from .device import fetch_device_details, fetch_device_list
33
+ from .dictionary import fetch_dictionary
34
+ from .exceptions import ApiError, NetworkError
35
+ from .history import fetch_attribute_history, fetch_state_history
36
+ from .models.alarm import Alarm, AlarmReport
37
+ from .models.common import PagedResult, TimePoint
38
+ from .models.config import ConfigBatchRead
39
+ from .models.dashboard import DashboardSummary, LocationDistribution, StationRankEntry
40
+ from .models.device import Device
41
+ from .models.dictionary import DictionaryData
42
+ from .models.history import HistorySeries
43
+ from .models.state import AttributeGroupSet, AttributeMetadata, DeviceState, EnergyFlow
44
+ from .models.station import Station, StationEnergyFlow, StationSummary
45
+ from .state import (
46
+ fetch_device_attribute_groups,
47
+ fetch_device_attributes,
48
+ fetch_device_state,
49
+ fetch_energy_flow,
50
+ )
51
+ from .station import (
52
+ fetch_station_details,
53
+ fetch_station_energy_flow,
54
+ fetch_station_income,
55
+ fetch_station_list,
56
+ fetch_station_state_summary,
57
+ )
58
+
59
+
60
+ class SiseliClient:
61
+ """Async client for the Siseli Cloud API."""
62
+
63
+ def __init__(
64
+ self,
65
+ account: str,
66
+ password: str,
67
+ *,
68
+ base_url: str = BASE_URL,
69
+ timezone: str = "UTC",
70
+ timeout: float = DEFAULT_TIMEOUT,
71
+ ) -> None:
72
+ self._auth = Auth(account, password)
73
+ self._timezone = timezone
74
+ self._http = httpx.AsyncClient(
75
+ base_url=base_url,
76
+ timeout=timeout,
77
+ headers={"Accept": "application/json"},
78
+ )
79
+
80
+ async def authenticate(self) -> None:
81
+ await self._auth.login(self._http)
82
+
83
+ async def close(self) -> None:
84
+ await self._http.aclose()
85
+
86
+ async def __aenter__(self) -> SiseliClient:
87
+ await self.authenticate()
88
+ return self
89
+
90
+ async def __aexit__(self, *_: Any) -> None:
91
+ await self.close()
92
+
93
+ async def _ensure_authenticated(self) -> None:
94
+ if not self._auth.is_authenticated():
95
+ await self._auth.login(self._http)
96
+
97
+ async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
98
+ await self._ensure_authenticated()
99
+ auth_headers = {
100
+ "IOT-Token": self._auth.access_token,
101
+ "IOT-Time-Zone": self._timezone,
102
+ }
103
+ try:
104
+ response = await self._http.request(
105
+ method, path, headers=auth_headers, **kwargs
106
+ )
107
+ response.raise_for_status()
108
+ except httpx.HTTPStatusError as exc:
109
+ raise NetworkError(
110
+ f"HTTP {exc.response.status_code} for {method} {path}"
111
+ ) from exc
112
+ except httpx.HTTPError as exc:
113
+ raise NetworkError(f"Request failed for {method} {path}: {exc}") from exc
114
+
115
+ body = response.json()
116
+ if body.get("code") != 0:
117
+ raise ApiError(body.get("code", -1), body.get("message", ""))
118
+ return body.get("data")
119
+
120
+ async def get_devices(
121
+ self,
122
+ *,
123
+ page: int = 1,
124
+ count: int = DEFAULT_PAGE_SIZE,
125
+ name: str = "",
126
+ serial_number: str = "",
127
+ station_id: str = "",
128
+ state: str = "",
129
+ ) -> list[Device]:
130
+ devices, _ = await fetch_device_list(
131
+ self._request,
132
+ page=page,
133
+ count=count,
134
+ name=name,
135
+ serial_number=serial_number,
136
+ station_id=station_id,
137
+ state=state,
138
+ )
139
+ return devices
140
+
141
+ async def get_all_devices(self) -> list[Device]:
142
+ all_devices: list[Device] = []
143
+ page = 1
144
+ while True:
145
+ devices, total = await fetch_device_list(
146
+ self._request,
147
+ page=page,
148
+ count=DEFAULT_PAGE_SIZE,
149
+ )
150
+ all_devices.extend(devices)
151
+ if len(all_devices) >= total:
152
+ break
153
+ page += 1
154
+ return all_devices
155
+
156
+ async def get_device(self, device_id: str) -> Device:
157
+ return await fetch_device_details(self._request, device_id)
158
+
159
+ async def get_device_state(
160
+ self,
161
+ device_id: str,
162
+ *,
163
+ data_source: int = DEFAULT_DATA_SOURCE,
164
+ ) -> DeviceState:
165
+ return await fetch_device_state(
166
+ self._request, device_id, data_source=data_source
167
+ )
168
+
169
+ async def get_device_attributes(
170
+ self,
171
+ device_id: str,
172
+ *,
173
+ category: str = "",
174
+ render_in: str = "",
175
+ ) -> list[AttributeMetadata]:
176
+ return await fetch_device_attributes(
177
+ self._request,
178
+ device_id,
179
+ category=category,
180
+ render_in=render_in,
181
+ )
182
+
183
+ async def get_device_attribute_groups(
184
+ self,
185
+ device_id: str,
186
+ *,
187
+ category: str = "",
188
+ render_in: str = "",
189
+ ) -> AttributeGroupSet:
190
+ return await fetch_device_attribute_groups(
191
+ self._request,
192
+ device_id,
193
+ category=category,
194
+ render_in=render_in,
195
+ )
196
+
197
+ async def get_energy_flow(
198
+ self,
199
+ device_id: str,
200
+ *,
201
+ data_source: int = DEFAULT_DATA_SOURCE,
202
+ ) -> EnergyFlow:
203
+ return await fetch_energy_flow(
204
+ self._request, device_id, data_source=data_source
205
+ )
206
+
207
+ async def get_attribute_history(
208
+ self,
209
+ device_id: str,
210
+ keys: Iterable[str],
211
+ *,
212
+ from_time: datetime | str | None = None,
213
+ to_time: datetime | str | None = None,
214
+ page: int = 1,
215
+ count: int = DEFAULT_PAGE_SIZE,
216
+ order_by_time_asc: bool = True,
217
+ ) -> HistorySeries:
218
+ return await fetch_attribute_history(
219
+ self._request,
220
+ device_id,
221
+ keys,
222
+ from_time=from_time,
223
+ to_time=to_time,
224
+ page=page,
225
+ count=count,
226
+ order_by_time_asc=order_by_time_asc,
227
+ )
228
+
229
+ async def get_state_history(
230
+ self,
231
+ device_id: str,
232
+ *,
233
+ from_time: datetime | str | None = None,
234
+ to_time: datetime | str | None = None,
235
+ page: int = 1,
236
+ count: int = DEFAULT_PAGE_SIZE,
237
+ order_by_time_asc: bool = False,
238
+ ) -> HistorySeries:
239
+ return await fetch_state_history(
240
+ self._request,
241
+ device_id,
242
+ from_time=from_time,
243
+ to_time=to_time,
244
+ page=page,
245
+ count=count,
246
+ order_by_time_asc=order_by_time_asc,
247
+ )
248
+
249
+ async def get_device_config(
250
+ self,
251
+ device_id: str,
252
+ *,
253
+ key: str = "",
254
+ config_id: str = "",
255
+ ) -> AttributeMetadata:
256
+ return await fetch_device_config(
257
+ self._request,
258
+ device_id,
259
+ key=key,
260
+ config_id=config_id,
261
+ )
262
+
263
+ async def get_cached_device_configs(
264
+ self,
265
+ device_id: str,
266
+ ) -> dict[str, AttributeMetadata]:
267
+ return await fetch_cached_device_configs(self._request, device_id)
268
+
269
+ async def read_device_configs(self, device_id: str) -> ConfigBatchRead:
270
+ return await fetch_device_configs(self._request, device_id)
271
+
272
+ async def get_device_config_batch(self, batch_read_id: str) -> ConfigBatchRead:
273
+ return await fetch_device_config_batch_details(self._request, batch_read_id)
274
+
275
+ async def set_device_config(
276
+ self,
277
+ device_id: str,
278
+ *,
279
+ key: str,
280
+ value: Any,
281
+ config_id: str = "",
282
+ ) -> AttributeMetadata:
283
+ return await write_device_config(
284
+ self._request,
285
+ device_id,
286
+ key=key,
287
+ value=value,
288
+ config_id=config_id,
289
+ )
290
+
291
+ async def get_stations(
292
+ self,
293
+ *,
294
+ page: int = 1,
295
+ count: int = DEFAULT_PAGE_SIZE,
296
+ name: str = "",
297
+ connected_grid_type: str = "",
298
+ state: str = "",
299
+ station_type: str = "",
300
+ ) -> list[Station]:
301
+ result = await fetch_station_list(
302
+ self._request,
303
+ page=page,
304
+ count=count,
305
+ name=name,
306
+ connected_grid_type=connected_grid_type,
307
+ state=state,
308
+ station_type=station_type,
309
+ )
310
+ return result.items
311
+
312
+ async def get_all_stations(self) -> list[Station]:
313
+ all_stations: list[Station] = []
314
+ page = 1
315
+ while True:
316
+ result = await fetch_station_list(
317
+ self._request,
318
+ page=page,
319
+ count=DEFAULT_PAGE_SIZE,
320
+ )
321
+ all_stations.extend(result.items)
322
+ if len(all_stations) >= result.total:
323
+ break
324
+ page += 1
325
+ return all_stations
326
+
327
+ async def get_station(self, station_id: str) -> Station:
328
+ return await fetch_station_details(self._request, station_id)
329
+
330
+ async def get_station_energy_flow(
331
+ self,
332
+ station_id: str,
333
+ *,
334
+ is_manual_refresh: bool = False,
335
+ ) -> StationEnergyFlow:
336
+ return await fetch_station_energy_flow(
337
+ self._request,
338
+ station_id,
339
+ is_manual_refresh=is_manual_refresh,
340
+ )
341
+
342
+ async def get_station_income(
343
+ self,
344
+ station_id: str,
345
+ aggregation: str,
346
+ *,
347
+ time: datetime | str | None = None,
348
+ ) -> list[TimePoint]:
349
+ return await fetch_station_income(
350
+ self._request,
351
+ station_id,
352
+ aggregation,
353
+ time=time,
354
+ )
355
+
356
+ async def get_station_summary(
357
+ self,
358
+ station_id: str,
359
+ summary_category_key: str,
360
+ aggregation: str,
361
+ *,
362
+ time: datetime | str | None = None,
363
+ ) -> StationSummary:
364
+ return await fetch_station_state_summary(
365
+ self._request,
366
+ station_id,
367
+ summary_category_key,
368
+ aggregation,
369
+ time=time,
370
+ )
371
+
372
+ async def get_latest_alarm(
373
+ self,
374
+ *,
375
+ certificate_dtu_id: str = "",
376
+ device_serial_number: str = "",
377
+ page: int = 1,
378
+ count: int = 1,
379
+ ) -> Alarm | None:
380
+ return await fetch_latest_alarm(
381
+ self._request,
382
+ certificate_dtu_id=certificate_dtu_id,
383
+ device_serial_number=device_serial_number,
384
+ page=page,
385
+ count=count,
386
+ )
387
+
388
+ async def get_alarms(
389
+ self,
390
+ *,
391
+ page: int = 1,
392
+ count: int = DEFAULT_PAGE_SIZE,
393
+ certificate_dtu_id: str = "",
394
+ device_serial_number: str = "",
395
+ from_time: datetime | str | None = None,
396
+ to_time: datetime | str | None = None,
397
+ is_processed: int | None = None,
398
+ level: int | None = None,
399
+ order_by_created_time_desc: bool = True,
400
+ ) -> PagedResult[Alarm]:
401
+ return await fetch_alarm_list(
402
+ self._request,
403
+ page=page,
404
+ count=count,
405
+ certificate_dtu_id=certificate_dtu_id,
406
+ device_serial_number=device_serial_number,
407
+ from_time=from_time,
408
+ to_time=to_time,
409
+ is_processed=is_processed,
410
+ level=level,
411
+ order_by_created_time_desc=order_by_created_time_desc,
412
+ )
413
+
414
+ async def get_alarm_report_headers(self) -> list[dict[str, Any]]:
415
+ return await fetch_alarm_report_headers(self._request)
416
+
417
+ async def get_alarm_report(self, record_id: str) -> dict[str, Any]:
418
+ return await fetch_alarm_report_details(self._request, record_id)
419
+
420
+ async def get_alarm_reports(
421
+ self,
422
+ *,
423
+ page: int = 1,
424
+ count: int = DEFAULT_PAGE_SIZE,
425
+ dtu_id: str = "",
426
+ state: int | None = None,
427
+ created_from_time: datetime | str | None = None,
428
+ created_to_time: datetime | str | None = None,
429
+ order_by_created_at_asc: bool = False,
430
+ ) -> PagedResult[AlarmReport]:
431
+ return await fetch_alarm_reports(
432
+ self._request,
433
+ page=page,
434
+ count=count,
435
+ dtu_id=dtu_id,
436
+ state=state,
437
+ created_from_time=created_from_time,
438
+ created_to_time=created_to_time,
439
+ order_by_created_at_asc=order_by_created_at_asc,
440
+ )
441
+
442
+ async def get_dashboard_summary(self) -> DashboardSummary:
443
+ return await fetch_dashboard_summary(self._request)
444
+
445
+ async def get_dashboard_daily_generation_time_rank(
446
+ self,
447
+ *,
448
+ asc: bool = False,
449
+ ) -> list[StationRankEntry]:
450
+ return await fetch_dashboard_daily_generation_time_rank(self._request, asc=asc)
451
+
452
+ async def get_dashboard_station_distribution(
453
+ self,
454
+ *,
455
+ east_longitude: float | None = None,
456
+ west_longitude: float | None = None,
457
+ north_latitude: float | None = None,
458
+ south_latitude: float | None = None,
459
+ level: int | None = None,
460
+ ) -> list[LocationDistribution]:
461
+ return await fetch_dashboard_station_distribution(
462
+ self._request,
463
+ east_longitude=east_longitude,
464
+ west_longitude=west_longitude,
465
+ north_latitude=north_latitude,
466
+ south_latitude=south_latitude,
467
+ level=level,
468
+ )
469
+
470
+ async def get_dashboard_monthly_generated_energy(self) -> list[TimePoint]:
471
+ return await fetch_dashboard_monthly_generated_energy(self._request)
472
+
473
+ async def get_dictionary(self, name: str) -> DictionaryData:
474
+ return await fetch_dictionary(self._request, name)
siseli/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """Remote device configuration retrieval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable
6
+
7
+ from ._utils import parse_datetime
8
+ from .models.config import ConfigBatchRead
9
+ from .models.state import AttributeMetadata
10
+ from .state import _parse_attribute_metadata
11
+
12
+ RequestFunc = Callable[..., Awaitable[Any]]
13
+
14
+
15
+ def _parse_config_map(raw: dict[str, dict] | None) -> dict[str, AttributeMetadata]:
16
+ if not raw:
17
+ return {}
18
+ return {key: _parse_attribute_metadata(item, key) for key, item in raw.items()}
19
+
20
+
21
+
22
+ def _parse_batch_read(raw: dict) -> ConfigBatchRead:
23
+ target_config = raw.get("targetConfig")
24
+ return ConfigBatchRead(
25
+ id=raw.get("id", ""),
26
+ device_id=raw.get("deviceId", ""),
27
+ scene=raw.get("scene"),
28
+ request_keys=raw.get("requestKeys", []) or [],
29
+ target_config=_parse_config_map(target_config if isinstance(target_config, dict) else None),
30
+ gather_protocol_version_id=raw.get("gatherProtocolVersionId"),
31
+ gather_protocol_version_code=raw.get("gatherProtocolVersionCode"),
32
+ start_at=parse_datetime(raw.get("startAt")),
33
+ end_at=parse_datetime(raw.get("endAt")),
34
+ error=raw.get("error"),
35
+ is_finished=raw.get("isFinished", False),
36
+ created_at=parse_datetime(raw.get("createdAt")),
37
+ raw=raw,
38
+ )
39
+
40
+
41
+ async def fetch_device_config(
42
+ request: RequestFunc,
43
+ device_id: str,
44
+ *,
45
+ key: str = "",
46
+ config_id: str = "",
47
+ ) -> AttributeMetadata:
48
+ """Read one remote device configuration value."""
49
+ data = await request(
50
+ "POST",
51
+ "/apis/remote/device/config/read",
52
+ params={"deviceId": device_id},
53
+ json={"id": config_id, "key": key},
54
+ )
55
+ return _parse_attribute_metadata(data, key)
56
+
57
+
58
+ async def write_device_config(
59
+ request: RequestFunc,
60
+ device_id: str,
61
+ *,
62
+ key: str,
63
+ value: Any,
64
+ config_id: str = "",
65
+ ) -> AttributeMetadata:
66
+ """Write one remote device configuration value."""
67
+ data = await request(
68
+ "POST",
69
+ "/apis/remote/device/config/write",
70
+ params={"deviceId": device_id},
71
+ json={"id": config_id, "key": key, "value": value},
72
+ )
73
+ return _parse_attribute_metadata(data, key)
74
+
75
+
76
+ async def fetch_cached_device_configs(
77
+ request: RequestFunc,
78
+ device_id: str,
79
+ ) -> dict[str, AttributeMetadata]:
80
+ """Return cached remote configurations for a device."""
81
+ data = await request(
82
+ "POST",
83
+ "/apis/remote/device/configs/cache/get",
84
+ params={"deviceId": device_id},
85
+ )
86
+ return _parse_config_map(data)
87
+
88
+
89
+ async def fetch_device_configs(
90
+ request: RequestFunc,
91
+ device_id: str,
92
+ ) -> ConfigBatchRead:
93
+ """Start a batch remote configuration read."""
94
+ data = await request(
95
+ "POST",
96
+ "/apis/remote/device/configs/read",
97
+ params={"deviceId": device_id},
98
+ )
99
+ return _parse_batch_read(data)
100
+
101
+
102
+ async def fetch_device_config_batch_details(
103
+ request: RequestFunc,
104
+ batch_read_id: str,
105
+ ) -> ConfigBatchRead:
106
+ """Return status/details for a batch remote configuration read."""
107
+ data = await request(
108
+ "GET",
109
+ "/apis/remote/device/configs/read/details",
110
+ params={"batchReadId": batch_read_id},
111
+ )
112
+ return _parse_batch_read(data)
siseli/const.py ADDED
@@ -0,0 +1,5 @@
1
+ BASE_URL = "https://solar.siseli.com"
2
+ DEFAULT_TIMEOUT = 30.0
3
+ DEFAULT_PAGE_SIZE = 20
4
+ # dataSource=1 observed in all telemetry requests in HAR captures
5
+ DEFAULT_DATA_SOURCE = 1
siseli/dashboard.py ADDED
@@ -0,0 +1,100 @@
1
+ """Dashboard summary helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Awaitable, Callable
6
+
7
+ from .models.common import CountByValue, TimePoint
8
+ from .models.dashboard import DashboardSummary, LocationDistribution, StationRankEntry
9
+ from .station import _parse_time_point
10
+
11
+ RequestFunc = Callable[..., Awaitable[Any]]
12
+
13
+
14
+ async def fetch_dashboard_summary(request: RequestFunc) -> DashboardSummary:
15
+ """Return top-level dashboard summary metrics."""
16
+ data = await request("POST", "/apis/dashboard/summary/commons", json={})
17
+ return DashboardSummary(
18
+ daily_produced_quantity=data.get("dailyProducedQuantity"),
19
+ total_produced_quantity=data.get("totalProducedQuantity"),
20
+ total_power=data.get("totalPower"),
21
+ saving_standard_carbon=data.get("savingStandardCarbon"),
22
+ co2_emission_reduction=data.get("co2EmissionReduction"),
23
+ so2_emission_reduction=data.get("so2EmissionReduction"),
24
+ nox_emission_reduction=data.get("noxEmissionReduction"),
25
+ devices_number=data.get("devicesNumber"),
26
+ all_installed_capacity=data.get("allInstalledCapacity"),
27
+ station_total_number=data.get("stationTotalNumber"),
28
+ station_state_summary=[
29
+ CountByValue(
30
+ value=item.get("state"),
31
+ count=item.get("count", 0),
32
+ label=item.get("stateDict"),
33
+ raw=item,
34
+ )
35
+ for item in data.get("stationStateSummary", [])
36
+ ],
37
+ raw=data,
38
+ )
39
+
40
+
41
+ async def fetch_dashboard_daily_generation_time_rank(
42
+ request: RequestFunc,
43
+ *,
44
+ asc: bool = False,
45
+ ) -> list[StationRankEntry]:
46
+ """Return dashboard station ranking by daily generation time."""
47
+ data = await request(
48
+ "POST",
49
+ "/apis/dashboard/summary/station/dailyGenerationTimeRank",
50
+ params={"asc": asc},
51
+ json={},
52
+ )
53
+ return [
54
+ StationRankEntry(name=item.get("name", ""), full_time=item.get("fullTime"), raw=item)
55
+ for item in data
56
+ ]
57
+
58
+
59
+ async def fetch_dashboard_station_distribution(
60
+ request: RequestFunc,
61
+ *,
62
+ east_longitude: float | None = None,
63
+ west_longitude: float | None = None,
64
+ north_latitude: float | None = None,
65
+ south_latitude: float | None = None,
66
+ level: int | None = None,
67
+ ) -> list[LocationDistribution]:
68
+ """Return dashboard station distribution by location."""
69
+ data = await request(
70
+ "POST",
71
+ "/apis/dashboard/summary/station/distribution/location",
72
+ json={
73
+ "eastLongitude": east_longitude,
74
+ "westLongitude": west_longitude,
75
+ "northLatitude": north_latitude,
76
+ "southLatitude": south_latitude,
77
+ "level": level,
78
+ },
79
+ )
80
+ return [
81
+ LocationDistribution(
82
+ name=item.get("name"),
83
+ longitude=item.get("longitude"),
84
+ latitude=item.get("latitude"),
85
+ raw=item,
86
+ )
87
+ for item in data
88
+ ]
89
+
90
+
91
+ async def fetch_dashboard_monthly_generated_energy(
92
+ request: RequestFunc,
93
+ ) -> list[TimePoint]:
94
+ """Return monthly generated energy points for the dashboard."""
95
+ data = await request(
96
+ "POST",
97
+ "/apis/dashboard/summary/station/generatedEnergy/monthly",
98
+ json={},
99
+ )
100
+ return [_parse_time_point(item) for item in data]