Tiny Modbus Master in Ruby

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().