rmodbus-ccutrer 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/NEWS.md +180 -0
- data/README.md +115 -0
- data/Rakefile +29 -0
- data/examples/perfomance_rtu.rb +56 -0
- data/examples/perfomance_rtu_via_tcp.rb +55 -0
- data/examples/perfomance_tcp.rb +55 -0
- data/examples/simple-xpca-gateway.rb +84 -0
- data/examples/use_rtu_via_tcp_modbus.rb +22 -0
- data/examples/use_tcp_modbus.rb +23 -0
- data/lib/rmodbus.rb +21 -0
- data/lib/rmodbus/client.rb +94 -0
- data/lib/rmodbus/client/slave.rb +345 -0
- data/lib/rmodbus/debug.rb +25 -0
- data/lib/rmodbus/errors.rb +42 -0
- data/lib/rmodbus/ext.rb +85 -0
- data/lib/rmodbus/options.rb +6 -0
- data/lib/rmodbus/proxy.rb +41 -0
- data/lib/rmodbus/rtu.rb +122 -0
- data/lib/rmodbus/rtu_client.rb +43 -0
- data/lib/rmodbus/rtu_server.rb +48 -0
- data/lib/rmodbus/rtu_slave.rb +48 -0
- data/lib/rmodbus/rtu_via_tcp_server.rb +35 -0
- data/lib/rmodbus/server.rb +246 -0
- data/lib/rmodbus/server/slave.rb +16 -0
- data/lib/rmodbus/sp.rb +36 -0
- data/lib/rmodbus/tcp.rb +31 -0
- data/lib/rmodbus/tcp_client.rb +25 -0
- data/lib/rmodbus/tcp_server.rb +67 -0
- data/lib/rmodbus/tcp_slave.rb +55 -0
- data/lib/rmodbus/version.rb +3 -0
- data/spec/client_spec.rb +88 -0
- data/spec/exception_spec.rb +120 -0
- data/spec/ext_spec.rb +52 -0
- data/spec/logging_spec.rb +89 -0
- data/spec/proxy_spec.rb +74 -0
- data/spec/read_rtu_response_spec.rb +92 -0
- data/spec/response_mismach_spec.rb +163 -0
- data/spec/rtu_client_spec.rb +86 -0
- data/spec/rtu_server_spec.rb +31 -0
- data/spec/rtu_via_tcp_client_spec.rb +76 -0
- data/spec/rtu_via_tcp_server_spec.rb +89 -0
- data/spec/slave_spec.rb +55 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/tcp_client_spec.rb +88 -0
- data/spec/tcp_server_spec.rb +158 -0
- metadata +206 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'time'
|
2
|
+
|
3
|
+
module ModBus
|
4
|
+
module Debug
|
5
|
+
attr_accessor :debug, :raise_exception_on_mismatch,
|
6
|
+
:read_retries, :read_retry_timeout
|
7
|
+
|
8
|
+
|
9
|
+
private
|
10
|
+
# Put log message on standard output
|
11
|
+
# @param [String] msg message for log
|
12
|
+
def log(msg)
|
13
|
+
$stdout.puts "#{Time.now.utc.iso8601(2)} #{msg}" if @debug
|
14
|
+
end
|
15
|
+
|
16
|
+
# Convert string of byte to string for log
|
17
|
+
# @example
|
18
|
+
# logging_bytes("\x1\xa\x8") => "[01][0a][08]"
|
19
|
+
# @param [String] msg input string
|
20
|
+
# @return [String] readable string of bytes
|
21
|
+
def logging_bytes(msg)
|
22
|
+
msg.unpack("H*").first.gsub(/\X{2}/, "[\\0]")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ModBus
|
2
|
+
module Errors
|
3
|
+
class ProxyException < StandardError
|
4
|
+
end
|
5
|
+
|
6
|
+
class ModBusException < StandardError
|
7
|
+
end
|
8
|
+
|
9
|
+
class IllegalFunction < ModBusException
|
10
|
+
end
|
11
|
+
|
12
|
+
class IllegalDataAddress < ModBusException
|
13
|
+
end
|
14
|
+
|
15
|
+
class IllegalDataValue < ModBusException
|
16
|
+
end
|
17
|
+
|
18
|
+
class SlaveDeviceFailure < ModBusException
|
19
|
+
end
|
20
|
+
|
21
|
+
class Acknowledge < ModBusException
|
22
|
+
end
|
23
|
+
|
24
|
+
class SlaveDeviceBus < ModBusException
|
25
|
+
end
|
26
|
+
|
27
|
+
class MemoryParityError < ModBusException
|
28
|
+
end
|
29
|
+
|
30
|
+
class ModBusTimeout < ModBusException
|
31
|
+
end
|
32
|
+
|
33
|
+
class ResponseMismatch < ModBusException
|
34
|
+
attr_reader :request, :response
|
35
|
+
def initialize(msg, request, response)
|
36
|
+
super(msg)
|
37
|
+
@request = request
|
38
|
+
@response = response
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/rmodbus/ext.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
class String
|
2
|
+
|
3
|
+
if RUBY_VERSION < "1.9"
|
4
|
+
def getbyte(index)
|
5
|
+
self[index].to_i
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def unpack_bits
|
10
|
+
array_bit = []
|
11
|
+
self.unpack('b*')[0].each_char do |c|
|
12
|
+
array_bit << c.to_i
|
13
|
+
end
|
14
|
+
array_bit
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get word by index
|
18
|
+
# @param [Integer] i index first bytes of word
|
19
|
+
# @return unpacked word
|
20
|
+
def getword(i)
|
21
|
+
self[i,2].unpack('n')[0]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Integer
|
26
|
+
|
27
|
+
# Shortcut or turning an integer into a word
|
28
|
+
def to_word
|
29
|
+
[self].pack('n')
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
class Array
|
35
|
+
|
36
|
+
# Given an array of 16bit Fixnum, we turn it into 32bit Int in big-endian order, halving the size
|
37
|
+
def to_32f
|
38
|
+
raise "Array requires an even number of elements to pack to 32bits: was #{self.size}" unless self.size.even?
|
39
|
+
self.each_slice(2).map { |(lsb, msb)| [msb, lsb].pack('n*').unpack('g')[0] }
|
40
|
+
end
|
41
|
+
|
42
|
+
# Given an array of 16bit Fixnum, we turn it into 32bit Int in little-endian order, halving the size
|
43
|
+
def to_32f_le
|
44
|
+
raise "Array requires an even number of elements to pack to 32bits: was #{self.size}" unless self.size.even?
|
45
|
+
self.each_slice(2).map { |(lsb, msb)| [lsb, msb].pack('n*').unpack('g')[0] }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Given an array of 32bit Floats, we turn it into an array of 16bit Fixnums, doubling the size
|
49
|
+
def from_32f
|
50
|
+
self.pack('g*').unpack('n*').each_slice(2).map { |arr| arr.reverse }.flatten
|
51
|
+
end
|
52
|
+
|
53
|
+
# Given an array of 16bit Fixnum, we turn it into 32bit Float in big-endian order, halving the size
|
54
|
+
def to_32i
|
55
|
+
raise "Array requires an even number of elements to pack to 32bits: was #{self.size}" unless self.size.even?
|
56
|
+
self.each_slice(2).map { |(lsb, msb)| [msb, lsb].pack('n*').unpack('N')[0] }
|
57
|
+
end
|
58
|
+
|
59
|
+
# Given an array of 32bit Fixnum, we turn it into an array of 16bit fixnums, doubling the size
|
60
|
+
def from_32i
|
61
|
+
self.pack('N*').unpack('n*').each_slice(2).map { |arr| arr.reverse }.flatten
|
62
|
+
end
|
63
|
+
|
64
|
+
def pack_to_word
|
65
|
+
word = 0
|
66
|
+
s = ""
|
67
|
+
mask = 0x01
|
68
|
+
|
69
|
+
self.each do |bit|
|
70
|
+
word |= mask if bit > 0
|
71
|
+
mask <<= 1
|
72
|
+
if mask == 0x100
|
73
|
+
mask = 0x01
|
74
|
+
s << word.chr
|
75
|
+
word = 0
|
76
|
+
end
|
77
|
+
end
|
78
|
+
unless mask == 0x01
|
79
|
+
s << word.chr
|
80
|
+
else
|
81
|
+
s
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module ModBus
|
2
|
+
# Given a slave and a type of operation, execute a single or multiple read using hash syntax
|
3
|
+
class ReadOnlyProxy
|
4
|
+
# Initialize a proxy for a slave and a type of operation
|
5
|
+
def initialize(slave, type)
|
6
|
+
@slave, @type = slave, type
|
7
|
+
end
|
8
|
+
|
9
|
+
# Read single or multiple values from a modbus slave depending on whether a Fixnum or a Range was given.
|
10
|
+
# Note that in the case of multiples, a pluralized version of the method is sent to the slave
|
11
|
+
def [](key)
|
12
|
+
if key.instance_of?(0.class)
|
13
|
+
@slave.send("read_#{@type}", key)
|
14
|
+
elsif key.instance_of?(Range)
|
15
|
+
@slave.send("read_#{@type}s", key.first, key.count)
|
16
|
+
else
|
17
|
+
raise ModBus::Errors::ProxyException, "Invalid argument, must be integer or range. Was #{key.class}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class ReadWriteProxy < ReadOnlyProxy
|
23
|
+
# Write single or multiple values to a modbus slave depending on whether a Fixnum or a Range was given.
|
24
|
+
# Note that in the case of multiples, a pluralized version of the method is sent to the slave. Also when
|
25
|
+
# writing multiple values, the number of elements must match the number of registers in the range or an exception is raised
|
26
|
+
def []=(key, val)
|
27
|
+
if key.instance_of?(0.class)
|
28
|
+
@slave.send("write_#{@type}", key, val)
|
29
|
+
elsif key.instance_of?(Range)
|
30
|
+
if key.count != val.size
|
31
|
+
raise ModBus::Errors::ProxyException, "The size of the range must match the size of the values (#{key.count} != #{val.size})"
|
32
|
+
end
|
33
|
+
|
34
|
+
@slave.send("write_#{@type}s", key.first, val)
|
35
|
+
else
|
36
|
+
raise ModBus::Errors::ProxyException, "Invalid argument, must be integer or range. Was #{key.class}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
data/lib/rmodbus/rtu.rb
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'digest/crc16_modbus'
|
2
|
+
require 'io/wait'
|
3
|
+
|
4
|
+
module ModBus
|
5
|
+
module RTU
|
6
|
+
private
|
7
|
+
|
8
|
+
# We have to read specific amounts of numbers of bytes from the network depending on the function code and content
|
9
|
+
def read_rtu_response(io)
|
10
|
+
# Read the slave_id and function code
|
11
|
+
msg = read(io, 2)
|
12
|
+
log logging_bytes(msg)
|
13
|
+
|
14
|
+
function_code = msg.getbyte(1)
|
15
|
+
case function_code
|
16
|
+
when 1,2,3,4 then
|
17
|
+
# read the third byte to find out how much more
|
18
|
+
# we need to read + CRC
|
19
|
+
msg += read(io, 1)
|
20
|
+
msg += read(io, msg.getbyte(2)+2)
|
21
|
+
when 5,6,15,16 then
|
22
|
+
# We just read in an additional 6 bytes
|
23
|
+
msg += read(io, 6)
|
24
|
+
when 22 then
|
25
|
+
msg += read(io, 8)
|
26
|
+
when 0x80..0xff then
|
27
|
+
msg += read(io, 3)
|
28
|
+
else
|
29
|
+
raise ModBus::Errors::IllegalFunction, "Illegal function: #{function_code}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def clean_input_buff
|
34
|
+
# empty the input buffer
|
35
|
+
if @io.class.public_method_defined? :flush_input
|
36
|
+
@io.flush_input
|
37
|
+
else
|
38
|
+
@io.flush
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def read(io, len)
|
43
|
+
result = ""
|
44
|
+
loop do
|
45
|
+
this_iter = io.read(len - result.length)
|
46
|
+
result.concat(this_iter) if this_iter
|
47
|
+
return result if result.length == len
|
48
|
+
io.wait_readable
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def read_rtu_request(io)
|
53
|
+
# Every message is a minimum of 4 bytes (slave id, function code, crc16)
|
54
|
+
msg = read(io, 4)
|
55
|
+
|
56
|
+
# If msg is nil, then our client never sent us anything and it's time to disconnect
|
57
|
+
return if msg.nil?
|
58
|
+
|
59
|
+
loop do
|
60
|
+
offset = 0
|
61
|
+
crc = msg[-2..-1].unpack("S<").first
|
62
|
+
|
63
|
+
# scan the bytestream for a valid CRC
|
64
|
+
loop do
|
65
|
+
break if offset >= msg.length - 3
|
66
|
+
calculated_crc = Digest::CRC16Modbus.checksum(msg[offset..-3])
|
67
|
+
if crc == calculated_crc
|
68
|
+
is_response = (msg.getbyte(offset + 1) & 0x80 == 0x80) ||
|
69
|
+
(msg.getbyte(offset) == @last_req_uid &&
|
70
|
+
msg.getbyte(offset + 1) == @last_req_func &&
|
71
|
+
@last_req_timestamp && Time.now.to_f - @last_req_timestamp < 5)
|
72
|
+
|
73
|
+
params = is_response ? parse_response(msg.getbyte(offset + 1), msg[(offset + 1)..-3]) :
|
74
|
+
parse_request(msg.getbyte(offset + 1), msg[(offset + 1)..-3])
|
75
|
+
|
76
|
+
unless params.nil?
|
77
|
+
if is_response
|
78
|
+
@last_req_uid = @last_req_func = @last_req_timestamp = nil
|
79
|
+
else
|
80
|
+
@last_req_uid = msg.getbyte(offset)
|
81
|
+
@last_req_func = msg.getbyte(offset + 1)
|
82
|
+
@last_req_timestamp = Time.now.to_f
|
83
|
+
end
|
84
|
+
log "Server RX discarding #{offset} bytes: #{logging_bytes(msg[0...offset])}" if offset != 0
|
85
|
+
log "Server RX (#{msg.size - offset} bytes): #{logging_bytes(msg[offset..-1])}"
|
86
|
+
return [msg.getbyte(offset), msg.getbyte(offset + 1), params, msg[offset + 1..-3], is_response]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
offset += 1
|
90
|
+
end
|
91
|
+
|
92
|
+
msg.concat(read(io, 1))
|
93
|
+
# maximum message size is 256, so that's as far as we have to
|
94
|
+
# be able to see at once
|
95
|
+
msg = msg[1..-1] if msg.length > 256
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def serve(io)
|
100
|
+
loop do
|
101
|
+
# read the RTU message
|
102
|
+
uid, func, params, pdu, is_response = read_rtu_request(io)
|
103
|
+
|
104
|
+
next if uid.nil?
|
105
|
+
|
106
|
+
pdu = exec_req(uid, func, params, pdu, is_response: is_response)
|
107
|
+
next unless pdu
|
108
|
+
|
109
|
+
@last_req_uid = @last_req_func = @last_req_timestamp = nil
|
110
|
+
resp = uid.chr + pdu
|
111
|
+
resp << [crc16(resp)].pack("S<")
|
112
|
+
log "Server TX (#{resp.size} bytes): #{logging_bytes(resp)}"
|
113
|
+
io.write resp
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Calc CRC16 for massage
|
118
|
+
def crc16(msg)
|
119
|
+
Digest::CRC16Modbus.checksum(msg)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module ModBus
|
2
|
+
# RTU client implementation
|
3
|
+
# @example
|
4
|
+
# RTUClient.connect('/dev/ttyS1', 9600) do |cl|
|
5
|
+
# cl.with_slave(uid) do |slave|
|
6
|
+
# slave.holding_registers[0..100]
|
7
|
+
# end
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# @example
|
11
|
+
# RTUClient.connect('127.0.0.1', 10002) do |cl|
|
12
|
+
# cl.with_slave(uid) do |slave|
|
13
|
+
# slave.holding_registers[0..100]
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @see TCP#open_tcp_connection
|
18
|
+
# @see SP#open_serial_port
|
19
|
+
# @see Client#initialize
|
20
|
+
class RTUClient < Client
|
21
|
+
include RTU
|
22
|
+
include SP
|
23
|
+
include TCP
|
24
|
+
|
25
|
+
protected
|
26
|
+
# Open serial port
|
27
|
+
def open_connection(port_or_ipaddr, arg = nil, opts = {})
|
28
|
+
if port_or_ipaddr.is_a?(IO) || port_or_ipaddr.respond_to?(:read)
|
29
|
+
port_or_ipaddr
|
30
|
+
elsif File.exist?(port_or_ipaddr) || port_or_ipaddr.start_with?('/dev') || port_or_ipaddr.start_with?('COM')
|
31
|
+
arg ||= 9600
|
32
|
+
open_serial_port(port_or_ipaddr, arg, opts)
|
33
|
+
else
|
34
|
+
arg ||= 10002
|
35
|
+
open_tcp_connection(port_or_ipaddr, arg, opts)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_slave(uid, io)
|
40
|
+
RTUSlave.new(uid, io)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module ModBus
|
2
|
+
# RTU server implementation
|
3
|
+
# @example
|
4
|
+
# srv = RTUServer.new('/dev/ttyS1', 9600)
|
5
|
+
# slave = srv.with_slave(1)
|
6
|
+
# slave.coils = [1,0,1,1]
|
7
|
+
# slave.discrete_inputs = [1,1,0,0]
|
8
|
+
# slave.holding_registers = [1,2,3,4]
|
9
|
+
# slave.input_registers = [1,2,3,4]
|
10
|
+
# srv.debug = true
|
11
|
+
# srv.start
|
12
|
+
class RTUServer
|
13
|
+
include Debug
|
14
|
+
include Server
|
15
|
+
include RTU
|
16
|
+
include SP
|
17
|
+
|
18
|
+
# Init RTU server
|
19
|
+
# @param [Integer] uid slave device
|
20
|
+
# @see SP#open_serial_port
|
21
|
+
def initialize(port, baud=9600, opts = {})
|
22
|
+
Thread.abort_on_exception = true
|
23
|
+
if port.is_a?(IO) || port.respond_to?(:read)
|
24
|
+
@sp = port
|
25
|
+
else
|
26
|
+
@sp = open_serial_port(port, baud, opts)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Start server
|
31
|
+
def start
|
32
|
+
@serv = Thread.new do
|
33
|
+
serve(@sp)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Stop server
|
38
|
+
def stop
|
39
|
+
Thread.kill(@serv)
|
40
|
+
@sp.close
|
41
|
+
end
|
42
|
+
|
43
|
+
# Join server
|
44
|
+
def join
|
45
|
+
@serv.join
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module ModBus
|
2
|
+
# RTU slave implementation
|
3
|
+
# @example
|
4
|
+
# RTUClient.connect(port, baud, opts) do |cl|
|
5
|
+
# cl.with_slave(uid) do |slave|
|
6
|
+
# slave.holding_registers[0..100]
|
7
|
+
# end
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# @see RTUClient#open_connection
|
11
|
+
# @see Client#with_slave
|
12
|
+
# @see Slave
|
13
|
+
class RTUSlave < Client::Slave
|
14
|
+
include RTU
|
15
|
+
|
16
|
+
private
|
17
|
+
# overide method for RTU implamentaion
|
18
|
+
# @see Slave#query
|
19
|
+
def send_pdu(pdu)
|
20
|
+
msg = @uid.chr + pdu
|
21
|
+
msg << [crc16(msg)].pack("S<")
|
22
|
+
|
23
|
+
clean_input_buff
|
24
|
+
@io.write msg
|
25
|
+
|
26
|
+
log "Tx (#{msg.size} bytes): " + logging_bytes(msg)
|
27
|
+
end
|
28
|
+
|
29
|
+
# overide method for RTU implamentaion
|
30
|
+
# @see Slave#query
|
31
|
+
def read_pdu
|
32
|
+
msg = read_rtu_response(@io)
|
33
|
+
|
34
|
+
log "Rx (#{msg.size} bytes): " + logging_bytes(msg)
|
35
|
+
|
36
|
+
if msg.getbyte(0) == @uid
|
37
|
+
return msg[1..-3] if msg[-2,2].unpack('S<')[0] == crc16(msg[0..-3])
|
38
|
+
log "Ignore package: don't match CRC"
|
39
|
+
else
|
40
|
+
log "Ignore package: don't match uid ID"
|
41
|
+
end
|
42
|
+
loop do
|
43
|
+
#waite timeout
|
44
|
+
sleep(0.1)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|