pysunsynkweb 0.0.2__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.
@@ -0,0 +1,6 @@
1
+ """The about file for version control"""
2
+
3
+ # SPDX-FileCopyrightText: 2024-present Francois <f@flve.uk>
4
+ #
5
+ # SPDX-License-Identifier: MIT
6
+ __version__ = "0.0.2"
@@ -0,0 +1,4 @@
1
+ """A package that wraps the Sunsynk web api (https://sunsynk.net)"""
2
+ # SPDX-FileCopyrightText: 2024-present Francois <f@flve.uk>
3
+ #
4
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,6 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Battery:
6
+ id: int
pysunsynkweb/const.py ADDED
@@ -0,0 +1,10 @@
1
+ """Constants for pysunsynkweb."""
2
+
3
+ BASE_URL = "https://api.sunsynk.net"
4
+ BASE_API = BASE_URL + "/api/v1"
5
+ BASE_HEADERS = {
6
+ "accept": "application/json",
7
+ "content-type": "application/json",
8
+ "accept-language": "en-US,en;q=0.5",
9
+ "accept-encoding": "gzip, deflate, br",
10
+ }
@@ -0,0 +1,5 @@
1
+ """Exceptions module"""
2
+
3
+
4
+ class AuthenticationFailed(Exception):
5
+ """Raised when authentication failed"""
@@ -0,0 +1,72 @@
1
+ from dataclasses import dataclass, field
2
+ import decimal
3
+ from typing import Union
4
+
5
+ from pysunsynkweb.const import BASE_API
6
+ from pysunsynkweb.pvstring import PVString
7
+ from pysunsynkweb.session import SunsynkwebSession
8
+
9
+
10
+ @dataclass
11
+ class Inverter:
12
+ sn: int
13
+ session: Union[SunsynkwebSession, None] = None
14
+ acc_pv: decimal.Decimal = decimal.Decimal(0)
15
+ acc_grid_export: decimal.Decimal = decimal.Decimal(0)
16
+ acc_grid_import: decimal.Decimal = decimal.Decimal(0)
17
+ acc_battery_discharge: decimal.Decimal = decimal.Decimal(0)
18
+ acc_battery_charge: decimal.Decimal = decimal.Decimal(0)
19
+ acc_load: decimal.Decimal = decimal.Decimal(0)
20
+ pv_strings: dict = field(default_factory=dict)
21
+
22
+ async def _get_total_grid(self):
23
+ returned = await self.session.get(
24
+ BASE_API + f"/inverter/grid/{self.sn}/realtime",
25
+ params={"lan": "en"},
26
+ )
27
+ self.acc_grid_export = decimal.Decimal(returned["data"]["etotalTo"])
28
+ self.acc_grid_import = decimal.Decimal(returned["data"]["etotalFrom"])
29
+
30
+ async def _get_total_battery(self):
31
+ returned = await self.session.get(
32
+ BASE_API + f"/inverter/battery/{self.sn}/realtime",
33
+ params={"lan": "en"},
34
+ )
35
+ self.acc_battery_charge = decimal.Decimal(returned["data"]["etotalChg"])
36
+ self.acc_battery_discharge = decimal.Decimal(returned["data"]["etotalDischg"])
37
+
38
+ async def _get_total_pv(self):
39
+ returned = await self.session.get(
40
+ BASE_API + f"/inverter/{self.sn}/total",
41
+ params={"lan": "en"},
42
+ )
43
+ self.acc_pv = sum(
44
+ [
45
+ decimal.Decimal(i["value"])
46
+ for i in returned["data"]["infos"][0]["records"]
47
+ ]
48
+ )
49
+
50
+ async def _get_total_load(self):
51
+ returned = await self.session.get(
52
+ BASE_API + f"/inverter/load/{self.sn}/realtime",
53
+ params={"lan": "en"},
54
+ )
55
+ self.acc_load = decimal.Decimal(returned["data"]["totalUsed"])
56
+
57
+ async def _update_strings(self):
58
+ returned = await self.session.get(
59
+ BASE_API + f"/inverter/{self.sn}/realtime/input"
60
+ )
61
+ strings_raw = returned["data"]["pvIV"]
62
+ for string in strings_raw:
63
+ self.pv_strings.setdefault(
64
+ string["pvNo"], PVString(id=string["pvNo"])
65
+ ).update_from_inv(string)
66
+
67
+ async def update(self):
68
+ await self._get_total_pv()
69
+ await self._get_total_grid()
70
+ await self._get_total_battery()
71
+ await self._get_total_load()
72
+ await self._update_strings()
pysunsynkweb/model.py ADDED
@@ -0,0 +1,172 @@
1
+ """Top level data model for the sunsynk web api."""
2
+
3
+ from dataclasses import dataclass, field
4
+ import decimal
5
+ import logging
6
+ import pprint
7
+ from typing import List, Union
8
+
9
+ from pysunsynkweb.inverter import Inverter
10
+
11
+ from .const import BASE_API
12
+ from .session import SunsynkwebSession
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class Plant:
19
+ """Proxy for the 'Plant' object in sunsynk web api.
20
+
21
+ A plant can host multiple inverters and other devices. Our plant object
22
+ carries a summary of the data from all inverters in the plant.
23
+
24
+ In the limited sample i've got, there is one plant per inverter.
25
+
26
+ """
27
+
28
+ id: int
29
+ master_id: int
30
+ name: str
31
+ status: int
32
+ battery_power: int = 0
33
+ state_of_charge: float = 0
34
+ load_power: int = 0
35
+ grid_power: int = 0
36
+ pv_power: int = 0
37
+ session: Union[SunsynkwebSession, None] = None
38
+ inverters: List[Inverter] = field(default_factory=list)
39
+
40
+ def __repr__(self):
41
+ """Summary of the plant"""
42
+ return f"""Plant {id}
43
+
44
+ {self.name}
45
+ Battery power {self.battery_power} W ({self.state_of_charge})%
46
+ Grid Power: {self.grid_power} W
47
+ PV Power: {self.pv_power} W
48
+ Accumulated PV Energy {self.acc_pv} KWh
49
+ Accumulated Grid export {self.acc_grid_export} KWh
50
+ Accumulated Grid import {self.acc_grid_import} KWh
51
+ Accumulated Load {self.acc_load} KWh
52
+ Accumulated Battery discharge {self.acc_battery_discharge} KWh
53
+ Accumulated Battery charge {self.acc_battery_charge} KWh
54
+
55
+ """
56
+
57
+ def ismaster(self):
58
+ """Is the plant a master plant.
59
+
60
+ Unused at the moment, but required when introducing read-write calls
61
+ (for instance to command to charge batteries from the grid).
62
+ """
63
+ return self.master_id == self.id
64
+
65
+ @classmethod
66
+ def from_api(cls, api_return, session):
67
+ """Create the plant from the return of the web api."""
68
+ return cls(
69
+ name=api_return["name"],
70
+ id=api_return["id"],
71
+ master_id=api_return["masterId"],
72
+ status=api_return["status"],
73
+ session=session,
74
+ )
75
+
76
+ async def enrich_inverters(self):
77
+ """Populate inverters' serial numbers.
78
+
79
+ The plant summary doesn't contain the inverters, so we have a
80
+ separate call to populate inverter's serial numbers.
81
+ """
82
+ returned = await self.session.get(
83
+ BASE_API + f"/plant/{self.id}/inverters",
84
+ params={"page": 1, "limit": 20, "type": -1, "status": -1},
85
+ )
86
+ self.inverters = [
87
+ Inverter(k["sn"], session=self.session) for k in returned["data"]["infos"]
88
+ ]
89
+
90
+ async def _get_instantaneous_data(self):
91
+ """Populate instantaneous data.
92
+
93
+ Instantaneous data is conveniently summarized in the 'flow' api end point.
94
+ """
95
+ returned = await self.session.get(
96
+ BASE_API + f"/plant/energy/{self.id}/flow",
97
+ params={"page": 1, "limit": 20},
98
+ )
99
+ _LOGGER.debug("Flow Api returned %s", pprint.pformat(returned))
100
+
101
+ self.battery_power = returned["data"]["battPower"]
102
+ if returned["data"]["toBat"]:
103
+ self.battery_power *= -1
104
+ self.state_of_charge = returned["data"]["soc"]
105
+ self.load_power = returned["data"]["loadOrEpsPower"]
106
+ self.grid_power = returned["data"]["gridOrMeterPower"]
107
+ if returned["data"]["toGrid"]:
108
+ self.grid_power *= -1
109
+ self.pv_power = returned["data"]["pvPower"]
110
+
111
+ async def update(self):
112
+ """Update all sensors."""
113
+ await self._get_instantaneous_data()
114
+ for inverter in self.inverters:
115
+ await inverter.update()
116
+
117
+ @property
118
+ def acc_pv(self):
119
+ return sum(i.acc_pv for i in self.inverters)
120
+
121
+ @property
122
+ def acc_grid_export(self):
123
+ return max(i.acc_grid_export for i in self.inverters)
124
+
125
+ @property
126
+ def acc_grid_import(self):
127
+ return max(i.acc_grid_import for i in self.inverters)
128
+
129
+ @property
130
+ def acc_battery_discharge(self):
131
+ return sum(i.acc_battery_discharge for i in self.inverters)
132
+
133
+ @property
134
+ def acc_battery_charge(self):
135
+ return sum(i.acc_battery_charge for i in self.inverters)
136
+
137
+ @property
138
+ def acc_load(self):
139
+ return sum(i.acc_load for i in self.inverters)
140
+
141
+
142
+ @dataclass
143
+ class Installation:
144
+ """An installation is a series of plants.
145
+
146
+ This integration presents the plants as a single entity.
147
+ """
148
+
149
+ plants: List[Plant]
150
+
151
+ @classmethod
152
+ def from_api(cls, api_return, session):
153
+ """Create the installation from the sunsynk web api."""
154
+ assert "data" in api_return
155
+ assert api_return["msg"] == "Success"
156
+ return cls(
157
+ plants=[Plant.from_api(ret, session) for ret in api_return["data"]["infos"]]
158
+ )
159
+
160
+ async def update(self):
161
+ """Update all the plants. They will in turn update their sensors."""
162
+ for plant in self.plants:
163
+ await plant.update()
164
+
165
+
166
+ async def get_plants(session: SunsynkwebSession):
167
+ """Start walking the plant composition."""
168
+ returned = await session.get(BASE_API + "/plants", params={"page": 1, "limit": 20})
169
+ installation = Installation.from_api(returned, session)
170
+ for plant in installation.plants:
171
+ await plant.enrich_inverters()
172
+ return installation
@@ -0,0 +1,41 @@
1
+ from dataclasses import dataclass
2
+ import datetime
3
+ import decimal
4
+
5
+
6
+ @dataclass
7
+ class PVString:
8
+ """A PV 'string'.
9
+
10
+ PV 'strings' are cables from a set of solar panels. Typically, your
11
+ installer will group the panels on string based on common orientation
12
+ or exposure to the sun.
13
+
14
+ """
15
+
16
+ id: int
17
+ voltage: decimal.Decimal = decimal.Decimal(0)
18
+ power: decimal.Decimal = decimal.Decimal(0)
19
+ amperage: decimal.Decimal = decimal.Decimal(0)
20
+ last_update: datetime.datetime = None
21
+
22
+ def update_from_inv(self, data):
23
+ """Update from invertor 'input' request.
24
+
25
+ ```
26
+ {
27
+ "id": null,
28
+ "pvNo": 1,
29
+ "vpv": "212.9",
30
+ "ipv": "1.0",
31
+ "ppv": "222.0",
32
+ "todayPv": "0.0",
33
+ "sn": "2211166856",
34
+ "time": "2024-06-13 07:54:05"
35
+ },
36
+ ```
37
+ """
38
+ self.last_update = datetime.datetime.strptime(data["time"], "%Y-%m-%d %H:%M:%S")
39
+ self.power = decimal.Decimal(data["ppv"])
40
+ self.voltage = decimal.Decimal(data["vpv"])
41
+ self.amperage = decimal.Decimal(data["ipv"])
@@ -0,0 +1,77 @@
1
+ """A session object
2
+
3
+ Maintaining the authentication with the sunsynk api and
4
+ wrapping repetitive things
5
+ """
6
+
7
+ import logging
8
+ import pprint
9
+
10
+ import aiohttp
11
+
12
+ from .const import BASE_HEADERS, BASE_URL
13
+ from .exceptions import AuthenticationFailed
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class SunsynkwebSession:
19
+ """the main entry point to sunsynk api.
20
+
21
+ Maintains http headers,
22
+ authentication token, etc.
23
+ """
24
+
25
+ def __init__(self, session: aiohttp.ClientSession, username: str, password: str) -> None:
26
+ """Pass an aiohttp client session and authentication items"""
27
+ self.session = session
28
+ self.bearer = None
29
+ self.username = username
30
+ self.password = password
31
+
32
+ async def get(self, *args, **kwargs):
33
+ """Run a GET query against the sunsynk api"""
34
+ if self.bearer is None:
35
+ await self._get_bearer_token()
36
+ headers = BASE_HEADERS.copy()
37
+ headers.update({"Authorization": f"Bearer{self.bearer}"})
38
+ kwargs["headers"] = headers
39
+ result = await self.session.get(*args, **kwargs)
40
+ result = await result.json()
41
+ if result.get("msg") != "Success" and result.get("code") == 401:
42
+ # expired token
43
+ await self._get_bearer_token()
44
+ result = await self.get(*args, **kwargs)
45
+ return result
46
+
47
+ async def _get_bearer_token(self):
48
+ """Get the bearer token for the sunsynk api."""
49
+ params = {
50
+ "username": self.username,
51
+ "password": self.password,
52
+ "grant_type": "password",
53
+ "client_id": "csp-web",
54
+ "source": "sunsynk",
55
+ "areaCode": "sunsynk",
56
+ }
57
+ returned = await self.session.post(BASE_URL + "/oauth/token", json=params, headers=BASE_HEADERS)
58
+
59
+ returned = await returned.json()
60
+ _LOGGER.debug("authentication attempt returned %s", pprint.pformat(returned))
61
+ # returned data looks like the below
62
+ # {
63
+ # "code": 0,
64
+ # "msg": "Success",
65
+ # "data": {
66
+ # "access_token": "VALUE",
67
+ # "token_type": "bearer",
68
+ # "refresh_token": "VALUE",
69
+ # "expires_in": 604799,
70
+ # "scope": "all",
71
+ # },
72
+ # "success": True,
73
+ # }
74
+ try:
75
+ self.bearer = returned["data"]["access_token"]
76
+ except KeyError as exc:
77
+ raise AuthenticationFailed from exc
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.3
2
+ Name: pysunsynkweb
3
+ Version: 0.0.2
4
+ Project-URL: Documentation, https://github.com/francoisverbeek/pysunsynkweb
5
+ Project-URL: Issues, https://github.com/francoisverbeek/pysunsynkweb/issues
6
+ Project-URL: Source, https://github.com/francoisverbeek/pysunsynkweb
7
+ Author-email: Francois <f@flve.uk>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.txt
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
19
+ Requires-Python: >=3.8
20
+ Requires-Dist: aiohttp
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Sunsynk Web
24
+
25
+ [![PyPI - Version](https://img.shields.io/pypi/v/pysunsynkweb.svg)](https://pypi.org/project/pysunsynkweb)
26
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pysunsynkweb.svg)](https://pypi.org/project/pysunsynkweb)
27
+
28
+ -----
29
+
30
+ ## Table of Contents
31
+
32
+ - [Installation](#installation)
33
+ - [License](#license)
34
+
35
+ ## Installation
36
+
37
+ ```console
38
+ pip install pysunsynkweb
39
+ ```
40
+
41
+ ## License
42
+
43
+ `pysunsynkweb` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,13 @@
1
+ pysunsynkweb/__about__.py,sha256=Iug5BLKo1Q_htAJ3DfAWjoemeuWjZ-BTsD9zs-if9J4,157
2
+ pysunsynkweb/__init__.py,sha256=W-7fTovQ_YrLkagXsWhaSSMdEbmaNHMU72Hmi2ul8jI,162
3
+ pysunsynkweb/battery.py,sha256=MRF4vtUxOuaJrsiEi4761FqtNE8a1Y038dAkV9VpZPo,74
4
+ pysunsynkweb/const.py,sha256=N2PnrEETMhNF6k2vqxzqDlmA9BKyfOSPA2VFsXMAu08,282
5
+ pysunsynkweb/exceptions.py,sha256=3XiZihDBmFM6aCV3MAl3sC3D8XsajL382ah7EAiwe3M,109
6
+ pysunsynkweb/inverter.py,sha256=JeTTylP2R41XU07E2u6OkoKeyAHDlFxb6BiHlqY4FW0,2596
7
+ pysunsynkweb/model.py,sha256=qIwcJ_VSjx7CY3XSAO3tqFIStO5vsEfFcIGPeEnORU0,5370
8
+ pysunsynkweb/pvstring.py,sha256=hJEofwkxqc3EOxvsQMpGdCT5QUgtjISKDCN-fLyEVP4,1250
9
+ pysunsynkweb/session.py,sha256=OP81Kyl1AnnP-8h3x4-mhGORRa4D4BrAu8HfVwv-QjY,2465
10
+ pysunsynkweb-0.0.2.dist-info/METADATA,sha256=SEUl3CuWhCsfCW2PCYk4HGbFSwusLZDw0mNb3bCsHow,1433
11
+ pysunsynkweb-0.0.2.dist-info/WHEEL,sha256=zEMcRr9Kr03x1ozGwg5v9NQBKn3kndp6LSoSlVg-jhU,87
12
+ pysunsynkweb-0.0.2.dist-info/licenses/LICENSE.txt,sha256=FlpRiuOvoN51zmppyHedCkDPsMo1ZB3PYxd-mV8X7t0,1085
13
+ pysunsynkweb-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.24.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-present Francois <f@flve.uk>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.