From 024ee926bcd88b312ea25f81225107d54e363b37 Mon Sep 17 00:00:00 2001 From: Neil Munday Date: Mon, 24 Jul 2023 21:56:24 +0100 Subject: [PATCH 1/3] fix: Ignore *.pyc files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c7853ee0..841db910 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ GivTCP/settings.py MQTT_TEST.py REST_TEST.py test.py +*.pyc From 28ea98dd82337c49399d86870854348c57261ae4 Mon Sep 17 00:00:00 2001 From: Neil Munday Date: Mon, 24 Jul 2023 22:01:21 +0100 Subject: [PATCH 2/3] feat: Add the ability to export metrics via Prometheus exporter --- Dockerfile | 2 +- GivTCP/prometheus_exporter.py | 100 ++++++++++++++++++++++++++++++++++ GivTCP/settings_template.py | 4 ++ README.md | 19 +++++++ docker-compose.yml | 3 + requirements.txt | 1 + startup.py | 17 +++++- 7 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 GivTCP/prometheus_exporter.py diff --git a/Dockerfile b/Dockerfile index e66d1827..20d733f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,6 +90,6 @@ ENV PALM_WEIGHT=35 ENV LOAD_HIST_WEIGHT="1" -EXPOSE 6345 1883 3000 6379 9181 +EXPOSE 6345 1883 3000 6379 6711 9181 CMD ["python3", "/app/startup.py"] diff --git a/GivTCP/prometheus_exporter.py b/GivTCP/prometheus_exporter.py new file mode 100644 index 00000000..5f861d96 --- /dev/null +++ b/GivTCP/prometheus_exporter.py @@ -0,0 +1,100 @@ +import argparse +import json +import re +import time + +from prometheus_client import start_http_server +from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily, REGISTRY + +import read as rd +from GivLUT import GivLUT + +logger = GivLUT.logger + + +class GivTcpCollector: + """ + Custom Prometheus exporter for GivTCP. + """ + + def collect(self): + """ + Gather metrics from GivTCP. + """ + logger.info("Collecting metrics for export") + + battery_temp_re = re.compile(r"^Battery_Cell_(?P[0-9]+)_Temperature$") + result = json.loads(rd.pubFromPickle()) + + if "Battery_Details" in result: + battery_labels = ["Serial"] + battery_capacity_total_gauge = GaugeMetricFamily("battery_capacity_total", "Battery capacity total", labels=battery_labels) + battery_capacity_remaining_gauge = GaugeMetricFamily("battery_capacity_remaining", "Battery capacity remaining", labels=battery_labels) + battery_capacity_percentage_gauge = GaugeMetricFamily("battery_capacity_percentage", "Battery capacity remaining as a percentage", labels=battery_labels) + battery_cell_temperature_gauge = GaugeMetricFamily("battery_cell_temperature", "Battery cell temperature", labels=["Serial", "Cell"]) + battery_voltage_gauge = GaugeMetricFamily("battery_voltage", "Battery voltage", labels=battery_labels) + battery_temperature_gauge = GaugeMetricFamily("battery_temperature", "Battery temperature", labels=battery_labels) + battery_cycles_counter = CounterMetricFamily("battery_cyles", "Number of battery cycle", labels=battery_labels) + + for serial, battery in result["Battery_Details"].items(): + capacity_percent = battery["Battery_Remaining_Capacity"] / battery["Battery_Design_Capacity"] + if capacity_percent > 1: + capacity_percent = 1 + battery_cycles_counter.add_metric([serial], battery["Battery_Cycles"]) + battery_capacity_total_gauge.add_metric([serial], battery["Battery_Design_Capacity"]) + battery_capacity_remaining_gauge.add_metric([serial], battery["Battery_Remaining_Capacity"]) + battery_capacity_percentage_gauge.add_metric([serial], f"{capacity_percent:.03f}") + battery_temperature_gauge.add_metric([serial], battery["Battery_Temperature"]) + battery_voltage_gauge.add_metric([serial], battery["Battery_Voltage"]) + + for key, value in battery.items(): + temp_match = battery_temp_re.match(key) + if temp_match: + cell = temp_match.group("cell") + battery_cell_temperature_gauge.add_metric([serial, cell], value) + + yield battery_cycles_counter + yield battery_capacity_total_gauge + yield battery_capacity_remaining_gauge + yield battery_capacity_percentage_gauge + yield battery_cell_temperature_gauge + yield battery_temperature_gauge + yield battery_voltage_gauge + + if "Invertor_Details" in result: + yield GaugeMetricFamily("invertor_temperature", "Invertor temperature", value=result["Invertor_Details"]["Invertor_Temperature"]) + + if "Power" in result: + if "Flows" in result["Power"]: + power_flow_gauge = GaugeMetricFamily("power_flow", "Power flow", labels=["Flow"]) + for flow, value in result["Power"]["Flows"].items(): + power_flow_gauge.add_metric([flow], value) + yield power_flow_gauge + + if "Power" in result["Power"]: + yield GaugeMetricFamily("charge_time_remaining", "Charge time remaining", value=result["Power"]["Power"]["Charge_Time_Remaining"]) + yield GaugeMetricFamily("discharge_time_remaining", "Discharge time remaining", value=result["Power"]["Power"]["Discharge_Time_Remaining"]) + yield GaugeMetricFamily("grid_voltage", "Grid voltage", value=result["Power"]["Power"]["Grid_Voltage"]) + yield GaugeMetricFamily("soc", "State of charge", value=result["Power"]["Power"]["SOC"]) + + power_gauage = GaugeMetricFamily("power", "Power", labels=["Metric"]) + power_re = re.compile(r"^(?P[\w]+)_Power$") + + for key, value in result["Power"]["Power"].items(): + power_match = power_re.match(key) + if power_match: + metric = power_match.group("metric") + power_gauage.add_metric([metric], value) + + yield power_gauage + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description='Provides Prometheus metrics for GivTCP', add_help=True) + parser.add_argument('-p', '--port', help='Specify the port to listen on', dest='port', type=int, default=9111) + args = parser.parse_args() + + start_http_server(args.port) + REGISTRY.register(GivTcpCollector()) + while True: + time.sleep(1) diff --git a/GivTCP/settings_template.py b/GivTCP/settings_template.py index 2bea6b2c..fd2fe2e1 100644 --- a/GivTCP/settings_template.py +++ b/GivTCP/settings_template.py @@ -31,6 +31,10 @@ class GiV_Settings: influxBucket="GivEnergy" #Optional - name of Bucket to put data into influxOrg="GivTCP" #Optional - Influx Organisation to use +# Prometheus Exporter Settings + Prometheus_Exporter= False #Optional - turns on the prometheus exporter. True or False + Prometheus_Port=6711 #Optional - the starting port number to listen on for the first invertor. Subsequent inverters will listen on the next port number in sequence. + # Home Assistant HA_Auto_D=True #Optional - Bool - Publishes Home assistant MQTT Auto Discovery messages to push data into HA automagically (requires MQTT to be enabled below) ha_device_prefix="" #Required - This is the prefix used at the start of every Home Assistant device created diff --git a/README.md b/README.md index 45cee83f..93dd55b2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ This will populate HA with all devices and entities for control and monitoring. | MQTT_TOPIC | GivEnergy/Data | Optional - default is Givenergy.| | MQTT_TOPIC_2 | GivEnergy/Data | Optional - Setting for second Inverter if configured. default is Givenergy.| | MQTT_TOPIC_2 | GivEnergy/Data | Optional - Setting for third Inverter if configured. default is Givenergy.| +| PROMETHEUS_EXPORTER | True | Optional - Set to True to enable the prometheus exporter. default is False.| +| PROMETHEUS_PORT | 6711 | The port number to listen on for the first inverter. Subsequent inverters will be 6712 and so on.| | LOG_LEVEL | Error | Optional - you can choose Error, Info or Debug. Output will be sent to the debug file location if specified, otherwise it is sent to stdout| | DEBUG_FILE_LOCATION | /usr/pi/data | Optional | | PRINT_RAW | False | Optional - If set to True the raw register values will be returned alongside the normal data | @@ -151,3 +153,20 @@ Root topic for control is: GivTCP provides a wrapper function REST.py which uses Flask to expose the read and control functions as RESTful http calls. To utilise this service you will need to either use a WSGI service such as gunicorn or use the pre-built Docker container. If Docker is running in Host mode then the REST service is available on port 6345 + +### Prometheus Exporter +To make use of the Prometheus exporter you will need to have a [Prometheus](https://prometheus.io) server configured. + +By setting `PROMETHEUS_EXPORTER` to `True` the `prometheus_exporter.py` script will be executed for each inverter, with the first listening on the value of `PROMETHEUS_PORT`. + +You can see the metrics exported by visiting http://localhost:6711/metrics + +You will need to update your Prometheus server's configuration to scrape the metrics from the exporter. An example is shown below: + +```yaml + - job_name: 'giv_energy' + static_configs: + - targets: [ 'localhost:6711' ] +``` + +You can then visualise the metrics using [Grafana](https://grafana.com/) for example. diff --git a/docker-compose.yml b/docker-compose.yml index a4aeee1d..1f0b29c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - "3000:3000" # This should match the WEB_DASH_PORT ENV below - "6379:6379" # Redis port for job queueing - "9181:9181" # RQ Dashboard to view job scheduling + - "6711:6711" # Prometheus exporter port(s) environment: ## Critical to update - NUMINVERTORS=1 # Set this to the number of Inverters in your setup, then replicate the next two lines for each inverter (changing the last number of the ENV) @@ -62,5 +63,7 @@ services: - PALM_BATT_UTILISATION=0.85 # Usable proportion of battery (100% less reserve and any charge limit) on a scale of 0-1 - PALM_WEIGHT=35 # Weighting used for final target soc - LOAD_HIST_WEIGHT=1 # Load History Weighting + - PROMETHEUS_EXPORTER=False # Set to True to enable the Prometheus exporter + - PROMETHEUS_PORT=6711 # Set the port for the first Prometheus exporter to listen on for the first inverter and so on restart: always privileged: true diff --git a/requirements.txt b/requirements.txt index 034f29cc..e4b13a24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ pickle-mixin flask-cors rq redis +prometheus-client git+https://github.com/jace/rq-dashboard.git \ No newline at end of file diff --git a/startup.py b/startup.py index d2a27565..54c1c28f 100644 --- a/startup.py +++ b/startup.py @@ -10,6 +10,7 @@ selfRun={} mqttClient={} gunicorn={} +prometheusExporter={} webDash={} rqWorker={} redis={} @@ -88,7 +89,7 @@ def palm_job(): redis=subprocess.Popen(["/usr/bin/redis-server","/app/redis.conf"]) logger.critical("Running Redis") -rqdash=subprocess.Popen(["/usr/local/bin/rq-dashboard"]) +#rqdash=subprocess.Popen(["/usr/local/bin/rq-dashboard"]) logger.critical("Running RQ Dashboard on port 9181") for inv in range(1,int(os.getenv('NUMINVERTORS'))+1): @@ -132,6 +133,9 @@ def palm_job(): else: outp.write(" MQTT_Topic=\""+str(os.getenv("MQTT_TOPIC_"+str(inv),"")+"\"\n")) + outp.write(" Prometheus_Exporter="+str(os.getenv("PROMETHEUS_EXPORTER"))+"\n") + outp.write(" Prometheus_Port="+str(os.getenv("PROMETHEUS_PORT"))+"\n") + outp.write(" Log_Level=\""+str(os.getenv("LOG_LEVEL","")+"\"\n")) #setup debug filename for each inv outp.write(" Influx_Output="+str(os.getenv("INFLUX_OUTPUT",""))+"\n") @@ -209,6 +213,11 @@ def palm_job(): if os.getenv('MQTT_OUTPUT')=="True" or isAddon: logger.critical ("Subscribing MQTT Broker for control") mqttClient[inv]=subprocess.Popen(["/usr/local/bin/python3",PATH+"/mqtt_client.py"]) + if (os.getenv('PROMETHEUS_EXPORTER')=="True" and os.getenv('PROMETHEUS_PORT') is not None) or isAddon: + PROMETHEUS_PORT = str(int(os.getenv("PROMETHEUS_PORT"))+inv-1) + logger.critical("Creating prometheus metric exporter on port "+PROMETHEUS_PORT) + command=shlex.split("/usr/local/bin/python3 "+PATH+"/prometheus_exporter.py -p " + PROMETHEUS_PORT) + prometheusExporter[inv]=subprocess.Popen(command) GUPORT=6344+inv logger.critical ("Starting Gunicorn on port "+str(GUPORT)) @@ -263,6 +272,12 @@ def palm_job(): logger.critical ("Starting Gunicorn on port "+str(GUPORT)) command=shlex.split("/usr/local/bin/gunicorn -w 3 -b :"+str(GUPORT)+" REST:giv_api") gunicorn[inv]=subprocess.Popen(command) + elif (os.getenv('PROMETHEUS_EXPORTER')=="True" and os.getenv('PROMETHEUS_PORT') is not None) and prometheusExporter[inv].poll() is not None: + PROMETHEUS_PORT = str(int(os.getenv("PROMETHEUS_PORT"))+inv-1) + os.chdir(PATH) + logger.critical("Prometheus died, creating prometheus metric exporter on port "+PROMETHEUS_PORT) + command=shlex.split("/usr/local/bin/python3 "+PATH+"/prometheus_exporter.py -p " + PROMETHEUS_PORT) + prometheusExporter[inv]=subprocess.Popen(command) if os.getenv('MQTT_ADDRESS')=="127.0.0.1" and not mqttBroker.poll()==None: logger.error("MQTT Broker process died. Restarting...") os.chdir(PATH) From 05c9219308000289409cf87329567a7fac30b1f1 Mon Sep 17 00:00:00 2001 From: Neil Munday Date: Mon, 24 Jul 2023 22:09:39 +0100 Subject: [PATCH 3/3] fix: re-instated rq-dashboard --- startup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/startup.py b/startup.py index 54c1c28f..4b61b57d 100644 --- a/startup.py +++ b/startup.py @@ -89,7 +89,7 @@ def palm_job(): redis=subprocess.Popen(["/usr/bin/redis-server","/app/redis.conf"]) logger.critical("Running Redis") -#rqdash=subprocess.Popen(["/usr/local/bin/rq-dashboard"]) +rqdash=subprocess.Popen(["/usr/local/bin/rq-dashboard"]) logger.critical("Running RQ Dashboard on port 9181") for inv in range(1,int(os.getenv('NUMINVERTORS'))+1):