Tiny Modbus Master in Ruby
Here’s a tiny Modbus master in Ruby that supports ASCII and RTU1 modes for all function (and subfunction) codes defined in the MODBUS Application Protocol Specification V1.1b.
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
-
Joachim Wuttke has published a similar version that handles TCP ⤴