pysolarcloud 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pysolarcloud/__init__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
|
|
8
|
+
from aiohttp import ClientResponse, ClientSession
|
|
9
|
+
|
|
10
|
+
_LOGGER = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class Server(StrEnum):
|
|
13
|
+
"""Enum of iSolarCloud servers."""
|
|
14
|
+
China = "https://gateway.isolarcloud.com"
|
|
15
|
+
International = "https://gateway.isolarcloud.com.hk"
|
|
16
|
+
Europe = "https://gateway.isolarcloud.eu"
|
|
17
|
+
Australia = "https://augateway.isolarcloud.com"
|
|
18
|
+
|
|
19
|
+
class AbstractAuth(ABC):
|
|
20
|
+
"""Abstract class to make authenticated requests.
|
|
21
|
+
|
|
22
|
+
Subclasses must implement the async_get_access_token method
|
|
23
|
+
and may call async_fetch_tokens and async_refresh_token.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, websession: ClientSession, server: Server | str, client_id: str, client_secret: str, app_id: str):
|
|
27
|
+
"""Initialize the authorization session."""
|
|
28
|
+
self.websession = websession
|
|
29
|
+
self.host = server.value if isinstance(server, Server) else server
|
|
30
|
+
self.appkey = client_id
|
|
31
|
+
self.access_key = client_secret
|
|
32
|
+
self.app_id = app_id
|
|
33
|
+
|
|
34
|
+
def auth_url(self, redirect_uri: str) -> str:
|
|
35
|
+
"""Return the URL to authorize the user."""
|
|
36
|
+
match self.host:
|
|
37
|
+
case Server.China.value:
|
|
38
|
+
cloud_id = 1
|
|
39
|
+
case Server.International.value:
|
|
40
|
+
cloud_id = 2
|
|
41
|
+
case Server.Europe.value:
|
|
42
|
+
cloud_id = 3
|
|
43
|
+
case Server.Australia.value:
|
|
44
|
+
cloud_id = 4
|
|
45
|
+
return f"https://web3.isolarcloud.eu/#/authorized-app?cloudId={cloud_id}&applicationId={self.app_id}&redirectUrl={redirect_uri}"
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def async_get_access_token(self) -> str:
|
|
49
|
+
"""Return a valid access token."""
|
|
50
|
+
|
|
51
|
+
async def request(self, path, data, *, lang="_en_US", **kwargs) -> ClientResponse:
|
|
52
|
+
"""Make a request to iSolarCloud.
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
path -- the path to request
|
|
56
|
+
data -- the data to send
|
|
57
|
+
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"
|
|
58
|
+
**kwargs -- additional arguments to pass to the request
|
|
59
|
+
"""
|
|
60
|
+
if not path.startswith("/"):
|
|
61
|
+
path = f"/{path}"
|
|
62
|
+
if headers := kwargs.pop("headers", {}):
|
|
63
|
+
headers = dict(headers)
|
|
64
|
+
access_token = await self.async_get_access_token()
|
|
65
|
+
headers = {**headers, "x-access-key": self.access_key, "Authorization": f"Bearer {access_token}"}
|
|
66
|
+
body = {**data, "appkey": self.appkey, "lang": lang}
|
|
67
|
+
return await self.websession.request(
|
|
68
|
+
"post", f"{self.host}{path}", json=body, **kwargs, headers=headers,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def async_fetch_tokens(self, code, redirect_uri, **kwargs) -> ClientResponse:
|
|
72
|
+
"""Fetch the access and refresh tokens."""
|
|
73
|
+
if headers := kwargs.pop("headers", {}):
|
|
74
|
+
headers = dict(headers)
|
|
75
|
+
headers = {**headers, "x-access-key": self.access_key, "Content-type": "application/json"}
|
|
76
|
+
body = {
|
|
77
|
+
"appkey": self.appkey,
|
|
78
|
+
"code": code,
|
|
79
|
+
"grant_type": "authorization_code",
|
|
80
|
+
"redirect_uri": redirect_uri
|
|
81
|
+
}
|
|
82
|
+
response = await self.websession.request("post", f"{self.host}/openapi/apiManage/token", json=body, headers=headers, **kwargs)
|
|
83
|
+
return await response.json()
|
|
84
|
+
|
|
85
|
+
async def async_refresh_tokens(self, refresh_token, **kwargs) -> ClientResponse:
|
|
86
|
+
"""Refresh the access token."""
|
|
87
|
+
if headers := kwargs.pop("headers", {}):
|
|
88
|
+
headers = dict(headers)
|
|
89
|
+
headers = {**headers, "x-access-key": self.access_key}
|
|
90
|
+
body = {
|
|
91
|
+
"appkey": self.appkey,
|
|
92
|
+
"refresh_token": refresh_token
|
|
93
|
+
}
|
|
94
|
+
response = await self.websession.request("post", f"{self.host}/openapi/apiManage/refreshToken", json=body, **kwargs, headers=headers)
|
|
95
|
+
return await response.json()
|
|
96
|
+
|
|
97
|
+
class Auth(AbstractAuth):
|
|
98
|
+
"""Class to authenticate with the SolarCloud API."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, host: str, appkey: str, access_key: str, app_id: str, *, websession: ClientSession = None):
|
|
101
|
+
"""Initialize the auth."""
|
|
102
|
+
if websession is None:
|
|
103
|
+
websession = ClientSession(raise_for_status=True)
|
|
104
|
+
super().__init__(websession, host, appkey, access_key, app_id)
|
|
105
|
+
self.tokens = None
|
|
106
|
+
|
|
107
|
+
async def async_authorize(self, code, redirect_uri):
|
|
108
|
+
"""Authorize the user."""
|
|
109
|
+
ts = await self.async_fetch_tokens(code, redirect_uri)
|
|
110
|
+
print(ts)
|
|
111
|
+
if "access_token" not in ts:
|
|
112
|
+
_LOGGER.error("Authorization failed: %s", str(ts))
|
|
113
|
+
return
|
|
114
|
+
self.tokens = {
|
|
115
|
+
"access_token": ts["access_token"],
|
|
116
|
+
"refresh_token": ts["refresh_token"],
|
|
117
|
+
"expires_at": int(time.time()) + ts["expires_in"] - 20,
|
|
118
|
+
}
|
|
119
|
+
_LOGGER.debug("Authorization succesful")
|
|
120
|
+
|
|
121
|
+
async def async_get_access_token(self) -> str:
|
|
122
|
+
"""Return a valid access token."""
|
|
123
|
+
if self.tokens is None:
|
|
124
|
+
raise PySolarCloudException({"error": "auth_not_initialised", "error_description": "You must authorize first."})
|
|
125
|
+
if self.tokens["expires_at"] < int(time.time()):
|
|
126
|
+
ts = await self.async_refresh_tokens(self.tokens["refresh_token"])
|
|
127
|
+
self.tokens = {
|
|
128
|
+
"access_token": ts["access_token"],
|
|
129
|
+
"refresh_token": ts["refresh_token"],
|
|
130
|
+
"expires_at": int(time.time()) + ts["expires_in"] - 20,
|
|
131
|
+
}
|
|
132
|
+
return self.tokens["access_token"]
|
|
133
|
+
|
|
134
|
+
class PySolarCloudException(Exception):
|
|
135
|
+
"""Exception class raised by PySolarCloud when communication with the iSolarCloud service fails."""
|
|
136
|
+
def __init__(self, err: dict):
|
|
137
|
+
super().__init__(err["error"])
|
|
138
|
+
self.error = err["error"]
|
|
139
|
+
self.error_description = err["error_description"]
|
|
140
|
+
self.req_serial_num = err.get("req_serial_num", None)
|
pysolarcloud/plants.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from . import AbstractAuth, PySolarCloudException, _LOGGER
|
|
2
|
+
|
|
3
|
+
class Plants:
|
|
4
|
+
"""Class to interact with the plants API."""
|
|
5
|
+
|
|
6
|
+
def __init__(self, auth: AbstractAuth, *, lang: str = "_en_US"):
|
|
7
|
+
"""Initialize the plants."""
|
|
8
|
+
self.auth = auth
|
|
9
|
+
self.lang = lang
|
|
10
|
+
|
|
11
|
+
async def async_get_plants(self) -> list[dict]:
|
|
12
|
+
"""Return the list of plants accessible to the user."""
|
|
13
|
+
uri = "/openapi/platform/queryPowerStationList"
|
|
14
|
+
res = await self.auth.request(uri, {"page": 1, "size": 100})
|
|
15
|
+
res.raise_for_status()
|
|
16
|
+
data = await res.json()
|
|
17
|
+
if "error" in data:
|
|
18
|
+
_LOGGER.error("Error response from %s: %s", uri, data)
|
|
19
|
+
raise PySolarCloudException(res)
|
|
20
|
+
plants = [plant for plant in data["result_data"]["pageList"]]
|
|
21
|
+
_LOGGER.debug("async_get_plants: %s", plants)
|
|
22
|
+
return plants
|
|
23
|
+
|
|
24
|
+
async def async_get_plant_details(self, plant_id: str | list[str]) -> list[dict]:
|
|
25
|
+
"""Return details about one or more plants."""
|
|
26
|
+
if isinstance(plant_id, list):
|
|
27
|
+
ps = ",".join(plant_id)
|
|
28
|
+
else:
|
|
29
|
+
ps = plant_id
|
|
30
|
+
uri = "/openapi/platform/getPowerStationDetail"
|
|
31
|
+
res = await self.auth.request(uri, {"ps_ids": ps})
|
|
32
|
+
data = await res.json()
|
|
33
|
+
if "error" in data:
|
|
34
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
35
|
+
raise PySolarCloudException(res)
|
|
36
|
+
plants = data["result_data"]["data_list"]
|
|
37
|
+
_LOGGER.debug("async_get_plant_details: %s", plants)
|
|
38
|
+
return plants
|
|
39
|
+
|
|
40
|
+
async def async_get_realtime_data(self, plant_id: str | list[str], *, measure_points=None) -> dict:
|
|
41
|
+
"""Return the latest realtime data from one or more plants.
|
|
42
|
+
|
|
43
|
+
plant_id: str | list[str] - The ID of the plant or a list of plant IDs.
|
|
44
|
+
measure_points: list[str] - A list of measure points to return. If None, all measure points are returned.
|
|
45
|
+
Data is returned as a dictionary of dictionaries:
|
|
46
|
+
{
|
|
47
|
+
plant_id: {
|
|
48
|
+
measure_point_code: {
|
|
49
|
+
"id": str, # Numerical identifier of the measure point
|
|
50
|
+
"code": str, # Readable code of the measure point (see measure_points dict)
|
|
51
|
+
"value": float | str,
|
|
52
|
+
"unit": str,
|
|
53
|
+
"name": str, # Name of the measure point (in the specified language)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
iSolarCloud data is updated every 5 minutes so polling more frequently than that is not useful.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(plant_id, list):
|
|
60
|
+
ps = plant_id
|
|
61
|
+
else:
|
|
62
|
+
ps = [plant_id]
|
|
63
|
+
if measure_points is None:
|
|
64
|
+
ms = list(self.measure_points.keys())
|
|
65
|
+
else:
|
|
66
|
+
measure_points_map = {v: k for k, v in self.measure_points.items()}
|
|
67
|
+
ms = [m if m.isdigit() else measure_points_map[m] for m in measure_points]
|
|
68
|
+
uri = "/openapi/platform/getPowerStationRealTimeData"
|
|
69
|
+
res = await self.auth.request(uri, {"ps_id_list": ps, "point_id_list": ms, "is_get_point_dict": "1"}, lang=self.lang)
|
|
70
|
+
res = await res.json()
|
|
71
|
+
if "error" in res:
|
|
72
|
+
_LOGGER.error("Error response from %s: %s", uri, res)
|
|
73
|
+
raise PySolarCloudException(res)
|
|
74
|
+
point_dict = dict([(str(point["point_id"]), point) for point in res["result_data"]["point_dict"]])
|
|
75
|
+
plants = {}
|
|
76
|
+
for plant in res["result_data"]["device_point_list"]:
|
|
77
|
+
data = [self._format_measure_point(k[1:], v, point_dict) for k,v in plant.items() if k[0]=='p' and k[1:].isdigit()]
|
|
78
|
+
data_as_dict = {d["code"]: d for d in data}
|
|
79
|
+
plants[str(plant["ps_id"])] = data_as_dict
|
|
80
|
+
_LOGGER.debug("async_get_realtime_data: %s", plants)
|
|
81
|
+
return plants
|
|
82
|
+
|
|
83
|
+
def _format_measure_point(self, point_id: str, point_value: str, point_dict: dict) -> dict:
|
|
84
|
+
try:
|
|
85
|
+
v = float(point_value) if point_value is not None else None
|
|
86
|
+
except ValueError:
|
|
87
|
+
v = point_value
|
|
88
|
+
return {
|
|
89
|
+
"id": point_id,
|
|
90
|
+
"code": self.measure_points.get(point_id, point_id),
|
|
91
|
+
"value": v,
|
|
92
|
+
"unit": point_dict.get(point_id, {}).get("point_unit", None),
|
|
93
|
+
"name": point_dict.get(point_id, {}).get("point_name", None),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
measure_points = {
|
|
97
|
+
"83022": "daily_yield", # Wh
|
|
98
|
+
"83024": "total_yield", # Wh
|
|
99
|
+
"83033": "power", # W
|
|
100
|
+
"83019": "power_fraction", # Plant Power/Installed Power of Plant
|
|
101
|
+
"83006": "meter_daily_yield", # Wh
|
|
102
|
+
"83020": "meter_total_yield", # Wh
|
|
103
|
+
"83011": "meter_e_daily_consumption", # Wh
|
|
104
|
+
"83021": "accumulative_power_consumption_by_meter", # Wh
|
|
105
|
+
"83032": "meter_ac_power", # W
|
|
106
|
+
"83007": "meter_pr", #
|
|
107
|
+
"83002": "inverter_ac_power", # W
|
|
108
|
+
"83009": "inverter_daily_yield", # Wh
|
|
109
|
+
"83004": "inverter_total_yield", # Wh
|
|
110
|
+
"83012": "p_radiation_h", # W/㎡
|
|
111
|
+
"83013": "daily_irradiation", # Wh/㎡
|
|
112
|
+
"83023": "plant_pr", #
|
|
113
|
+
"83005": "daily_equivalent_hours", # h
|
|
114
|
+
"83025": "plant_equivalent_hours", # h
|
|
115
|
+
"83018": "daily_yield_theoretical", # Wh
|
|
116
|
+
"83001": "inverter_ac_power_normalization", # W/Wp
|
|
117
|
+
"83008": "daily_equivalent_hours_of_inverter", # h
|
|
118
|
+
"83010": "inverter_pr", #
|
|
119
|
+
"83016": "plant_ambient_temperature", # ℃
|
|
120
|
+
"83017": "plant_module_temperature", # ℃
|
|
121
|
+
"83046": "pcs_total_active_power", # W
|
|
122
|
+
"83052": "total_load_active_power", # W
|
|
123
|
+
"83067": "total_active_power_of_pv", # W
|
|
124
|
+
"83097": "daily_direct_energy_consumption", # Wh
|
|
125
|
+
"83100": "total_direct_energy_consumption", # Wh
|
|
126
|
+
"83102": "energy_purchased_today", # Wh
|
|
127
|
+
"83105": "total_purchased_energy", # Wh
|
|
128
|
+
"83106": "load_power", # W
|
|
129
|
+
"83118": "daily_load_consumption", # Wh
|
|
130
|
+
"83124": "total_load_consumption", # Wh
|
|
131
|
+
"83119": "daily_feed_in_energy_pv", # Wh
|
|
132
|
+
"83072": "feed_in_energy_today", # Wh
|
|
133
|
+
"83075": "feed_in_energy_total", # Wh
|
|
134
|
+
"83252": "battery_level_soc", #
|
|
135
|
+
"83129": "battery_soc", #
|
|
136
|
+
"83232": "total_field_soc", #
|
|
137
|
+
"83233": "total_field_maximum_rechargeable_power", # W
|
|
138
|
+
"83234": "total_field_maximum_dischargeable_power", # W
|
|
139
|
+
"83235": "total_field_chargeable_energy", # Wh
|
|
140
|
+
"83236": "total_field_dischargeable_energy", # Wh
|
|
141
|
+
"83237": "total_field_energy_storage_maximum_reactive_power", # W
|
|
142
|
+
"83238": "total_field_energy_storage_active_power", # W
|
|
143
|
+
"83239": "total_field_reactive_power", # var
|
|
144
|
+
"83240": "total_field_power_factor", #
|
|
145
|
+
"83243": "daily_field_charge_capacity", # Wh
|
|
146
|
+
"83241": "total_field_charge_capacity", # Wh
|
|
147
|
+
"83244": "daily_field_discharge_capacity", # Wh
|
|
148
|
+
"83242": "total_field_discharge_capacity", # Wh
|
|
149
|
+
"83548": "total_number_of_charge_discharge", #
|
|
150
|
+
"83549": "grid_active_power", # W
|
|
151
|
+
"83419": "daily_highest_inverter_power_inverter_installed_capacity", #
|
|
152
|
+
"83317": "power_forecast", # W
|
|
153
|
+
"83318": "planned_es_charging_discharging_power", # W
|
|
154
|
+
"83319": "planned_es_soc", #
|
|
155
|
+
"83320": "planned_charging_power", # Wh
|
|
156
|
+
"83321": "planned_discharging_power", # Wh
|
|
157
|
+
"83322": "ess_daily_charge_ems", # Wh
|
|
158
|
+
"83324": "energy_storage_cumulative_charge", # Wh
|
|
159
|
+
"83323": "ess_daily_discharge_ems", # Wh
|
|
160
|
+
"83325": "cumulative_discharge", # Wh
|
|
161
|
+
"83327": "energy_storage_remaining_charge", # Wh
|
|
162
|
+
"83326": "energy_storage_active_power_ems", # W
|
|
163
|
+
"83328": "grid_active_power_ems", # W
|
|
164
|
+
"83329": "pv_active_power_ems", # W
|
|
165
|
+
"83330": "load_active_power_ems", # W
|
|
166
|
+
"83331": "daily_pv_yield_ems", # Wh
|
|
167
|
+
"83332": "total_pv_yield", # Wh
|
|
168
|
+
"83334": "energy_storage_soc_ems", #
|
|
169
|
+
"83335": "energy_storage_remaining_charge_ems", # Wh
|
|
170
|
+
}
|
|
@@ -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,94 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: pysolarcloud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A library to interact with Sungrow's iSolarCloud API
|
|
5
|
+
Author-email: Tore Green <bugjam@e-dreams.dk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugjam/pysolarcloud
|
|
8
|
+
Project-URL: Issues, https://github.com/bugjam/pysolarcloud/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Requires-Python: >=3.7
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE.txt
|
|
16
|
+
Requires-Dist: aiohttp
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
20
|
+
Dynamic: provides-extra
|
|
21
|
+
Dynamic: requires-dist
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
|
|
24
|
+
# pysolarcloud
|
|
25
|
+
|
|
26
|
+
A Python package to interact with the [iSolarCloud API](https://developer-api.isolarcloud.com/) by Sungrow.
|
|
27
|
+
|
|
28
|
+
The current version has only very basic functionality:
|
|
29
|
+
* OAuth2 authentication
|
|
30
|
+
* Getting a list plants
|
|
31
|
+
* Getting details of a plant
|
|
32
|
+
* Getting "real-time" data of a plant (Data is updated every 5 minutes according to Sungrow's documentation)
|
|
33
|
+
|
|
34
|
+
## Quirks
|
|
35
|
+
The iSolarCloud API is quite new and not very mature. Some tips:
|
|
36
|
+
* The authorisation flow is based on OAuth2 but doesn't work exactly as you would expect
|
|
37
|
+
* 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.
|
|
38
|
+
* 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.
|
|
39
|
+
* The API documentation lists a lot of data points which do not seem to be returned from my inverter, it probably varies between models.
|
|
40
|
+
* There are different iSolarCloud servers for different regions, see the `pysolarcloud.Server` enum
|
|
41
|
+
* API endpoints accept a language code but respond with Chinese text when when English is requested
|
|
42
|
+
|
|
43
|
+
# Usage
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install pysolarcloud
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Register your app
|
|
52
|
+
1. Create an account in the [iSolarCloud Developer Portal](https://developer-api.isolarcloud.com/)
|
|
53
|
+
2. Create an app in the developer portal
|
|
54
|
+
* Answer "Yes" to authorize with OAuth2.0
|
|
55
|
+
* Enter a Redirect URL for your app (this can be changed later)
|
|
56
|
+
3. Wait for approval by Sungrow
|
|
57
|
+
4. Find the needed configuration details in the developer portal. You will need:
|
|
58
|
+
* Appkey
|
|
59
|
+
* Secret Key
|
|
60
|
+
* Application Id (This is shown as a query parameter within the Authorize URL in the developer portal)
|
|
61
|
+
|
|
62
|
+
## Example
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pysolarcloud import Auth, Server
|
|
66
|
+
from pysolarcloud.plants import Plants
|
|
67
|
+
|
|
68
|
+
app_key = "your app key"
|
|
69
|
+
secret_key = "your secret key"
|
|
70
|
+
app_id = "your app id"
|
|
71
|
+
redirect_uri = "your redirect uri"
|
|
72
|
+
|
|
73
|
+
auth = Auth(Server.Europe, app_key, secret_key, app_id)
|
|
74
|
+
url = Auth.auth_url(redirect_uri)
|
|
75
|
+
```
|
|
76
|
+
1. Redirect user to `url`
|
|
77
|
+
2. User selects plant(s) and grants authorisation
|
|
78
|
+
3. iSolarCloud will redirect the user to `redirect_uri` with query parameter `code`
|
|
79
|
+
```python
|
|
80
|
+
await auth.async_authorize(code, redirect_uri)
|
|
81
|
+
plants_api = Plants(auth)
|
|
82
|
+
plant_list = await plants_api.async_get_plants()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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`.
|
|
86
|
+
|
|
87
|
+
# Contributions
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
I don't currently have a need for the Grid Control APIs and I might not be able to test them on my own plant[^1] - but let me know if you are interested.
|
|
91
|
+
|
|
92
|
+
[^1]: because it's controlled by [Heartbeat](https://1komma5.com/en/offer/energymanager-heartbeat/)
|
|
93
|
+
|
|
94
|
+
Enjoy!
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pysolarcloud/__init__.py,sha256=OPACPbLOOayqH4ryv-88m28r64l9scHbat4t6dV8mZ4,6031
|
|
2
|
+
pysolarcloud/plants.py,sha256=xK5_zere9SnqcAvSq_2mae5pUdmeNJEyh8A-CssH7cs,7948
|
|
3
|
+
pysolarcloud-0.1.0.dist-info/LICENSE.txt,sha256=Vma6PIRPNDEelPJFEff5lxpxlQBdIv_9eeFlMK1Cc4Q,1068
|
|
4
|
+
pysolarcloud-0.1.0.dist-info/METADATA,sha256=38VVx42vzAwACaPTZEVD8QgzvVAQy2AcKYu2GIE8RAM,3833
|
|
5
|
+
pysolarcloud-0.1.0.dist-info/WHEEL,sha256=EaM1zKIUYa7rQnxGiOCGhzJABRwy4WO57rWMR3_tj4I,91
|
|
6
|
+
pysolarcloud-0.1.0.dist-info/top_level.txt,sha256=JsfYsG6nh4vbllpPiUNEVFOALTD6WDEFQBMM2xdzLWU,13
|
|
7
|
+
pysolarcloud-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pysolarcloud
|