pyrenac 0.0.2__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.
pyrenac-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gastush
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pyrenac-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrenac
3
+ Version: 0.0.2
4
+ Summary: A library to fetch data from Renac Inverter
5
+ Author-email: gastush <gastush@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gastush/pyrenac
8
+ Project-URL: Issues, https://github.com/gastush/pyrenac/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # pyrenac
17
+ Python library to fetch data from Renac Inverter
18
+
19
+ [![Publish Python distribution to PyPI and TestPyPI](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml)
20
+
21
+ The only tested inverter so far is a R3-8K-DT (that's the one I own)
22
+ I tried to add basic support for qn Hybrid model based on some sample data I got, but could not test it live yet...
@@ -0,0 +1,7 @@
1
+ # pyrenac
2
+ Python library to fetch data from Renac Inverter
3
+
4
+ [![Publish Python distribution to PyPI and TestPyPI](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml)
5
+
6
+ The only tested inverter so far is a R3-8K-DT (that's the one I own)
7
+ I tried to add basic support for qn Hybrid model based on some sample data I got, but could not test it live yet...
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "pyrenac"
3
+ version = "0.0.2"
4
+ authors = [
5
+ { name="gastush", email="gastush@gmail.com" },
6
+ ]
7
+ description = "A library to fetch data from Renac Inverter"
8
+ readme = "README.md"
9
+ requires-python = ">=3.9"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: OS Independent",
13
+ ]
14
+ license = "MIT"
15
+ license-files = ["LICEN[CS]E*"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/gastush/pyrenac"
19
+ Issues = "https://github.com/gastush/pyrenac/issues"
20
+
21
+ [build-system]
22
+ requires = ["setuptools >= 77.0.3"]
23
+ build-backend = "setuptools.build_meta"
24
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,450 @@
1
+ """LIbrary providing access to the Renac SEC APIs.
2
+
3
+ Beware that this library as only been tested against On-Grid Inverters
4
+ """
5
+
6
+ import asyncio
7
+ from collections.abc import Coroutine
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+ import logging
12
+ import threading
13
+ from typing import Any, TypeVar
14
+
15
+ import aiohttp
16
+ import requests
17
+
18
+ __all__ = [
19
+ "run_coroutine_sync",
20
+ ]
21
+
22
+ T = TypeVar("T")
23
+
24
+
25
+ def run_coroutine_sync(coroutine: Coroutine[Any, Any, T], timeout: float = 30) -> T:
26
+ def run_in_new_loop():
27
+ new_loop = asyncio.new_event_loop()
28
+ asyncio.set_event_loop(new_loop)
29
+ try:
30
+ return new_loop.run_until_complete(coroutine)
31
+ finally:
32
+ new_loop.close()
33
+
34
+ try:
35
+ loop = asyncio.get_running_loop()
36
+ except RuntimeError:
37
+ return asyncio.run(coroutine)
38
+
39
+ if threading.current_thread() is threading.main_thread():
40
+ if not loop.is_running():
41
+ return loop.run_until_complete(coroutine)
42
+ else:
43
+ with ThreadPoolExecutor() as pool:
44
+ future = pool.submit(run_in_new_loop)
45
+ return future.result(timeout=timeout)
46
+ else:
47
+ return asyncio.run_coroutine_threadsafe(coroutine, loop).result()
48
+
49
+
50
+ API_ROOT = "https://sec.bg.renacpower.cn:8084/api/"
51
+ RENAC_API_ROOT = "https://sec.bg.renacpower.cn:8084/renac/"
52
+ BG_API_ROOT = "https://sec.bg.renacpower.cn:8084/bg/"
53
+
54
+ _LOGGER = logging.getLogger(__name__)
55
+
56
+ InverterType = Enum("InverterType", ["ONGRID", "HYBRID"])
57
+
58
+ class Inverter:
59
+ """The base class that represent the Inverter."""
60
+
61
+ def __init__(self, api_client) -> None:
62
+ """Initialize the Inverter."""
63
+ self.api_client = api_client
64
+ self._manufacturer = None
65
+ self._model = None
66
+ self._version = None
67
+ self._type = None
68
+
69
+ def _get_base_data(self):
70
+ pass
71
+
72
+ @property
73
+ def type(self) -> Any:
74
+ """The Inverter type."""
75
+ return self._type
76
+
77
+
78
+ class OnGridInverter(Inverter):
79
+ """An On-Grid ivnerter."""
80
+
81
+ def __init__(self, api_client) -> None:
82
+ """Initialize the Inverter."""
83
+ super().__init__(api_client)
84
+ self._type = InverterType.ONGRID
85
+
86
+
87
+ class HybridInverter(Inverter):
88
+ """An hybrid inverter."""
89
+
90
+ def __init__(self, api_client) -> None:
91
+ """Initialize the Inverter."""
92
+ super().__init__(api_client)
93
+ self._type = InverterType.HYBRID
94
+
95
+
96
+ @dataclass
97
+ class RenacInverterData:
98
+ """Renac inverter data class."""
99
+
100
+ name: str
101
+ version: str
102
+ fwversion: str
103
+ registration_time: str
104
+ equipment_type: str
105
+ equipment_serial: str
106
+
107
+
108
+ class PyRenac:
109
+ """The API wrapper."""
110
+
111
+ def __init__(self, username, password) -> None:
112
+ """Initialize the librqry."""
113
+ _LOGGER.info("New PyRenac instance %s", username)
114
+ self.username = username
115
+ self.password = password
116
+ self.emailSn = None
117
+ self.equipSn = None
118
+ self.token = None
119
+ self.station_id = None
120
+ self.inverterData = None
121
+ inverterData = run_coroutine_sync(self.async_get_inverter_data())
122
+ self.equipSn = inverterData.equipment_serial
123
+ self.inverterData = inverterData
124
+
125
+ def getSerial(self):
126
+ """Get the serial number of the inverter."""
127
+ return self.equipSn
128
+
129
+ def getUniqueId(self, field):
130
+ """Get a unique id for q given field name."""
131
+ return "renac_" + field + "_" + self.equipSn
132
+
133
+ def _login_request(self):
134
+ return {"loginName": self.username, "password": self.password}
135
+
136
+ def _fetch_request(self):
137
+ return {"sn": self.equipSn, "email": self.emailSn}
138
+
139
+ async def async_login(self) -> None:
140
+ """Login to the Renac SEC API backend."""
141
+ _LOGGER.info("Requesting authorization")
142
+ req_json = self._login_request()
143
+ async with (
144
+ aiohttp.ClientSession() as session,
145
+ session.post(API_ROOT + "login/", json=req_json) as resp,
146
+ ):
147
+ _LOGGER.debug(resp.status)
148
+ loginResponse = await resp.json(content_type=None)
149
+ _LOGGER.debug("Got %s", loginResponse.get("email"))
150
+ self.emailSn = loginResponse.get("email")
151
+ self.token = loginResponse.get("Token")
152
+
153
+ def login(self) -> None:
154
+ """Login to the Renac SEC API backend."""
155
+ _LOGGER.info("Requesting authorization")
156
+ req_json = self._login_request()
157
+ resp = requests.post(API_ROOT + "login", json=req_json, timeout=60)
158
+ if resp.status_code == 200:
159
+ loginResponse = resp.json()
160
+ _LOGGER.debug("Got %s", loginResponse.get("email"))
161
+ self.emailSn = loginResponse.get("email")
162
+ self.token = loginResponse.get("Token")
163
+
164
+ def fetch_field_value(self, data, field):
165
+ """Fetch the given field from the data."""
166
+ _LOGGER.debug("Fetch field value %s", field)
167
+ if data is not None:
168
+ return data.get(field)
169
+ return None
170
+
171
+ async def async_fetch(self, field):
172
+ """Fetch the data identified by the field."""
173
+ data = await self.async_fetch_all()
174
+ if data is not None:
175
+ return data.get(field)
176
+ return None
177
+
178
+ def fetch(self, field):
179
+ """Fetch the data identified by the field."""
180
+ data = self.fetch_all()
181
+ if data is not None:
182
+ return data.get(field)
183
+ return None
184
+
185
+ async def async_ensure_login(self):
186
+ """Ensure that we have a valid Token to be used."""
187
+ if self.token is None:
188
+ _LOGGER.info("Token is null, new fresh login sequence required")
189
+ await self.async_login()
190
+
191
+ def ensure_login(self):
192
+ """Ensure that we have a valid Token to be used."""
193
+ if self.token is None:
194
+ _LOGGER.info("Token is null, new fresh login sequence required")
195
+ self.login()
196
+
197
+ async def async_fetch_all(self):
198
+ """Fetch all the data.
199
+
200
+ A login will be done if needed to retrieve the right token.
201
+ """
202
+ _LOGGER.debug("Fetching all data")
203
+ data = None
204
+ await self.async_ensure_login()
205
+ req_json = {"sn": self.equipSn, "email": self.emailSn}
206
+ headers = {"Token": self.token}
207
+ timeout = aiohttp.ClientTimeout(total=30)
208
+ async with (
209
+ aiohttp.ClientSession() as session,
210
+ session.post(
211
+ API_ROOT + "equipDetail/",
212
+ json=req_json,
213
+ headers=headers,
214
+ timeout=timeout,
215
+ ) as resp,
216
+ ):
217
+ if resp.status == 200:
218
+ response = await resp.json(content_type=None)
219
+ if "results" in response:
220
+ data = response.get("results")
221
+ else:
222
+ _LOGGER.info("Null results. assuming a new Token is required")
223
+ self.token = None
224
+ else:
225
+ raise ("Failed to read sensor " + str(resp.status))
226
+
227
+ return data
228
+
229
+ def fetch_all(self):
230
+ """Fetch all the data.
231
+
232
+ A login will be done if needed to retrieve the right token.
233
+ """
234
+ _LOGGER.debug("Fetching all data")
235
+ data = None
236
+ self.ensure_login()
237
+ req_json = {"sn": self.equipSn, "email": self.emailSn}
238
+ headers = {"Token": self.token}
239
+ resp = requests.post(
240
+ API_ROOT + "equipDetail/", headers=headers, json=req_json, timeout=60
241
+ )
242
+ if resp.status == 200:
243
+ response = resp.json()
244
+ if "results" in response:
245
+ data = response.get("results")
246
+ else:
247
+ _LOGGER.info("Null results. assuming a new Token is required")
248
+ self.token = None
249
+ else:
250
+ raise ("Failed to read sensor " + str(resp.status))
251
+
252
+ return data
253
+
254
+ def getType(self, data) -> InverterType:
255
+ """Get the Inverter Type from the availqble fields."""
256
+ inverterType = None
257
+ try:
258
+ value = self.fetch_field_value(
259
+ data, "BATTERY_CAPACITY"
260
+ ) # HYBRID inverter have a battery.
261
+ if value is None:
262
+ inverterType = InverterType.ONGRID
263
+ else:
264
+ inverterType = InverterType.HYBRID
265
+ except KeyError:
266
+ inverterType = InverterType.ONGRID # Assume this is an On-Grid inverter.
267
+ return inverterType
268
+
269
+ def _station_list_request(self):
270
+ return {
271
+ "export_type": 0,
272
+ "installer_name": "",
273
+ "offset": 0,
274
+ "rows": 10,
275
+ "station_name": "",
276
+ "station_type": None,
277
+ "status": None,
278
+ "user_id": self.emailSn,
279
+ "user_name": "",
280
+ }
281
+
282
+ async def async_get_station_id(self):
283
+ """Get the station id."""
284
+ if self.station_id is None:
285
+ await self.async_ensure_login()
286
+ req_json = self._station_list_request()
287
+ headers = {"Token": self.token}
288
+ timeout = aiohttp.ClientTimeout(total=30)
289
+ async with (
290
+ aiohttp.ClientSession() as session,
291
+ session.post(
292
+ API_ROOT + "station/list",
293
+ json=req_json,
294
+ headers=headers,
295
+ timeout=timeout,
296
+ ) as resp,
297
+ ):
298
+ if resp.status == 200:
299
+ response = await resp.json(content_type=None)
300
+ if "data" in response:
301
+ _LOGGER.warning(response)
302
+ self.station_id = response["data"]["list"][0]["station_id"]
303
+ else:
304
+ _LOGGER.info("Null results. assuming a new Token is required")
305
+ self.token = None
306
+ else:
307
+ raise ("Failed to read sensor " + str(resp.status))
308
+ _LOGGER.info("Got station_id %s", self.station_id)
309
+ return self.station_id
310
+
311
+ def get_station_id(self):
312
+ """Get the station id."""
313
+ if self.station_id is None:
314
+ self.ensure_login()
315
+ req_json = self._station_list_request()
316
+ headers = {"Token": self.token}
317
+ resp = requests.post(
318
+ API_ROOT + "station/list", headers=headers, json=req_json, timeout=60
319
+ )
320
+ if resp.status == 200:
321
+ response = resp.json()
322
+ if "data" in response:
323
+ self.station_id = response["data"]["list"][0]["station_id"]
324
+ else:
325
+ _LOGGER.info("Null results. assuming a new Token is required")
326
+ self.token = None
327
+ else:
328
+ raise ("Failed to read sensor " + str(resp.status))
329
+ _LOGGER.info("Got station_id %s", self.station_id)
330
+ return self.station_id
331
+
332
+ async def async_get_historical_data(self, date):
333
+ """Get Historical production data for the given date time range."""
334
+ data = None
335
+ await self.ensure_login()
336
+ station_id = await self.get_station_id()
337
+ req_json = {"station_id": station_id, "time": str(date), "time_type": 1}
338
+ headers = {"Token": self.token}
339
+ timeout = aiohttp.ClientTimeout(total=30)
340
+ async with (
341
+ aiohttp.ClientSession() as session,
342
+ session.post(
343
+ RENAC_API_ROOT + "station/energy",
344
+ json=req_json,
345
+ headers=headers,
346
+ timeout=timeout,
347
+ ) as resp,
348
+ ):
349
+ if resp.status == 200:
350
+ response = await resp.json(content_type=None)
351
+ if "data" in response:
352
+ data = response["data"]
353
+ else:
354
+ _LOGGER.info("Null results. assuming a new Token is required")
355
+ self.token = None
356
+ else:
357
+ raise ("Failed to read sensor " + str(resp.status))
358
+ return data
359
+
360
+ def get_inverter_data(self) -> RenacInverterData:
361
+ """Get details about the inverter itslef."""
362
+ inverterData = None
363
+ self.ensure_login()
364
+ station_id = self.get_station_id()
365
+ req_json = {
366
+ "station_id": station_id,
367
+ "user_id": self.emailSn,
368
+ "status": 0,
369
+ "offset": 0,
370
+ "rows": 1,
371
+ }
372
+ headers = {"Token": self.token}
373
+ resp = requests.post(
374
+ API_ROOT + "station/list", headers=headers, json=req_json, timeout=60
375
+ )
376
+ if resp.status == 200:
377
+ response = resp.json(content_type=None)
378
+ if "data" in response:
379
+ data = response["data"]["list"][0]
380
+ inverterData = RenacInverterData(
381
+ version=data[0].get("VERSION"),
382
+ fwversion=data[0].get("FIRMWARE_VER"),
383
+ name=data[0].get("STATION_NAME"),
384
+ equipment_type=data[0].get("EQU_TYPE"),
385
+ registration_time=data[0].get("REG_TIME"),
386
+ equipment_serial=data[0].get("INV_SN"),
387
+ )
388
+ else:
389
+ _LOGGER.info("Null results. assuming a new Token is required")
390
+ self.token = None
391
+ else:
392
+ raise ("Failed to read sensor " + str(resp.status))
393
+ return inverterData
394
+
395
+ async def async_get_inverter_data(self) -> RenacInverterData:
396
+ """Get details about the inverter itslef."""
397
+ if self.inverterData is None:
398
+ await self.async_ensure_login()
399
+ station_id = await self.async_get_station_id()
400
+ req_json = {
401
+ "station_id": station_id,
402
+ "user_id": self.emailSn,
403
+ "status": 0,
404
+ "offset": 0,
405
+ "rows": 1,
406
+ }
407
+ headers = {"Token": self.token}
408
+ timeout = aiohttp.ClientTimeout(total=30)
409
+ async with (
410
+ aiohttp.ClientSession() as session,
411
+ session.post(
412
+ BG_API_ROOT + "equList",
413
+ json=req_json,
414
+ headers=headers,
415
+ timeout=timeout,
416
+ ) as resp,
417
+ ):
418
+ if resp.status == 200:
419
+ response = await resp.json(content_type=None)
420
+ if "data" in response:
421
+ _LOGGER.warning(response)
422
+ data = response["data"]["list"]
423
+ if len(data) > 0:
424
+ self.inverterData = RenacInverterData(
425
+ version=data[0].get("VERSION"),
426
+ fwversion=data[0].get("FIRMWARE_VER"),
427
+ name=data[0].get("STATION_NAME"),
428
+ equipment_type=data[0].get("EQU_TYPE"),
429
+ registration_time=data[0].get("REG_TIME"),
430
+ equipment_serial=data[0].get("INV_SN"),
431
+ )
432
+ else:
433
+ _LOGGER.info("Null results. assuming a new Token is required")
434
+ self.token = None
435
+ else:
436
+ raise ("Failed to read sensor " + str(resp.status))
437
+ return self.inverterData
438
+
439
+
440
+ class InverterFactory:
441
+ """The inverter factory."""
442
+
443
+ def getInverter(self, api_client: PyRenac) -> Inverter:
444
+ """Get the inverter based on his type."""
445
+ inverterType = api_client.getType()
446
+ if inverterType is inverterType.ONGRID:
447
+ return OnGridInverter(api_client)
448
+ if inverterType is InverterType.HYBRID:
449
+ return HybridInverter(api_client)
450
+ raise ValueError("Unsupported Inverter type")
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrenac
3
+ Version: 0.0.2
4
+ Summary: A library to fetch data from Renac Inverter
5
+ Author-email: gastush <gastush@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gastush/pyrenac
8
+ Project-URL: Issues, https://github.com/gastush/pyrenac/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # pyrenac
17
+ Python library to fetch data from Renac Inverter
18
+
19
+ [![Publish Python distribution to PyPI and TestPyPI](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/gastush/pyrenac/actions/workflows/publish-to-pypi.yml)
20
+
21
+ The only tested inverter so far is a R3-8K-DT (that's the one I own)
22
+ I tried to add basic support for qn Hybrid model based on some sample data I got, but could not test it live yet...
@@ -0,0 +1,8 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/pyrenac/pyrenac.py
5
+ src/pyrenac.egg-info/PKG-INFO
6
+ src/pyrenac.egg-info/SOURCES.txt
7
+ src/pyrenac.egg-info/dependency_links.txt
8
+ src/pyrenac.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ pyrenac