├── LICENSE ├── README.md ├── example_register_definition.csv └── modbus2mqtt.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Oliver Wagner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | modbus2mqtt 2 | =========== 3 | 4 | Written and (C) 2015 Oliver Wagner 5 | 6 | Provided under the terms of the MIT license. 7 | 8 | 9 | Overview 10 | -------- 11 | modbus2mqtt is a Modbus master which continously polls slaves and publishes 12 | register values via MQTT. 13 | 14 | It is intended as a building block in heterogenous smart home environments where 15 | an MQTT message broker is used as the centralized message bus. 16 | See https://github.com/mqtt-smarthome for a rationale and architectural overview. 17 | 18 | 19 | Dependencies 20 | ------------ 21 | * Eclipse Paho for Python - http://www.eclipse.org/paho/clients/python/ 22 | * modbus-tk for Modbus communication - https://github.com/ljean/modbus-tk/ 23 | 24 | 25 | Command line options 26 | -------------------- 27 | usage: modbus2mqtt.py [-h] [--mqtt-host MQTT_HOST] [--mqtt-port MQTT_PORT] 28 | [--mqtt-topic MQTT_TOPIC] [--rtu RTU] 29 | [--rtu-baud RTU_BAUD] [--rtu-parity {even,odd,none}] 30 | --registers REGISTERS [--log LOG] [--syslog] 31 | 32 | optional arguments: 33 | -h, --help show this help message and exit 34 | --mqtt-host MQTT_HOST 35 | MQTT server address. Defaults to "localhost" 36 | --mqtt-port MQTT_PORT 37 | MQTT server port. Defaults to 1883 38 | --mqtt-topic MQTT_TOPIC 39 | Topic prefix to be used for subscribing/publishing. 40 | Defaults to "modbus/" 41 | --clientid MQTT_CLIENT_ID 42 | optional prefix for MQTT Client ID 43 | 44 | --rtu RTU pyserial URL (or port name) for RTU serial port 45 | --rtu-baud RTU_BAUD Baud rate for serial port. Defaults to 19200 46 | --rtu-parity {even,odd,none} 47 | Parity for serial port. Defaults to even. 48 | --registers REGISTERS 49 | Register specification file. Must be specified 50 | --force FORCE 51 | optional interval (secs) to publish existing values 52 | does not override a register's poll interval. 53 | Defaults to 0 (publish only on change). 54 | 55 | --log LOG set log level to the specified value. Defaults to 56 | WARNING. Try DEBUG for maximum detail 57 | --syslog enable logging to syslog 58 | 59 | 60 | Register definition 61 | ------------------- 62 | The Modbus registers which are to be polled are defined in a CSV file with 63 | the following columns: 64 | 65 | * *Topic suffix* 66 | The topic where the respective register will be published into. Will 67 | be prefixed with the global topic prefix and "status/". 68 | * *Register offset* 69 | The register number, depending on the function code. Zero-based. 70 | * *Size (in words)* 71 | The register size in words. 72 | * *Format* 73 | The format how to interpret the register value. This can be two parts, split 74 | by a ":" character. 75 | The first part uses the Python 76 | "struct" module notation. Common examples: 77 | - >H unsigned short 78 | - >f float 79 | 80 | The second part is optional and specifies a Python format string, e.g. 81 | %.2f 82 | to format the value to two decimal digits. 83 | * *Polling frequency* 84 | How often the register is to be polled, in seconds. Only integers. 85 | * *SlaveID* 86 | The Modbus address of the slave to query. Defaults to 1. 87 | * *FunctionCode* 88 | The Modbus function code to use for querying the register. Defaults 89 | to 4 (READ REGISTER). Only change if you know what you are doing. 90 | 91 | Not all columns need to be specified. Unspecified columns take their 92 | default values. The default values for subsequent rows can be set 93 | by specifying a magic topic suffix of *DEFAULT* 94 | 95 | Topics 96 | ------ 97 | Values are published as simple strings to topics with the general , 98 | the function code "/status/" and the topic suffix specified per register. 99 | A value will only be published if it's textual representation has changed, 100 | e.g. _after_ formatting has been applied. The published MQTT messages have 101 | the retain flag set. 102 | 103 | A special topic "/connected" is maintained. 104 | It's a enum stating whether the module is currently running and connected to 105 | the broker (1) and to the Modbus interface (2). 106 | 107 | Setting Modbus coils (FC=5) and registers (FC=6) 108 | ------------------------------------------------ 109 | 110 | modbus2mqtt subscibes to two topics: 111 | 112 | - prefix/set/+/5/+ # where the first + is the slaveId and the second is the register 113 | - prefix/set/+/6/+ # payload values are written the the devices (assumes 16bit Int) 114 | 115 | There is only limited sanity checking currently on the payload values. 116 | 117 | 118 | Changelog 119 | --------- 120 | * 0.4 - 2015/07/31 - nzfarmer 121 | - added support for MQTT subscribe + Mobdus write 122 | Topics are of the form: prefix/set/// (payload = value to write) 123 | - added CNTL-C for controlled exit 124 | - added --clientid for MQTT connections 125 | - added --force to repost register values regardless of change every x seconds where x >0 126 | 127 | * 0.3 - 2015/05/26 - owagner 128 | - support optional string format specification 129 | * 0.2 - 2015/05/26 - owagner 130 | - added "--rtu-parity" option to set the parity for RTU serial communication. Defaults to "even", 131 | to be inline with Modbus specification 132 | - changed default for "--rtu-baud" to 19200, to be inline with Modbus specification 133 | 134 | * 0.1 - 2015/05/25 - owagner 135 | - Initial version 136 | 137 | -------------------------------------------------------------------------------- /example_register_definition.csv: -------------------------------------------------------------------------------- 1 | "Topic","Register","Size","Format","Frequency","Slave","FunctionCode" 2 | # 3 | # Example register definition file. 4 | # Device is a Eastron SDM630 power meter. Register specification e.g. 5 | # available at http://www.ausboard.net.au/index_files/Eastron/Eastron%20Modbus%20Registers.pdf 6 | # 7 | # The Slave ID is assumed to be 1 (which is default for the SDM630) 8 | # The function code used for reading is READ REGISTER (4), which is default 9 | # Data format for all registers is float. Polling interval is 15s. 10 | # 11 | # All those defaults are set with a magic "DEFAULT" topic definition and 12 | # are then inherited by subsequent register definitions. 13 | DEFAULT,,2,>f:%.1f,15,1,4 14 | # 15 | phase1/voltage,0 16 | phase2/voltage,2 17 | phase3/voltage,4 18 | phase1/power,12 19 | phase2/power,14 20 | phase3/power,16 21 | total/power,52 22 | # 23 | # We want two decimal digits now 24 | # 25 | DEFAULT,,,>f:%.2f 26 | phase1/current,6 27 | phase2/current,8 28 | phase3/current,10 29 | freq,70 -------------------------------------------------------------------------------- /modbus2mqtt.py: -------------------------------------------------------------------------------- 1 | # 2 | # modbus2mqtt - Modbus master with MQTT publishing 3 | # 4 | # Written and (C) 2015 by Oliver Wagner 5 | # Provided under the terms of the MIT license 6 | # 7 | # Requires: 8 | # - Eclipse Paho for Python - http://www.eclipse.org/paho/clients/python/ 9 | # - modbus-tk for Modbus communication - https://github.com/ljean/modbus-tk/ 10 | # 11 | 12 | import argparse 13 | import logging 14 | import logging.handlers 15 | import time 16 | import socket 17 | import paho.mqtt.client as mqtt 18 | import serial 19 | import io 20 | import sys 21 | import csv 22 | import signal 23 | 24 | import modbus_tk 25 | import modbus_tk.defines as cst 26 | from modbus_tk import modbus_rtu 27 | from modbus_tk import modbus_tcp 28 | 29 | version="0.5" 30 | 31 | parser = argparse.ArgumentParser(description='Bridge between ModBus and MQTT') 32 | parser.add_argument('--mqtt-host', default='localhost', help='MQTT server address. Defaults to "localhost"') 33 | parser.add_argument('--mqtt-port', default='1883', type=int, help='MQTT server port. Defaults to 1883') 34 | parser.add_argument('--mqtt-topic', default='modbus/', help='Topic prefix to be used for subscribing/publishing. Defaults to "modbus/"') 35 | parser.add_argument('--clientid', default='modbus2mqtt', help='Client ID prefix for MQTT connection') 36 | parser.add_argument('--rtu', help='pyserial URL (or port name) for RTU serial port') 37 | parser.add_argument('--rtu-baud', default='19200', type=int, help='Baud rate for serial port. Defaults to 19200') 38 | parser.add_argument('--rtu-parity', default='even', choices=['even','odd','none'], help='Parity for serial port. Defaults to even') 39 | parser.add_argument('--tcp', help='Act as a Modbus TCP master, connecting to host TCP') 40 | parser.add_argument('--tcp-port', default='502', type=int, help='Port for Modbus TCP. Defaults to 502') 41 | parser.add_argument('--registers', required=True, help='Register definition file. Required!') 42 | parser.add_argument('--log', help='set log level to the specified value. Defaults to WARNING. Use DEBUG for maximum detail') 43 | parser.add_argument('--syslog', action='store_true', help='enable logging to syslog') 44 | parser.add_argument('--force', default='0',type=int, help='publish values after "force" seconds since publish regardless of change. Defaults to 0 (change only)') 45 | args=parser.parse_args() 46 | 47 | if args.log: 48 | logging.getLogger().setLevel(args.log) 49 | if args.syslog: 50 | logging.getLogger().addHandler(logging.handlers.SysLogHandler()) 51 | else: 52 | logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) 53 | 54 | topic=args.mqtt_topic 55 | if not topic.endswith("/"): 56 | topic+="/" 57 | 58 | logging.info('Starting modbus2mqtt V%s with topic prefix \"%s\"' %(version, topic)) 59 | 60 | def signal_handler(signal, frame): 61 | print('Exiting ' + sys.argv[0]) 62 | sys.exit(0) 63 | signal.signal(signal.SIGINT, signal_handler) 64 | 65 | class Register: 66 | def __init__(self,topic,frequency,slaveid,functioncode,register,size,format): 67 | self.topic=topic 68 | self.frequency=int(frequency) 69 | self.slaveid=int(slaveid) 70 | self.functioncode=int(functioncode) 71 | self.register=int(register) 72 | self.size=int(size) 73 | self.format=format.split(":",2) 74 | self.next_due=0 75 | self.lastval=None 76 | self.last = None 77 | 78 | def checkpoll(self): 79 | if self.next_due int(args.force)): 90 | self.lastval=r 91 | fulltopic=topic+"status/"+self.topic 92 | logging.info("Publishing " + fulltopic) 93 | mqc.publish(fulltopic,self.lastval,qos=0,retain=True) 94 | self.last = time.time() 95 | except modbus_tk.modbus.ModbusError as exc: 96 | logging.error("Error reading "+self.topic+": Slave returned %s - %s", exc, exc.get_exception_code()) 97 | except Exception as exc: 98 | logging.error("Error reading "+self.topic+": %s", exc) 99 | 100 | 101 | registers=[] 102 | 103 | # Now lets read the register definition 104 | with open(args.registers,"r") as csvfile: 105 | dialect=csv.Sniffer().sniff(csvfile.read(8192)) 106 | csvfile.seek(0) 107 | defaultrow={"Size":1,"Format":">H","Frequency":60,"Slave":1,"FunctionCode":4} 108 | reader=csv.DictReader(csvfile,fieldnames=["Topic","Register","Size","Format","Frequency","Slave","FunctionCode"],dialect=dialect) 109 | for row in reader: 110 | # Skip header row 111 | if row["Frequency"]=="Frequency": 112 | continue 113 | # Comment? 114 | if row["Topic"][0]=="#": 115 | continue 116 | if row["Topic"]=="DEFAULT": 117 | temp=dict((k,v) for k,v in row.iteritems() if v is not None and v!="") 118 | defaultrow.update(temp) 119 | continue 120 | freq=row["Frequency"] 121 | if freq is None or freq=="": 122 | freq=defaultrow["Frequency"] 123 | slave=row["Slave"] 124 | if slave is None or slave=="": 125 | slave=defaultrow["Slave"] 126 | fc=row["FunctionCode"] 127 | if fc is None or fc=="": 128 | fc=defaultrow["FunctionCode"] 129 | fmt=row["Format"] 130 | if fmt is None or fmt=="": 131 | fmt=defaultrow["Format"] 132 | size=row["Size"] 133 | if size is None or size=="": 134 | size=defaultrow["Size"] 135 | r=Register(row["Topic"],freq,slave,fc,row["Register"],size,fmt) 136 | registers.append(r) 137 | 138 | logging.info('Read %u valid register definitions from \"%s\"' %(len(registers), args.registers)) 139 | 140 | 141 | def messagehandler(mqc,userdata,msg): 142 | 143 | try: 144 | (prefix,function,slaveid,functioncode,register) = msg.topic.split("/") 145 | if function != 'set': 146 | return 147 | if int(slaveid) not in range(0,255): 148 | logging.warning("on message - invalid slaveid " + msg.topic) 149 | return 150 | 151 | if not (int(register) >= 0 and int(register) < sys.maxint): 152 | logging.warning("on message - invalid register " + msg.topic) 153 | return 154 | 155 | if functioncode == str(cst.WRITE_SINGLE_COIL): 156 | logging.info("Writing single coil " + register) 157 | elif functioncode == str(cst.WRITE_SINGLE_REGISTER): 158 | logging.info("Writing single register " + register) 159 | else: 160 | logging.error("Error attempting to write - invalid function code " + msg.topic) 161 | return 162 | 163 | res=master.execute(int(slaveid),int(functioncode),int(register),output_value=int(msg.payload)) 164 | 165 | except Exception as e: 166 | logging.error("Error on message " + msg.topic + " :" + str(e)) 167 | 168 | def connecthandler(mqc,userdata,rc): 169 | logging.info("Connected to MQTT broker with rc=%d" % (rc)) 170 | mqc.subscribe(topic+"set/+/"+str(cst.WRITE_SINGLE_REGISTER)+"/+") 171 | mqc.subscribe(topic+"set/+/"+str(cst.WRITE_SINGLE_COIL)+"/+") 172 | mqc.publish(topic+"connected",2,qos=1,retain=True) 173 | 174 | def disconnecthandler(mqc,userdata,rc): 175 | logging.warning("Disconnected from MQTT broker with rc=%d" % (rc)) 176 | 177 | try: 178 | clientid=args.clientid + "-" + str(time.time()) 179 | mqc=mqtt.Client(client_id=clientid) 180 | mqc.on_connect=connecthandler 181 | mqc.on_message=messagehandler 182 | mqc.on_disconnect=disconnecthandler 183 | mqc.will_set(topic+"connected",0,qos=2,retain=True) 184 | mqc.disconnected =True 185 | mqc.connect(args.mqtt_host,args.mqtt_port,60) 186 | mqc.loop_start() 187 | 188 | if args.rtu: 189 | master=modbus_rtu.RtuMaster(serial.serial_for_url(args.rtu,baudrate=args.rtu_baud,parity=args.rtu_parity[0].upper())) 190 | elif args.tcp: 191 | master=modbus_tcp.TcpMaster(args.tcp,args.tcp_port) 192 | else: 193 | logging.error("You must specify a modbus access method, either --rtu or --tcp") 194 | sys.exit(1) 195 | 196 | master.set_verbose(True) 197 | master.set_timeout(5.0) 198 | 199 | while True: 200 | for r in registers: 201 | r.checkpoll() 202 | time.sleep(1) 203 | 204 | except Exception as e: 205 | logging.error("Unhandled error [" + str(e) + "]") 206 | sys.exit(1) 207 | --------------------------------------------------------------------------------