diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9a9842b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:alpine + +WORKDIR /app + +COPY modbus2mqtt.py ./ +COPY modbus2mqtt modbus2mqtt/ + +RUN mkdir -p /app/conf/ + +# upgrade pip to avoid warnings during the docker build +RUN pip install --root-user-action=ignore --upgrade pip + +RUN pip install --root-user-action=ignore --no-cache-dir pyserial pymodbus +RUN pip install --root-user-action=ignore --no-cache-dir paho-mqtt + +ENTRYPOINT [ "python", "-u", "./modbus2mqtt.py", "--config", "/app/conf/modbus2mqtt.csv" ] diff --git a/LICENSE b/LICENSE index d32c049..205f700 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,8 @@ The MIT License (MIT) -Copyright (c) 2015 Oliver Wagner +Copyright (c) 2018 Max Brueggemann + +Contains code from modbus2mqtt, copyright (c) 2015 Oliver Wagner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1aba38f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE diff --git a/README.md b/README.md index d07d062..2663df8 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,195 @@ -modbus2mqtt -=========== +spicierModbus2mqtt +================== - Written and (C) 2015 Oliver Wagner + +Written and (C) 2018 Max Brueggemann + +Contains code from modbus2mqtt, written and (C) 2015 Oliver Wagner - Provided under the terms of the MIT license. +Provided under the terms of the MIT license. Overview -------- -modbus2mqtt is a Modbus master which continously polls slaves and publishes -register values via MQTT. +spicierModbus2mqtt is a Modbus master which continuously polls slaves and publishes +values via MQTT. -It is intended as a building block in heterogenous smart home environments where +It is intended as a building block in heterogeneous smart home environments where an MQTT message broker is used as the centralized message bus. -See https://github.com/mqtt-smarthome for a rationale and architectural overview. + +Changelog +--------- +- version 0.72, 12. of November 2023: add loop break option again due to user request, reconnect on modbus tcp connection loss and serial device not found +- version 0.68, 31. of October 2023: use asyncio with pymodbus, add option to use function code 16 when writing single registers, remove loop break option +- version 0.66, 29. of November 2022: print Modbus exceptions generated by devices +- version 0.65, 25. of November 2022: fixed incompatibility with newer version of pymodbus, readjusted the Dockerfile +- version 0.64, 16. of October 2022: Adjustment of the Dockerfile (enforcing pymodbus 2.5.3, otherwise it tries to use 3.0 and this failes); typo fixed; main-/poller-loop log entry +- version 0.63, 04. of October 2022: added reading and writing of int32 values, fix regarding negative floats +- version 0.62, 6. of February 2022: major refactoring, project is now python module available via pip +- version 0.5, 21. of September 2019: print error messages in case of badly configured pollers +- version 0.4, 25. of May 2019: When writing to a device, updated states are now published immediately, if writing was successful. + +Spicier? +------------ +Main improvements over modbus2mqtt by Oliver Wagner: +- more abstraction when writing to coils/registers using mqtt. Writing is now + possible without having to know slave id, reference, function code etc. +- specific coils/registers can be made read only +- multiple slave devices on one bus are now fully supported +- polling speed has been increased significantly. With modbus RTU @ 38400 baud + more than 80 transactions per second have been achieved. +- switched over to pymodbus which is in active development. +- Improved error handling, the software will continuously retry when the network or device goes down. + +Feel free to contribute! -Dependencies +Installation ------------ +run `sudo pip3 install modbus2mqtt` + +Without installation +-------------------- +Requirements: + +* python3 * Eclipse Paho for Python - http://www.eclipse.org/paho/clients/python/ -* modbus-tk for Modbus communication - https://github.com/ljean/modbus-tk/ +* pymodbus - https://github.com/riptideio/pymodbus +Installation of requirements: -Command line options --------------------- - usage: modbus2mqtt.py [-h] [--mqtt-host MQTT_HOST] [--mqtt-port MQTT_PORT] - [--mqtt-topic MQTT_TOPIC] [--rtu RTU] - [--rtu-baud RTU_BAUD] [--rtu-parity {even,odd,none}] - --registers REGISTERS [--log LOG] [--syslog] - - optional arguments: - -h, --help show this help message and exit - --mqtt-host MQTT_HOST - MQTT server address. Defaults to "localhost" - --mqtt-port MQTT_PORT - MQTT server port. Defaults to 1883 - --mqtt-topic MQTT_TOPIC - Topic prefix to be used for subscribing/publishing. - Defaults to "modbus/" - --clientid MQTT_CLIENT_ID - optional prefix for MQTT Client ID - - --rtu RTU pyserial URL (or port name) for RTU serial port - --rtu-baud RTU_BAUD Baud rate for serial port. Defaults to 19200 - --rtu-parity {even,odd,none} - Parity for serial port. Defaults to even. - --registers REGISTERS - Register specification file. Must be specified - --force FORCE - optional interval (secs) to publish existing values - does not override a register's poll interval. - Defaults to 0 (publish only on change). - - --log LOG set log level to the specified value. Defaults to - WARNING. Try DEBUG for maximum detail - --syslog enable logging to syslog - - -Register definition +* Install python3 and python3-pip and python3-serial (on a Debian based system something like sudo apt install python3 python3-pip python3-serial will likely get you there) +* run pip3 install pymodbus +* run pip3 install paho-mqtt + +Usage +----- +If you've installed using pip: + +* example for rtu and mqtt broker on localhost: `modbus2mqtt --rtu /dev/ttyS0 --rtu-baud 38400 --rtu-parity none --mqtt-host localhost --config testing.csv` +* example for tcp slave and mqtt broker + on localhost: `modbus2mqtt --tcp localhost --config testing.csv` + remotely: `modbus2mqtt --tcp 192.168.1.7 --config example.csv --mqtt-host mqtt.eclipseprojects.io` + + +If you haven't installed modbus2mqtt you can run modbus2mqtt.py from the root directory of this repo directly: + +* example for rtu and mqtt broker on localhost: `python3 modbus2mqtt.py --rtu /dev/ttyS0 --rtu-baud 38400 --rtu-parity none --mqtt-host localhost --config testing.csv` +* example for tcp slave and mqtt broker + on localhost: `python3 modbus2mqtt.py --tcp localhost --config testing.csv` + remotely: `python3 modbus2mqtt.py --tcp 192.168.1.7 --config example.csv --mqtt-host mqtt.eclipseprojects.io` + +For docker support see below. + +Configuration file ------------------- -The Modbus registers which are to be polled are defined in a CSV file with -the following columns: - -* *Topic suffix* - The topic where the respective register will be published into. Will - be prefixed with the global topic prefix and "status/". -* *Register offset* - The register number, depending on the function code. Zero-based. -* *Size (in words)* - The register size in words. -* *Format* - The format how to interpret the register value. This can be two parts, split - by a ":" character. - The first part uses the Python - "struct" module notation. Common examples: - - >H unsigned short - - >f float - - The second part is optional and specifies a Python format string, e.g. - %.2f - to format the value to two decimal digits. -* *Polling frequency* - How often the register is to be polled, in seconds. Only integers. -* *SlaveID* - The Modbus address of the slave to query. Defaults to 1. -* *FunctionCode* - The Modbus function code to use for querying the register. Defaults - to 4 (READ REGISTER). Only change if you know what you are doing. - -Not all columns need to be specified. Unspecified columns take their -default values. The default values for subsequent rows can be set -by specifying a magic topic suffix of *DEFAULT* +THE FIRST LINE OF THE CONFIG FILE HAS TO BE: + +"type","topic","col2","col3","col4","col5","col6" + +The Modbus data which is to be polled is defined in a CSV file. +There are two types of rows, each with different columns; a "Poller" object and a "Reference" object. In the "Poller" object we define the type of the modbus data and how the request to the device should look like (which modbus references are to be read, for example: holding registers at references 0 to 10). With the reference object we define (among other things) to which topic the data of a certain data point (registers, coil..) is going to be published. +Modbus references are as transmitted on the wire. In the traditional numbering scheme these would have been called offsets. E. g. to read 400020 you would use reference 20. +Refer to the example.csv for more details. + +* Use "coils", for modbus functioncode 1 +* Use "input status", for modbus functioncode 2 +* Use "holding registers", for modbus functioncode 3 +* Use "input registers", for modbus functioncode 4 + +Reference objects link to the modbus reference address and define specific details about that register or bit. +Pollers and references are used together like this: +``` +poll,kitchen,7,0,5,coil,1.0 +ref,light0,0,rw +ref,light1,1,rw +ref,light2,2,rw +ref,light3,3,rw +ref,light4,4,rw +``` +This will poll from Modbus slave id 7, starting at coil offset 0, for 5 coils, 1.0 times a second. + +The first coil 0 will then be sent as an MQTT message with topic modbus/kitchen/state/light0. + +The second coil 1 will then be sent as an MQTT message with topic modbus/kitchen/state/light1 and so on. + + +Note that the reference addresses are absolute addresses and are NOT related to the start address of the poller! If you define a reference that is not within the pollers range you will get an error message. +So another example: +``` +poll,someTopic,1,2,11,coil,1.0 +ref,light9,9,rw +``` +This will poll states of 11 coils from slave device 1 once a second, starting at coil 2. +The state of coil 9 will be published to mqtt with the topic modbus/someTopic/state/light0 +if column 3 contains an 'r'. + +If you publish a value (in case of a coil: True or False) to modbus/someTopic/set/light0 and +column 3 contains a 'w', the new state will be written to coil 9 of the slave device. + + +Some other "interpretations" of register contents are also supported: +``` +poll,garage,1,0,10,holding_register,2 +ref,counter1,0,rw,float32BE +ref,counter2,2,rw,uint16 +ref,somestring,3,rw,string6 +``` +This will poll 10 consecutive registers from Modbus slave id 1, starting at holding register 0. + +The last row now contains the data format. Supported values: float32BE, float32LE, uint32BE, uint32LE, uint16 (default), stringXXX with XXX being the string length in bytes. + +Note that a float32BE will of course span over two registers (0 and 1 in the above example) and that you can still define another reference object occupying the same registers. This might come in handy if you want to modify a small part of a string separately. + Topics ------ -Values are published as simple strings to topics with the general , -the function code "/status/" and the topic suffix specified per register. -A value will only be published if it's textual representation has changed, -e.g. _after_ formatting has been applied. The published MQTT messages have +Values are published as strings to topic: + +"prefix/poller topic/state/reference topic" + +A value will only be published if it's raw data has changed, +e.g. _before_ any formatting has been applied. The published MQTT messages have the retain flag set. -A special topic "/connected" is maintained. -It's a enum stating whether the module is currently running and connected to +A special topic "prefix/connected" is maintained. +It states whether the module is currently running and connected to the broker (1) and to the Modbus interface (2). -Setting Modbus coils (FC=5) and registers (FC=6) +We also maintain a "connected"-Topic for each poller (prefix/poller_topic/connected). This is useful when using Modbus RTU with multiple slave devices because a non-responsive device can be detected. + +For diagnostic purposes (mainly for Modbus via serial) the topics prefix/poller_topic/state/diagnostics_errors_percent and prefix/poller_topic/state/diagnostics_errors_total are available. This feature can be enabled by passing the argument "--diagnostics-rate X" with x being the amount of seconds between each recalculation and publishing of the error rate in percent and the amount of errors within the time frame X. Set X to something like 600 to get diagnostic messages every 10 minutes. + +Writing to Modbus coils and registers ------------------------------------------------ -modbus2mqtt subscibes to two topics: +spiciermodbus2mqtt subscribes to: -- prefix/set/+/5/+ # where the first + is the slaveId and the second is the register -- prefix/set/+/6/+ # payload values are written the the devices (assumes 16bit Int) +"prefix/poller topic/set/reference topic" -There is only limited sanity checking currently on the payload values. +If you want to write to a coil: -Changelog ---------- -* 0.4 - 2015/07/31 - nzfarmer - - added support for MQTT subscribe + Mobdus write - Topics are of the form: prefix/set/// (payload = value to write) - - added CNTL-C for controlled exit - - added --clientid for MQTT connections - - added --force to repost register values regardless of change every x seconds where x >0 - -* 0.3 - 2015/05/26 - owagner - - support optional string format specification -* 0.2 - 2015/05/26 - owagner - - added "--rtu-parity" option to set the parity for RTU serial communication. Defaults to "even", - to be inline with Modbus specification - - changed default for "--rtu-baud" to 19200, to be inline with Modbus specification - -* 0.1 - 2015/05/25 - owagner - - Initial version - +mosquitto_pub -h -t modbus/somePoller/set/someReference -m "True" + +to a register: + +mosquitto_pub -h -t modbus/somePoller/set/someReference -m "12346" + +Scripts addToHomeAssistant.py and create-openhab-conf.py +------------------------------------------------ +These scripts are not really part of this project, but I decided to include them anyway. They were written because I grew more and more frustrated with the Modbus capabilities of OpenHAB and Home Assistant. + +So what exactly do they do? Completely different things actually. + +* addToHomeAssistant.py can only be run within modbus2mqtt.py. It can be invoked by passing --add-to-homeassistant when running modbus2mqtt.py. It uses MQTT messages to add all the stuff from the .csv file to home assistant automatically. Just try it. I recommend using a non productive instance of Home Assistant for testing :-) + + +* create-openhab-conf.py can be used independently. It parses the .csv file and creates configuration files (.things and .items) for OpenHAB (version 2+ only). This is of course not necessary for using spicierModbus2mqtt whit OpenHab but it removes a lot of hassle from it. I use it to create a basic working structure and then rename and rearrange the items by hand. + +Docker +------ + +spicierModbus2mqtt can be run as a docker container, using the included Dockerfile. It allows all usual configuration options, with the expectation that it's configuration is at `/app/conf/modbus2mqtt.csv`. For example: + +`docker build -t modbus2mqtt . && docker run -v $(pwd)/example.csv:/app/conf/modbus2mqtt.csv modbus2mqtt --tcp --mqtt-host ` diff --git a/create-openhab-conf.py b/create-openhab-conf.py new file mode 100644 index 0000000..4cd4484 --- /dev/null +++ b/create-openhab-conf.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +import argparse +import csv + +class Thing: + def __init__(self,pollerTopic,adr): + self.pollerTopic=pollerTopic + self.channels=[] + self.adr=adr + + def addChannel(self,chan): + self.channels.append(chan) + +class Channel: + def __init__(self,thing,refname,chantype,rw,interpretation): + self.refname=refname + self.map=None + self.commandTopic=None + self.thing=thing + self.chantype=None + if chantype == "register" and "r" in rw and not "w" in rw: + self.chantype = "number" + self.map="" + elif chantype == "register" and "w" in rw: + self.chantype = "number" + self.map="" + self.commandTopic=globaltopic+"/"+self.thing.pollerTopic+"/set/"+self.refname + elif chantype == "coil" and "w" in rw: + self.chantype = "switch" + self.commandTopic=globaltopic+"/"+self.thing.pollerTopic+"/set/"+self.refname + self.map=", on=\"True\", off=\"False\"" + elif chantype == "coil" and not "w" in rw: + self.chantype = "contact" + self.map=", on=\"True\", off=\"False\"" + elif chantype == "input_status" and "r" in rw: + self.chantype = "contact" + self.map=", on=\"True\", off=\"False\"" + self.rw=rw + self.stateTopic=globaltopic+"/"+self.thing.pollerTopic+"/state/"+self.refname + + +thinglist=[] + +def getconf(x): + if len(x.channels)<1: + return "" + outstring="" + outstring+="Thing mqtt:topic:"+x.pollerTopic+" \""+x.pollerTopic+"\" ("+brokerstring+") {\n" + outstring+=" Channels:\n" + outstring+=" Type contact : "+x.pollerTopic+"_connected [ stateTopic=\"" + globaltopic+"/"+x.pollerTopic+"/connected\", on=\"True\", off=\"False\" ]\n" + for y in x.channels: + outstring+=" Type "+y.chantype+" : "+y.refname+" [ stateTopic=\""+y.stateTopic+"\"" + if y.commandTopic: + outstring+=", commandTopic=\""+y.commandTopic+"\"" + outstring+=y.map + outstring+=" ]\n" + outstring+="}\n\n" + return outstring + +def getitemconf(x): + if len(x.channels)<1: + return "" + outstring="" + outstring+="Contact "+x.pollerTopic+"_"+x.pollerTopic+"_connected"+" \""+x.pollerTopic+"_"+x.pollerTopic+"_connected\" " + outstring+="{ channel=\"mqtt:topic:"+x.pollerTopic+":"+x.pollerTopic+"_connected\" }\n" + for y in x.channels: + outstring+=y.chantype.capitalize()+" "+x.pollerTopic+"_"+y.refname+" \""+x.pollerTopic+"_"+y.refname+"\" " + outstring+="{ channel=\"mqtt:topic:"+x.pollerTopic+":"+y.refname+"\" }" + outstring+="\n" + return outstring + + + +parser = argparse.ArgumentParser(description='Bridge between ModBus and MQTT') +parser.add_argument('globaltopic', help='from argument of modbus2mqtt.py, usually modbus') +parser.add_argument('brokerstring', help='Broker string for ex. mqtt:broker:2c8f40c6') +parser.add_argument('config', help='Configuration file. Required!') + +args=parser.parse_args() +globaltopic=args.globaltopic +brokerstring=args.brokerstring + +verbosity = 10 + +with open(args.config,"r") as csvfile: + csvfile.seek(0) + reader=csv.DictReader(csvfile) + currentPoller=None + pollerTopic=None + pollerType=None + for row in reader: + if row["type"]=="poller" or row["type"]=="poll": + rate = float(row["col6"]) + slaveid = int(row["col2"]) + reference = int(row["col3"]) + size = int(row["col4"]) + + if row["col5"] == "holding_register": + functioncode = 3 + dataType="int16" + if size>123: #applies to TCP, RTU should support 125 registers. But let's be safe. + currentPoller=None + if verbosity>=1: + print("Too many registers (max. 123). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "coil": + functioncode = 1 + dataType="bool" + if size>2000: + currentPoller=None + if verbosity>=1: + print("Too many coils (max. 2000). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "input_register": + functioncode = 4 + dataType="int16" + if size>123: + currentPoller=None + if verbosity>=1: + print("Too many registers (max. 123). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "input_status": + functioncode = 2 + dataType="bool" + if size>2000: + currentPoller=None + if verbosity>=1: + print("Too many inputs (max. 2000). Ignoring poller "+row["topic"]+".") + continue + + else: + print("Unknown function code ("+row["col5"]+" ignoring poller "+row["topic"]+".") + currentPoller=None + continue + pollerType = row["col5"] + pollerTopic = row["topic"] + currentPoller = None + for x in thinglist: + if x.pollerTopic == pollerTopic: + currentPoller = x + if currentPoller is None: + currentPoller = Thing(pollerTopic,slaveid) + thinglist.append(currentPoller) + continue + elif row["type"]=="reference" or row["type"]=="ref": + if currentPoller is not None: + if pollerType == "coil": + chan=Channel(currentPoller,row["topic"],"coil",row["col3"],"") + currentPoller.channels.append(chan) + elif pollerType == "holding_register": + chan=Channel(currentPoller,row["topic"],"register",row["col3"],"") + currentPoller.channels.append(chan) + elif pollerType == "input_register": + chan=Channel(currentPoller,row["topic"],"register","r","") + currentPoller.channels.append(chan) + elif pollerType == "input_status": + chan=Channel(currentPoller,row["topic"],"input_status","r","") + currentPoller.channels.append(chan) + else: + print("No poller for reference "+row["topic"]+".") + +outstring = "" +for x in thinglist: + outfile = open(x.pollerTopic+".things","w+") + outfile.write(getconf(x)) + outfile.close() + outfile = open(x.pollerTopic+".items","w+") + outfile.write(getitemconf(x)) + outfile.close() diff --git a/example.csv b/example.csv new file mode 100644 index 0000000..0a6d54a --- /dev/null +++ b/example.csv @@ -0,0 +1,81 @@ +"type","topic","col2","col3","col4","col5","col6" +# DO NOT REMOVE THE FIRST LINE! +# Example register definition file. +# +# You need to define a Poller and then one or more References for that poller. +# The Poller will 'poll' the slaveid and bring back 1 or more registers/bits. +# The References must then match up with the polled range to define the topic for each. +################################################################################# +# Poller-object +# Columns: +# type, topic, slaveid, reference, size, functioncode, rate +# +# Possible values for columns: +# type: poll +# topic: any string without spaces +# slaveid: integer 1 to 254 +# reference: integer 0 to 65535 (Modbus references are as transmitted on the wire. +# In the traditional numbering scheme these would have been called offsets. E. g. to +# read 400020 you would use reference 20.) +# size: integer 0 to 65535 (No. of registers to poll, value must not exceed the limits of Modbus of course) +# functionscode: coil, input_status, holding_register, input_register +# rate: float 0.0 to some really big number +# +# functionscode equivalents: coil, input_status, holding_register, input_register +# 1 2 3 4 +# +# Example poller-object: +# poll,someTopic,1,2,5,coil,1.0 +# Will poll states of 5 coils from slave device 1 once a second, starting at coil 2. +# +################################################################################# +# Reference-Object +# Columns: +# type, topic, reference, rw, data type, scaling factor +# type: ref +# topic: any string without spaces +# reference: integer 0 to 65535 (This is the modbus offset and should match the poller ref) +# rw: r, w or rw +# data type (registers only): uint16, float32BE, float32LE, uint32BE, uint32LE, string (defaults to uint16) +# scaling factor (registers only): a factor by which the read value is multiplied before publishing to mqtt. At the moment this only works when reading from Modbus. +# +# Example reference-object: +# ref,light0,2,rw +# The state of coil 2 will be published to mqtt with the topic modbus/someTopic/state/light0 +# if column 3 contains an 'r'. +# If you publish a value (in case of a coil: True or False) to modbus/someTopic/set/light0 and +# column 3 contains a 'w', the new state will be written to the slave device. +# +################################################################################# +# Columns: +# type, topic, slaveid, reference, size, functioncode, rate +# type, topic, reference, rw, data type, +# +poll,kitchen,7,0,4,coil,0.002 +ref,light0,0,rw +ref,light1,1,rw +ref,light2,2,rw +ref,light3,3,rw + +poll,kitchen,7,0,12,holding_register,0.5 +ref,someWritableRegister,0,rw + +poll,heating,32,52,4,holding_register,1 +ref,temperature_pre,52,r +ref,temperature_post,53,r +ref,temperature_setpoint,54,rw +ref,temperature_outdoors,55,r,int16,0.1 +ref,temperature_indoors,56,r,,0.1 +#temperature_outdoors and temperature_indoors are examples of using a scaling factor (0.1 in this case). Before publishing, the values of these registers will be multiplied by 0.1, therefore dividing them by ten. + +poll,ioModule,10,1,2,input_status,0 +ref,doorbell,1,r +ref,mailbox_sensor,2,r + +poll,garage,1,0,10,holding_register,2 +ref,counter1,0,rw,float32BE +#register 0 as low word, register 1 as high word +ref,counter2,1,rw,uint16 +#treat the contents of register 1 as unsigned int. (note that counter1 overlaps counter2. This is possible and may be useful) +ref,somestring,3,rw,string6 +#registers 3, 4 and 5 as a 6 character string diff --git a/example_register_definition.csv b/example_register_definition.csv deleted file mode 100644 index 1c8bfc5..0000000 --- a/example_register_definition.csv +++ /dev/null @@ -1,29 +0,0 @@ -"Topic","Register","Size","Format","Frequency","Slave","FunctionCode" -# -# Example register definition file. -# Device is a Eastron SDM630 power meter. Register specification e.g. -# available at http://www.ausboard.net.au/index_files/Eastron/Eastron%20Modbus%20Registers.pdf -# -# The Slave ID is assumed to be 1 (which is default for the SDM630) -# The function code used for reading is READ REGISTER (4), which is default -# Data format for all registers is float. Polling interval is 15s. -# -# All those defaults are set with a magic "DEFAULT" topic definition and -# are then inherited by subsequent register definitions. -DEFAULT,,2,>f:%.1f,15,1,4 -# -phase1/voltage,0 -phase2/voltage,2 -phase3/voltage,4 -phase1/power,12 -phase2/power,14 -phase3/power,16 -total/power,52 -# -# We want two decimal digits now -# -DEFAULT,,,>f:%.2f -phase1/current,6 -phase2/current,8 -phase3/current,10 -freq,70 \ No newline at end of file diff --git a/modbus2mqtt.py b/modbus2mqtt.py index 0a16cdb..ee97091 100644 --- a/modbus2mqtt.py +++ b/modbus2mqtt.py @@ -1,207 +1,4 @@ -# -# modbus2mqtt - Modbus master with MQTT publishing -# -# Written and (C) 2015 by Oliver Wagner -# Provided under the terms of the MIT license -# -# Requires: -# - Eclipse Paho for Python - http://www.eclipse.org/paho/clients/python/ -# - modbus-tk for Modbus communication - https://github.com/ljean/modbus-tk/ -# - -import argparse -import logging -import logging.handlers -import time -import socket -import paho.mqtt.client as mqtt -import serial -import io -import sys -import csv -import signal - -import modbus_tk -import modbus_tk.defines as cst -from modbus_tk import modbus_rtu -from modbus_tk import modbus_tcp - -version="0.5" - -parser = argparse.ArgumentParser(description='Bridge between ModBus and MQTT') -parser.add_argument('--mqtt-host', default='localhost', help='MQTT server address. Defaults to "localhost"') -parser.add_argument('--mqtt-port', default='1883', type=int, help='MQTT server port. Defaults to 1883') -parser.add_argument('--mqtt-topic', default='modbus/', help='Topic prefix to be used for subscribing/publishing. Defaults to "modbus/"') -parser.add_argument('--clientid', default='modbus2mqtt', help='Client ID prefix for MQTT connection') -parser.add_argument('--rtu', help='pyserial URL (or port name) for RTU serial port') -parser.add_argument('--rtu-baud', default='19200', type=int, help='Baud rate for serial port. Defaults to 19200') -parser.add_argument('--rtu-parity', default='even', choices=['even','odd','none'], help='Parity for serial port. Defaults to even') -parser.add_argument('--tcp', help='Act as a Modbus TCP master, connecting to host TCP') -parser.add_argument('--tcp-port', default='502', type=int, help='Port for Modbus TCP. Defaults to 502') -parser.add_argument('--registers', required=True, help='Register definition file. Required!') -parser.add_argument('--log', help='set log level to the specified value. Defaults to WARNING. Use DEBUG for maximum detail') -parser.add_argument('--syslog', action='store_true', help='enable logging to syslog') -parser.add_argument('--force', default='0',type=int, help='publish values after "force" seconds since publish regardless of change. Defaults to 0 (change only)') -args=parser.parse_args() - -if args.log: - logging.getLogger().setLevel(args.log) -if args.syslog: - logging.getLogger().addHandler(logging.handlers.SysLogHandler()) -else: - logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) - -topic=args.mqtt_topic -if not topic.endswith("/"): - topic+="/" - -logging.info('Starting modbus2mqtt V%s with topic prefix \"%s\"' %(version, topic)) - -def signal_handler(signal, frame): - print('Exiting ' + sys.argv[0]) - sys.exit(0) -signal.signal(signal.SIGINT, signal_handler) - -class Register: - def __init__(self,topic,frequency,slaveid,functioncode,register,size,format): - self.topic=topic - self.frequency=int(frequency) - self.slaveid=int(slaveid) - self.functioncode=int(functioncode) - self.register=int(register) - self.size=int(size) - self.format=format.split(":",2) - self.next_due=0 - self.lastval=None - self.last = None - - def checkpoll(self): - if self.next_due int(args.force)): - self.lastval=r - fulltopic=topic+"status/"+self.topic - logging.info("Publishing " + fulltopic) - mqc.publish(fulltopic,self.lastval,qos=0,retain=True) - self.last = time.time() - except modbus_tk.modbus.ModbusError as exc: - logging.error("Error reading "+self.topic+": Slave returned %s - %s", exc, exc.get_exception_code()) - except Exception as exc: - logging.error("Error reading "+self.topic+": %s", exc) - - -registers=[] - -# Now lets read the register definition -with open(args.registers,"r") as csvfile: - dialect=csv.Sniffer().sniff(csvfile.read(8192)) - csvfile.seek(0) - defaultrow={"Size":1,"Format":">H","Frequency":60,"Slave":1,"FunctionCode":4} - reader=csv.DictReader(csvfile,fieldnames=["Topic","Register","Size","Format","Frequency","Slave","FunctionCode"],dialect=dialect) - for row in reader: - # Skip header row - if row["Frequency"]=="Frequency": - continue - # Comment? - if row["Topic"][0]=="#": - continue - if row["Topic"]=="DEFAULT": - temp=dict((k,v) for k,v in row.iteritems() if v is not None and v!="") - defaultrow.update(temp) - continue - freq=row["Frequency"] - if freq is None or freq=="": - freq=defaultrow["Frequency"] - slave=row["Slave"] - if slave is None or slave=="": - slave=defaultrow["Slave"] - fc=row["FunctionCode"] - if fc is None or fc=="": - fc=defaultrow["FunctionCode"] - fmt=row["Format"] - if fmt is None or fmt=="": - fmt=defaultrow["Format"] - size=row["Size"] - if size is None or size=="": - size=defaultrow["Size"] - r=Register(row["Topic"],freq,slave,fc,row["Register"],size,fmt) - registers.append(r) - -logging.info('Read %u valid register definitions from \"%s\"' %(len(registers), args.registers)) - - -def messagehandler(mqc,userdata,msg): - - try: - (prefix,function,slaveid,functioncode,register) = msg.topic.split("/") - if function != 'set': - return - if int(slaveid) not in range(0,255): - logging.warning("on message - invalid slaveid " + msg.topic) - return - - if not (int(register) >= 0 and int(register) < sys.maxint): - logging.warning("on message - invalid register " + msg.topic) - return - - if functioncode == str(cst.WRITE_SINGLE_COIL): - logging.info("Writing single coil " + register) - elif functioncode == str(cst.WRITE_SINGLE_REGISTER): - logging.info("Writing single register " + register) - else: - logging.error("Error attempting to write - invalid function code " + msg.topic) - return - - res=master.execute(int(slaveid),int(functioncode),int(register),output_value=int(msg.payload)) - - except Exception as e: - logging.error("Error on message " + msg.topic + " :" + str(e)) - -def connecthandler(mqc,userdata,rc): - logging.info("Connected to MQTT broker with rc=%d" % (rc)) - mqc.subscribe(topic+"set/+/"+str(cst.WRITE_SINGLE_REGISTER)+"/+") - mqc.subscribe(topic+"set/+/"+str(cst.WRITE_SINGLE_COIL)+"/+") - mqc.publish(topic+"connected",2,qos=1,retain=True) - -def disconnecthandler(mqc,userdata,rc): - logging.warning("Disconnected from MQTT broker with rc=%d" % (rc)) - -try: - clientid=args.clientid + "-" + str(time.time()) - mqc=mqtt.Client(client_id=clientid) - mqc.on_connect=connecthandler - mqc.on_message=messagehandler - mqc.on_disconnect=disconnecthandler - mqc.will_set(topic+"connected",0,qos=2,retain=True) - mqc.disconnected =True - mqc.connect(args.mqtt_host,args.mqtt_port,60) - mqc.loop_start() - - if args.rtu: - master=modbus_rtu.RtuMaster(serial.serial_for_url(args.rtu,baudrate=args.rtu_baud,parity=args.rtu_parity[0].upper())) - elif args.tcp: - master=modbus_tcp.TcpMaster(args.tcp,args.tcp_port) - else: - logging.error("You must specify a modbus access method, either --rtu or --tcp") - sys.exit(1) - - master.set_verbose(True) - master.set_timeout(5.0) - - while True: - for r in registers: - r.checkpoll() - time.sleep(1) - -except Exception as e: - logging.error("Unhandled error [" + str(e) + "]") - sys.exit(1) - \ No newline at end of file +#!/usr/bin/env python +from modbus2mqtt.modbus2mqtt import main +if __name__ == '__main__': + main() diff --git a/modbus2mqtt/__init__.py b/modbus2mqtt/__init__.py new file mode 100644 index 0000000..4265cc3 --- /dev/null +++ b/modbus2mqtt/__init__.py @@ -0,0 +1 @@ +#!/usr/bin/env python diff --git a/modbus2mqtt/__main__.py b/modbus2mqtt/__main__.py new file mode 100644 index 0000000..3469707 --- /dev/null +++ b/modbus2mqtt/__main__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +from .modbus2mqtt import main +main() diff --git a/modbus2mqtt/addToHomeAssistant.py b/modbus2mqtt/addToHomeAssistant.py new file mode 100644 index 0000000..e8eb758 --- /dev/null +++ b/modbus2mqtt/addToHomeAssistant.py @@ -0,0 +1,41 @@ +class HassConnector: + def __init__(self,mqc,globaltopic,verbosity): + self.mqc=mqc + self.globaltopic=globaltopic + self.verbosity=verbosity + + def addAll(self,referenceList): + if(self.verbosity): + print("Adding all references to Home Assistant") + for r in referenceList: + if "r" in r.rw and not "w" in r.rw: + if r.poller.dataType == "bool": + self.addBinarySensor(r) + if r.poller.dataType == "int16": + self.addSensor(r) + if "w" in r.rw and "r" in r.rw: + if r.poller.dataType == "bool": + self.addSwitch(r) + if r.poller.dataType == "int16": #currently I have no idea what entity type to use here.. + self.addSensor(r) + + def addBinarySensor(self,ref): + if(self.verbosity): + print("Adding binary sensor "+ref.topic+" to HASS") + self.mqc.publish("homeassistant/binary_sensor/"+self.globaltopic[0:-1]+"_"+ref.device.name+"_"+ref.topic+"/config","{\"name\": \""+ref.device.name+"_"+ref.topic+"\", \"state_topic\": \""+self.globaltopic+ref.device.name+"/state/"+ref.topic+"\", \"payload_on\": \"True\", \"payload_off\": \"False\"}",qos=0,retain=True) + + def addSensor(self,ref): + if(self.verbosity): + print("Adding sensor "+ref.topic+" to HASS") + self.mqc.publish("homeassistant/sensor/"+self.globaltopic[0:-1]+"_"+ref.device.name+"_"+ref.topic+"/config","{\"name\": \""+ref.device.name+"_"+ref.topic+"\", \"state_topic\": \""+self.globaltopic+ref.device.name+"/state/"+ref.topic+"\"}",qos=0,retain=True) + + def addSwitch(self,ref): + if(self.verbosity): + print("Adding switch "+ref.topic+" to HASS") + self.mqc.publish("homeassistant/switch/"+self.globaltopic[0:-1]+"_"+ref.device.name+"_"+ref.topic+"/config","{\"name\": \""+ref.device.name+"_"+ref.topic+"\", \"state_topic\": \""+self.globaltopic+ref.device.name+"/state/"+ref.topic+"\", \"state_on\": \"True\", \"state_off\": \"False\", \"command_topic\": \""+self.globaltopic+ref.device.name+"/set/"+ref.topic+"\", \"payload_on\": \"True\", \"payload_off\": \"False\"}",qos=0,retain=True) + +# def removeAll(self,referenceList): +# for ref in referenceList: +# print("blah") +# self.mqc.publish(self.globaltopic+ref.device.name+"/"+ref.topic,"",qos=0) + diff --git a/modbus2mqtt/dataTypes.py b/modbus2mqtt/dataTypes.py new file mode 100644 index 0000000..fb12701 --- /dev/null +++ b/modbus2mqtt/dataTypes.py @@ -0,0 +1,243 @@ +import math +import struct + +class DataTypes: + def parsebool(refobj,payload): + if payload == 'True' or payload == 'true' or payload == '1' or payload == 'TRUE': + value = True + elif payload == 'False' or payload == 'false' or payload == '0' or payload == 'FALSE': + value = False + else: + value = None + return value + + def combinebool(refobj,val): + try: + len(val) + return bool(val[0]) + except: + return bool(val) + + def parseString(refobj,msg): + out=[] + if len(msg)<=refobj.stringLength: + for x in range(1,len(msg)+1): + if math.fmod(x,2)>0: + out.append(ord(msg[x-1])<<8) + else: + pass + out[int(x/2-1)]+=ord(msg[x-1]) + else: + out = None + return out + def combineString(refobj,val): + out="" + for x in val: + out+=chr(x>>8) + out+=chr(x&0x00FF) + return out + + def parseint16(refobj,msg): + try: + value=int(msg) + if value > 32767 or value < -32768: + out = None + else: + out = value&0xFFFF + except: + out=None + return out + def combineint16(refobj,val): + try: + len(val) + myval=val[0] + except: + myval=val + + if (myval & 0x8000) > 0: + out = -((~myval & 0x7FFF)+1) + else: + out = myval + return out + + def parseuint32LE(refobj,msg): + try: + value=int(msg) + if value > 4294967295 or value < 0: + out = None + else: + out=[int(value>>16),int(value&0x0000FFFF)] + except: + out=None + return out + def combineuint32LE(refobj,val): + out = val[0]*65536 + val[1] + return out + + def parseuint32BE(refobj,msg): + try: + value=int(msg) + if value > 4294967295 or value < 0: + out = None + else: + out=[int(value&0x0000FFFF),int(value>>16)] + except: + out=None + return out + def combineuint32BE(refobj,val): + out = val[0] + val[1]*65536 + return out + + def parseint32LE(refobj,msg): + #try: + # value=int(msg) + # value = int.from_bytes(value.to_bytes(4, 'little', signed=False), 'little', signed=True) + #except: + # out=None + #return out + return None + def combineint32LE(refobj,val): + out = val[0]*65536 + val[1] + out = int.from_bytes(out.to_bytes(4, 'little', signed=False), 'little', signed=True) + return out + + def parseint32BE(refobj,msg): + #try: + # value=int(msg) + # value = int.from_bytes(value.to_bytes(4, 'big', signed=False), 'big', signed=True) + #except: + # out=None + #return out + return None + def combineint32BE(refobj,val): + out = val[0] + val[1]*65536 + out = int.from_bytes(out.to_bytes(4, 'big', signed=False), 'big', signed=True) + return out + + def parseuint16(refobj,msg): + try: + value=int(msg) + if value > 65535 or value < 0: + value = None + except: + value=None + return value + def combineuint16(refobj,val): + try: + len(val) + return val[0] + except: + return val + + def parsefloat32LE(refobj,msg): + try: + out=None + #value=int(msg) + #if value > 4294967295 or value < 0: + # out = None + #else: + # out=[int(value&0x0000FFFF),int(value>>16)] + except: + out=None + return out + def combinefloat32LE(refobj,val): + out = str(struct.unpack('=f', struct.pack('=I',int(val[0])<<16|int(val[1])))[0]) + return out + + def parsefloat32BE(refobj,msg): + try: + out=None + #value=int(msg) + #if value > 4294967295 or value < 0: + # out = None + #else: + # out=[int(value&0x0000FFFF),int(value>>16)] + except: + out=None + return out + def combinefloat32BE(refobj,val): + out = str(struct.unpack('=f', struct.pack('=I',int(val[1])<<16|int(val[0])))[0]) + return out + + def parseListUint16(refobj,msg): + out=[] + try: + msg=msg.rstrip() + msg=msg.lstrip() + msg=msg.split(" ") + if len(msg) != refobj.regAmount: + return None + for x in range(0, len(msg)): + out.append(int(msg[x])) + except: + return None + return out + def combineListUint16(refobj,val): + out="" + for x in val: + out+=str(x)+" " + return out + + def parseDataType(refobj,conf): + if conf is None or conf == "uint16" or conf == "": + refobj.regAmount=1 + refobj.parse=DataTypes.parseuint16 + refobj.combine=DataTypes.combineuint16 + elif conf.startswith("list-uint16-"): + try: + length = int(conf[12:15]) + except: + length = 1 + if length > 50: + print("Data type list-uint16: length too long") + length = 50 + refobj.parse=DataTypes.parseListUint16 + refobj.combine=DataTypes.combineListUint16 + refobj.regAmount=length + elif conf.startswith("string"): + try: + length = int(conf[6:9]) + except: + length = 2 + if length > 100: + print("Data type string: length too long") + length = 100 + if math.fmod(length,2) != 0: + length=length-1 + print("Data type string: length must be divisible by 2") + refobj.parse=DataTypes.parseString + refobj.combine=DataTypes.combineString + refobj.stringLength=length + refobj.regAmount=int(length/2) + elif conf == "int32LE": + refobj.parse=DataTypes.parseint32LE + refobj.combine=DataTypes.combineint32LE + refobj.regAmount=2 + elif conf == "int32BE": + refobj.regAmount=2 + refobj.parse=DataTypes.parseint32BE + refobj.combine=DataTypes.combineint32BE + elif conf == "int16": + refobj.regAmount=1 + refobj.parse=DataTypes.parseint16 + refobj.combine=DataTypes.combineint16 + elif conf == "uint32LE": + refobj.regAmount=2 + refobj.parse=DataTypes.parseuint32LE + refobj.combine=DataTypes.combineuint32LE + elif conf == "uint32BE": + refobj.regAmount=2 + refobj.parse=DataTypes.parseuint32BE + refobj.combine=DataTypes.combineuint32BE + elif conf == "bool": + refobj.regAmount=1 + refobj.parse=DataTypes.parsebool + refobj.combine=DataTypes.combinebool + elif conf == "float32LE": + refobj.regAmount=2 + refobj.parse=DataTypes.parsefloat32LE + refobj.combine=DataTypes.combinefloat32LE + elif conf == "float32BE": + refobj.regAmount=2 + refobj.parse=DataTypes.parsefloat32BE + refobj.combine=DataTypes.combinefloat32BE diff --git a/modbus2mqtt/modbus2mqtt.py b/modbus2mqtt/modbus2mqtt.py new file mode 100644 index 0000000..b1645ee --- /dev/null +++ b/modbus2mqtt/modbus2mqtt.py @@ -0,0 +1,682 @@ +# spicierModbus2mqtt - Modbus TCP/RTU to MQTT bridge (and vice versa) +# https://github.com/mbs38/spicierModbus2mqtt +# +# Written in 2018 by Max Brueggemann +# +# +# Provided under the terms of the MIT license. + +# Contains a bunch of code taken from: +# modbus2mqtt - Modbus master with MQTT publishing +# Written and (C) 2015 by Oliver Wagner +# Provided under the terms of the MIT license. + +# Main improvements over modbus2mqtt: +# - more abstraction when writing to coils/registers using mqtt. Writing is now +# possible without having to know slave id, reference, function code etc. +# - specific coils/registers can be made read only +# - multiple slave devices on one bus are now supported +# - polling speed has been increased sgnificantly. With modbus RTU @ 38400 baud +# more than 80 transactions per second have been achieved. +# - switched over to pymodbus which is in active development + + +# Requires: +# - Eclipse Paho for Python - http://www.eclipse.org/paho/clients/python/ +# - pymodbus - https://github.com/riptideio/pymodbus +#!/usr/bin/env python + +import argparse +import time +import socket +import paho.mqtt.client as mqtt +import serial +import io +import sys +import csv +import signal +import random +import ssl +import math +import struct +import queue + +from .addToHomeAssistant import HassConnector +from .dataTypes import DataTypes + +import pymodbus +import asyncio + +from pymodbus.client import ( + AsyncModbusSerialClient, + AsyncModbusTcpClient, + AsyncModbusTlsClient, + AsyncModbusUdpClient, +) +from pymodbus.exceptions import ModbusIOException + +__version__ = "0.72" +mqtt_port = None +mqc = None +parser = None +args = None +verbosity = None +addToHass = None +loopBreak = None +globaltopic = None +pollers = [] +deviceList = [] +referenceList = [] +master = None +control = None + +writeQueue = queue.SimpleQueue() + +class Control: + def __init__(self): + self.runLoop = True + def stopLoop(self): + self.runLoop = False + +def signal_handler(signal, frame): + global control + print('Exiting ' + sys.argv[0]) + control.stopLoop() + +class Device: + def __init__(self,name,slaveid): + self.name=name + self.occupiedTopics=[] + self.writableReferences=[] + self.slaveid=slaveid + self.errorCount=0 + self.pollCount=0 + self.next_due=time.clock_gettime(0)+args.diagnostics_rate + if verbosity>=2: + print('Added new device \"'+self.name+'\"') + + def publishDiagnostics(self): + if args.diagnostics_rate>0: + if self.next_due=2: + print("Added new poller "+str(self.topic)+","+str(self.functioncode)+","+str(self.dataType)+","+str(self.reference)+","+str(self.size)+",") + + + def failCount(self,failed): + self.device.pollCount+=1 + if not failed: + self.failcounter=0 + if not self.connected: + self.connected = True + mqc.publish(globaltopic + self.topic +"/connected", "True", qos=1, retain=True) + else: + self.device.errorCount+=1 + if self.failcounter==3: + if args.autoremove: + self.disabled=True + if verbosity >=1: + print("Poller "+self.topic+" with Slave-ID "+str(self.slaveid)+" disabled (functioncode: "+str(self.functioncode)+", start reference: "+str(self.reference)+", size: "+str(self.size)+").") + for p in pollers: #also fail all pollers with the same slave id + if p.slaveid == self.slaveid: + p.failcounter=3 + p.disabled=True + if verbosity >=1: + print("Poller "+p.topic+" with Slave-ID "+str(p.slaveid)+" disabled (functioncode: "+str(p.functioncode)+", start reference: "+str(p.reference)+", size: "+str(p.size)+").") + self.failcounter=4 + self.connected = False + mqc.publish(globaltopic + self.topic +"/connected", "False", qos=1, retain=True) + else: + if self.failcounter<3: + self.failcounter+=1 + + async def poll(self): + result = None + failed = False + try: + time.sleep(0.002) + if self.functioncode == 3: + result = await master.read_holding_registers(self.reference, self.size, slave=self.slaveid) + if result.function_code < 0x80: + data = result.registers + else: + failed = True + if self.functioncode == 1: + result = await master.read_coils(self.reference, self.size, slave=self.slaveid) + if result.function_code < 0x80: + data = result.bits + else: + failed = True + if self.functioncode == 2: + result = await master.read_discrete_inputs(self.reference, self.size, slave=self.slaveid) + if result.function_code < 0x80: + data = result.bits + else: + failed = True + if self.functioncode == 4: + result = await master.read_input_registers(self.reference, self.size, slave=self.slaveid) + if result.function_code < 0x80: + data = result.registers + else: + failed = True + if not failed: + if verbosity>=4: + print("Read MODBUS, FC:"+str(self.functioncode)+", DataType:"+str(self.dataType)+", ref:"+str(self.reference)+", Qty:"+str(self.size)+", SI:"+str(self.slaveid)) + print("Read MODBUS, DATA:"+str(data)) + for ref in self.readableReferences: + val = data[ref.relativeReference:(ref.regAmount+ref.relativeReference)] + ref.checkPublish(val) + else: + if verbosity>=1: + print("Slave device "+str(self.slaveid)+" responded with error code:"+str(result).split(',', 3)[2].rstrip(')')) + except Exception as e: + failed = True + if verbosity>=1: + print("Error talking to slave device:"+str(self.slaveid)+" ("+str(e)+")") + self.failCount(failed) + + async def checkPoll(self): + if time.clock_gettime(0) >= self.next_due and not self.disabled: + await self.poll() + self.next_due=time.clock_gettime(0)+self.rate + + def addReference(self,myRef): + #check reference configuration and maybe add to this poller or to the list of writable things + if myRef.topic not in self.device.occupiedTopics: + self.device.occupiedTopics.append(myRef.topic) + + if "r" in myRef.rw or "w" in myRef.rw: + myRef.device=self.device + if verbosity >= 2: + print('Added new reference \"' + myRef.topic + '\"') + if "r" in myRef.rw: + if myRef.checkSanity(self.reference,self.size): + self.readableReferences.append(myRef) + if "w" not in myRef.rw: + referenceList.append(myRef) + + else: + print("Reference \""+str(myRef.reference)+"\" with topic "+myRef.topic+" is not in range ("+str(self.reference)+" to "+str(int(self.reference+self.size-1))+") of poller \""+self.topic+"\", therefore ignoring it for polling.") + if "w" in myRef.rw: + if self.functioncode == 3: #holding registers + myRef.writefunctioncode=6 #preset single register + if self.functioncode == 1: #coils + myRef.writefunctioncode=5 #force single coil + if self.functioncode == 2: #read input status, not writable + print("Reference \""+str(myRef.reference)+"\" with topic "+myRef.topic+" in poller \""+self.topic+"\" is not writable (discrete input)") + if self.functioncode == 4: #read input register, not writable + print("Reference \""+str(myRef.reference)+"\" with topic "+myRef.topic+" in poller \""+self.topic+"\" is not writable (input register)") + if myRef.writefunctioncode is not None: + self.device.writableReferences.append(myRef) + referenceList.append(myRef) + else: + print("Reference \""+str(myRef.reference)+"\" with topic "+myRef.topic+" in poller \""+self.topic+"\" is neither read nor writable, therefore ignoring it.") + else: + print("Reference topic ("+str(myRef.topic)+") is already occupied for poller \""+self.topic+"\", therefore ignoring it.") + +class Reference: + def __init__(self,topic,reference,dtype,rw,poller,scaling): + self.topic=topic + self.reference=int(reference) + self.lastval=None + self.scale=None + self.regAmount=None + self.stringLength=None + + if scaling: + try: + self.scale=float(scaling) + except ValueError as e: + if verbosity>=1: + print("Scaling Error:", e) + self.rw=rw + self.relativeReference=None + self.writefunctioncode=None + self.device=None + self.poller=poller + if self.poller.functioncode == 1: + DataTypes.parseDataType(self,"bool") + + elif self.poller.functioncode == 2: + DataTypes.parseDataType(self,"bool") + else: + DataTypes.parseDataType(self,dtype) + + def checkSanity(self,reference,size): + if self.reference in range(reference,size+reference) and self.reference+self.regAmount-1 in range(reference,size+reference): + self.relativeReference=self.reference-reference + return True + + def checkPublish(self,val): + # Only publish messages after the initial connection has been made. If it became disconnected then the offline buffer will store messages, + # but only after the intial connection was made. + if mqc.initial_connection_made == True: + val = self.combine(self,val) + if self.lastval != val or args.always_publish: + self.lastval = val + if self.scale: + val = val * self.scale + try: + publish_result = mqc.publish(globaltopic+self.device.name+"/state/"+self.topic,val,retain=True) + if verbosity>=4: + print("published MQTT topic: " + str(self.device.name+"/state/"+self.topic)+" value: " + str(self.lastval)+" RC:"+str(publish_result.rc)) + except: + if verbosity>=1: + print("Error publishing MQTT topic: " + str(self.device.name+"/state/"+self.topic)+"value: " + str(self.lastval)) + +async def writehandler(userdata,msg): + if str(msg.topic) == globaltopic+"reset-autoremove": + if not args.autoremove and verbosity>=1: + print("ERROR: Received autoremove-reset command but autoremove is not enabled. Check flags.") + if args.autoremove: + payload = str(msg.payload.decode("utf-8")) + if payload == "True" or payload == "1": + if verbosity>=1: + print("Reactivating previously disabled pollers (command from MQTT)") + for p in pollers: + if p.disabled == True: + p.disabled = False + p.failcounter = 0 + if verbosity>=1: + print("Reactivated poller "+p.topic+" with Slave-ID "+str(p.slaveid)+ " and functioncode "+str(p.functioncode)+".") + + return + (prefix,device,function,reference) = msg.topic.split("/") + if function != 'set': + return + myRef = None + myDevice = None + for iterDevice in deviceList: + if iterDevice.name == device: + myDevice = iterDevice + if myDevice == None: # no such device + return + for iterRef in myDevice.writableReferences: + if iterRef.topic == reference: + myRef=iterRef + if myRef == None: # no such reference + return + payload = str(msg.payload.decode("utf-8")) + time.sleep(0.002) + if myRef.writefunctioncode == 5: + value = myRef.parse(myRef,str(payload)) + if value != None: + result = await master.write_coil(int(myRef.reference),value,slave=int(myRef.device.slaveid)) + try: + if result.function_code < 0x80: + myRef.checkPublish(value) # writing was successful => we can assume, that the corresponding state can be set and published + if verbosity>=3: + print("Writing coils values to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" successful.") + else: + if verbosity>=1: + print("Writing to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" using function code "+str(myRef.writefunctioncode)+" FAILED! (Devices responded with errorcode"+str(result).split(',', 3)[2].rstrip(')')+". Maybe bad configuration?)") + + except: + if verbosity>=1: + print("Error writing to slave device "+str(myDevice.slaveid)+" (maybe CRC error or timeout)") + else: + if verbosity >= 1: + print("Writing to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" using function code "+str(myRef.writefunctioncode)+" not possible. Given value is not \"True\" or \"False\".") + + + if myRef.writefunctioncode == 6: + value = myRef.parse(myRef,str(payload)) + if value is not None: + try: + valLen=len(value) + except: + valLen=1 + if valLen>1 or args.avoid_fc6: + result = await master.write_registers(int(myRef.reference),value,slave=myRef.device.slaveid) + else: + result = await master.write_register(int(myRef.reference),value,slave=myRef.device.slaveid) + try: + if result.function_code < 0x80: + myRef.checkPublish(value) # writing was successful => we can assume, that the corresponding state can be set and published + if verbosity>=3: + print("Writing register value(s) to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" using function code "+str(myRef.writefunctioncode)+" successful.") + else: + if verbosity>=1: + print("Writing to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" using function code "+str(myRef.writefunctioncode)+" FAILED! (Devices responded with errorcode"+str(result).split(',', 3)[2].rstrip(')')+". Maybe bad configuration?)") + except: + if verbosity >= 1: + print("Error writing to slave device "+str(myDevice.slaveid)+" (maybe CRC error or timeout)") + else: + if verbosity >= 1: + print("Writing to device "+str(myDevice.name)+", Slave-ID="+str(myDevice.slaveid)+" at Reference="+str(myRef.reference)+" using function code "+str(myRef.writefunctioncode)+" not possible. Value does not fulfill criteria.") + +def messagehandler(mqc,userdata,msg): + writeQueue.put((userdata, msg)) + +def connecthandler(mqc,userdata,flags,rc): + if rc == 0: + mqc.initial_connection_made = True + if verbosity>=2: + print("MQTT Broker connected succesfully: " + args.mqtt_host + ":" + str(mqtt_port)) + mqc.subscribe(globaltopic + "+/set/+") + mqc.subscribe(globaltopic + "reset-autoremove") + if verbosity>=2: + print("Subscribed to MQTT topic: "+globaltopic + "+/set/+") + mqc.publish(globaltopic + "connected", "True", qos=1, retain=True) + elif rc == 1: + if verbosity>=1: + print("MQTT Connection refused – incorrect protocol version") + elif rc == 2: + if verbosity>=1: + print("MQTT Connection refused – invalid client identifier") + elif rc == 3: + if verbosity>=1: + print("MQTT Connection refused – server unavailable") + elif rc == 4: + if verbosity>=1: + print("MQTT Connection refused – bad username or password") + elif rc == 5: + if verbosity>=1: + print("MQTT Connection refused – not authorised") + +def disconnecthandler(mqc,userdata,rc): + if verbosity >= 2: + print("MQTT Disconnected, RC:"+str(rc)) + +def loghandler(mgc, userdata, level, buf): + if verbosity >= 4: + print("MQTT LOG:" + buf) + +def main(): + asyncio.run(async_main(), debug=False) + +async def async_main(): + global parser + global args + global verbosity + global addToHass + global loopBreak + global globaltopic + global control + + parser = argparse.ArgumentParser(description='Bridge between ModBus and MQTT') + parser.add_argument('--mqtt-host', default='localhost', help='MQTT server address. Defaults to "localhost"') + parser.add_argument('--mqtt-port', default=None, type=int, help='Defaults to 8883 for TLS or 1883 for non-TLS') + parser.add_argument('--mqtt-topic', default='modbus/', help='Topic prefix to be used for subscribing/publishing. Defaults to "modbus/"') + parser.add_argument('--mqtt-user', default=None, help='Username for authentication (optional)') + parser.add_argument('--mqtt-pass', default="", help='Password for authentication (optional)') + parser.add_argument('--mqtt-use-tls', action='store_true', help='Use TLS') + parser.add_argument('--mqtt-insecure', action='store_true', help='Use TLS without providing certificates') + parser.add_argument('--mqtt-cacerts', default=None, help="Path to keychain including ") + parser.add_argument('--mqtt-tls-version', default=None, help='TLS protocol version, can be one of tlsv1.2 tlsv1.1 or tlsv1') + parser.add_argument('--rtu',help='pyserial URL (or port name) for RTU serial port') + parser.add_argument('--rtu-baud', default='19200', type=int, help='Baud rate for serial port. Defaults to 19200') + parser.add_argument('--rtu-parity', default='even', choices=['even','odd','none'], help='Parity for serial port. Defaults to even') + parser.add_argument('--tcp', help='Act as a Modbus TCP master, connecting to host TCP') + parser.add_argument('--tcp-port', default='502', type=int, help='Port for MODBUS TCP. Defaults to 502') + parser.add_argument('--set-modbus-timeout',default='1',type=float, help='Response time-out for MODBUS devices') + parser.add_argument('--config', required=True, help='Configuration file. Required!') + parser.add_argument('--verbosity', default='3', type=int, help='Verbose level, 0=silent, 1=errors only, 2=connections, 3=mb writes, 4=all') + parser.add_argument('--autoremove',action='store_true',help='Automatically remove poller if modbus communication has failed three times. Removed pollers can be reactivated by sending "True" or "1" to topic modbus/reset-autoremove') + parser.add_argument('--add-to-homeassistant',action='store_true',help='Add devices to Home Assistant using Home Assistant\'s MQTT-Discovery') + parser.add_argument('--always-publish',action='store_true',help='Always publish values, even if they did not change.') + parser.add_argument('--set-loop-break',default=None,type=float, help='Set pause in main polling loop. Defaults to 10ms.') + parser.add_argument('--diagnostics-rate',default='0',type=int, help='Time in seconds after which for each device diagnostics are published via mqtt. Set to sth. like 600 (= every 10 minutes) or so.') + parser.add_argument('--avoid-fc6',action='store_true', help='If set, use function code 16 (write multiple registers) even when just writing a single register') + control = Control() + signal.signal(signal.SIGINT, signal_handler) + + args=parser.parse_args() + verbosity=args.verbosity + loopBreak=args.set_loop_break + if loopBreak is None: + loopBreak=0.01 + addToHass=False + addToHass=args.add_to_homeassistant + + globaltopic=args.mqtt_topic + + if not globaltopic.endswith("/"): + globaltopic+="/" + + if verbosity>=0: + print('Starting spiciermodbus2mqtt V%s with topic prefix \"%s\"' %(__version__, globaltopic)) + + # type, topic, slaveid, ref, size, functioncode, rate + # type, topic, reference, rw, interpretation, scaling, + + # Now let's read the config file + with open(args.config,"r") as csvfile: + csvfile.seek(0) + reader=csv.DictReader(csvfile) + currentPoller=None + for row in reader: + if row["type"]=="poller" or row["type"]=="poll": + rate = float(row["col6"]) + slaveid = int(row["col2"]) + reference = int(row["col3"]) + size = int(row["col4"]) + + if row["col5"] == "holding_register": + functioncode = 3 + dataType="int16" + if size>123: #applies to TCP, RTU should support 125 registers. But let's be safe. + currentPoller=None + if verbosity>=1: + print("Too many registers (max. 123). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "coil": + functioncode = 1 + dataType="bool" + if size>2000: #some implementations don't seem to support 2008 coils/inputs + currentPoller=None + if verbosity>=1: + print("Too many coils (max. 2000). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "input_register": + functioncode = 4 + dataType="int16" + if size>123: + currentPoller=None + if verbosity>=1: + print("Too many registers (max. 123). Ignoring poller "+row["topic"]+".") + continue + elif row["col5"] == "input_status": + functioncode = 2 + dataType="bool" + if size>2000: + currentPoller=None + if verbosity>=1: + print("Too many inputs (max. 2000). Ignoring poller "+row["topic"]+".") + continue + + else: + print("Unknown function code ("+row["col5"]+" ignoring poller "+row["topic"]+".") + currentPoller=None + continue + currentPoller = Poller(row["topic"],rate,slaveid,functioncode,reference,size,dataType) + pollers.append(currentPoller) + continue + elif row["type"]=="reference" or row["type"]=="ref": + if currentPoller is not None: + currentPoller.addReference(Reference(row["topic"],row["col2"],row["col4"],row["col3"],currentPoller,row["col5"])) + else: + print("No poller for reference "+row["topic"]+".") + + #Setup MODBUS Master + global master + if args.rtu: + if args.rtu_parity == "none": + parity = "N" + if args.rtu_parity == "odd": + parity = "O" + if args.rtu_parity == "even": + parity = "E" + master = AsyncModbusSerialClient(port=args.rtu, stopbits = 1, bytesize = 8, parity = parity, baudrate = int(args.rtu_baud), timeout=args.set_modbus_timeout) + + elif args.tcp: + master = AsyncModbusTcpClient(args.tcp, port=args.tcp_port,client_id="modbus2mqtt", clean_session=False) + else: + print("You must specify a modbus access method, either --rtu or --tcp") + sys.exit(1) + + #Setup MQTT Broker + global mqtt_port + mqtt_port = args.mqtt_port + + if mqtt_port is None: + if args.mqtt_use_tls: + mqtt_port = 8883 + else: + mqtt_port = 1883 + + clientid=globaltopic + "-" + str(time.time()) + global mqc + mqc=mqtt.Client(client_id=clientid) + mqc.on_connect=connecthandler + mqc.on_message=messagehandler + mqc.on_disconnect=disconnecthandler + mqc.on_log= loghandler + mqc.will_set(globaltopic+"connected","False",qos=2,retain=True) + mqc.initial_connection_attempted = False + mqc.initial_connection_made = False + if args.mqtt_user or args.mqtt_pass: + mqc.username_pw_set(args.mqtt_user, args.mqtt_pass) + + if args.mqtt_use_tls: + if args.mqtt_tls_version == "tlsv1.2": + tls_version = ssl.PROTOCOL_TLSv1_2 + elif args.mqtt_tls_version == "tlsv1.1": + tls_version = ssl.PROTOCOL_TLSv1_1 + elif args.mqtt_tls_version == "tlsv1": + tls_version = ssl.PROTOCOL_TLSv1 + elif args.mqtt_tls_version is None: + tls_version = None + else: + if verbosity >= 2: + print("Unknown TLS version - ignoring") + tls_version = None + + if args.mqtt_insecure: + cert_regs = ssl.CERT_NONE + else: + cert_regs = ssl.CERT_REQUIRED + + mqc.tls_set(ca_certs=args.mqtt_cacerts, certfile= None, keyfile=None, cert_reqs=cert_regs, tls_version=tls_version) + + if args.mqtt_insecure: + mqc.tls_insecure_set(True) + + if len(pollers)<1: + print("No pollers. Exitting.") + sys.exit(0) + + #Main Loop + modbus_connected = False + current_poller = 0 + while control.runLoop: + time.sleep(loopBreak) + modbus_connected = master.connected + if not modbus_connected: + print("Connecting to MODBUS...") + await master.connect() + modbus_connected = master.connected + if modbus_connected: + if verbosity >= 2: + print("MODBUS connected successfully") + else: + for p in pollers: + p.failed=True + if p.failcounter<3: + p.failcounter=3 + p.failCount(p.failed) + if verbosity >= 1: + print("MODBUS connection error (mainLoop), trying again...") + time.sleep(0.5) + + if not mqc.initial_connection_attempted: + try: + print("Connecting to MQTT Broker: " + args.mqtt_host + ":" + str(mqtt_port) + "...") + mqc.connect(args.mqtt_host, mqtt_port, 60) + mqc.initial_connection_attempted = True #Once we have connected the mqc loop will take care of reconnections. + mqc.loop_start() + #Setup HomeAssistant + if(addToHass): + adder=HassConnector(mqc,globaltopic,verbosity>=1) + adder.addAll(referenceList) + if verbosity >= 1: + print("MQTT Loop started") + except: + if verbosity>=1: + print("Socket Error connecting to MQTT broker: " + args.mqtt_host + ":" + str(mqtt_port) + ", check LAN/Internet connection, trying again...") + + if mqc.initial_connection_made: #Don't start polling unless the initial connection to MQTT has been made, no offline MQTT storage will be available until then. + if modbus_connected: + try: + if len(pollers) > 0: + await pollers[current_poller].checkPoll() + current_poller = current_poller + 1 + if current_poller == len(pollers): + current_poller=0 + if not writeQueue.empty(): + writeObj = writeQueue.get(False) + await writehandler(writeObj[0],writeObj[1]) + + for d in deviceList: + d.publishDiagnostics() + anyAct=False + + for p in pollers: + if p.disabled is not True: + anyAct=True + + if not anyAct: + time.sleep(0.010) + for p in pollers: + if p.disabled == True: + p.disabled = False + p.failcounter = 0 + if verbosity>=1: + print("Reactivated poller "+p.topic+" with Slave-ID "+str(p.slaveid)+ " and functioncode "+str(p.functioncode)+".") + except Exception as e: + if verbosity>=1: + print("Error: "+str(e)+" when polling or publishing, trying again...") + await master.close() + #adder.removeAll(referenceList) + sys.exit(1) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..466dac5 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +import re +from setuptools import setup + + +version = re.search( + '^__version__\s*=\s*"(.*)"', + open('modbus2mqtt/modbus2mqtt.py').read(), + re.M + ).group(1) + + +# for some stupid reason we need restructured text here. +# For now I'll stick with markdown.. +#with open("README.md", "rb") as f: +# long_descr = f.read().decode("utf-8") +long_descr = "" + +setup( + name = "modbus2mqtt", + packages = ["modbus2mqtt"], + install_requires=[ + 'paho-mqtt', + 'pymodbus', + 'serial', + ], + entry_points = { + "console_scripts": ['modbus2mqtt = modbus2mqtt.modbus2mqtt:main'] + }, + version = version, + description = "Bridge from Modbus to MQTT", + long_description = long_descr, + author = "Max Brüggemann", + author_email = "mail@maxbrueggemann.de", + url = "https://github.com/mbs38/spicierModbus2mqtt", + ) +