Here's a tiny Modbus master in Ruby that supports ASCII and RTU modes for all function (and subfunction) codes defined in the MODBUS Application Protocol Specification V1.1b. (Joachim Wuttke has published a similar version that handles TCP.) Serial port handling in Ruby is provided by Guillaume Pierronnet's Ruby/SerialPort code.
Basic usage is straightforward:
# Create the object and attach it to COM4 on Windows (for example) using default # baud rate, stop bits, and parity. mb = Modbus.new 3 # Send some simple commands to slave id 39. mb.diagClear 39 # clear the slave's diagnostic counters (§6.8.1.10) coils = mb.readCoils 39, 2, 16 # read 16 coils starting at coil 3 (§6.1) mb.writeRegister 39, 5, 0xde # write the value 222 to register 6 (§6.6) regs = mb.readRegisters 39, 12, 3 # read registers 13-15 (§6.3) mb.diagRestartComm 39, false # restart the slave's comm system, but don't clear its log (§6.8.1.01) log = mb.getEventLog 39 # retrieve the slave's raw event log data (§6.10) mb.showEvents log # pretty print the events # Read records from three separate files on slave 17 (§6.14). block_1 = [1, 12, 80] # bytes 13-92 from file 1 block_2 = [14, 2087, 12] # bytes 2086-2097 from file 14 block_3 = [2, 47, 3] # bytes 46-48 from file 2 recs = mb.readFileRecords 17, [block_1, block_2, block_3] ...etc...
This code was originally designed for interactive use via irb -r modbus.rb, but it could be handy in other contexts, of course. I/O calls block—command methods don't return until a device responds—which is inconvenient if you want to send a broadcast message, since no devices respond to them by design. rx() may be modified to change this behavior, but only if SerialPort is recompiled for asynchronous I/O. Otherwise, Ruby's green threads will always interfere (native threads are promised in future Ruby interpreters).
Anyway, here's the code, conveniently free of any useful comments or documentation. Enjoy.
#!/usr/bin/ruby ## --------------------------------------------------------------------------- ## ## Modbus Master in Ruby ## Copyright (C) 2006-8 Peter Heinrich ## ## 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 <http://www.gnu.org/licenses/>. ## ## $URL: svn://saphum.com/PIC/modbus/trunk/modbus.rb $ ## $Revision: 250 $ ## ## This file defines a tiny Modbus master (client) suitable for testing and ## debugging of the PIC Modbus slave (server) library. This master is de- ## signed for interactive use in irb, exposing specific Modbus send/receive ## actions as public methods. ## ## --------------------------------------------------------------------------- ## $Author: peter $ ## $Date: 2007-12-10 08:44:57 +0000 (Mon, 10 Dec 2007) $ ## --------------------------------------------------------------------------- require "serialport" DEF_BAUD = 19200 DEF_DATABITS = 8 DEF_STOPBITS = 1 DEF_PARITY = SerialPort::EVEN class Integer def to_word return (self >> 8).chr + (0xff & self).chr end def to_long return (self >> 16).to_word + (0xffff & self).to_word end end class Modbus @@errors = { 1 => "Illegal Function", 2 => "Illegal Data Address", 3 => "Illegal Data Value", 4 => "Slave Device Failure", 5 => "Acknowledge", 6 => "Slave Device Busy", 8 => "Memory Parity Error", 10 => "Gateway Path Unavailable" } def initialize( port = 1, rtu = true, baud = DEF_BAUD, stopbits = DEF_STOPBITS, parity = DEF_PARITY ) $databits = rtu ? 8 : 7 $parity = parity begin @sp = SerialPort.open( port, baud, $databits, stopbits, $parity ); rescue StandardError => bang puts "Couldn't initialize serial port (#{bang})." end end def ascii! $databits = 7 end def rtu! $databits = 8 end def is_rtu? 8 == $databits end def setParity( parity ) $parity = parity end def is_error?( pdu ) if 0 != (0x80 & pdu[ 0 ]) puts "Error: #{@@errors[ pdu[ 1 ] ]}" return true end return false end def crc( pdu ) sum = 0xffff pdu.each_byte do |b| sum ^= b 8.times do carry = (1 == 1 & sum) sum = 0x7fff & (sum >> 1) sum ^= 0xa001 if carry end end sum end def lrc( pdu ) 0xff & -pdu.sum( 128 ) end def tx( slave, pdu ) if is_rtu? adu = slave.chr adu += pdu sum = crc( adu ) adu += (0xff & sum).chr + (sum >> 8).chr else adu = ':' pdu = slave.chr + pdu pdu.each_byte { |b| adu += "%02x" % b } adu += "%02x\r\n" % lrc( pdu ) end @sp.puts( adu ) if @sp end def rx adu = @sp.gets if @sp puts "Receiving \"#{adu}\"" if is_rtu? slave = adu[ 0 ] pdu = adu[ 1..-3 ] sum = crc( adu[ 0..-3 ] ) if sum != adu[ -2 ] + (adu[ -1 ] << 8) puts( "CRC incorrect! (Calculated 0x%04x)" % sum ) end else data = adu[ 1..-3 ] pdu = "" (0...data.length).step( 2 ) { |i| pdu << data[ i..i+1 ].hex.chr } sum = lrc( pdu[ 0..-2 ] ) if sum != pdu[ -1 ] puts( "LRC incorrect! (Calculated 0x%02x, found 0x%02x)" % [sum, pdu[ -1 ]] ) end slave = pdu[ 0 ] pdu = pdu[ 1..-2 ] end return slave, pdu end def showEvents( log ) log.each_with_index do |e, i| if 0 == e puts "%2d Communication Restart" % i elsif 4 == e puts "%2d Entering Listen Only Mode" % i elsif 0 != (0x80 & e) print "%2d Message Received: " % i print "Broadcast/ " if 0 != (0x40 & e) print "Listen-only/ " if 0 != (0x20 & e) print "Overrun/ " if 0 != (0x10 & e) print "Checksum/" if 0 != (0x02 & e) puts else print "%2d Message Sent: " % i print "Listen-only/ " if 0 != (0x20 & e) print "Timeout/ " if 0 != (0x10 & e) print "NAK err/ " if 0 != (0x08 & e) print "Busy err/ " if 0 != (0x04 & e) print "Abort err/ " if 0 != (0x02 & e) print "Read err/" if 0 != (0x01 & e) puts end end end def diagClear( slave ) tx( slave, 8.chr + 10.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagClear]" end end def diagClearOverrun( slave ) tx( slave, 8.chr + 20.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagClearOverrun]" end end def diagGetBusyCount( slave ) tx( slave, 8.chr + 17.to_word ) slave, pdu = rx() unless is_error?( pdu ) busy = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetBusyCount]:" puts " busy: #{busy}" busy end end def diagGetErrorCount( slave ) tx( slave, 8.chr + 12.to_word ) slave, pdu = rx() unless is_error?( pdu ) errors = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetErrorCount]:" puts " bus errors: #{errors}" errors end end def diagGetExceptCount( slave ) tx( slave, 8.chr + 13.to_word ) slave, pdu = rx() unless is_error?( pdu ) errors = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetExceptCount]" puts " exceptions: #{errors}" errors end end def diagGetMsgCount( slave ) tx( slave, 8.chr + 11.to_word ) slave, pdu = rx() unless is_error?( pdu ) messages = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetMsgCount]" puts " messages: #{messages}" messages end end def diagGetNAKCount( slave ) tx( slave, 8.chr + 16.to_word ) slave, pdu = rx() unless is_error?( pdu ) naks = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetNAKCount]" puts " NAKs: #{naks}" naks end end def diagGetNoRespCount( slave ) tx( slave, 8.chr + 15.to_word ) slave, pdu = rx() unless is_error?( pdu ) noResp = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetNoRespCount]" puts " no response: #{noResp}" noResp end end def diagGetOverrunCount( slave ) tx( slave, 8.chr + 18.to_word ) slave, pdu = rx() unless is_error?( pdu ) overruns = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetOverrunCount]" puts " overruns: #{overruns}" overruns end end def diagGetRegister( slave ) tx( slave, 8.chr + 2.to_word ) slave, pdu = rx() unless is_error?( pdu ) register = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetRegister]" puts " register: #{register}" end end def diagGetSlaveMsgCount( slave ) tx( slave, 8.chr + 14.to_word ) slave, pdu = rx() unless is_error?( pdu ) messages = pdu[ 3, 2 ].unpack( "n" )[ 0 ] puts "Slave #{slave} [diagGetSlaveMsgCount]" puts " messages: #{messages}" end end def diagRestartComm( slave, clearLog = false ) tx( slave, 8.chr + 1.to_word + (clearLog ? 0 : 0xff00).to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagRestartComm]:" puts " log: #{clearLog ? "cleared" : "preserved"}" end end def diagReturnQuery( slave, data ) tx( slave, 8.chr + 0.to_word + data.pack( "c*" ) ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagReturnQuery]" pdu[ 3..-1 ].unpack( "c*" ) end end def diagSetDelim( slave, delim ) tx( slave, 8.chr + 3.to_word + delim + 0.chr ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagSetDelim]:" puts " delimiter: \"" + delim + "\"" end end def diagSetListenOnly( slave ) tx( slave, 8.chr + 4.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [diagSetListenOnly]" end end def encapCANopen( slave ) puts "Application-dependent; implement as appropriate" end def encapGetDeviceId( slave, idCode, objectId, array = nil ) tx( slave, 43.chr + 14.chr + idCode.chr + objectId.chr ) slave, pdu = rx() unless is_error?( pdu ) conformity = pdu[ 3 ] moreFollows = (0 != pdu[ 4 ]) nextObjectId = pdu[ 5 ] objectCount = pdu[ 6 ] continuation = !array.nil? array = [] if array.nil? list = pdu[ 7..-1 ] while 0 < list.length do length = list[ 1 ] array << [ list[ 0 ], list[ 2, length ] ] list = list[ 2+length..-1 ] end encapGetDeviceId( slave, idCode, nextObjectId, array ) if moreFollows if !continuation puts "Slave #{slave} [encapGetDeviceId]" puts " conformity : 0x%02x" % conformity puts " object count: #{array.length}" array.each {|o| puts " Object #{o[ 0 ]}: #{o[ 1 ]}" } end array end end def getEventCount( slave ) tx( slave, 11.chr ) slave, pdu = rx() unless is_error?( pdu ) status, events = pdu[1, 4].unpack( "n2" ) puts "Slave #{slave} [getEventCount]:" puts " status: #{0 == status ? "READY" : "BUSY"}" puts " events: #{events}" end end def getEventLog( slave ) tx( slave, 12.chr ) slave, pdu = rx() unless is_error?( pdu ) status, events, messages = pdu[2, 6].unpack( "n3" ) log = pdu[8..-1].unpack( "c*" ) puts "Slave #{slave} [getEventLog]:" puts " status: #{0 == status ? "READY" : "BUSY"}" puts " events: #{events}" puts " messages: #{messages}" log end end def getExceptions( slave ) tx( slave, 7.chr ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [getExceptions]:" 8.times { |i| puts " exception ##{i}: #{0 == (1 << i) & pdu[ 1 ] ? "NO" : "YES"}" } end end def getSlaveId( slave ) tx( slave, 17.chr ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [getSlaveId]" pdu[ 2..-1 ] end end def readCoils( slave, address, count ) tx( slave, 1.chr + address.to_word + count.to_word ) slave, pdu = rx() unless is_error?( pdu ) coils = [] pdu[ 2..-1 ].unpack( "b*" ).join.each_byte { |b| coils << b - ?0 } puts "Slave #{slave} [readCoils]" coils[ 0, count ] end end def readDiscretes( slave, address, count ) tx( slave, 2.chr + address.to_word + count.to_word ) slave, pdu = rx() unless is_error?( pdu ) discretes = [] pdu[ 2..-1 ].unpack( "b*" ).join.each_byte { |b| discretes << b - ?0 } puts "Slave #{slave} [readDiscretes]" discretes[ 0, count ] end end def readFIFOQueue( slave, queue ) tx( slave, 24.chr + queue.to_word ) slave, pdu = rx() unless is_error?( pdu ) queue = pdu[ 5..-1 ].unpack( "n*" ) puts "Slave #{slave} [readFIFOQueue]" queue end end # [[file1, start1, count1], [file2, start2, count2], ... ] def readFileRecords( slave, subreqs ) pdu = "" subreqs.each { |sr| pdu << 6.chr << sr.pack( "nnn" ) } tx( slave, 20.chr + pdu.length.chr + pdu ) slave, pdu = rx() unless is_error?( pdu ) records = [] offset = 2 while offset < pdu.length - 3 do records << pdu[ 2 + offset, pdu[ offset ] - 1 ].unpack( "n*" ) offset += pdu[ offset ] + 1 end puts "Slave #{slave} [readFileRecord]:" puts " records: #{records.length}" puts " total: #{pdu[ 1 ]} bytes" records end end def readInputs( slave, address, count ) tx( slave, 4.chr + address.to_word + count.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [readInputs]" inputs = pdu[ 2..-1 ].unpack( "n*" ) end end def readRegisters( slave, address, count ) tx( slave, 3.chr + address.to_word + count.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [readRegisters]" registers = pdu[ 2..-1 ].unpack( "n*" ) end end def readWriteRegs( slave, readAddr, count, writeAddr, values ) length = values.length tx( slave, 23.chr + readAddr.to_word + count.to_word + writeAddr.to_word + length.to_word + (length << 1).chr + values.pack( "n*" ) ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [readWriteRegs]" inputs = pdu[ 2..-1 ].unpack( "n*" ) end end def writeCoil( slave, address, value ) tx( slave, 5.chr + address.to_word + (0 == value ? 0 : 0xff00).to_word ) slave, pdu = rx() unless is_error?( pdu ) value = pdu[ 3, 2 ].unpack( "n" ) puts "Slave #{slave} [writeCoil]:" puts " coil #{address}: #{0 == value[ 0 ] ? "RESET" : "SET"}" value end end def writeCoils( slave, address, values ) count = values.length pdu = "" 0.step( count, 8 ) { |i| pdu << values[ i...i+8 ].join.reverse.to_i( 2 ).chr } tx( slave, 15.chr + address.to_word + count.to_word + pdu.length.chr + pdu ) slave, pdu = rx() unless is_error?( pdu ) count = pdu[ 3, 2 ].unpack( "n" ) puts "Slave #{slave} [writeCoils]:" puts " #{count} coil(s) written" end end # [[file1, start1, [data1]], [file2, start2, [data2]], ... ] def writeFileRecord( slave, subreqs ) pdu = "" subreqs.each { |sr| pdu << 6.chr << sr.pack( "nn" ) << sr[ 2 ].pack( "n*" ) } tx( slave, 21.chr + pdu.length.chr + pdu ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [writeFileRecord]:" puts " records: #{subreqs.length}" puts " total: #{pdu[ 1 ]} bytes" end end def writeRegister( slave, address, value ) tx( slave, 6.chr + address.to_word + value.to_word ) slave, pdu = rx() unless is_error?( pdu ) value = pdu[ 3, 2 ].unpack( "n" ) puts "Slave #{slave} [writeRegister]:" puts " register #{address}: 0x%04x" % value value end end def writeRegisters( slave, address, values ) length = values.length tx( slave, 16.chr + address.to_word + length.to_word + (length << 1).chr + values.pack( "n*" ) ) slave, pdu = rx() unless is_error?( pdu ) count = pdu[ 3, 2 ].unpack( "n" ) puts "Slave #{slave} [writeRegisters]:" puts " #{count} register(s) written" end end def writeRegMask( slave, address, andMask, orMask ) tx( slave, 22.chr + address.to_word + andMask.to_word + orMask.to_word ) slave, pdu = rx() unless is_error?( pdu ) puts "Slave #{slave} [writeRegMask]" end end end
Comments
Nice work. You left the much
Nice work.
You left the much simpler problem of writing a ruby master for modbus over ethernet/tcp as an exercise to the reader.
Actually, a solution has been published quite a while ago: http://www.messen-und-deuten.de/modbus.html
Kind regards - Joachim
Thanks, Joachim. Perhaps we
Thanks, Joachim. Perhaps we should combine both versions into a single gem. ;)
Doh! Just discovered that
Doh! Just discovered that Modbus in ASCII mode expects the LRC to be calculated on data bytes before two-byte encoding. This seems broken to me, since I think of a message checksum as a sum of the actual bytes in the message, not bytes somehow extracted from the message (via decoding or decryption or whatever). It means that the transport agent must know the transformation necessary to produce real message bytes. In the Modbus case, maybe this isn't a big deal (since the transport agent is usually the recipient, too, and would extract the bytes anyway), but it's not a very clean design.
I updated the code above with the relevant fixes to rx() and tx().