modbus 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5559848be6158b9fdf9c0d2a8d030b08769f181510cfba3a59f90a7a8af9e723
4
+ data.tar.gz: aade6afbf693c033ccb3bd4b021592d0dc09ceff7247ce4a9f66de3491e0bba2
5
+ SHA512:
6
+ metadata.gz: 5b2ff3ee1a9dfcc6ce804879ec11158db23c1581ce4e9134d439fbc1035e7d8b150cd65a291d91dac3a6a2e150459768dcf83a10967f6469eb0c07a3fd55c11a
7
+ data.tar.gz: f2409419ec8923a69dccc82e6d340138e320126befa6f7f9add418419a24aa376ce1147f0b33d4f8f3e7451e905b9ea226b1a21f655628b1c0d8703f97510d5b
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Ruby Modbus
2
+
3
+ Constructs [modbus standard](http://www.modbus.org/specs.php) datagrams that make it easy to communicate with devices on modbus networks.
4
+ It does not implement the transport layer so you can use it with native ruby, eventmachine, celluloid or the like.
5
+
6
+ [![Build Status](https://travis-ci.org/acaprojects/ruby-modbus.svg?branch=master)](https://travis-ci.org/acaprojects/ruby-modbus)
7
+
8
+ You'll need a gateway that supports TCP/IP.
9
+
10
+
11
+ ## Install the gem
12
+
13
+ Install it with [RubyGems](https://rubygems.org/)
14
+
15
+ gem install modbus
16
+
17
+ or add this to your Gemfile if you use [Bundler](http://gembundler.com/):
18
+
19
+ gem 'modbus'
20
+
21
+
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ require 'modbus'
27
+
28
+ modbus = Modbus.new
29
+
30
+ # Reading input obtained from the network
31
+ # The class instance performs buffering and yields complete ADUs
32
+ modbus.read(byte_string) do |adu|
33
+ adu.header.transaction_identifier # => 32
34
+
35
+ # Response PDU returned
36
+ if adu.exception?
37
+ puts adu.pdu.exception_code
38
+ else
39
+ case adu.function_code
40
+ when 0x01
41
+ puts adu.pdu.read.data.bytes
42
+ end
43
+ end
44
+ end
45
+
46
+
47
+ # You can generate requests like so (writing multipe coils starting from 123)
48
+ request = modbus.write_coils(123, true, true, false)
49
+ byte_string = request.to_binary_s
50
+
51
+ # Read 4 coils starting from address 123
52
+ request = modbus.read_coils(123, 4)
53
+ byte_string = request.to_binary_s
54
+
55
+ # Send byte_string to the modbus gateway to execute the request
56
+
57
+ ```
58
+
59
+
60
+ ## License and copyright
61
+
62
+ MIT
data/lib/modbus.rb ADDED
@@ -0,0 +1,191 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ require 'bindata'
5
+
6
+ require 'modbus/tcp_header'
7
+ require 'modbus/request'
8
+ require 'modbus/response'
9
+ require 'modbus/exception'
10
+ require 'modbus/adu'
11
+
12
+ class Modbus
13
+ CODES = {
14
+ read_coils: 0x01,
15
+ read_inputs: 0x02,
16
+ read_holding_registers: 0x03,
17
+ read_input_registers: 0x04,
18
+ write_coil: 0x05,
19
+ write_register: 0x06,
20
+ write_multiple_coils: 0x0F,
21
+ write_multiple_registers: 0x10,
22
+
23
+ # Serial only
24
+ read_exception_status: 0x07,
25
+ diagnostics: 0x08,
26
+ get_event_counter: 0x0B,
27
+ get_event_log: 0x0C,
28
+ get_server_id: 0x11,
29
+
30
+ # unsupported
31
+ read_file_record: 0x14,
32
+ write_file_record: 0x15,
33
+ mask_write_register: 0x16,
34
+ read_write_registers: 0x17,
35
+ read_fifo_queue: 0x17,
36
+ encapsulated_interface_transport: 0x2B,
37
+ canopen_general_request: 0x0D,
38
+ read_device_identification: 0x0E
39
+ }
40
+ CODES.merge!(CODES.invert)
41
+ CODES.freeze
42
+
43
+ def initialize
44
+ @transaction = 0
45
+ end
46
+
47
+ # Decodes an ADU from wire format and sets the attributes of this object.
48
+ #
49
+ # @param data [String] The bytes to decode.
50
+ def read(data)
51
+ @buffer ||= String.new
52
+ @buffer << data
53
+
54
+ error = nil
55
+
56
+ loop do
57
+ # not enough data in buffer to know the length
58
+ break if @buffer.bytesize < 6
59
+
60
+ header = TCPHeader.new
61
+ header.read(@buffer[0..5])
62
+
63
+ # the headers unit identifier is included in the length
64
+ total_length = header.request_length + 6
65
+
66
+ # Extract just the request from the buffer
67
+ break if @buffer.bytesize < total_length
68
+ request = @buffer.slice!(0...total_length)
69
+ function_code = request.getbyte(7)
70
+
71
+ # Yield the complete responses
72
+ begin
73
+ if function_code <= 0x80
74
+ response = ResponsePDU.new
75
+ response.read(request[6..-1])
76
+ else # Error response
77
+ response = ExceptionPDU.new
78
+ response.read(request[6..-1])
79
+ function_code = function_code - 0x80
80
+ end
81
+
82
+ yield ADU.new header, function_code, response
83
+ rescue => e
84
+ error = e
85
+ end
86
+ end
87
+
88
+ raise error if error
89
+ end
90
+
91
+ [:read_coils, :read_inputs, :read_holding_registers, :read_input_registers].each do |function|
92
+ define_method function do |address, count = 1|
93
+ adu = request_adu function
94
+ request = adu.pdu
95
+ request.get.address = address.to_i
96
+ request.get.quantity = count.to_i
97
+ adu
98
+ end
99
+ end
100
+
101
+ def write_coils(address, *values)
102
+ values = values.flatten
103
+
104
+ if values.length > 1
105
+ write_multiple_coils(address, *values)
106
+ else
107
+ adu = request_adu :write_coil
108
+ request = adu.pdu
109
+ request.put.address = address.to_i
110
+ request.put.data = values.first ? 0xFF00 : 0x0
111
+ adu
112
+ end
113
+ end
114
+
115
+ def write_registers(address, *values)
116
+ values = values.flatten.map! { |value| value.to_i }
117
+
118
+ if values.length > 1
119
+ adu = request_adu :write_multiple_registers
120
+ request = adu.pdu
121
+ request.put_multiple.address = address.to_i
122
+ request.put_multiple.quantity = values.length
123
+ request.put_multiple.data = values.pack('n*')
124
+ adu
125
+ else
126
+ adu = request_adu :write_register
127
+ request = adu.pdu
128
+ request.put.address = address.to_i
129
+ request.put.data = values.first
130
+ adu
131
+ end
132
+ end
133
+
134
+ protected
135
+
136
+ def write_multiple_coils(address, *values)
137
+ size = values.length
138
+
139
+ bytes = []
140
+ byte = 0
141
+ bit = 0
142
+ loop do
143
+ value = values.shift
144
+ if value
145
+ byte = byte | (1 << bit)
146
+ end
147
+ break if values.empty?
148
+ bit += 1
149
+ if bit >= 8
150
+ bytes << byte
151
+ byte = 0
152
+ bit = 0
153
+ end
154
+ end
155
+ bytes << byte
156
+
157
+ adu = request_adu :write_multiple_coils
158
+ request = adu.pdu
159
+ request.put_multiple.address = address.to_i
160
+ request.put_multiple.quantity = size
161
+ request.put_multiple.data = bytes.pack('C*')
162
+ adu
163
+ end
164
+
165
+ def next_id
166
+ id = @transaction
167
+ @transaction += 1
168
+ @transaction = 0 if @transaction > 0xFFFF
169
+ id
170
+ end
171
+
172
+ def new_header
173
+ header = TCPHeader.new
174
+ header.transaction_identifier = next_id
175
+ header.protocol_identifier = 0
176
+ header
177
+ end
178
+
179
+ def request_pdu(code)
180
+ request = RequestPDU.new
181
+ # This seems to be the default
182
+ request.unit_identifier = 0xFF
183
+ request.function_code = code
184
+ request
185
+ end
186
+
187
+ def request_adu(function)
188
+ code = CODES[function]
189
+ ADU.new(new_header, code, request_pdu(code))
190
+ end
191
+ end
data/lib/modbus/adu.rb ADDED
@@ -0,0 +1,48 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ class Modbus
5
+ # TCP ADU are sent via TCP to registered port 502
6
+ Modbus::ADU = Struct.new :header, :function_code, :pdu do
7
+ def exception?
8
+ pdu.is_a?(ExceptionPDU)
9
+ end
10
+
11
+ def to_binary_s
12
+ data = pdu.to_binary_s
13
+ header.request_length = data.bytesize
14
+ "#{header.to_binary_s}#{data}"
15
+ end
16
+
17
+ def function_name
18
+ CODES[function_code]
19
+ end
20
+
21
+ def value
22
+ return EXCEPTIONS[pdu.exception_code] || "unknown error 0x#{pdu.exception_code.to_s(16)}" if exception?
23
+ return nil unless pdu.is_a?(ResponsePDU) && READ_CODES.include?(function_code)
24
+
25
+ bytes = pdu.get.data_length
26
+ bin_str = pdu.get.data
27
+
28
+ case function_name
29
+ when :read_coils, :read_inputs
30
+ values = []
31
+
32
+ # extract the bits and return an array of true / false values
33
+ bin_str.each_byte do |byte|
34
+ bit = 0
35
+ loop do
36
+ values << ((byte & (1 << bit)) > 0)
37
+ bit += 1
38
+ break if bit >= 8
39
+ end
40
+ end
41
+ values
42
+ when :read_holding_registers, :read_input_registers
43
+ # these are all 16bit integers
44
+ bin_str.unpack('n*')
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ class Modbus
5
+ EXCEPTIONS = {
6
+ 0x01 => 'illegal function',
7
+ 0x02 => 'illegal data address',
8
+ 0x03 => 'illegal data value',
9
+ 0x04 => 'server device failure',
10
+ 0x05 => 'acknowledge', # processing will take some time, no need to retry
11
+ 0x06 => 'server device busy',
12
+ 0x08 => 'memory parity error',
13
+ 0x0A => 'gateway path unavailable',
14
+ 0x0B => 'gateway device failed to respond'
15
+ }
16
+
17
+ class ExceptionPDU < BinData::Record
18
+ endian :big
19
+
20
+ uint8 :unit_identifier
21
+ uint8 :function_code
22
+ uint8 :exception_code
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ class Modbus
5
+ READ_CODES = [0x01, 0x02, 0x03, 0x04].freeze
6
+ WRITE_CODES = [0x05, 0x06].freeze
7
+ MULTIPLE_CODES = [0x0F, 0x10].freeze
8
+
9
+ class RequestPDU < BinData::Record
10
+ endian :big
11
+
12
+ # This field is used for intra-system routing purpose (default to 0xFF)
13
+ uint8 :unit_identifier
14
+ uint8 :function_code
15
+
16
+ struct :get, onlyif: -> { READ_CODES.include? function_code } do
17
+ uint16 :address
18
+ uint16 :quantity
19
+ end
20
+
21
+ struct :put, onlyif: -> { WRITE_CODES.include? function_code } do
22
+ uint16 :address
23
+ uint16 :data
24
+ end
25
+
26
+ struct :put_multiple, onlyif: -> { MULTIPLE_CODES.include? function_code } do
27
+ uint16 :address
28
+ uint16 :quantity
29
+
30
+ uint8 :data_length, value: -> { put_multiple.data.bytesize }
31
+ string :data, read_length: -> { put_multiple.data_length }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,22 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ class Modbus
5
+ class ResponsePDU < BinData::Record
6
+ endian :big
7
+
8
+ # This field is used for intra-system routing purpose (default to 0xFF)
9
+ uint8 :unit_identifier
10
+ uint8 :function_code
11
+
12
+ struct :get, onlyif: -> { READ_CODES.include? function_code } do
13
+ uint8 :data_length, value: -> { get.data.bytesize }
14
+ string :data, read_length: -> { get.data_length }
15
+ end
16
+
17
+ struct :put, onlyif: -> { WRITE_CODES.include?(function_code) || MULTIPLE_CODES.include?(function_code) } do
18
+ uint16 :address
19
+ uint16 :data
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ class Modbus
5
+ # TCP ADU are sent via TCP to registered port 502
6
+ class TCPHeader < BinData::Record
7
+ endian :big
8
+
9
+ # used for transaction pairing
10
+ uint16 :transaction_identifier
11
+
12
+ # The MODBUS protocol is identified by the value 0.
13
+ uint16 :protocol_identifier
14
+
15
+ # byte count of the following fields, including the Unit Identifier and data fields.
16
+ uint16 :request_length
17
+ end
18
+ end
data/modbus.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true, encoding: ASCII-8BIT
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "modbus"
5
+ s.version = '1.0.0'
6
+ s.authors = ["Stephen von Takach"]
7
+ s.email = ["steve@aca.im"]
8
+ s.licenses = ["MIT"]
9
+ s.homepage = "https://github.com/acaprojects/ruby-modbus"
10
+ s.summary = "Modbus protocol on Ruby"
11
+ s.description = <<-EOF
12
+ Constructs Modbus standard datagrams that make it easy to communicate with devices on Modbus networks
13
+ EOF
14
+
15
+
16
+ s.add_dependency 'bindata', '~> 2.3'
17
+
18
+ s.add_development_dependency 'rspec', '~> 3.5'
19
+ s.add_development_dependency 'rake', '~> 12'
20
+
21
+
22
+ s.files = Dir["{lib}/**/*"] + %w(modbus.gemspec README.md)
23
+ s.test_files = Dir["spec/**/*"]
24
+ s.extra_rdoc_files = ["README.md"]
25
+
26
+ s.require_paths = ["lib"]
27
+ end
@@ -0,0 +1,73 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ require 'modbus'
5
+
6
+ # Example captures
7
+ # http://www.pcapr.net/browse?q=modbus
8
+
9
+ describe 'modbus protocol helper' do
10
+ before :each do
11
+ @modbus = Modbus.new
12
+ @response = nil
13
+ end
14
+
15
+ it "should parse and generate the same string" do
16
+ data = "\x0\x0\x0\x0\x0\x4\x1\x1\x1\x1"
17
+ @modbus.read(data) { |adu| @response = adu }
18
+ expect(@response.to_binary_s).to eq(data)
19
+ @response = nil
20
+
21
+ data = "\x0\x0\x0\x0\x0\x4\x1\x2\x1\x1"
22
+ @modbus.read(data) { |adu| @response = adu }
23
+ expect(@response.to_binary_s).to eq(data)
24
+ @response = nil
25
+ end
26
+
27
+ it "should generate a read coil request" do
28
+ data = @modbus.read_coils(0).to_binary_s
29
+ expect(data).to eq("\x0\x0\x0\x0\x0\x6\xFF\x1\x0\x0\x0\x1")
30
+ end
31
+
32
+ it "should generate a read inputs request" do
33
+ data = @modbus.read_inputs(0).to_binary_s
34
+ expect(data).to eq("\x0\x0\x0\x0\x0\x6\xFF\x2\x0\x0\x0\x1")
35
+ end
36
+
37
+ it "should generate a write coils request" do
38
+ data = @modbus.write_coils(4, true).to_binary_s
39
+ expect(data).to eq("\x00\x00\x00\x00\x00\x06\xFF\x05\x00\x04\xFF\x00")
40
+
41
+ data = @modbus.write_coils(3, false).to_binary_s
42
+ expect(data).to eq("\x00\x01\x00\x00\x00\x06\xFF\x05\x00\x03\x00\x00")
43
+
44
+ data = @modbus.write_coils(4, true, true).to_binary_s
45
+ expect(data).to eq("\x00\x02\x00\x00\x00\x08\xFF\x0F\x00\x04\x00\x02\x1\x3")
46
+
47
+ data = @modbus.write_coils(4, false, true).to_binary_s
48
+ expect(data).to eq("\x00\x03\x00\x00\x00\x08\xFF\x0F\x00\x04\x00\x02\x1\x2")
49
+
50
+ data = @modbus.write_coils(4, true, true, true, true, true, true, true, true, false, true).to_binary_s
51
+ expect(data).to eq("\x00\x04\x00\x00\x00\x09\xFF\x0F\x00\x04\x00\x0A\x2\xFF\x2")
52
+ end
53
+
54
+ it "should generate a write registers request" do
55
+ data = @modbus.write_registers(5, [1, 2, 3, 4]).to_binary_s
56
+ expect(data).to eq("\x00\x00\x00\x00\x00\x0F\xFF\x10\x00\x05\x00\x04\x8\x00\x01\x00\x02\x00\x03\x00\x04")
57
+
58
+ data = @modbus.write_registers(5, 4).to_binary_s
59
+ expect(data).to eq("\x00\x01\x00\x00\x00\x06\xFF\x06\x00\x05\x00\x04")
60
+ end
61
+
62
+ it "should return the response values" do
63
+ data = "\x00\x00\x00\x00\x00\x04\xFF\x01\x1\x3"
64
+ @modbus.read(data) { |adu| @response = adu }
65
+ expect(@response.value).to eq([true, true, false, false, false, false, false, false])
66
+ expect(@response.function_name).to eq(:read_coils)
67
+
68
+ data = "\x00\x00\x00\x00\x00\x07\xFF\x03\x4\x0\x3\x1\x00"
69
+ @modbus.read(data) { |adu| @response = adu }
70
+ expect(@response.value).to eq([3, 256])
71
+ expect(@response.function_name).to eq(:read_holding_registers)
72
+ end
73
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: modbus
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen von Takach
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-08-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bindata
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.5'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.5'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12'
55
+ description: " Constructs Modbus standard datagrams that make it easy to communicate
56
+ with devices on Modbus networks\n"
57
+ email:
58
+ - steve@aca.im
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files:
62
+ - README.md
63
+ files:
64
+ - README.md
65
+ - lib/modbus.rb
66
+ - lib/modbus/adu.rb
67
+ - lib/modbus/exception.rb
68
+ - lib/modbus/request.rb
69
+ - lib/modbus/response.rb
70
+ - lib/modbus/tcp_header.rb
71
+ - modbus.gemspec
72
+ - spec/modbus_spec.rb
73
+ homepage: https://github.com/acaprojects/ruby-modbus
74
+ licenses:
75
+ - MIT
76
+ metadata: {}
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubyforge_project:
93
+ rubygems_version: 2.7.7
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Modbus protocol on Ruby
97
+ test_files:
98
+ - spec/modbus_spec.rb