Skip to content

Commit 67b43c2

Browse files
authored
Merge pull request #1 from JasperE84/dev
Release 1.0.3
2 parents 4ef8dac + 6029c1d commit 67b43c2

13 files changed

+509
-99
lines changed

Examples/docker-compose.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,20 @@ services:
3737
image: jsprnl/pyfusionsolardatarelay:latest
3838
#build: ./PyFusionSolarDataRelay
3939
restart: unless-stopped
40-
network_mode: host
4140
depends_on:
4241
- influxdb
4342
- mosquitto
4443
environment:
4544
- pvdebug=True
4645
- pvsysname=inverter01
4746

47+
- pvfusionsolar=True
4848
- pvfusionsolarkkid=GET_THIS_STRING_FROM_YOUR_KIOSK_URL
49-
- pvfusioninterval=120 #please note that the kiosk current power only seems to update each 30mins
49+
- pvfusionminutecron=0,30 # Please note that the kiosk only seems to update each 30mins
5050

5151
- pvinflux=True
5252
- pvinflux2=True
53-
- pvifhost=localservername
53+
- pvifhost=influxdb
5454
- pvif2protocol=http
5555
- pvif2org=acme
5656
- pvif2bucket=fusionsolar
@@ -61,9 +61,15 @@ services:
6161
- pvpvoutputapikey=GENERATE_THIS_AND_SYSTEMID_ON_PVOUTPUT.ORG
6262

6363
- pvmqtt=True
64-
- pvmqtthost=localservername
64+
- pvmqtthost=mosquitto
6565
- pvmqttauth=False
6666

67+
- pvgridrelay=False
68+
- pvgridrelaykenterean=000000000000000000
69+
- pvgridrelaykentermeterid=0000000000
70+
71+
- pvgridrelaykenterpasswd=secretpassword
72+
6773
volumes:
6874
- /etc/localtime:/etc/localtime
6975

38.5 KB
Loading

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Huawei FusionSolar Kiosk API to InfluxDB, MQTT and PVOutput relay
22
This is a python project intended to fetch data from the **Huawei FusionSolar** public **kiosk** and relay it to **InfluxDB** and/or **PVOutput.org** and/or **MQTT**.
33

