python-siseli 0.1.0__tar.gz
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.
- python_siseli-0.1.0/PKG-INFO +57 -0
- python_siseli-0.1.0/README.md +44 -0
- python_siseli-0.1.0/pyproject.toml +25 -0
- python_siseli-0.1.0/python_siseli.egg-info/PKG-INFO +57 -0
- python_siseli-0.1.0/python_siseli.egg-info/SOURCES.txt +33 -0
- python_siseli-0.1.0/python_siseli.egg-info/dependency_links.txt +1 -0
- python_siseli-0.1.0/python_siseli.egg-info/requires.txt +6 -0
- python_siseli-0.1.0/python_siseli.egg-info/top_level.txt +1 -0
- python_siseli-0.1.0/setup.cfg +4 -0
- python_siseli-0.1.0/siseli/__init__.py +51 -0
- python_siseli-0.1.0/siseli/_utils.py +29 -0
- python_siseli-0.1.0/siseli/alarms.py +159 -0
- python_siseli-0.1.0/siseli/auth.py +96 -0
- python_siseli-0.1.0/siseli/client.py +474 -0
- python_siseli-0.1.0/siseli/config.py +112 -0
- python_siseli-0.1.0/siseli/const.py +5 -0
- python_siseli-0.1.0/siseli/dashboard.py +100 -0
- python_siseli-0.1.0/siseli/device.py +89 -0
- python_siseli-0.1.0/siseli/dictionary.py +37 -0
- python_siseli-0.1.0/siseli/exceptions.py +28 -0
- python_siseli-0.1.0/siseli/history.py +123 -0
- python_siseli-0.1.0/siseli/models/__init__.py +50 -0
- python_siseli-0.1.0/siseli/models/alarm.py +41 -0
- python_siseli-0.1.0/siseli/models/auth.py +19 -0
- python_siseli-0.1.0/siseli/models/common.py +51 -0
- python_siseli-0.1.0/siseli/models/config.py +28 -0
- python_siseli-0.1.0/siseli/models/dashboard.py +45 -0
- python_siseli-0.1.0/siseli/models/device.py +29 -0
- python_siseli-0.1.0/siseli/models/dictionary.py +18 -0
- python_siseli-0.1.0/siseli/models/history.py +34 -0
- python_siseli-0.1.0/siseli/models/state.py +106 -0
- python_siseli-0.1.0/siseli/models/station.py +82 -0
- python_siseli-0.1.0/siseli/state.py +184 -0
- python_siseli-0.1.0/siseli/station.py +181 -0
- python_siseli-0.1.0/tests/test_phase3.py +269 -0
|
@@ -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,44 @@
|
|
|
1
|
+
# python-siseli
|
|
2
|
+
|
|
3
|
+
Python SDK for [Siseli Cloud](https://solar.siseli.com).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install python-siseli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from siseli import SiseliClient
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with SiseliClient("user@example.com", "secret", timezone="Europe/Warsaw") as client:
|
|
19
|
+
# Discover devices
|
|
20
|
+
devices = await client.get_devices()
|
|
21
|
+
device = devices[0]
|
|
22
|
+
print(f"Device: {device.name} ({device.id})")
|
|
23
|
+
|
|
24
|
+
# Latest telemetry
|
|
25
|
+
state = await client.get_device_state(device.id)
|
|
26
|
+
grid = state.get("gridVoltage")
|
|
27
|
+
if grid:
|
|
28
|
+
print(f"Grid voltage: {grid.value_display} {grid.unit}")
|
|
29
|
+
|
|
30
|
+
# Energy flow diagram
|
|
31
|
+
flow = await client.get_energy_flow(device.id)
|
|
32
|
+
if flow.battery:
|
|
33
|
+
print(f"Battery: {flow.battery.value.value_display} {flow.battery.value.unit}")
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Development plan
|
|
39
|
+
|
|
40
|
+
See [`roadmap.md`](roadmap.md) for the full development plan.
|
|
41
|
+
|
|
42
|
+
## API documentation
|
|
43
|
+
|
|
44
|
+
See [`docs/api.md`](docs/api.md).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-siseli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Siseli Cloud"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8",
|
|
19
|
+
"pytest-asyncio>=0.23",
|
|
20
|
+
"respx>=0.21",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
25
|
+
include = ["siseli*"]
|
|
@@ -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,33 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
python_siseli.egg-info/PKG-INFO
|
|
4
|
+
python_siseli.egg-info/SOURCES.txt
|
|
5
|
+
python_siseli.egg-info/dependency_links.txt
|
|
6
|
+
python_siseli.egg-info/requires.txt
|
|
7
|
+
python_siseli.egg-info/top_level.txt
|
|
8
|
+
siseli/__init__.py
|
|
9
|
+
siseli/_utils.py
|
|
10
|
+
siseli/alarms.py
|
|
11
|
+
siseli/auth.py
|
|
12
|
+
siseli/client.py
|
|
13
|
+
siseli/config.py
|
|
14
|
+
siseli/const.py
|
|
15
|
+
siseli/dashboard.py
|
|
16
|
+
siseli/device.py
|
|
17
|
+
siseli/dictionary.py
|
|
18
|
+
siseli/exceptions.py
|
|
19
|
+
siseli/history.py
|
|
20
|
+
siseli/state.py
|
|
21
|
+
siseli/station.py
|
|
22
|
+
siseli/models/__init__.py
|
|
23
|
+
siseli/models/alarm.py
|
|
24
|
+
siseli/models/auth.py
|
|
25
|
+
siseli/models/common.py
|
|
26
|
+
siseli/models/config.py
|
|
27
|
+
siseli/models/dashboard.py
|
|
28
|
+
siseli/models/device.py
|
|
29
|
+
siseli/models/dictionary.py
|
|
30
|
+
siseli/models/history.py
|
|
31
|
+
siseli/models/state.py
|
|
32
|
+
siseli/models/station.py
|
|
33
|
+
tests/test_phase3.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
siseli
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -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
|