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 [1]. (Joachim Wuttke has published a similar version that handles TCP [2].) Serial port handling in Ruby is provided by Guillaume Pierronnet's Ruby/SerialPort [3] 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
Links:
[1] http://www.modbus.org/docs/Modbus_Application_Protocol_V1_1b.pdf
[2] http://www.messen-und-deuten.de/modbus.html
[3] http://ruby-serialport.rubyforge.org/
[4] http://saphum.com/category/projects/modbus
[5] http://saphum.com/category/software/languages/ruby