4+
Additionally this project can also fetch and relay grid usage data from the Dutch meetdata.nl API provider by **Kenter**.
5+
46
Credits go to the [Grott project](https://github.com/johanmeijer/grott). Many bits of code, structure and ideas are borrowed from there.
57

68
[![GitHub release](https://img.shields.io/github/release/JasperE84/PyFusionSolarDataRelay?include_prereleases=&sort=semver&color=2ea44f)](https://github.com/JasperE84/PyFusionSolarDataRelay/releases/)
@@ -14,31 +16,44 @@ A local settings file (such as .yml or .ini) has not been implemented yet, but p
1416

1517
Check out [Examples/docker-compose.yml](https://github.com/JasperE84/PyFusionSolarDataRelay/blob/main/Examples/docker-compose.yml) for a docker configuration example.
1618

19+
# Breaking changes in the release
20+
The fusionsolarinterval configuration paramters has been replaced by two cron settings defaulting to poll fusionsolar data each half our.
21+
1722
# About Huawei FusionSolar Kiosk mode
1823
FusionSolar is Huawei's online monitoring platform for their PV inverters. FusionSolar features a kiosk mode. When enabled, a kiosk url is generated which is publically accessible. The kiosk web app fetches its data from a JSON backend. It is this backend where this project fetches the PV data.
1924
Fetching data from the kiosk mode can be beneficial to those without direct access to the official API and/or the inverter Modbus-TCP. For instance when the inverter is logging to fusionsolar over a direct cellular connection configured and fitted by an installer unable to provide API access rights to third parties.
2025

26+
# About Kenter's API and matching PVOutput intervals
27+
Fusion solar data fetching is planned by cron in order to exactly specify at what times the data should reload. This way, it is possible to synchronise the intervals of fusionsolar and gridkenter datapoints, which end up showing on PVOutput. That's relevant because if the gridkenter data class is fetched, meetdata.nl does not provide live measurements. Instead it provides historic measurements with a certain interval (15 minutes interval with the most recent data point 3 days old in my case). If this interval doesn't match the fusionsolar interval, then PVOutput will show distorted graphs because it won't have a datapoint for both PV production and grid usage for each interval. (Fusionsolar kiosk API only updates each half hour). See [this url](https://crontab.guru/) for help with finding the right cron config.
28+
2129
# About PVOutput.org
2230
[PVOutput.org](https://pvoutput.org/) is a free service for sharing and comparing PV output data.
31+
![PVOutput dashboard screenshot](./Examples/pvoutput-measurement-result-example.png)
2332

2433
# About InfluxDB
2534
[InfluxDB](https://www.influxdata.com/) is an open source time series database on which dashboards can easily be built. For instance using [Grafana](https://grafana.com/)
2635

2736
# About MQTT
2837
MQTT is an OASIS standard messaging protocol for the Internet of Things (IoT). It is designed as an extremely lightweight publish/subscribe messaging transport that is ideal for connecting remote devices. MQTT can be used to relay the PV data to various home automation software such as [Home Assistant](https://www.home-assistant.io/)
2938

39+
# About Kenter's meetdata.nl
40+
Kenter provides measurement services for **commercially rented** grid transformers. This project can fetch energy usage data from this API and post it to InfluxDB and PVOutput. MQTT is not supported for posting Kenter data, as Kenter's latest measurement data is usually 3 days old.
41+
3042
# Configuration parameter documentation
3143
| Parameter | Environment variable | Description | Default |
3244
| --- | --- | --- | --- |
3345
| debug | pvdebug | Enables verbose logging | True |
3446
| pvsysname | pvsysname | Definition of 'measurement' name for InfluxDB | inverter01 |
47+
| fusionsolar | pvfusionsolar | Can be `True` or `False`, determines if fusionsolar kiosk API is enabled | True |
3548
| fusionsolarurl | pvfusionsolarurl | Link to the fusionsolar kiosk data backend | [Click url](https://region01eu5.fusionsolar.huawei.com/rest/pvms/web/kiosk/v1/station-kiosk-file?kk=) |
3649
| fusionsolarkkid | pvfusionsolarkkid | Unique kiosk ID, can be found by looking the kiosk URL and then taking the code after `kk=` | GET_THIS_FROM_KIOSK_URL |
37-
| fusioninterval | pvfusioninterval | Seconds between fusionsolar data polling and relay | 120 |
50+
| fusionhourcron | pvfusionhourcron | Hour component for python cron job to fetch and process data from fusionsolar. | * |
51+
| fusionminutecron | pvfusionminutecron | Minute component for python cron job to fetch and process data from fusionsolar | 0,30 |
3852
| pvoutput | pvpvoutput | Can be `True` or `False`, determines if PVOutput.org API is enabled | False |
3953
| pvoutputapikey | pvpvoutputapikey | API Key for PVOutput.org | yourapikey |
4054
| pvoutputsystemid | pvoutputsystemid | System ID for PVOutput.org, should be numeric | 12345 |
41-
| pvoutputurl | pvpvoutputurl | API url for PVOutput.org | [Click url](https://pvoutput.org/service/r2/addstatus.jsp)
55+
| pvoutputurl | pvpvoutputurl | API url for PVOutput.org live output posting | [Click url](https://pvoutput.org/service/r2/addstatus.jsp)
56+
| pvoutputbatchurl | pvpvoutputbatchurl | API url for PVOutput.org historic data batch posting (used for grid data from meetdata.nl) | [Click url](https://pvoutput.org/service/r2/addbatchstatus.jsp)
4257
| influx | pvinflux | Can be `True` or `False`, determines if InfluxDB processing is enabled | False |
4358
| influx2 | pvinflux2 | If `True` the InfluxDBv2 methods are used. If `False` InfluxDBv1 methods are used | True |
4459
| ifhost | pvifhost | Hostname of the influxdb server | localhost |
@@ -57,6 +72,16 @@ MQTT is an OASIS standard messaging protocol for the Internet of Things (IoT). I
5772
| mqttuser | pvmqttuser | MQTT Username | fusionsolar |
5873
| mqttpasswd | pvmqttpasswd | MQTT Password | fusionsolar |
5974
| mqtttopic | pvmqtttopic | MQTT Topic for publishing | energy/pyfusionsolar |
75+
| gridrelay | pvgridrelay | Can be `True` or `False`, determines if data is fetched from Kenter's meetdata.nl API | False |
76+
| gridrelaysysname | pvgridrelaysysname | Grid transformer name for InfluxDB transformer data | transformer01 |
77+
| gridrelayinterval | pvgridrelayinterval | Interval in seconds to fetch data from meetdata.nl and post to PVOutput and InfluxDB | 43200 |
78+
| gridrelaykenterurl | pvgridrelaykenterurl | Kenter API url for fetching transformer grid measurements | [Click url](https://webapi.meetdata.nl) |
79+
| gridrelaykenterean | pvgridrelaykenterean | EAN code for transformer on Kenter's www.meetdata.nl | XXX |
80+
| gridrelaykentermeterid | pvgridrelaykentermeterid | MeterID as shown on Kenter's www.meetdata.nl | XXX |
81+
| gridrelaykenteruser | pvgridrelaykenteruser | Username for Kenter's API | user |
82+
| gridrelaykenterpasswd | pvgridrelaykenterpasswd | Password for Kenter's API | passwd |
83+
| gridrelaydaysback | pvgridrelaydaysback | Kenter's meetdata.nl does not provide live data. Data is only available up until an X amount of days back. May vary per transformer. | 3 |
84+
| gridrelaypvoutputspan | pvgridrelaypvoutputspan | In my case meetdata.nl has datapoints for each 15mins. Setting this to a value of 2, will calculate averages over 2 datapoints spanning half an hour before posting to PVOutput. This way the datapoint interval between the grid usage data and fusionsolar PV production data matches, resulting in nice diagrams on PVOutput.org | 2 |
6085

6186
# Grafana dashboard example
6287
A grafana dashboard export is included in the Examples subfolder in the Git repository.
@@ -79,5 +104,14 @@ Take the following steps to achieve this:
79104
Result:
80105
![Xibo layout screenshot](./Examples/grafana-embedded-in-xibo-layout.png)
81106

107+
# Changelog
108+
| Version | Description |
109+
| --- | --- |
110+
| 1.0.3 | Grid transformer usage measurement polling from Kenter's meetdata.nl API has been implemented |
111+
| 1.0.3 | Changed docker-compose.yml template not to use host networking mode |
112+
| 1.0.3 | pv.py now uses separate threads for PvRelay and GridRelay classes |
113+
| 1.0.3 | Implemented apscheduler's cron implementation to be able to specify exact moments to fetch fusionsolar data |
114+
| 1.0.3 | Code and method name refactoring including PvConf type hints in classes where this class was injected as method parameter |
115+
=======
82116

83117
Released under [MIT](/LICENSE) by [@JasperE84](https://github.com/JasperE84).

gridkenter.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import requests
2+
from requests.auth import HTTPBasicAuth
3+
from datetime import datetime, timedelta
4+
import json
5+
from pvconf import PvConf
6+
7+
8+
class GridKenter:
9+
def __init__(self, conf: PvConf, logger):
10+
self.conf = conf
11+
self.logger = logger
12+
self.logger.debug("GridKenter class instantiated")
13+
14+
def fetch_gridkenter_data(self, days_back):
15+
self.logger.info(
16+
"Requesting data for {} from GridKenter API...".format(
17+
self.conf.gridrelaysysname
18+
)
19+
)
20+
req_time = datetime.now() - timedelta(days=days_back)
21+
req_year = req_time.strftime("%Y")
22+
req_month = req_time.strftime("%m")
23+
req_day = req_time.strftime("%d")
24+
25+
try:
26+
url = f"{self.conf.gridrelaykenterurl}/api/1/measurements/{self.conf.gridrelaykenterean}/{self.conf.gridrelaykentermeterid}/{req_year}/{req_month}/{req_day}"
27+
self.logger.debug(f"Fetching URL: {url}")
28+
29+
response = requests.get(
30+
url,
31+
auth=HTTPBasicAuth(
32+
self.conf.gridrelaykenteruser, self.conf.gridrelaykenterpasswd
33+
),
34+
verify=False,
35+
)
36+
except Exception as e:
37+
raise Exception(
38+
"Error fetching data from GridKenter API: '{}'".format(str(e))
39+
)
40+
41+
try:
42+
response_json = response.json()
43+
except Exception as e:
44+
raise Exception(
45+
"Error while parsing JSON response from GridKenter API: '{}'".format(
46+
str(e)
47+
)
48+
)
49+
50+
if not "16180" in response_json:
51+
raise Exception(
52+
f"GridKenter API response does not contain '16180' (levering tbv allocatie) key. Data possibly not ready yet. Response: {json.dumps(response_json)}"
53+
)
54+
55+
# Checking required realKpi elements and transforming kW(h) to W(h)
56+
if len(response_json["16180"]) == 0:
57+
raise Exception(
58+
"'16180' (levering tbv allocatie) key does not contain data. Data possibly not ready yet. Response: {json.dumps(response_json)}"
59+
)
60+
61+
grid_data_obj = {
62+
"sysname": self.conf.gridrelaysysname,
63+
"ean": self.conf.gridrelaykenterean,
64+
"meter_id": self.conf.gridrelaykentermeterid,
65+
"grid_net_consumption": [],
66+
}
67+
68+
prev_ts = None
69+
70+
for measure in response_json["16180"]:
71+
# Meting en valide, of handmatig goedgekeurd
72+
if (measure["origin"] == "m" and measure["status"] == "v") or measure[
73+
"status"
74+
] == "m":
75+
# Calculate powerload
76+
ts = datetime.fromtimestamp(measure["timestamp"])
77+
if prev_ts == None:
78+
seconds_from_prev_ts = (
79+
ts - ts.replace(hour=0, minute=0, second=0, microsecond=0)
80+
).total_seconds()
81+
else:
82+
seconds_from_prev_ts = (ts - prev_ts).total_seconds()
83+
prev_ts = ts
84+
calculated_power = round(
85+
measure["value"] * 3600 / seconds_from_prev_ts, 3
86+
)
87+
88+
# self.logger.debug(
89+
# f"Measurement local ts: {datetime.fromtimestamp(measure['timestamp']).strftime('%Y-%m-%d %H:%M:%S')} kWh: {measure['value']} kW (calculated): {calculated_power}"
90+
# )
91+
92+
grid_data_obj["grid_net_consumption"].append(
93+
{
94+
"timestamp": measure["timestamp"],
95+
"interval_energy": measure["value"] * 1000,
96+
"interval_power_avg": calculated_power * 1000,
97+
}
98+
)
99+
100+
return grid_data_obj

gridrelay.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import time
2+
from pvinflux import PvInflux
3+
from pvoutputorg import PvOutputOrg
4+
from pvconf import PvConf
5+
from gridkenter import GridKenter
6+
from pvmqtt import PvMqtt
7+
8+
9+
class GridRelay:
10+
def __init__(self, conf: PvConf, logger):
11+
self.conf = conf
12+
self.logger = logger
13+
self.logger.debug("GridRelay class instantiated")
14+
15+
self.gridkenter = GridKenter(conf, logger)
16+
self.pvoutput = PvOutputOrg(conf, logger)
17+
self.pvmqtt = PvMqtt(conf, logger)
18+
self.pvinflux = PvInflux(self.conf, self.logger)
19+
self.pvinflux_initialized = False
20+
21+
self.logger.info("Starting GridRelay on separate thread")
22+
self.start()
23+
24+
def start(self):
25+
self.logger.debug("GridRelay waiting 5sec to initialize docker-compose containers")
26+
time.sleep(5)
27+
28+
while 1:
29+
try:
30+
grid_measurement_data = self.gridkenter.fetch_gridkenter_data(self.conf.gridrelaydaysback)
31+
self.write_gridkenter_to_influxdb(grid_measurement_data)
32+
self.write_gridkenter_to_pvoutput(grid_measurement_data)
33+
except:
34+
self.logger.exception(
35+
"Uncaught exception in GridRelay data processing loop."
36+
)
37+
38+
self.logger.debug("Waiting for next interval...")
39+
time.sleep(self.conf.gridrelayinterval)
40+
41+
def write_gridkenter_to_pvoutput(self, grid_measurement_data):
42+
if self.conf.pvoutput:
43+
try:
44+
self.pvoutput.write_griddata_to_pvoutput(grid_measurement_data)
45+
except:
46+
self.logger.exception("Error writing GridData to PVOutput.org")
47+
48+
def write_gridkenter_to_influxdb(self, grid_measurement_data):
49+
if self.conf.influx:
50+
if self.pvinflux_initialized == False:
51+
self.pvinflux_initialized = self.pvinflux.initialize()
52+
53+
if self.pvinflux_initialized:
54+
self.pvinflux.pvinflux_write_griddata(grid_measurement_data)
55+
else:
56+
self.logger.debug("Writing data to Influx skipped, not initialized yet.")
57+
58+

pv.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import sys
22
import logging
3+
import time
4+
from threading import Thread
35
from pvconf import PvConf
46
from pvrelay import PvRelay
7+
from gridrelay import GridRelay
58

69
# Logger
710
logger = logging.getLogger()
@@ -10,7 +13,7 @@
1013
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
1114
streamHandler.setFormatter(formatter)
1215
logger.addHandler(streamHandler)
13-
logger.info("PyFusionSolarDataRelay 1.0.2 started")
16+
logger.info("PyFusionSolarDataRelay 1.0.3 started")
1417

1518
# Config
1619
conf = PvConf(logger)
@@ -21,10 +24,20 @@
2124
else:
2225
logger.setLevel(logging.INFO)
2326

24-
# Start relay
25-
relay = PvRelay(conf, logger)
27+
# Start PvRelay and KenterRelay
2628
try:
27-
relay.main()
29+
if __name__ == '__main__':
30+
if conf.fusionsolar:
31+
fs_thread = Thread(target = PvRelay, args=[conf, logger])
32+
fs_thread.daemon = True
33+
fs_thread.start()
34+
if conf.gridrelay:
35+
gr_thread = Thread(target = GridRelay, args=[conf, logger])
36+
gr_thread.daemon = True
37+
gr_thread.start()
38+
while True:
39+
time.sleep(1)
2840
except KeyboardInterrupt:
2941
logger.info("Ctrl C - Stopping relay")
3042
sys.exit(0)
43+

0 commit comments

Comments
 (0)