diff --git a/README.md b/README.md new file mode 100755 index 0000000..7887f26 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Trannergy MQTT +MQTT client for Trannergy solar inverter (PVL5400). Written in Python 3.x +This program might also work for Ginlong and Omnik inverters. +The inverter has to be connected to the LAN network where this script is running. + +Includes Home Assistant MQTT Auto Discovery. +## Usage: +* Copy `systemd/trannergy-mqtt.service` to `/etc/systemd/system` +* Adapt path in `trannergy-mqtt.service` to your install location (default: `/opt/iot/trannergy`) +* Copy `config.rename.py` to `config.py` and adapt for your configuration (minimal: mqtt ip, username, password) +* `sudo systemctl enable trannery-mqtt` +* `sudo systemctl start trannergy-mqtt` + +Use +http://mqtt-explorer.com/ +to test & inspect MQTT messages + +## Requirements +* paho-mqtt +* python 3.x + +Tested under Linux; there is no reason why it does not work under Windows. + +## Licence +GPL v3 + +## Versions +1.2.2: +* Initial version on github \ No newline at end of file diff --git a/config.py b/config.rename.py old mode 100755 new mode 100644 similarity index 50% rename from config.py rename to config.rename.py index b022edb..de2f7f3 --- a/config.py +++ b/config.rename.py @@ -1,35 +1,57 @@ -#!/usr/bin/python3 - -""" - Rename to config.py - - Configure: - - MQTT client - - Debug level - -""" - -# [ LOGLEVELS ] -# DEBUG, INFO, WARNING, ERROR, CRITICAL -loglevel = "INFO" - -# Using local dns names was not always reliable with PAHO -MQTT_BROKER = "192.168.1.1" -MQTT_PORT = 1883 -MQTT_CLIENT_UNIQ = 'mqtt-trannergy' -MQTT_QOS = 1 -MQTT_USERNAME = "ijntema" -MQTT_PASSWORD = "mosquitto0000" - -# Max nrof MQTT messages per second -# Set to 0 for unlimited rate -MQTT_RATE = 100 - -MQTT_TOPIC_PREFIX = "solar/trannergy/roof_w" - -# [ InfluxDB ] -# Add a influxdb database tag, for Telegraf processing (database:INFLUXDB) -# This is not required for core functionality of this parser -# Set to None if Telegraf is not used -INFLUXDB = "solar" -#INFLUXDB = None \ No newline at end of file +#!/usr/bin/python3 + +""" + Rename to config.py + + Configure: + - MQTT client + - Debug level + +""" + +# [ LOGLEVELS ] +# DEBUG, INFO, WARNING, ERROR, CRITICAL +loglevel = "INFO" + +# Using local dns names was not always reliable with PAHO +MQTT_BROKER = "192.168.1.1" +MQTT_PORT = 1883 +MQTT_CLIENT_UNIQ = 'mqtt-trannergy' +MQTT_QOS = 1 +MQTT_USERNAME = "ijntema" +MQTT_PASSWORD = "mosquitto0000" + +# Max nrof MQTT messages per second transmitted by MQTT client +# Set to 0 for unlimited rate +MQTT_RATE = 100 + +MQTT_TOPIC_PREFIX = "solar/trannergy/roof_w" + +# [ InfluxDB ] +# Add a influxdb database tag, for Telegraf processing (database:INFLUXDB) +# This is not required for core functionality of this parser +# Set to None if Telegraf is not used +INFLUXDB = "solar" +#INFLUXDB = None + +#[ TRANNERGY INVERTER ] +INV_SERIAL = "PVL5400N177E4008" + +# Required when using TCPCLIENT reader +INV_SERIAL_LOGGER = 625830567 +INV_IP = "192.168.1.53" +INV_TCPCLIENTPORT = 8899 + +# NROF parameter reads from inverter per hour (60 equals every minute) +INV_READ_RATE = 60 + +# Select one of the two available readers +# +# If supported: can read high frequent all inverter parameters +INV_READER="TCPCLIENT" + +# Inverter sends every 5 minutes an update of all parameters +# Configure in inverter +# Advanced settings +# Server: ip address to ip where this python script is running; port 3203 +#INV_READER="LISTEN" \ No newline at end of file diff --git a/hadiscovery.py b/hadiscovery.py new file mode 100755 index 0000000..2f10845 --- /dev/null +++ b/hadiscovery.py @@ -0,0 +1,144 @@ +#!/usr/bin/python3 + +""" + Send Home Assistant Auto Discovery MQTT messages + + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +""" +import copy +import time +import threading +import json + +# Local imports +import config as cfg + +# Logging +import __main__ +import logging +import os +script = os.path.basename(__main__.__file__) +script = os.path.splitext(script)[0] +logger = logging.getLogger(script + "." + __name__) + + +class Discovery(threading.Thread): + + def __init__(self, stopper, mqtt, version): + """ + class init + + Keyword arguments: + :param threading.Event() stopper: + :param mqtt.mqttclient() mqtt: reference to mqtt client + :param str version: version of the program + """ + + logger.debug(">>") + super().__init__() + self.__stopper = stopper + self.__mqtt = mqtt + self.__version = version + self.__interval = 3600/cfg.HA_INTERVAL + self.__lastmqtt = 0 + self.__listofjsondicts = list() + + + def __del__(self): + logger.debug(">>") + + + def __create_discovery_JSON(self): + """ + Create the HA/MQTT Autodiscovery messages + + https://www.home-assistant.io/docs/mqtt/discovery/ + https://www.home-assistant.io/integrations/sensor/ + https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics + + Returns: + None + """ + d = {} + + # create device JSON + d["name"] = "trannergy inverter" + d["unique_id"] = "trannergy-device" + d["state_topic"] = cfg.MQTT_TOPIC_PREFIX + "/status" + d["icon"] = "mdi:home-automation" + d["device"] = {"name": "trannergy inverter", + "sw_version": self.__version, + "model": "PVL5400N/trannergy-mqtt", + "manufacturer": "hansij66 @github.com", + "identifiers": ["trannergy"] + } + self.__listofjsondicts.append( copy.deepcopy(d) ) + + # Create entries + d.clear() + d["unique_id"] = "p_ac1" + d["state_topic"] = cfg.MQTT_TOPIC_PREFIX + d["name"] = "Power generated L1" + d["unit_of_measurement"] = "W" + # d["state_class"] = "measurement" + d["value_template"] = "{{value_json.p_ac1}}" + d["device_class"] = "power" + d["icon"] = "mdi:gauge" + d["device"] = {"identifiers": ["trannergy"]} + self.__listofjsondicts.append( copy.deepcopy(d)) + + d.clear() + d["unique_id"] = "yield_total" + d["state_topic"] = cfg.MQTT_TOPIC_PREFIX + d["name"] = "EL generated" + d["unit_of_measurement"] = "Wh" + d["value_template"] = "{{value_json.yield_total}}" + d["device_class"] = "energy" + d["state_class"] = "total" + d["icon"] = "mdi:counter" + d["device"] = {"identifiers": ["trannergy"]} + self.__listofjsondicts.append(copy.deepcopy(d)) + + def run(self): + """ + + Returns: + None + """ + logger.debug(">>") + + self.__create_discovery_JSON() + + # infinite loop + if cfg.HA_DISCOVERY: + while not self.__stopper.is_set(): + # calculate time elapsed since last MQTT + t_elapsed = int(time.time()) - self.__lastmqtt + + if t_elapsed > self.__interval: + for dict in self.__listofjsondicts: + topic = "homeassistant/sensor/" + cfg.HA_MQTT_DISCOVERY_TOPIC_PREFIX + "/" + dict["unique_id"] + "/config" + self.__mqtt.do_publish(topic, json.dumps(dict, separators=(',', ':')), retain=True) + self.__lastmqtt = int(time.time()) + else: + # wait... + time.sleep(0.5) + + # If configured, remove MQTT Auto Discovery configuration + if cfg.HA_DELETECONFIG: + for dict in self.__listofjsondicts: + topic = "homeassistant/sensor/" + cfg.MQTT_TOPIC_PREFIX + "/" + dict["unique_id"] + "/config" + self.__mqtt.do_publish(topic, "") diff --git a/trannergy_tcpclient.py b/trannergy_tcpclient.py new file mode 100755 index 0000000..d140421 --- /dev/null +++ b/trannergy_tcpclient.py @@ -0,0 +1,179 @@ +""" +Read Trannergy telegrams via TCP Client. + + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +""" + +import socket +import threading +import time +import binascii +import config as cfg +#import inverter_msg as msg + +# Logging +import __main__ +import logging +import os +script = os.path.basename(__main__.__file__) +script = os.path.splitext(script)[0] +logger = logging.getLogger(script + "." + __name__) + + +class TaskReadSerial(threading.Thread): + + def __init__(self, trigger, stopper, telegram): + """ + Args: + :param threading.Event() trigger: signals that new telegram is available + :param threading.Event() stopper: stops thread + :param list() telegram: trannergy telegram + """ + + logger.debug(">>") + super().__init__() + self.__trigger = trigger + self.__stopper = stopper + self.__telegram = telegram + self.__counter = 0 + self.__lastread = 0 + self.__interval = 3600/cfg.INV_READ_RATE + self.__requestmsg = self.__request_string(cfg.INV_SERIAL_LOGGER) + self.__sock = None + + def __del__(self): + logger.debug(">>") + + def __socket_connect(self): + """ + Create socket/connection to inverter + """ + + while not self.__stopper.is_set(): + try: + self.__sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.__sock.connect((cfg.INV_IP, cfg.INV_TCPCLIENTPORT)) + return + except Exception as e: + logger.info(f"Read SOCKS: {type(e).__name__}: {str(e)}") + time.sleep(30) + + def __request_string(self, ser): + """ + Reuse from + https://github.com/jbouwh/omnikdatalogger/blob/dev/apps/omnikdatalogger/omnik/InverterMsg.py + The request string is build from several parts. The first part is a + fixed 4 char string; the second part is the reversed hex notation of + the Wi-Fi logger s/n twice; then again a fixed string of two chars; a checksum of + the double s/n with an offset; and finally a fixed ending char. + + Args: + :param str ser: s/n inverter wifi module + """ + responseString = b"\x68\x02\x40\x30" + + doublehex = hex(ser)[2:] * 2 + hexlist = [ + bytes.fromhex(doublehex[i: i + 2]) + for i in reversed(range(0, len(doublehex), 2)) + ] + + cs_count = 115 + sum([ord(c) for c in hexlist]) + cs = bytes.fromhex(hex(cs_count)[-2:]) + responseString += b"".join(hexlist) + b"".join([b"\x01\x00", cs, b"\x16"]) + return responseString + + def __read_serial(self): + """ + Reads Trannergy telegrams; stores in global variable (self.__telegram) + Sets threading event to signal other clients (parser) that + new telegram is available. + + :raises Exceptions + """ + logger.debug(">>") + + while not self.__stopper.is_set(): + + # wait till parser has copied telegram content + # ...we need the opposite of trigger.wait()...block when set; not available + while self.__trigger.is_set(): + time.sleep(0.5) + + self.__telegram.clear() + + # Sending request message to Inverter + self.__sock.sendall(self.__requestmsg) + + # Wait for response (listen and block thread) + rawdata = self.__sock.recv(1024) + + serial = str(rawdata[15:31], encoding="UTF-8") + + logger.debug(f"SERIAL={serial}") + if serial != cfg.INV_SERIAL: + logger.debug(f"INCORRECT MESSAGE={serial}") + # try again...start over.... + continue + + # convert to more readable + hexdata = binascii.hexlify(rawdata) + #hexdata = str(binascii.b2a_base64(rawmsg)) + + ## Often, a zero length message is received + ## A valid payload is 206 bytes + #if len(hexdata) >= 206: + # convert to more readable & add to telegram + logger.debug(f"hexdata = {hexdata}") + + self.__counter += 1 + + # add a counter as first field to the list + self.__telegram.append(f"{self.__counter}") + self.__telegram.append(f"{hexdata}") + + # Trigger that new telegram is available for MQTT + logger.debug("Set trigger") + self.__trigger.set() + + while not self.__stopper.is_set(): + t_elapsed = int(time.time()) - self.__lastread + if t_elapsed > self.__interval: + self.__lastread = int(time.time()) + break + else: + # wait... + time.sleep(1) + + logger.debug("<<") + + def run(self): + logger.debug(">>") + + self.__socket_connect() + + while not self.__stopper.is_set(): + try: + self.__read_serial() + except Exception as e: + logger.warning(f"Exception {e}") + time.sleep(10) + self.__sock.close() + self.__socket_connect() + + self.__sock.close() + + logger.debug("<<")