sungrow-isolarcloud 0.5.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.
- sungrow_isolarcloud-0.5.0/LICENSE.txt +18 -0
- sungrow_isolarcloud-0.5.0/MANIFEST.in +2 -0
- sungrow_isolarcloud-0.5.0/PKG-INFO +151 -0
- sungrow_isolarcloud-0.5.0/README.md +126 -0
- sungrow_isolarcloud-0.5.0/pyproject.toml +29 -0
- sungrow_isolarcloud-0.5.0/setup.cfg +4 -0
- sungrow_isolarcloud-0.5.0/setup.py +28 -0
- sungrow_isolarcloud-0.5.0/src/pysolarcloud/__init__.py +151 -0
- sungrow_isolarcloud-0.5.0/src/pysolarcloud/control.py +236 -0
- sungrow_isolarcloud-0.5.0/src/pysolarcloud/plants.py +396 -0
- sungrow_isolarcloud-0.5.0/src/sungrow_isolarcloud.egg-info/PKG-INFO +151 -0
- sungrow_isolarcloud-0.5.0/src/sungrow_isolarcloud.egg-info/SOURCES.txt +13 -0
- sungrow_isolarcloud-0.5.0/src/sungrow_isolarcloud.egg-info/dependency_links.txt +1 -0
- sungrow_isolarcloud-0.5.0/src/sungrow_isolarcloud.egg-info/requires.txt +5 -0
- sungrow_isolarcloud-0.5.0/src/sungrow_isolarcloud.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright 2025 Tore Green
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is furnished
|
|
8
|
+
to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
17
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sungrow-isolarcloud
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A library to interact with Sungrow's iSolarCloud API (KRoperUK fork with battery, EV charger and dispatch extensions)
|
|
5
|
+
Author: KRoperUK
|
|
6
|
+
Author-email: Tore Green <bugjam@e-dreams.dk>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/KRoperUK/pysolarcloud
|
|
9
|
+
Project-URL: Issues, https://github.com/KRoperUK/pysolarcloud/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Requires-Python: >=3.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE.txt
|
|
17
|
+
Requires-Dist: aiohttp
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: provides-extra
|
|
23
|
+
Dynamic: requires-dist
|
|
24
|
+
Dynamic: requires-python
|
|
25
|
+
|
|
26
|
+
# sungrow-isolarcloud
|
|
27
|
+
|
|
28
|
+
A maintained fork of the [pysolarcloud](https://github.com/bugjam/pysolarcloud) library for interacting with Sungrow's [iSolarCloud API](https://developer-api.isolarcloud.com/).
|
|
29
|
+
|
|
30
|
+
Install from PyPI:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install sungrow-isolarcloud
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This fork adds:
|
|
37
|
+
* Support for requesting **additional / custom measure points** without modifying the upstream point map (useful for battery charge/discharge power fields that vary by inverter model).
|
|
38
|
+
* A best-effort **per-device realtime** helper for devices such as EV chargers (`Plants.async_get_device_realtime`).
|
|
39
|
+
* A **heartbeat** helper for External EMS dispatch mode (`Control.async_heartbeat` / `Control.heartbeat_loop`).
|
|
40
|
+
* Convenience constants for dispatch command value sets (`Control.CHARGE_DISCHARGE_COMMANDS`, `Control.FORCED_CHARGING`).
|
|
41
|
+
|
|
42
|
+
The package supports the following functionality:
|
|
43
|
+
* OAuth2 authentication
|
|
44
|
+
* Getting a list plants
|
|
45
|
+
* Getting details of a plant
|
|
46
|
+
* Getting devices of a plant
|
|
47
|
+
* Getting "real-time" data of a plant (Data is updated every 5 minutes according to Sungrow's documentation)
|
|
48
|
+
* Getting historical data
|
|
49
|
+
* Getting and updating grid control settings
|
|
50
|
+
|
|
51
|
+
## Quirks
|
|
52
|
+
The iSolarCloud API is quite new and not very mature. Some tips:
|
|
53
|
+
* The authorisation flow is based on OAuth2 but doesn't work exactly as you would expect
|
|
54
|
+
* The `state` parameter is not passed back after to the authorisation step. This makes it more tricky to resume the flow in a client application.
|
|
55
|
+
* User is asked to approve the authorisation if the flow is invoked again, e.g. in case the tokens have expired - unlike many OAuth2 implementations who will perform a "silent" authorisation if the user has already approved the access.
|
|
56
|
+
* The API documentation lists a lot of data points which do not seem to be returned from my inverter, it probably varies between models.
|
|
57
|
+
* There are different iSolarCloud servers for different regions, see the `pysolarcloud.Server` enum
|
|
58
|
+
* API endpoints accept a language code but respond with Chinese text when when English is requested
|
|
59
|
+
|
|
60
|
+
# Usage
|
|
61
|
+
|
|
62
|
+
## Register your app
|
|
63
|
+
1. Create an account in the [iSolarCloud Developer Portal](https://developer-api.isolarcloud.com/)
|
|
64
|
+
2. Create an app in the developer portal
|
|
65
|
+
* Answer "Yes" to authorize with OAuth2.0
|
|
66
|
+
* Enter a Redirect URL for your app (this can be changed later)
|
|
67
|
+
3. Wait for approval by Sungrow
|
|
68
|
+
4. Find the needed configuration details in the developer portal. You will need:
|
|
69
|
+
* Appkey
|
|
70
|
+
* Secret Key
|
|
71
|
+
* Application Id (This is shown as a query parameter within the Authorize URL in the developer portal)
|
|
72
|
+
|
|
73
|
+
## Example
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pysolarcloud import Auth, Server
|
|
77
|
+
from pysolarcloud.plants import Plants
|
|
78
|
+
|
|
79
|
+
app_key = "your app key"
|
|
80
|
+
secret_key = "your secret key"
|
|
81
|
+
app_id = "your app id"
|
|
82
|
+
redirect_uri = "your redirect uri"
|
|
83
|
+
|
|
84
|
+
auth = Auth(Server.Europe, app_key, secret_key, app_id)
|
|
85
|
+
url = auth.auth_url(redirect_uri)
|
|
86
|
+
```
|
|
87
|
+
1. Redirect user to `url`
|
|
88
|
+
2. User selects plant(s) and grants authorisation
|
|
89
|
+
3. iSolarCloud will redirect the user to `redirect_uri` with query parameter `code`
|
|
90
|
+
```python
|
|
91
|
+
await auth.async_authorize(code, redirect_uri)
|
|
92
|
+
plants_api = Plants(auth)
|
|
93
|
+
plant_list = await plants_api.async_get_plants()
|
|
94
|
+
if plant_list:
|
|
95
|
+
print(f"{len(plant_list)} plants found:")
|
|
96
|
+
for plant in plant_list:
|
|
97
|
+
print(f"Plant ID: {plant["ps_id"]}, Name: {plant["ps_name"]}")
|
|
98
|
+
else:
|
|
99
|
+
print("No plants found.")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
print("\nFetching detailed information for each plant...\n")
|
|
103
|
+
plant_ids = [str(plant["ps_id"]) for plant in plant_list]
|
|
104
|
+
plant_details = await plants_api.async_get_plant_details(plant_ids)
|
|
105
|
+
for plant in plant_details:
|
|
106
|
+
print(f"Details for Plant ID {plant["ps_id"]}: {plant}")
|
|
107
|
+
|
|
108
|
+
print("\nFetching real-time data for each plant...\n")
|
|
109
|
+
real_time_data = await plants_api.async_get_realtime_data(plant_ids)
|
|
110
|
+
for plant_id, data in real_time_data.items():
|
|
111
|
+
# Print only the data points where value is not None
|
|
112
|
+
data_values = {k: v for k, v in data.items() if v and v.get("value") is not None}
|
|
113
|
+
print(f"Real-time data for Plant ID {plant_id}: {data_values}")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `Auth` class keeps the access between calls and refreshes it when needed. If you prefer to manage this state yourself, you can create your own subclass of `AbstractAuth`.
|
|
117
|
+
|
|
118
|
+
## Grid Control
|
|
119
|
+
|
|
120
|
+
The `Control` class enables retrieving and updating grid control settings. Parameters and value sets are documented in the iSolarCloud Developer portal.
|
|
121
|
+
|
|
122
|
+
### Example
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pysolarcloud.control import Control
|
|
126
|
+
from pysolarcloud.plants import DeviceType
|
|
127
|
+
|
|
128
|
+
devices = await plants_api.async_get_plant_devices(plant_id, device_types=[DeviceType.ENERGY_STORAGE_SYSTEM])
|
|
129
|
+
device_uuid = devices[0]["uuid"]
|
|
130
|
+
control_api = Control(auth)
|
|
131
|
+
# Fetch current config
|
|
132
|
+
current_settings = await control_api.async_read_parameters(device_uuid)
|
|
133
|
+
print(current_settings)
|
|
134
|
+
# Make an update using the canonical command values
|
|
135
|
+
await control_api.async_update_parameters(
|
|
136
|
+
device_uuid,
|
|
137
|
+
{
|
|
138
|
+
"charge_discharge_command": Control.CHARGE_DISCHARGE_COMMANDS["charge"],
|
|
139
|
+
"charge_discharge_power": "2500",
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# When using External EMS mode, send a heartbeat periodically to keep the inverter in dispatch mode.
|
|
144
|
+
# 10017 = external_ems_heartbeat, value is the heartbeat interval in seconds (1-1000).
|
|
145
|
+
await control_api.async_heartbeat(device_uuid, interval_seconds=60)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
# Contributions
|
|
149
|
+
Ideas or contributions are welcome. I am not afiliated with Sungrow, I'm just another user of the API. My main use case will be a HomeAssistant integration based on this package.
|
|
150
|
+
|
|
151
|
+
Enjoy!
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# sungrow-isolarcloud
|
|
2
|
+
|
|
3
|
+
A maintained fork of the [pysolarcloud](https://github.com/bugjam/pysolarcloud) library for interacting with Sungrow's [iSolarCloud API](https://developer-api.isolarcloud.com/).
|
|
4
|
+
|
|
5
|
+
Install from PyPI:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install sungrow-isolarcloud
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This fork adds:
|
|
12
|
+
* Support for requesting **additional / custom measure points** without modifying the upstream point map (useful for battery charge/discharge power fields that vary by inverter model).
|
|
13
|
+
* A best-effort **per-device realtime** helper for devices such as EV chargers (`Plants.async_get_device_realtime`).
|
|
14
|
+
* A **heartbeat** helper for External EMS dispatch mode (`Control.async_heartbeat` / `Control.heartbeat_loop`).
|
|
15
|
+
* Convenience constants for dispatch command value sets (`Control.CHARGE_DISCHARGE_COMMANDS`, `Control.FORCED_CHARGING`).
|
|
16
|
+
|
|
17
|
+
The package supports the following functionality:
|
|
18
|
+
* OAuth2 authentication
|
|
19
|
+
* Getting a list plants
|
|
20
|
+
* Getting details of a plant
|
|
21
|
+
* Getting devices of a plant
|
|
22
|
+
* Getting "real-time" data of a plant (Data is updated every 5 minutes according to Sungrow's documentation)
|
|
23
|
+
* Getting historical data
|
|
24
|
+
* Getting and updating grid control settings
|
|
25
|
+
|
|
26
|
+
## Quirks
|
|
27
|
+
The iSolarCloud API is quite new and not very mature. Some tips:
|
|
28
|
+
* The authorisation flow is based on OAuth2 but doesn't work exactly as you would expect
|
|
29
|
+
* The `state` parameter is not passed back after to the authorisation step. This makes it more tricky to resume the flow in a client application.
|
|
30
|
+
* User is asked to approve the authorisation if the flow is invoked again, e.g. in case the tokens have expired - unlike many OAuth2 implementations who will perform a "silent" authorisation if the user has already approved the access.
|
|
31
|
+
* The API documentation lists a lot of data points which do not seem to be returned from my inverter, it probably varies between models.
|
|
32
|
+
* There are different iSolarCloud servers for different regions, see the `pysolarcloud.Server` enum
|
|
33
|
+
* API endpoints accept a language code but respond with Chinese text when when English is requested
|
|
34
|
+
|
|
35
|
+
# Usage
|
|
36
|
+
|
|
37
|
+
## Register your app
|
|
38
|
+
1. Create an account in the [iSolarCloud Developer Portal](https://developer-api.isolarcloud.com/)
|
|
39
|
+
2. Create an app in the developer portal
|
|
40
|
+
* Answer "Yes" to authorize with OAuth2.0
|
|
41
|
+
* Enter a Redirect URL for your app (this can be changed later)
|
|
42
|
+
3. Wait for approval by Sungrow
|
|
43
|
+
4. Find the needed configuration details in the developer portal. You will need:
|
|
44
|
+
* Appkey
|
|
45
|
+
* Secret Key
|
|
46
|
+
* Application Id (This is shown as a query parameter within the Authorize URL in the developer portal)
|
|
47
|
+
|
|
48
|
+
## Example
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pysolarcloud import Auth, Server
|
|
52
|
+
from pysolarcloud.plants import Plants
|
|
53
|
+
|
|
54
|
+
app_key = "your app key"
|
|
55
|
+
secret_key = "your secret key"
|
|
56
|
+
app_id = "your app id"
|
|
57
|
+
redirect_uri = "your redirect uri"
|
|
58
|
+
|
|
59
|
+
auth = Auth(Server.Europe, app_key, secret_key, app_id)
|
|
60
|
+
url = auth.auth_url(redirect_uri)
|
|
61
|
+
```
|
|
62
|
+
1. Redirect user to `url`
|
|
63
|
+
2. User selects plant(s) and grants authorisation
|
|
64
|
+
3. iSolarCloud will redirect the user to `redirect_uri` with query parameter `code`
|
|
65
|
+
```python
|
|
66
|
+
await auth.async_authorize(code, redirect_uri)
|
|
67
|
+
plants_api = Plants(auth)
|
|
68
|
+
plant_list = await plants_api.async_get_plants()
|
|
69
|
+
if plant_list:
|
|
70
|
+
print(f"{len(plant_list)} plants found:")
|
|
71
|
+
for plant in plant_list:
|
|
72
|
+
print(f"Plant ID: {plant["ps_id"]}, Name: {plant["ps_name"]}")
|
|
73
|
+
else:
|
|
74
|
+
print("No plants found.")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
print("\nFetching detailed information for each plant...\n")
|
|
78
|
+
plant_ids = [str(plant["ps_id"]) for plant in plant_list]
|
|
79
|
+
plant_details = await plants_api.async_get_plant_details(plant_ids)
|
|
80
|
+
for plant in plant_details:
|
|
81
|
+
print(f"Details for Plant ID {plant["ps_id"]}: {plant}")
|
|
82
|
+
|
|
83
|
+
print("\nFetching real-time data for each plant...\n")
|
|
84
|
+
real_time_data = await plants_api.async_get_realtime_data(plant_ids)
|
|
85
|
+
for plant_id, data in real_time_data.items():
|
|
86
|
+
# Print only the data points where value is not None
|
|
87
|
+
data_values = {k: v for k, v in data.items() if v and v.get("value") is not None}
|
|
88
|
+
print(f"Real-time data for Plant ID {plant_id}: {data_values}")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The `Auth` class keeps the access between calls and refreshes it when needed. If you prefer to manage this state yourself, you can create your own subclass of `AbstractAuth`.
|
|
92
|
+
|
|
93
|
+
## Grid Control
|
|
94
|
+
|
|
95
|
+
The `Control` class enables retrieving and updating grid control settings. Parameters and value sets are documented in the iSolarCloud Developer portal.
|
|
96
|
+
|
|
97
|
+
### Example
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from pysolarcloud.control import Control
|
|
101
|
+
from pysolarcloud.plants import DeviceType
|
|
102
|
+
|
|
103
|
+
devices = await plants_api.async_get_plant_devices(plant_id, device_types=[DeviceType.ENERGY_STORAGE_SYSTEM])
|
|
104
|
+
device_uuid = devices[0]["uuid"]
|
|
105
|
+
control_api = Control(auth)
|
|
106
|
+
# Fetch current config
|
|
107
|
+
current_settings = await control_api.async_read_parameters(device_uuid)
|
|
108
|
+
print(current_settings)
|
|
109
|
+
# Make an update using the canonical command values
|
|
110
|
+
await control_api.async_update_parameters(
|
|
111
|
+
device_uuid,
|
|
112
|
+
{
|
|
113
|
+
"charge_discharge_command": Control.CHARGE_DISCHARGE_COMMANDS["charge"],
|
|
114
|
+
"charge_discharge_power": "2500",
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# When using External EMS mode, send a heartbeat periodically to keep the inverter in dispatch mode.
|
|
119
|
+
# 10017 = external_ems_heartbeat, value is the heartbeat interval in seconds (1-1000).
|
|
120
|
+
await control_api.async_heartbeat(device_uuid, interval_seconds=60)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
# Contributions
|
|
124
|
+
Ideas or contributions are welcome. I am not afiliated with Sungrow, I'm just another user of the API. My main use case will be a HomeAssistant integration based on this package.
|
|
125
|
+
|
|
126
|
+
Enjoy!
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sungrow-isolarcloud"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="Tore Green", email="bugjam@e-dreams.dk" },
|
|
6
|
+
{ name="KRoperUK" },
|
|
7
|
+
]
|
|
8
|
+
description = "A library to interact with Sungrow's iSolarCloud API (KRoperUK fork with battery, EV charger and dispatch extensions)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"Topic :: Home Automation",
|
|
16
|
+
]
|
|
17
|
+
license = { text = "MIT" }
|
|
18
|
+
dynamic = [
|
|
19
|
+
"dependencies",
|
|
20
|
+
"optional-dependencies"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["setuptools"]
|
|
25
|
+
build-backend = "setuptools.build_meta"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/KRoperUK/pysolarcloud"
|
|
29
|
+
Issues = "https://github.com/KRoperUK/pysolarcloud/issues"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="pysolarcloud",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(where="src"),
|
|
7
|
+
package_dir={"": "src"},
|
|
8
|
+
install_requires=[
|
|
9
|
+
"aiohttp",
|
|
10
|
+
],
|
|
11
|
+
extras_require={
|
|
12
|
+
"dev": [
|
|
13
|
+
"pytest",
|
|
14
|
+
"pytest-asyncio",
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
entry_points={
|
|
18
|
+
"console_scripts": [
|
|
19
|
+
# Add any command-line scripts here
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
classifiers=[
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
],
|
|
27
|
+
python_requires=">=3.7",
|
|
28
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""A Python library to interact with Sungrow's iSolarCloud API."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from urllib.parse import quote_plus
|
|
8
|
+
|
|
9
|
+
from aiohttp import ClientResponse, ClientSession
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class Server(StrEnum):
|
|
14
|
+
"""Enum of iSolarCloud servers."""
|
|
15
|
+
China = "https://gateway.isolarcloud.com"
|
|
16
|
+
International = "https://gateway.isolarcloud.com.hk"
|
|
17
|
+
Europe = "https://gateway.isolarcloud.eu"
|
|
18
|
+
Australia = "https://augateway.isolarcloud.com"
|
|
19
|
+
|
|
20
|
+
class AbstractAuth(ABC):
|
|
21
|
+
"""Abstract class to make authenticated requests.
|
|
22
|
+
|
|
23
|
+
Subclasses must implement the async_get_access_token method
|
|
24
|
+
and may call async_fetch_tokens and async_refresh_tokens.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, websession: ClientSession, server: Server | str, client_id: str, client_secret: str, app_id: str):
|
|
28
|
+
"""Initialize the authorization session."""
|
|
29
|
+
self.websession = websession
|
|
30
|
+
self.host = server.value if isinstance(server, Server) else server
|
|
31
|
+
self.appkey = client_id
|
|
32
|
+
self.access_key = client_secret
|
|
33
|
+
self.app_id = app_id
|
|
34
|
+
|
|
35
|
+
def auth_url(self, redirect_uri: str) -> str:
|
|
36
|
+
"""Return the URL to authorize the user."""
|
|
37
|
+
match self.host:
|
|
38
|
+
case Server.China.value:
|
|
39
|
+
auth_server = "web3.isolarcloud.com"
|
|
40
|
+
cloud_id = 1
|
|
41
|
+
case Server.International.value:
|
|
42
|
+
auth_server = "web3.isolarcloud.com.hk"
|
|
43
|
+
cloud_id = 2
|
|
44
|
+
case Server.Europe.value:
|
|
45
|
+
auth_server = "web3.isolarcloud.eu"
|
|
46
|
+
cloud_id = 3
|
|
47
|
+
case Server.Australia.value:
|
|
48
|
+
auth_server = "auweb3.isolarcloud.com"
|
|
49
|
+
cloud_id = 7
|
|
50
|
+
return f"https://{auth_server}/#/authorized-app?cloudId={cloud_id}&applicationId={self.app_id}&redirectUrl={quote_plus(redirect_uri)}"
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def async_get_access_token(self) -> str:
|
|
54
|
+
"""Return a valid access token."""
|
|
55
|
+
|
|
56
|
+
async def request(self, path, data, *, lang="_en_US", **kwargs) -> ClientResponse:
|
|
57
|
+
"""Make a request to iSolarCloud.
|
|
58
|
+
|
|
59
|
+
Parameters:
|
|
60
|
+
path -- the path to request
|
|
61
|
+
data -- the data to send
|
|
62
|
+
lang -- the language to use (default "_en_US", supported languages are "_en_US", "_zh_CN", "_ja_JP", "_es_ES", "_de_DE", "_pt_BR", "_fr_FR", "_it_IT", "_ko_KR", "_nl_NL", "_pl_PL", "_vi_VN", "_zh_TW"
|
|
63
|
+
**kwargs -- additional arguments to pass to the request
|
|
64
|
+
"""
|
|
65
|
+
if not path.startswith("/"):
|
|
66
|
+
path = f"/{path}"
|
|
67
|
+
if headers := kwargs.pop("headers", {}):
|
|
68
|
+
headers = dict(headers)
|
|
69
|
+
access_token = await self.async_get_access_token()
|
|
70
|
+
headers = {**headers, "x-access-key": self.access_key, "Authorization": f"Bearer {access_token}"}
|
|
71
|
+
body = {**data, "appkey": self.appkey, "lang": lang}
|
|
72
|
+
return await self.websession.request(
|
|
73
|
+
"post", f"{self.host}{path}", json=body, **kwargs, headers=headers,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def async_fetch_tokens(self, code, redirect_uri, **kwargs) -> ClientResponse:
|
|
77
|
+
"""Fetch the access and refresh tokens."""
|
|
78
|
+
if headers := kwargs.pop("headers", {}):
|
|
79
|
+
headers = dict(headers)
|
|
80
|
+
headers = {**headers, "x-access-key": self.access_key, "Content-type": "application/json"}
|
|
81
|
+
body = {
|
|
82
|
+
"appkey": self.appkey,
|
|
83
|
+
"code": code,
|
|
84
|
+
"grant_type": "authorization_code",
|
|
85
|
+
"redirect_uri": redirect_uri
|
|
86
|
+
}
|
|
87
|
+
response = await self.websession.request("post", f"{self.host}/openapi/apiManage/token", json=body, headers=headers, **kwargs)
|
|
88
|
+
return await response.json()
|
|
89
|
+
|
|
90
|
+
async def async_refresh_tokens(self, refresh_token, **kwargs) -> ClientResponse:
|
|
91
|
+
"""Refresh the access token."""
|
|
92
|
+
if headers := kwargs.pop("headers", {}):
|
|
93
|
+
headers = dict(headers)
|
|
94
|
+
headers = {**headers, "x-access-key": self.access_key}
|
|
95
|
+
body = {
|
|
96
|
+
"appkey": self.appkey,
|
|
97
|
+
"refresh_token": refresh_token
|
|
98
|
+
}
|
|
99
|
+
response = await self.websession.request("post", f"{self.host}/openapi/apiManage/refreshToken", json=body, **kwargs, headers=headers)
|
|
100
|
+
return await response.json()
|
|
101
|
+
|
|
102
|
+
class Auth(AbstractAuth):
|
|
103
|
+
"""Class to authenticate with the SolarCloud API."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, host: str, appkey: str, access_key: str, app_id: str, *, websession: ClientSession = None):
|
|
106
|
+
"""Initialize the auth."""
|
|
107
|
+
if websession is None:
|
|
108
|
+
websession = ClientSession(raise_for_status=True)
|
|
109
|
+
super().__init__(websession, host, appkey, access_key, app_id)
|
|
110
|
+
self.tokens = None
|
|
111
|
+
|
|
112
|
+
async def async_authorize(self, code, redirect_uri):
|
|
113
|
+
"""Authorize the user."""
|
|
114
|
+
ts = await self.async_fetch_tokens(code, redirect_uri)
|
|
115
|
+
print(ts)
|
|
116
|
+
if "access_token" not in ts:
|
|
117
|
+
_LOGGER.error("Authorization failed: %s", str(ts))
|
|
118
|
+
return
|
|
119
|
+
self.tokens = {
|
|
120
|
+
"access_token": ts["access_token"],
|
|
121
|
+
"refresh_token": ts["refresh_token"],
|
|
122
|
+
"expires_at": int(time.time()) + ts["expires_in"] - 20,
|
|
123
|
+
}
|
|
124
|
+
_LOGGER.debug("Authorization succesful")
|
|
125
|
+
|
|
126
|
+
async def async_get_access_token(self) -> str:
|
|
127
|
+
"""Return a valid access token."""
|
|
128
|
+
if self.tokens is None:
|
|
129
|
+
raise PySolarCloudException({"error": "auth_not_initialised", "error_description": "You must authorize first."})
|
|
130
|
+
if self.tokens["expires_at"] < int(time.time()):
|
|
131
|
+
ts = await self.async_refresh_tokens(self.tokens["refresh_token"])
|
|
132
|
+
self.tokens = {
|
|
133
|
+
"access_token": ts["access_token"],
|
|
134
|
+
"refresh_token": ts["refresh_token"],
|
|
135
|
+
"expires_at": int(time.time()) + ts["expires_in"] - 20,
|
|
136
|
+
}
|
|
137
|
+
return self.tokens["access_token"]
|
|
138
|
+
|
|
139
|
+
class PySolarCloudException(Exception):
|
|
140
|
+
"""Exception class raised by PySolarCloud when communication with the iSolarCloud service fails."""
|
|
141
|
+
def __init__(self, err: dict|str):
|
|
142
|
+
if isinstance(err, dict):
|
|
143
|
+
super().__init__(err["error"])
|
|
144
|
+
self.error = err["error"]
|
|
145
|
+
self.error_description = err.get("error_description")
|
|
146
|
+
self.req_serial_num = err.get("req_serial_num", None)
|
|
147
|
+
else:
|
|
148
|
+
super().__init__(err)
|
|
149
|
+
self.error = err
|
|
150
|
+
self.error_description = None
|
|
151
|
+
self.req_serial_num = None
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from . import AbstractAuth, PySolarCloudException, _LOGGER
|
|
4
|
+
|
|
5
|
+
class Control:
|
|
6
|
+
"""Class to interact with the Grid Control API."""
|
|
7
|
+
def __init__(self, auth: AbstractAuth, *, lang: str = "_en_US"):
|
|
8
|
+
"""Initialize the control API."""
|
|
9
|
+
self.auth = auth
|
|
10
|
+
|
|
11
|
+
async def async_param_config_verification(self, device_uuid: str, set_type: int) -> bool:
|
|
12
|
+
"""Verifies whether the device supports parameter configuration."""
|
|
13
|
+
uri = "/openapi/platform/paramSettingCheck"
|
|
14
|
+
res = await self.auth.request(uri, {"set_type": set_type, "uuid": str(device_uuid)})
|
|
15
|
+
res.raise_for_status()
|
|
16
|
+
data = await res.json()
|
|
17
|
+
_LOGGER.debug("async_param_config_verification: %s", data)
|
|
18
|
+
if data.get("result_code") == "1" and data["result_data"]["check_result"] == "1":
|
|
19
|
+
supported = data["result_data"]["dev_result_list"][0]["check_result"]
|
|
20
|
+
if supported == "1":
|
|
21
|
+
return True
|
|
22
|
+
else:
|
|
23
|
+
return False
|
|
24
|
+
raise PySolarCloudException(f"Could not check support for device {device_uuid} set_type {set_type}: {data}")
|
|
25
|
+
|
|
26
|
+
async def async_check_read_support(self, device_uuid: str) -> bool:
|
|
27
|
+
"""Check if the device supports read operations."""
|
|
28
|
+
return await self.async_param_config_verification(device_uuid, 2)
|
|
29
|
+
|
|
30
|
+
async def async_check_update_support(self, device_uuid: str) -> bool:
|
|
31
|
+
"""Check if the device supports read operations."""
|
|
32
|
+
return await self.async_param_config_verification(device_uuid, 0)
|
|
33
|
+
|
|
34
|
+
async def wait_for_task(self, device_uuid: str, task_id: str) -> dict:
|
|
35
|
+
"""Poll for the task to be completed."""
|
|
36
|
+
uri = "/openapi/platform/getParamSettingTask"
|
|
37
|
+
params = {
|
|
38
|
+
"task_id": str(task_id),
|
|
39
|
+
"uuid": str(device_uuid),
|
|
40
|
+
}
|
|
41
|
+
await asyncio.sleep(2)
|
|
42
|
+
while True:
|
|
43
|
+
res = await self.auth.request(uri, params)
|
|
44
|
+
res.raise_for_status()
|
|
45
|
+
data = await res.json()
|
|
46
|
+
_LOGGER.debug("wait_for_task: %s", data)
|
|
47
|
+
if data.get("result_code") == "1" and data["result_data"]["command_status"] == 2:
|
|
48
|
+
# Task is still running
|
|
49
|
+
await asyncio.sleep(5)
|
|
50
|
+
continue
|
|
51
|
+
elif data.get("result_code") == "1" and data["result_data"]["command_status"] == 8:
|
|
52
|
+
return data["result_data"]["param_list"]
|
|
53
|
+
else:
|
|
54
|
+
_LOGGER.error("Task not successful %s: %s", task_id, data)
|
|
55
|
+
raise PySolarCloudException(f"Task not succesful {task_id}: {data}")
|
|
56
|
+
|
|
57
|
+
async def async_read_parameters(self, device_uuid: str, param_list : list[str]|None = None) -> dict:
|
|
58
|
+
"""Read the parameters from the device."""
|
|
59
|
+
uri = "/openapi/platform/paramSetting"
|
|
60
|
+
if param_list is None:
|
|
61
|
+
ps = self.config_parameters.keys()
|
|
62
|
+
else:
|
|
63
|
+
param_map = {v: k for k, v in self.config_parameters.items()}
|
|
64
|
+
ps = [param_map.get(p,p) for p in param_list]
|
|
65
|
+
_LOGGER.debug("async_read_parameters: param_list=%s", ps)
|
|
66
|
+
plist = [ { "param_code": p, "set_value": "" } for p in ps ]
|
|
67
|
+
params = {
|
|
68
|
+
"set_type": 2,
|
|
69
|
+
"uuid": str(device_uuid),
|
|
70
|
+
"task_name": f"Readback {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
71
|
+
"expire_second": 120,
|
|
72
|
+
"param_list": plist,
|
|
73
|
+
}
|
|
74
|
+
res = await self.auth.request(uri, params)
|
|
75
|
+
res.raise_for_status()
|
|
76
|
+
data = await res.json()
|
|
77
|
+
_LOGGER.debug("async_read_parameters: %s", data)
|
|
78
|
+
if data.get("result_code") == "1" and data["result_data"]["check_result"] == "1" \
|
|
79
|
+
and data["result_data"]["dev_result_list"][0]["code"] == "1":
|
|
80
|
+
task_id = data["result_data"]["dev_result_list"][0]["task_id"]
|
|
81
|
+
results = await self.wait_for_task(device_uuid, task_id)
|
|
82
|
+
return [self._format_param_readout(param, param["return_value"]) for param in results]
|
|
83
|
+
raise PySolarCloudException(f"Could not read parameters from device {device_uuid}: {data}")
|
|
84
|
+
|
|
85
|
+
async def async_update_parameters(self, device_uuid: str, param_values : dict) -> dict:
|
|
86
|
+
"""Update parameters to the device."""
|
|
87
|
+
uri = "/openapi/platform/paramSetting"
|
|
88
|
+
param_codes = {v: k for k, v in self.config_parameters.items()}
|
|
89
|
+
plist = [ { "param_code": param_codes.get(str(p),str(p)), "set_value": str(v) } for p,v in param_values.items() ]
|
|
90
|
+
_LOGGER.debug("async_update_parameters: param_valuest=%s", plist)
|
|
91
|
+
params = {
|
|
92
|
+
"set_type": 0,
|
|
93
|
+
"uuid": str(device_uuid),
|
|
94
|
+
"task_name": f"Update {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
95
|
+
"expire_second": 120,
|
|
96
|
+
"param_list": plist,
|
|
97
|
+
}
|
|
98
|
+
res = await self.auth.request(uri, params)
|
|
99
|
+
res.raise_for_status()
|
|
100
|
+
data = await res.json()
|
|
101
|
+
_LOGGER.debug("async_update_parameters: %s", data)
|
|
102
|
+
if data.get("result_code") == "1" and data["result_data"]["check_result"] == "1" \
|
|
103
|
+
and data["result_data"]["dev_result_list"][0]["code"] == "1":
|
|
104
|
+
task_id = data["result_data"]["dev_result_list"][0]["task_id"]
|
|
105
|
+
results = await self.wait_for_task(device_uuid, task_id)
|
|
106
|
+
return [self._format_param_readout(param, param["set_value"]) for param in results]
|
|
107
|
+
raise PySolarCloudException(f"Could not update parameters of device {device_uuid}: {data}")
|
|
108
|
+
|
|
109
|
+
async def async_heartbeat(self, device_uuid: str, interval_seconds: int) -> None:
|
|
110
|
+
"""Send a single External EMS heartbeat (param 10017) and return.
|
|
111
|
+
|
|
112
|
+
The iSolarCloud API expects the heartbeat value to be the polling interval itself
|
|
113
|
+
(1-1000 seconds, see Appendix 10 of the developer portal). When the EMS stops sending
|
|
114
|
+
heartbeats the inverter reverts to its default mode.
|
|
115
|
+
|
|
116
|
+
For a long-running heartbeat use :meth:`heartbeat_loop` instead.
|
|
117
|
+
"""
|
|
118
|
+
if not 1 <= interval_seconds <= 1000:
|
|
119
|
+
raise ValueError("heartbeat interval must be between 1 and 1000 seconds")
|
|
120
|
+
await self.async_update_parameters(device_uuid, {"external_ems_heartbeat": str(interval_seconds)})
|
|
121
|
+
|
|
122
|
+
async def heartbeat_loop(self, device_uuid: str, interval_seconds: int, stop_event: asyncio.Event) -> None:
|
|
123
|
+
"""Continuously send External EMS heartbeats until *stop_event* is set.
|
|
124
|
+
|
|
125
|
+
Each heartbeat refreshes param 10017 to *interval_seconds*. Sleeps
|
|
126
|
+
``interval_seconds`` between heartbeats so the inverter never times out.
|
|
127
|
+
"""
|
|
128
|
+
if not 1 <= interval_seconds <= 1000:
|
|
129
|
+
raise ValueError("heartbeat interval must be between 1 and 1000 seconds")
|
|
130
|
+
while not stop_event.is_set():
|
|
131
|
+
try:
|
|
132
|
+
await self.async_heartbeat(device_uuid, interval_seconds)
|
|
133
|
+
except PySolarCloudException as err:
|
|
134
|
+
_LOGGER.warning("EMS heartbeat failed for %s: %s", device_uuid, err)
|
|
135
|
+
try:
|
|
136
|
+
await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
|
|
137
|
+
except asyncio.TimeoutError:
|
|
138
|
+
continue
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def _format_param_readout(self, param: str, value: str) -> dict:
|
|
142
|
+
"""Format the parameter response."""
|
|
143
|
+
readout = {
|
|
144
|
+
"id": param["param_code"],
|
|
145
|
+
"code": self.config_parameters.get(param["param_code"], param["param_code"]),
|
|
146
|
+
"name": param["point_name"],
|
|
147
|
+
"value": value,
|
|
148
|
+
"unit": param.get("unit", ""),
|
|
149
|
+
"precision": param.get("set_precision", None),
|
|
150
|
+
}
|
|
151
|
+
if param.get("set_val_name"):
|
|
152
|
+
value_set_names = param["set_val_name"].split("|")
|
|
153
|
+
value_set_values = param["set_val_name_val"].split("|")
|
|
154
|
+
if value in value_set_values:
|
|
155
|
+
readout["value"] = value_set_names[value_set_values.index(value)]
|
|
156
|
+
readout["value_set"] = dict(zip(value_set_names, value_set_values))
|
|
157
|
+
else:
|
|
158
|
+
try:
|
|
159
|
+
readout["value"] = float(value)
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
return readout
|
|
163
|
+
|
|
164
|
+
# Canonical name -> on-the-wire value for the `charge_discharge_command` parameter
|
|
165
|
+
# (10004). The API returns either the numeric value or one of these names depending
|
|
166
|
+
# on firmware.
|
|
167
|
+
CHARGE_DISCHARGE_COMMANDS = {
|
|
168
|
+
"stop": "204",
|
|
169
|
+
"charge": "170",
|
|
170
|
+
"discharge": "187",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Canonical name -> on-the-wire value for `forced_charging` (10065).
|
|
174
|
+
FORCED_CHARGING = {
|
|
175
|
+
"disable": "85",
|
|
176
|
+
"enable": "170",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
config_parameters = {
|
|
180
|
+
"10001": "soc_upper_limit",
|
|
181
|
+
"10002": "soc_lower_limit",
|
|
182
|
+
"10004": "charge_discharge_command",
|
|
183
|
+
"10005": "charge_discharge_power",
|
|
184
|
+
"10007": "limited_power_switch",
|
|
185
|
+
"10008": "active_power_limit_ratio",
|
|
186
|
+
"10009": "reactive_power_regulation_mode",
|
|
187
|
+
"10010": "q_t",
|
|
188
|
+
"10011": "power_on",
|
|
189
|
+
"10012": "feed_in_limitation",
|
|
190
|
+
"10013": "feed_in_limitation_value",
|
|
191
|
+
"10014": "feed_in_limitation_ratio",
|
|
192
|
+
"10017": "external_ems_heartbeat",
|
|
193
|
+
"10024": "battery_first",
|
|
194
|
+
"10025": "active_power_soft_start_after_fault",
|
|
195
|
+
"10026": "active_power_soft_start_time_after_fault",
|
|
196
|
+
"10027": "active_power_soft_start",
|
|
197
|
+
"10028": "active_power_soft_start_gradient",
|
|
198
|
+
"10029": "active_power_gradient_control",
|
|
199
|
+
"10030": "active_power_decline_gradient",
|
|
200
|
+
"10031": "active_power_rising_gradient",
|
|
201
|
+
"10032": "active_power_setting_persistence",
|
|
202
|
+
"10033": "shutdown_when_active_power_limit_to_0",
|
|
203
|
+
"10034": "reactive_response",
|
|
204
|
+
"10035": "reactive_power_regulation_time",
|
|
205
|
+
"10036": "pf",
|
|
206
|
+
"10065": "forced_charging",
|
|
207
|
+
"10066": "forced_charging_valid_time",
|
|
208
|
+
"10067": "forced_charging_start_time_1_hour",
|
|
209
|
+
"10068": "forced_charging_start_time_1_minute",
|
|
210
|
+
"10069": "forced_charging_end_time_1_hour",
|
|
211
|
+
"10070": "forced_charging_end_time_1_minute",
|
|
212
|
+
"10071": "forced_charging_target_soc_1",
|
|
213
|
+
"10072": "forced_charging_start_time_2_hour",
|
|
214
|
+
"10073": "forced_charging_start_time_2_minute",
|
|
215
|
+
"10074": "forced_charging_end_time_2_hour",
|
|
216
|
+
"10075": "forced_charging_end_time_2_minute",
|
|
217
|
+
"10076": "forced_charging_target_soc_2",
|
|
218
|
+
"10091": "max_charging_power",
|
|
219
|
+
"10092": "max_discharging_power",
|
|
220
|
+
|
|
221
|
+
# These are defined in API documentation but are rejected by the API as duplicates of 10071 and 10076
|
|
222
|
+
# "10015": "forced_charging_target_soc1",
|
|
223
|
+
# "10016": "forced_charging_target_soc2",
|
|
224
|
+
|
|
225
|
+
# These are defined in API documentation but cause validation error from the API
|
|
226
|
+
# "10003": "energy_management_mode",
|
|
227
|
+
# "10006": "existing_inverter",
|
|
228
|
+
# "10082": "charge_discharge_command_in_external_dispatch_mode",
|
|
229
|
+
# "10083": "charging_discharging_power_in_external_dispatch_mode",
|
|
230
|
+
# "10084": "power_limiting_command_in_external_dispatch_mode",
|
|
231
|
+
# "10085": "ems_heartbeat_settings_in_external_dispatch_mode",
|
|
232
|
+
# "10086": "energy_management_mode",
|
|
233
|
+
# "10087": "feed_in_limitation_ratio_in_external_dispatch_mode",
|
|
234
|
+
# "10088": "feed_in_limitation_on_off_in_external_dispatch_mode",
|
|
235
|
+
# "10089": "feed_in_limitation_value_in_external_dispatch_mode",
|
|
236
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from . import AbstractAuth, PySolarCloudException, _LOGGER
|
|
4
|
+
|
|
5
|
+
class DeviceType(Enum):
|
|
6
|
+
"""Enum for the device types used by async_get_plant_devices."""
|
|
7
|
+
INVERTER = 1
|
|
8
|
+
CONTAINER = 2
|
|
9
|
+
GRID_CONNECTION_POINT = 3
|
|
10
|
+
COMBINER_BOX = 4
|
|
11
|
+
METEO_STATION = 5
|
|
12
|
+
TRANSFORMER = 6
|
|
13
|
+
METER = 7
|
|
14
|
+
UPS = 8
|
|
15
|
+
DATA_LOGGER = 9
|
|
16
|
+
STRING = 10
|
|
17
|
+
PLANT = 11
|
|
18
|
+
CIRCUIT_PROTECTION = 12
|
|
19
|
+
SPLITTING_DEVICE = 13
|
|
20
|
+
ENERGY_STORAGE_SYSTEM = 14
|
|
21
|
+
SAMPLING_DEVICE = 15
|
|
22
|
+
EMU = 16
|
|
23
|
+
UNIT = 17
|
|
24
|
+
TEMPERATURE_AND_HUMIDITY_SENSOR = 18
|
|
25
|
+
INTELLIGENT_POWER_DISTRIBUTION_CABINET = 19
|
|
26
|
+
DISPLAY_DEVICE = 20
|
|
27
|
+
AC_POWER_DISTRIBUTED_CABINET = 21
|
|
28
|
+
COMMUNICATION_MODULE = 22
|
|
29
|
+
SYSTEM_BMS = 23
|
|
30
|
+
ARRAY_BMS = 24
|
|
31
|
+
DC_DC = 25
|
|
32
|
+
ENERGY_MANAGEMENT_SYSTEM = 26
|
|
33
|
+
TRACKING_SYSTEM = 27
|
|
34
|
+
WIND_ENERGY_CONVERTER = 28
|
|
35
|
+
SVG = 29
|
|
36
|
+
PT_CABINET = 30
|
|
37
|
+
BUS_PROTECTION = 31
|
|
38
|
+
CLEANING_DEVICE = 32
|
|
39
|
+
DIRECT_CURRENT_CABINET = 33
|
|
40
|
+
PUBLIC_MEASUREMENT_AND_CONTROL = 34
|
|
41
|
+
ENERGY_STORAGE_SYSTEM_2 = 37
|
|
42
|
+
BATTERY = 43
|
|
43
|
+
BATTERY_CLUSTER_MANAGEMENT_UNIT = 44
|
|
44
|
+
LOCAL_CONTROLLER = 45
|
|
45
|
+
BATTERY_SYSTEM_CONTROLLER = 52
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DeviceFaultStaus(Enum):
|
|
49
|
+
"""Enum for the device fault status used by async_get_plant_devices."""
|
|
50
|
+
FAULT = 1
|
|
51
|
+
ALARM = 2
|
|
52
|
+
NORMAL = 4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Plants:
|
|
56
|
+
"""Class to interact with the plants API."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, auth: AbstractAuth, *, lang: str = "_en_US"):
|
|
59
|
+
"""Initialize the plants."""
|
|
60
|
+
self.auth = auth
|
|
61
|
+
self.lang = lang
|
|
62
|
+
|
|
63
|
+
async def async_get_plants(self) -> list[dict]:
|
|
64
|
+
"""Return the list of plants accessible to the user."""
|
|
65
|
+
uri = "/openapi/platform/queryPowerStationList"
|
|
66
|
+
res = await self.auth.request(uri, {"page": 1, "size": 100})
|
|
67
|
+
res.raise_for_status()
|
|
68
|
+
data = await res.json()
|
|
69
|
+
if "error" in data:
|
|
70
|
+
_LOGGER.error("Error response from %s: %s", uri, data)
|
|
71
|
+
raise PySolarCloudException(res)
|
|
72
|
+
plants = [plant for plant in data["result_data"]["pageList"]]
|
|
73
|
+
_LOGGER.debug("async_get_plants: %s", plants)
|
|
74
|
+
return plants
|
|
75
|
+
|
|
76
|
+
async def async_get_plant_details(self, plant_id: str | list[str]) -> list[dict]:
|
|
77
|
+
"""Return details about one or more plants."""
|
|
78
|
+
if isinstance(plant_id, list):
|
|
79
|
+
ps = ",".join(plant_id)
|
|
80
|
+
else:
|
|
81
|
+
ps = plant_id
|
|
82
|
+
uri = "/openapi/platform/getPowerStationDetail"
|
|
83
|
+
res = await self.auth.request(uri, {"ps_ids": ps})
|
|
84
|
+
res.raise_for_status()
|
|
85
|
+
data = await res.json()
|
|
86
|
+
if "error" in data:
|
|
87
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
88
|
+
raise PySolarCloudException(res)
|
|
89
|
+
plants = data["result_data"]["data_list"]
|
|
90
|
+
_LOGGER.debug("async_get_plant_details: %s", plants)
|
|
91
|
+
return plants
|
|
92
|
+
|
|
93
|
+
async def async_get_plant_devices(self, plant_id: str, *, device_types: list[DeviceType | int] = []) -> list[dict]:
|
|
94
|
+
"""Return details about the devices for a plant."""
|
|
95
|
+
uri = "/openapi/platform/getDeviceListByPsId"
|
|
96
|
+
params = {"ps_id": plant_id, "page": 1, "size": 100}
|
|
97
|
+
if device_types:
|
|
98
|
+
params["device_type_list"] = [str(d.value) if isinstance(d, DeviceType) else str(d) for d in device_types]
|
|
99
|
+
res = await self.auth.request(uri, params)
|
|
100
|
+
res.raise_for_status()
|
|
101
|
+
data = await res.json()
|
|
102
|
+
if "error" in data:
|
|
103
|
+
_LOGGER.error("Error response from %s: %s", uri, data)
|
|
104
|
+
raise PySolarCloudException(res)
|
|
105
|
+
devices = data["result_data"]["pageList"]
|
|
106
|
+
for device in devices:
|
|
107
|
+
# Convert the device type and fault status to enums
|
|
108
|
+
if device["device_type"] in DeviceType:
|
|
109
|
+
device["device_type"] = DeviceType(device["device_type"])
|
|
110
|
+
if device["dev_fault_status"] in DeviceFaultStaus:
|
|
111
|
+
device["dev_fault_status"] = DeviceFaultStaus(device["dev_fault_status"])
|
|
112
|
+
_LOGGER.debug("async_get_plant_devices: %s", devices)
|
|
113
|
+
return devices
|
|
114
|
+
|
|
115
|
+
async def async_get_realtime_data(
|
|
116
|
+
self,
|
|
117
|
+
plant_id: str | list[str],
|
|
118
|
+
*,
|
|
119
|
+
measure_points=None,
|
|
120
|
+
extra_measure_points: dict[str, str] | None = None,
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Return the latest realtime data from one or more plants.
|
|
123
|
+
|
|
124
|
+
plant_id: str | list[str] - The ID of the plant or a list of plant IDs.
|
|
125
|
+
measure_points: list[str] - A list of measure points to return. If None, all measure points are returned.
|
|
126
|
+
extra_measure_points: dict[str, str] - Mapping of additional point_id -> code pairs to
|
|
127
|
+
request alongside the defaults. Useful for surfacing fields the upstream library
|
|
128
|
+
hasn't catalogued (e.g. newer battery or EV-charger point IDs). Returned data points
|
|
129
|
+
use the codes supplied here verbatim.
|
|
130
|
+
|
|
131
|
+
Data is returned as a dictionary of dictionaries:
|
|
132
|
+
{
|
|
133
|
+
plant_id: {
|
|
134
|
+
measure_point_code: {
|
|
135
|
+
"id": str, # Numerical identifier of the measure point
|
|
136
|
+
"code": str, # Readable code of the measure point (see measure_points dict)
|
|
137
|
+
"value": float | str,
|
|
138
|
+
"unit": str,
|
|
139
|
+
"name": str, # Name of the measure point (in the specified language)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
iSolarCloud data is updated every 5 minutes so polling more frequently than that is not useful.
|
|
144
|
+
"""
|
|
145
|
+
if isinstance(plant_id, list):
|
|
146
|
+
ps = plant_id
|
|
147
|
+
else:
|
|
148
|
+
ps = [plant_id]
|
|
149
|
+
# Merge the canonical measure_points map with any caller-supplied extras for this call
|
|
150
|
+
# only — we deliberately do not mutate the class-level dict so concurrent callers and
|
|
151
|
+
# other Plants instances see the upstream defaults.
|
|
152
|
+
effective_points = dict(self.measure_points)
|
|
153
|
+
if extra_measure_points:
|
|
154
|
+
effective_points.update(extra_measure_points)
|
|
155
|
+
if measure_points is None:
|
|
156
|
+
ms = list(effective_points.keys())
|
|
157
|
+
else:
|
|
158
|
+
measure_points_map = {v: k for k, v in effective_points.items()}
|
|
159
|
+
ms = [m if m.isdigit() else measure_points_map[m] for m in measure_points]
|
|
160
|
+
uri = "/openapi/platform/getPowerStationRealTimeData"
|
|
161
|
+
res = await self.auth.request(uri, {"ps_id_list": ps, "point_id_list": ms, "is_get_point_dict": "1"}, lang=self.lang)
|
|
162
|
+
res = await res.json()
|
|
163
|
+
if "error" in res:
|
|
164
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
165
|
+
raise PySolarCloudException(res)
|
|
166
|
+
point_dict = dict([(str(point["point_id"]), point) for point in res["result_data"]["point_dict"]])
|
|
167
|
+
plants = {}
|
|
168
|
+
for plant in res["result_data"]["device_point_list"]:
|
|
169
|
+
data = [self._format_measure_point(k[1:], v, point_dict, effective_points) for k,v in plant.items() if k[0]=='p' and k[1:].isdigit()]
|
|
170
|
+
data_as_dict = {d["code"]: d for d in data}
|
|
171
|
+
plants[str(plant["ps_id"])] = data_as_dict
|
|
172
|
+
_LOGGER.debug("async_get_realtime_data: %s", plants)
|
|
173
|
+
return plants
|
|
174
|
+
|
|
175
|
+
async def async_get_device_realtime(
|
|
176
|
+
self,
|
|
177
|
+
plant_id: str,
|
|
178
|
+
device_type: DeviceType | int | str,
|
|
179
|
+
*,
|
|
180
|
+
extra_measure_points: dict[str, str] | None = None,
|
|
181
|
+
) -> dict:
|
|
182
|
+
"""Best-effort device-level realtime fetch for non-inverter devices.
|
|
183
|
+
|
|
184
|
+
The iSolarCloud plant realtime endpoint aggregates all points at the plant level and does
|
|
185
|
+
not separate per-device data for chargers, batteries, etc. Some accounts / regions expose
|
|
186
|
+
a per-device endpoint; when it is not available, this method returns an empty dict rather
|
|
187
|
+
than raising, so callers can feature-detect gracefully.
|
|
188
|
+
|
|
189
|
+
Returns a dict keyed by device uuid, each value being the same measure-point structure as
|
|
190
|
+
:meth:`async_get_realtime_data`.
|
|
191
|
+
"""
|
|
192
|
+
if isinstance(device_type, DeviceType):
|
|
193
|
+
type_id = device_type.value
|
|
194
|
+
else:
|
|
195
|
+
type_id = int(device_type)
|
|
196
|
+
effective_points = dict(self.measure_points)
|
|
197
|
+
if extra_measure_points:
|
|
198
|
+
effective_points.update(extra_measure_points)
|
|
199
|
+
uri = "/openapi/platform/getDeviceRealTimeData"
|
|
200
|
+
res = await self.auth.request(
|
|
201
|
+
uri,
|
|
202
|
+
{
|
|
203
|
+
"ps_id": str(plant_id),
|
|
204
|
+
"device_type": str(type_id),
|
|
205
|
+
"point_id_list": list(effective_points.keys()),
|
|
206
|
+
"is_get_point_dict": "1",
|
|
207
|
+
},
|
|
208
|
+
lang=self.lang,
|
|
209
|
+
)
|
|
210
|
+
# Many accounts do not have this endpoint; treat transport / API errors as "unsupported"
|
|
211
|
+
# and let the caller decide how to surface that. We deliberately swallow only the
|
|
212
|
+
# "endpoint missing" class of failure, not generic 4xx/5xx.
|
|
213
|
+
if res.status in (404, 405):
|
|
214
|
+
_LOGGER.debug("Device realtime endpoint unavailable for plant %s type %s", plant_id, type_id)
|
|
215
|
+
return {}
|
|
216
|
+
res = await res.json()
|
|
217
|
+
if "error" in res:
|
|
218
|
+
error_code = res["error"].get("error") if isinstance(res["error"], dict) else None
|
|
219
|
+
if error_code in {"endpoint_not_found", "invalid_request"}:
|
|
220
|
+
_LOGGER.debug("Device realtime endpoint rejected request: %s", res)
|
|
221
|
+
return {}
|
|
222
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
223
|
+
raise PySolarCloudException(res)
|
|
224
|
+
point_dict_items = res.get("result_data", {}).get("point_dict", []) or []
|
|
225
|
+
point_dict = {str(p["point_id"]): p for p in point_dict_items}
|
|
226
|
+
device_lists = res.get("result_data", {}).get("device_point_list", []) or []
|
|
227
|
+
out: dict[str, dict] = {}
|
|
228
|
+
for device in device_lists:
|
|
229
|
+
uuid = str(device.get("uuid") or device.get("device_id") or "")
|
|
230
|
+
if not uuid:
|
|
231
|
+
continue
|
|
232
|
+
data = [
|
|
233
|
+
self._format_measure_point(k[1:], v, point_dict, effective_points)
|
|
234
|
+
for k, v in device.items()
|
|
235
|
+
if k[0] == 'p' and k[1:].isdigit()
|
|
236
|
+
]
|
|
237
|
+
out[uuid] = {d["code"]: d for d in data}
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
async def async_get_historical_data(self, plant_id: str | list[str], start_time: datetime, end_time: datetime = None, *, measure_points=None, interval=timedelta(minutes=60)) -> dict:
|
|
241
|
+
"""Return historical data from one or more plants.
|
|
242
|
+
|
|
243
|
+
plant_id: str | list[str] - The ID of the plant or a list of plant IDs.
|
|
244
|
+
start_time: datetime - The start time of the data to retrieve.
|
|
245
|
+
end_time: datetime - The end time of the data to retrieve. If end_time is not specified, 3 hours of data is returned.
|
|
246
|
+
measure_points: list[str] - A list of measure points to return. If None, all measure points are returned.
|
|
247
|
+
interval: timedelta - The interval in minutes between data points. The minimum interval is 1 minute. Default is 60 minutes.
|
|
248
|
+
Data is returned as a dictionary of lists:
|
|
249
|
+
{
|
|
250
|
+
plant_id: [
|
|
251
|
+
{
|
|
252
|
+
"timestamp": datetime,
|
|
253
|
+
"id": str, # Numerical identifier of the measure point
|
|
254
|
+
"code": str, # Readable code of the measure point (see measure_points dict)
|
|
255
|
+
"value": float | str,
|
|
256
|
+
"unit": str,
|
|
257
|
+
"name": str, # Name of the measure point (in the specified language)
|
|
258
|
+
}
|
|
259
|
+
]
|
|
260
|
+
}
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(plant_id, list):
|
|
263
|
+
ps = str(plant_id)
|
|
264
|
+
else:
|
|
265
|
+
ps = [plant_id]
|
|
266
|
+
if measure_points is None:
|
|
267
|
+
ms = list(self.measure_points.keys())
|
|
268
|
+
else:
|
|
269
|
+
measure_points_map = {v: k for k, v in self.measure_points.items()}
|
|
270
|
+
ms = [m if m.isdigit() else measure_points_map[m] for m in measure_points]
|
|
271
|
+
if end_time is None:
|
|
272
|
+
end_time = start_time + timedelta(hours=3)
|
|
273
|
+
TS_FORMAT = "%Y%m%d%H%M%S"
|
|
274
|
+
uri = "/openapi/platform/getPowerStationPointMinuteDataList"
|
|
275
|
+
params = {
|
|
276
|
+
"ps_id_list": ps,
|
|
277
|
+
"points": ",".join(["p"+m for m in ms]),
|
|
278
|
+
"is_get_point_dict": "1",
|
|
279
|
+
"start_time_stamp": start_time.strftime(TS_FORMAT),
|
|
280
|
+
"end_time_stamp": end_time.strftime(TS_FORMAT),
|
|
281
|
+
"minute_interval": str(interval.seconds // 60),
|
|
282
|
+
}
|
|
283
|
+
res = await self.auth.request(uri, params, lang=self.lang)
|
|
284
|
+
res = await res.json()
|
|
285
|
+
if res.get("result_code") != "1":
|
|
286
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
287
|
+
raise PySolarCloudException(res)
|
|
288
|
+
point_dict = dict([(str(point["point_id"]), point) for point in res["result_data"]["point_dict"]])
|
|
289
|
+
plants = {}
|
|
290
|
+
for plant_id, plant in res["result_data"].items():
|
|
291
|
+
if plant_id == "point_dict":
|
|
292
|
+
continue
|
|
293
|
+
series = []
|
|
294
|
+
for frame in plant:
|
|
295
|
+
data = {}
|
|
296
|
+
ts = datetime.strptime(frame["time_stamp"], TS_FORMAT)
|
|
297
|
+
for k,v in frame.items():
|
|
298
|
+
if k == "time_stamp":
|
|
299
|
+
continue
|
|
300
|
+
else:
|
|
301
|
+
data = self._format_measure_point(k[1:], v, point_dict, self.measure_points)
|
|
302
|
+
data["timestamp"] = ts
|
|
303
|
+
series.append(data)
|
|
304
|
+
plants[str(plant_id)] = series
|
|
305
|
+
_LOGGER.debug("async_get_historical_data: %s", plants)
|
|
306
|
+
return plants
|
|
307
|
+
|
|
308
|
+
def _format_measure_point(self, point_id: str, point_value: str, point_dict: dict, measure_points: dict | None = None) -> dict:
|
|
309
|
+
try:
|
|
310
|
+
v = float(point_value) if point_value is not None else None
|
|
311
|
+
except ValueError:
|
|
312
|
+
v = point_value
|
|
313
|
+
code_map = measure_points if measure_points is not None else self.measure_points
|
|
314
|
+
return {
|
|
315
|
+
"id": point_id,
|
|
316
|
+
"code": code_map.get(point_id, point_id),
|
|
317
|
+
"value": v,
|
|
318
|
+
"unit": point_dict.get(point_id, {}).get("point_unit", None),
|
|
319
|
+
"name": point_dict.get(point_id, {}).get("point_name", None),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
measure_points = {
|
|
323
|
+
"83022": "daily_yield", # Wh
|
|
324
|
+
"83024": "total_yield", # Wh
|
|
325
|
+
"83033": "power", # W
|
|
326
|
+
"83019": "power_fraction", # Plant Power/Installed Power of Plant
|
|
327
|
+
"83006": "meter_daily_yield", # Wh
|
|
328
|
+
"83020": "meter_total_yield", # Wh
|
|
329
|
+
"83011": "meter_e_daily_consumption", # Wh
|
|
330
|
+
"83021": "accumulative_power_consumption_by_meter", # Wh
|
|
331
|
+
"83032": "meter_ac_power", # W
|
|
332
|
+
"83007": "meter_pr", #
|
|
333
|
+
"83002": "inverter_ac_power", # W
|
|
334
|
+
"83009": "inverter_daily_yield", # Wh
|
|
335
|
+
"83004": "inverter_total_yield", # Wh
|
|
336
|
+
"83012": "p_radiation_h", # W/㎡
|
|
337
|
+
"83013": "daily_irradiation", # Wh/㎡
|
|
338
|
+
"83023": "plant_pr", #
|
|
339
|
+
"83005": "daily_equivalent_hours", # h
|
|
340
|
+
"83025": "plant_equivalent_hours", # h
|
|
341
|
+
"83018": "daily_yield_theoretical", # Wh
|
|
342
|
+
"83001": "inverter_ac_power_normalization", # W/Wp
|
|
343
|
+
"83008": "daily_equivalent_hours_of_inverter", # h
|
|
344
|
+
"83010": "inverter_pr", #
|
|
345
|
+
"83016": "plant_ambient_temperature", # ℃
|
|
346
|
+
"83017": "plant_module_temperature", # ℃
|
|
347
|
+
"83046": "pcs_total_active_power", # W
|
|
348
|
+
"83052": "total_load_active_power", # W
|
|
349
|
+
"83067": "total_active_power_of_pv", # W
|
|
350
|
+
"83097": "daily_direct_energy_consumption", # Wh
|
|
351
|
+
"83100": "total_direct_energy_consumption", # Wh
|
|
352
|
+
"83102": "energy_purchased_today", # Wh
|
|
353
|
+
"83105": "total_purchased_energy", # Wh
|
|
354
|
+
"83106": "load_power", # W
|
|
355
|
+
"83118": "daily_load_consumption", # Wh
|
|
356
|
+
"83124": "total_load_consumption", # Wh
|
|
357
|
+
"83119": "daily_feed_in_energy_pv", # Wh
|
|
358
|
+
"83072": "feed_in_energy_today", # Wh
|
|
359
|
+
"83075": "feed_in_energy_total", # Wh
|
|
360
|
+
"83252": "battery_level_soc", #
|
|
361
|
+
"83129": "battery_soc", #
|
|
362
|
+
"83232": "total_field_soc", #
|
|
363
|
+
"83233": "total_field_maximum_rechargeable_power", # W
|
|
364
|
+
"83234": "total_field_maximum_dischargeable_power", # W
|
|
365
|
+
"83235": "total_field_chargeable_energy", # Wh
|
|
366
|
+
"83236": "total_field_dischargeable_energy", # Wh
|
|
367
|
+
"83237": "total_field_energy_storage_maximum_reactive_power", # W
|
|
368
|
+
"83238": "total_field_energy_storage_active_power", # W
|
|
369
|
+
"83239": "total_field_reactive_power", # var
|
|
370
|
+
"83240": "total_field_power_factor", #
|
|
371
|
+
"83243": "daily_field_charge_capacity", # Wh
|
|
372
|
+
"83241": "total_field_charge_capacity", # Wh
|
|
373
|
+
"83244": "daily_field_discharge_capacity", # Wh
|
|
374
|
+
"83242": "total_field_discharge_capacity", # Wh
|
|
375
|
+
"83548": "total_number_of_charge_discharge", #
|
|
376
|
+
"83549": "grid_active_power", # W
|
|
377
|
+
"83419": "daily_highest_inverter_power_inverter_installed_capacity", #
|
|
378
|
+
"83317": "power_forecast", # W
|
|
379
|
+
"83318": "planned_es_charging_discharging_power", # W
|
|
380
|
+
"83319": "planned_es_soc", #
|
|
381
|
+
"83320": "planned_charging_power", # Wh
|
|
382
|
+
"83321": "planned_discharging_power", # Wh
|
|
383
|
+
"83322": "ess_daily_charge_ems", # Wh
|
|
384
|
+
"83324": "energy_storage_cumulative_charge", # Wh
|
|
385
|
+
"83323": "ess_daily_discharge_ems", # Wh
|
|
386
|
+
"83325": "cumulative_discharge", # Wh
|
|
387
|
+
"83327": "energy_storage_remaining_charge", # Wh
|
|
388
|
+
"83326": "energy_storage_active_power_ems", # W
|
|
389
|
+
"83328": "grid_active_power_ems", # W
|
|
390
|
+
"83329": "pv_active_power_ems", # W
|
|
391
|
+
"83330": "load_active_power_ems", # W
|
|
392
|
+
"83331": "daily_pv_yield_ems", # Wh
|
|
393
|
+
"83332": "total_pv_yield", # Wh
|
|
394
|
+
"83334": "energy_storage_soc_ems", #
|
|
395
|
+
"83335": "energy_storage_remaining_charge_ems", # Wh
|
|
396
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sungrow-isolarcloud
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: A library to interact with Sungrow's iSolarCloud API (KRoperUK fork with battery, EV charger and dispatch extensions)
|
|
5
|
+
Author: KRoperUK
|
|
6
|
+
Author-email: Tore Green <bugjam@e-dreams.dk>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/KRoperUK/pysolarcloud
|
|
9
|
+
Project-URL: Issues, https://github.com/KRoperUK/pysolarcloud/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Topic :: Home Automation
|
|
14
|
+
Requires-Python: >=3.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE.txt
|
|
17
|
+
Requires-Dist: aiohttp
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: provides-extra
|
|
23
|
+
Dynamic: requires-dist
|
|
24
|
+
Dynamic: requires-python
|
|
25
|
+
|
|
26
|
+
# sungrow-isolarcloud
|
|
27
|
+
|
|
28
|
+
A maintained fork of the [pysolarcloud](https://github.com/bugjam/pysolarcloud) library for interacting with Sungrow's [iSolarCloud API](https://developer-api.isolarcloud.com/).
|
|
29
|
+
|
|
30
|
+
Install from PyPI:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install sungrow-isolarcloud
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This fork adds:
|
|
37
|
+
* Support for requesting **additional / custom measure points** without modifying the upstream point map (useful for battery charge/discharge power fields that vary by inverter model).
|
|
38
|
+
* A best-effort **per-device realtime** helper for devices such as EV chargers (`Plants.async_get_device_realtime`).
|
|
39
|
+
* A **heartbeat** helper for External EMS dispatch mode (`Control.async_heartbeat` / `Control.heartbeat_loop`).
|
|
40
|
+
* Convenience constants for dispatch command value sets (`Control.CHARGE_DISCHARGE_COMMANDS`, `Control.FORCED_CHARGING`).
|
|
41
|
+
|
|
42
|
+
The package supports the following functionality:
|
|
43
|
+
* OAuth2 authentication
|
|
44
|
+
* Getting a list plants
|
|
45
|
+
* Getting details of a plant
|
|
46
|
+
* Getting devices of a plant
|
|
47
|
+
* Getting "real-time" data of a plant (Data is updated every 5 minutes according to Sungrow's documentation)
|
|
48
|
+
* Getting historical data
|
|
49
|
+
* Getting and updating grid control settings
|
|
50
|
+
|
|
51
|
+
## Quirks
|
|
52
|
+
The iSolarCloud API is quite new and not very mature. Some tips:
|
|
53
|
+
* The authorisation flow is based on OAuth2 but doesn't work exactly as you would expect
|
|
54
|
+
* The `state` parameter is not passed back after to the authorisation step. This makes it more tricky to resume the flow in a client application.
|
|
55
|
+
* User is asked to approve the authorisation if the flow is invoked again, e.g. in case the tokens have expired - unlike many OAuth2 implementations who will perform a "silent" authorisation if the user has already approved the access.
|
|
56
|
+
* The API documentation lists a lot of data points which do not seem to be returned from my inverter, it probably varies between models.
|
|
57
|
+
* There are different iSolarCloud servers for different regions, see the `pysolarcloud.Server` enum
|
|
58
|
+
* API endpoints accept a language code but respond with Chinese text when when English is requested
|
|
59
|
+
|
|
60
|
+
# Usage
|
|
61
|
+
|
|
62
|
+
## Register your app
|
|
63
|
+
1. Create an account in the [iSolarCloud Developer Portal](https://developer-api.isolarcloud.com/)
|
|
64
|
+
2. Create an app in the developer portal
|
|
65
|
+
* Answer "Yes" to authorize with OAuth2.0
|
|
66
|
+
* Enter a Redirect URL for your app (this can be changed later)
|
|
67
|
+
3. Wait for approval by Sungrow
|
|
68
|
+
4. Find the needed configuration details in the developer portal. You will need:
|
|
69
|
+
* Appkey
|
|
70
|
+
* Secret Key
|
|
71
|
+
* Application Id (This is shown as a query parameter within the Authorize URL in the developer portal)
|
|
72
|
+
|
|
73
|
+
## Example
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pysolarcloud import Auth, Server
|
|
77
|
+
from pysolarcloud.plants import Plants
|
|
78
|
+
|
|
79
|
+
app_key = "your app key"
|
|
80
|
+
secret_key = "your secret key"
|
|
81
|
+
app_id = "your app id"
|
|
82
|
+
redirect_uri = "your redirect uri"
|
|
83
|
+
|
|
84
|
+
auth = Auth(Server.Europe, app_key, secret_key, app_id)
|
|
85
|
+
url = auth.auth_url(redirect_uri)
|
|
86
|
+
```
|
|
87
|
+
1. Redirect user to `url`
|
|
88
|
+
2. User selects plant(s) and grants authorisation
|
|
89
|
+
3. iSolarCloud will redirect the user to `redirect_uri` with query parameter `code`
|
|
90
|
+
```python
|
|
91
|
+
await auth.async_authorize(code, redirect_uri)
|
|
92
|
+
plants_api = Plants(auth)
|
|
93
|
+
plant_list = await plants_api.async_get_plants()
|
|
94
|
+
if plant_list:
|
|
95
|
+
print(f"{len(plant_list)} plants found:")
|
|
96
|
+
for plant in plant_list:
|
|
97
|
+
print(f"Plant ID: {plant["ps_id"]}, Name: {plant["ps_name"]}")
|
|
98
|
+
else:
|
|
99
|
+
print("No plants found.")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
print("\nFetching detailed information for each plant...\n")
|
|
103
|
+
plant_ids = [str(plant["ps_id"]) for plant in plant_list]
|
|
104
|
+
plant_details = await plants_api.async_get_plant_details(plant_ids)
|
|
105
|
+
for plant in plant_details:
|
|
106
|
+
print(f"Details for Plant ID {plant["ps_id"]}: {plant}")
|
|
107
|
+
|
|
108
|
+
print("\nFetching real-time data for each plant...\n")
|
|
109
|
+
real_time_data = await plants_api.async_get_realtime_data(plant_ids)
|
|
110
|
+
for plant_id, data in real_time_data.items():
|
|
111
|
+
# Print only the data points where value is not None
|
|
112
|
+
data_values = {k: v for k, v in data.items() if v and v.get("value") is not None}
|
|
113
|
+
print(f"Real-time data for Plant ID {plant_id}: {data_values}")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `Auth` class keeps the access between calls and refreshes it when needed. If you prefer to manage this state yourself, you can create your own subclass of `AbstractAuth`.
|
|
117
|
+
|
|
118
|
+
## Grid Control
|
|
119
|
+
|
|
120
|
+
The `Control` class enables retrieving and updating grid control settings. Parameters and value sets are documented in the iSolarCloud Developer portal.
|
|
121
|
+
|
|
122
|
+
### Example
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pysolarcloud.control import Control
|
|
126
|
+
from pysolarcloud.plants import DeviceType
|
|
127
|
+
|
|
128
|
+
devices = await plants_api.async_get_plant_devices(plant_id, device_types=[DeviceType.ENERGY_STORAGE_SYSTEM])
|
|
129
|
+
device_uuid = devices[0]["uuid"]
|
|
130
|
+
control_api = Control(auth)
|
|
131
|
+
# Fetch current config
|
|
132
|
+
current_settings = await control_api.async_read_parameters(device_uuid)
|
|
133
|
+
print(current_settings)
|
|
134
|
+
# Make an update using the canonical command values
|
|
135
|
+
await control_api.async_update_parameters(
|
|
136
|
+
device_uuid,
|
|
137
|
+
{
|
|
138
|
+
"charge_discharge_command": Control.CHARGE_DISCHARGE_COMMANDS["charge"],
|
|
139
|
+
"charge_discharge_power": "2500",
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# When using External EMS mode, send a heartbeat periodically to keep the inverter in dispatch mode.
|
|
144
|
+
# 10017 = external_ems_heartbeat, value is the heartbeat interval in seconds (1-1000).
|
|
145
|
+
await control_api.async_heartbeat(device_uuid, interval_seconds=60)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
# Contributions
|
|
149
|
+
Ideas or contributions are welcome. I am not afiliated with Sungrow, I'm just another user of the API. My main use case will be a HomeAssistant integration based on this package.
|
|
150
|
+
|
|
151
|
+
Enjoy!
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
setup.py
|
|
6
|
+
src/pysolarcloud/__init__.py
|
|
7
|
+
src/pysolarcloud/control.py
|
|
8
|
+
src/pysolarcloud/plants.py
|
|
9
|
+
src/sungrow_isolarcloud.egg-info/PKG-INFO
|
|
10
|
+
src/sungrow_isolarcloud.egg-info/SOURCES.txt
|
|
11
|
+
src/sungrow_isolarcloud.egg-info/dependency_links.txt
|
|
12
|
+
src/sungrow_isolarcloud.egg-info/requires.txt
|
|
13
|
+
src/sungrow_isolarcloud.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pysolarcloud
|