rmodbus-ccutrer 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|