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.
@@ -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,2 @@
1
+ include README.md
2
+ exclude tests/*
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ aiohttp
2
+
3
+ [dev]
4
+ pytest
5
+ pytest-asyncio