modbus-cli 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .*swp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in modbus-cli.gemspec
4
+ gemspec
data/README.markdown ADDED
@@ -0,0 +1,212 @@
1
+ modbus-cli
2
+ ==========
3
+
4
+ modbus-cli is a command line utility that lets you read and write data using
5
+ the Modbus TCP protocol (ethernet only, no serial line). It supports different
6
+ data formats (bool, int, word, float, dword), allows you to save data to a file
7
+ and dump it back to your device, acting as a backup tool, or allowing you to
8
+ move blocks in memory.
9
+
10
+ [Home page]:http://www.github.com/tallakt/momdbus-cli
11
+
12
+ Installation
13
+ ------------
14
+
15
+ Install ruby. Then install the gem:
16
+
17
+ $ gem install modbus-cli
18
+
19
+ The pure ruby gem should run on most rubies.
20
+
21
+ Quick Start
22
+ -----------
23
+
24
+ Lets start by reading five words from our device startig from address %MW100.
25
+
26
+ $ modbus read 192.168.0.1 %MW100 5
27
+
28
+ which writes
29
+
30
+ %MW100 0
31
+ %MW101 0
32
+ %MW102 0
33
+ %MW103 0
34
+ %MW104 0
35
+
36
+ We chose to write the address in Schneider format, %MW100, but you can also use Modicon naming convention.
37
+ The following achieves the same as the previous line
38
+
39
+ $ modbus read 192.168.0.1 400101 5
40
+
41
+ To read coils run the command
42
+
43
+ $ modbus read 192.168.0.1 %M100 5
44
+
45
+ or
46
+
47
+ $ modbus read 192.168.0.1 101 5
48
+
49
+ You get three subcommands, read, write and dump. The dump commands writes data previously read
50
+ using the read command back to its original location. You can get more info on the commands by
51
+ using the help parameter
52
+
53
+ $ modbus read --help
54
+
55
+ To write data, write the values after the offset
56
+
57
+ $ modbus write 192.168.0.1 101 1 2 3 4 5
58
+
59
+ Please be aware that there is no protection here - you could easily mess up a running production
60
+ system by doing this.
61
+
62
+ Data Types
63
+ ----------
64
+
65
+ For Schneider format you can choose between different data types by using different addresses,
66
+ When using Modicon addresses, you may specify the data type with an additional parameter.
67
+ The supported data types are shown in the following table
68
+
69
+ <table>
70
+ <tr>
71
+ <th>Data type</th>
72
+ <th>Data size</th>
73
+ <th>Schneider address</th>
74
+ <th>Modicon address</th>
75
+ <th>Parameter</th>
76
+ </tr>
77
+ <tr>
78
+ <td>word (default, unsigned)</td>
79
+ <td>16 bits</td>
80
+ <td>%MW100</td>
81
+ <td>400101</td>
82
+ <td>--word</td>
83
+ </tr>
84
+ <tr>
85
+ <td>integer (signed)</td>
86
+ <td>16 bits</td>
87
+ <td>%MW100</td>
88
+ <td>400101</td>
89
+ <td>--int</td>
90
+ </tr>
91
+ <tr>
92
+ <td>floating point</td>
93
+ <td>32 bits</td>
94
+ <td>%MF100</td>
95
+ <td>400101</td>
96
+ <td>--float</td>
97
+ </tr>
98
+ <tr>
99
+ <td>double word</td>
100
+ <td>32 bits</td>
101
+ <td>%MD100</td>
102
+ <td>400101</td>
103
+ <td>--dword</td>
104
+ </tr>
105
+ <tr>
106
+ <td>boolean (coils)</td>
107
+ <td>1 bit</td>
108
+ <td>%M100</td>
109
+ <td>101</td>
110
+ <td>N/A</td>
111
+ </tr>
112
+ </table>
113
+
114
+ To read a floating point value, issue the following command
115
+
116
+ $ modbus read %MF100 2
117
+
118
+ which should give you something like
119
+
120
+ %MF100 0.0
121
+ %MF102 0.0
122
+
123
+ or alternatively
124
+
125
+ $ modbus read --float 400101 2
126
+
127
+ giving
128
+
129
+ 400101 0.0
130
+ 400103 0.0
131
+
132
+ The modbus command supports the addressing areas 1..99999 for coils and 400001..499999 for the rest using Modicon addresses. Using Schneider addresses the %M addresses are in a separate memory from %MW values, but %MW, %MD, %MF all reside in a shared memory, so %MW0 and %MW1 share the memory with %MF0.
133
+
134
+
135
+ Reading and dumping to files
136
+ ----------------------------
137
+
138
+ The following functionality has a few potential uses:
139
+
140
+ * Storing a backup of PLC memory containing setpoints and such in event og hardware failure
141
+ * Moving a block from one location in the PLC to another location
142
+ * Copy data from one machine to another
143
+
144
+ First, start by reading data from your device to be stored in a file
145
+
146
+ $ modbus read --output mybackup.yml 192.168.0.1 400001 1000
147
+
148
+ on Linux you may want to look at the text file by doing
149
+
150
+ $ less mybackup.yml
151
+
152
+ on Windows try loading the file in Wordpad.
153
+
154
+ To restore the memory at a later time, run the command (again a word of warning, this can mess
155
+ up a running production system)
156
+
157
+ $ modbus dump mybackup.yml
158
+
159
+ The modbus command supports multiple files, so feel free to write
160
+
161
+ $ modbus dump *.yml
162
+
163
+ To write the data back to a different device, use the --host parameter
164
+
165
+ $ modbus dump --host 192.168.0.2 mybackup.yml
166
+
167
+ or for a different memory location
168
+
169
+ $ modbus dump --offset 401001 192.168.0.2 mybackup.yml
170
+
171
+ or for a different slave id
172
+
173
+ $ modbus dump --slave 88 192.168.0.2 mybackup.yml
174
+
175
+ Slave ids are not commonly necessary when working with Modbus TCP.
176
+
177
+ Contributing to modbus-cli
178
+ --------------------------
179
+
180
+ Feel free to fork the project on GitHub and send fork requests. Please
181
+ try to have each feature separated in commits.
182
+
183
+
184
+
185
+ License
186
+ -------
187
+
188
+ (The MIT License)
189
+
190
+ Copyright (C) 2011 Tallak Tveide
191
+
192
+ Permission is hereby granted, free of charge, to any person obtaining a copy
193
+ of this software and associated documentation files (the "Software"), to
194
+ deal in the Software without restriction, including without limitation the
195
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
196
+ sell copies of the Software, and to permit persons to whom the Software is
197
+ furnished to do so, subject to the following conditions:
198
+
199
+ The above copyright notice and this permission notice shall be included in
200
+ all copies or substantial portions of the Software.
201
+
202
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
203
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
204
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
205
+ THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
206
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
207
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
208
+
209
+
210
+
211
+
212
+
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
data/bin/modbus ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+
4
+ require 'modbus-cli'
5
+
6
+ Modbus::Cli::CommandLineRunner.run
7
+
data/lib/modbus-cli.rb ADDED
@@ -0,0 +1,33 @@
1
+ require 'clamp'
2
+
3
+ # do it this way to not have serialport warning on startup
4
+ # require 'rmodbus'
5
+ require 'rmodbus/errors'
6
+ require 'rmodbus/ext'
7
+ require 'rmodbus/debug'
8
+ require 'rmodbus/options'
9
+ require 'rmodbus/rtu'
10
+ require 'rmodbus/tcp'
11
+ require 'rmodbus/slave'
12
+ require 'rmodbus/client'
13
+ require 'rmodbus/server'
14
+ require 'rmodbus/tcp_slave'
15
+ require 'rmodbus/tcp_client'
16
+ require 'rmodbus/tcp_server'
17
+
18
+ require 'modbus-cli/version'
19
+ require 'modbus-cli/read_command'
20
+ require 'modbus-cli/write_command'
21
+ require 'modbus-cli/dump_command'
22
+
23
+ module Modbus
24
+ module Cli
25
+ DEFAULT_SLAVE = 1
26
+
27
+ class CommandLineRunner < Clamp::Command
28
+ subcommand 'read', 'read from the device', ReadCommand
29
+ subcommand 'write', 'write to the device', WriteCommand
30
+ subcommand 'dump', 'copy contents of read file to the device', DumpCommand
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,130 @@
1
+ module Modbus
2
+ module Cli
3
+ module CommandsCommon
4
+
5
+ MAX_WRITE_COILS = 1968
6
+ MAX_WRITE_WORDS = 123
7
+ DEFAULT_SLAVE = 1
8
+
9
+ module ClassMethods
10
+
11
+ def host_parameter
12
+ parameter 'HOST', 'IP address or hostname for the Modbus device', :attribute_name => :host
13
+ end
14
+
15
+
16
+ def address_parameter
17
+ parameter 'ADDRESS', 'Start address, in Schneider format (eg %M100, %MW100)', :attribute_name => :address do |a|
18
+ schneider_match(a) || modicon_match(a) || raise(ArgumentError, "Illegal address #{a}")
19
+ end
20
+ end
21
+
22
+ def datatype_options
23
+ option ["-w", "--word"], :flag, "use signed 16 bit integers"
24
+ option ["-i", "--int"], :flag, "use signed 16 bit integers"
25
+ option ["-d", "--dword"], :flag, "use signed 16 bit integers"
26
+ option ["-f", "--float"], :flag, "use signed 16 bit integers"
27
+ end
28
+
29
+ def format_options
30
+ option ["--modicon"], :flag, "use Modicon addressing (eg. coil: 101, word: 400001)"
31
+ option ["--schneider"], :flag, "use Schneider addressing (eg. coil: %M100, word: %MW0, float: %MF0, dword: %MD0)"
32
+ end
33
+
34
+ def slave_option
35
+ option ["-s", "--slave"], 'ID', "use slave id ID", :default => 1 do |s|
36
+ Integer(s).tap {|slave| raise ArgumentError 'Slave must be in the range 0..255' unless (0..255).member?(slave) }
37
+ end
38
+ end
39
+
40
+ def output_option
41
+ end
42
+ end
43
+
44
+
45
+ def data_size
46
+ case addr_type
47
+ when :bit, :word, :int
48
+ 1
49
+ when :float, :dword
50
+ 2
51
+ end
52
+ end
53
+
54
+
55
+ def addr_offset
56
+ address[:offset]
57
+ end
58
+
59
+ def addr_type
60
+ if int?
61
+ :int
62
+ elsif dword?
63
+ :dword
64
+ elsif float?
65
+ :float
66
+ elsif word?
67
+ :word
68
+ else
69
+ address[:datatype]
70
+ end
71
+ end
72
+
73
+
74
+ def schneider_match(address)
75
+ schneider_match = address.match /%M([FWD])?(\d+)/i
76
+ if schneider_match
77
+ {:offset => schneider_match[2].to_i, :format => :schneider}.tap do |result|
78
+ case schneider_match[1]
79
+ when nil
80
+ result[:datatype] = :bit
81
+ when 'W', 'w'
82
+ result[:datatype] = :word
83
+ when 'F', 'f'
84
+ result[:datatype] = :float
85
+ when 'D', 'd'
86
+ result[:datatype] = :dword
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+
93
+ def modicon_match(address)
94
+ if address.match /^\d+$/
95
+ offset = address.to_i
96
+ case offset
97
+ when 1..99999
98
+ {:offset => offset - 1, :datatype => :bit, :format => :modicon}
99
+ when 400001..499999
100
+ {:offset => offset - 400001, :datatype => :word, :format => :modicon}
101
+ end
102
+ end
103
+ end
104
+
105
+ def addr_format
106
+ if schneider?
107
+ :schneider
108
+ elsif modicon?
109
+ :modicon
110
+ else
111
+ address[:format]
112
+ end
113
+ end
114
+
115
+ def sliced_write_registers(sl, offset, data)
116
+ (0..(data.count - 1)).each_slice(MAX_WRITE_WORDS) do |slice|
117
+ result = sl.write_holding_registers(slice.first + offset, data.values_at(*slice))
118
+ end
119
+ end
120
+
121
+ def sliced_write_coils(sl, offset, data)
122
+ (0..(data.count - 1)).each_slice(MAX_WRITE_COILS) do |slice|
123
+ result = sl.write_multiple_coils(slice.first + offset, data.values_at(*slice))
124
+ end
125
+ end
126
+
127
+ end
128
+ end
129
+ end
130
+
@@ -0,0 +1,64 @@
1
+ require 'modbus-cli/commands_common'
2
+ require 'yaml'
3
+
4
+ module Modbus
5
+ module Cli
6
+ class DumpCommand < Clamp::Command
7
+ extend CommandsCommon::ClassMethods
8
+ include CommandsCommon
9
+
10
+ parameter 'FILES ...', 'restore data in FILES to original devices (created by modbus read command)', :attribute_name => :files do |f|
11
+ f.map do |filename|
12
+ YAML.load_file(filename).dup.tap do |ff|
13
+ #parameter takes presedence
14
+ ff[:host] = host || ff[:host]
15
+ ff[:slave] = slave || ff[:slave]
16
+ ff[:offset] = offset || ff[:offset]
17
+ end
18
+ end
19
+ end
20
+
21
+ option ["-h", "--host"], 'ADDR', "use the address/hostname ADDR instead of the stored one"
22
+
23
+ option ["-s", "--slave"], 'ID', "use slave ID instead of the stored one" do |s|
24
+ Integer(s).tap {|slave| raise ArgumentError 'Slave address should be in the range 0..255' unless (0..255).member? slave }
25
+ end
26
+
27
+ option ["-o", "--offset"], 'OFFSET', "start writing at address OFFSET instead of original location" do |o|
28
+ raise ArgumentError 'Illegal offset address: ' + o unless modicon_match(o) || schneider_match(o)
29
+ o
30
+ end
31
+
32
+ def execute
33
+ host_ids = files.map {|d| d[:host] }.sort.uniq
34
+ host_ids.each {|host_id| execute_host host_id }
35
+ end
36
+
37
+ def execute_host(host_id)
38
+ slave_ids = files.select {|d| d[:host] == host_id }.map {|d| d[:slave] }.sort.uniq
39
+ ModBus::TCPClient.connect(host_id) do |client|
40
+ slave_ids.each {|slave_id| execute_slave host_id, slave_id, client }
41
+ end
42
+ end
43
+
44
+ def execute_slave(host_id, slave_id, client)
45
+ client.with_slave(slave_id) do |slave|
46
+ files.select {|d| d[:host] == host_id && d[:slave] == slave_id }.each do |file_data|
47
+ execute_single_file slave, file_data
48
+ end
49
+ end
50
+ end
51
+
52
+ def execute_single_file(slave, file_data)
53
+ address = modicon_match(file_data[:offset].to_s) || schneider_match(file_data[:offset].to_s)
54
+ case address[:datatype]
55
+ when :bit
56
+ sliced_write_coils slave, address[:offset], file_data[:data]
57
+ when :word
58
+ sliced_write_registers slave, address[:offset], file_data[:data]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
@@ -0,0 +1,160 @@
1
+ require 'modbus-cli/commands_common'
2
+ require 'yaml'
3
+
4
+ module Modbus
5
+ module Cli
6
+ class ReadCommand < Clamp::Command
7
+ extend CommandsCommon::ClassMethods
8
+ include CommandsCommon
9
+
10
+ MAX_READ_COIL_COUNT = 2000
11
+ MAX_READ_WORD_COUNT = 125
12
+
13
+ datatype_options
14
+ format_options
15
+ slave_option
16
+ host_parameter
17
+ address_parameter
18
+ option ["-o", "--output"], 'FILE', "write results to file FILE"
19
+
20
+ parameter 'COUNT', 'number of data to read', :attribute_name => :count do |c|
21
+ result = Integer(c)
22
+ raise ArgumentError, 'Count must be positive' if result <= 0
23
+ result
24
+
25
+ end
26
+
27
+ def read_floats(sl)
28
+ floats = read_and_unpack(sl, 'g')
29
+ (0...count).each do |n|
30
+ puts "#{ '%-10s' % address_to_s(addr_offset + n * data_size)} #{nice_float('% 16.8f' % floats[n])}"
31
+ end
32
+ end
33
+
34
+ def read_dwords(sl)
35
+ dwords = read_and_unpack(sl, 'N')
36
+ (0...count).each do |n|
37
+ puts "#{ '%-10s' % address_to_s(addr_offset + n * data_size)} #{'%10d' % dwords[n]}"
38
+ end
39
+ end
40
+
41
+ def read_registers(sl, options = {})
42
+ data = read_data_words(sl)
43
+ if options[:int]
44
+ data = data.pack('S').unpack('s')
45
+ end
46
+ read_range.zip(data).each do |pair|
47
+ puts "#{ '%-10s' % address_to_s(pair.first)} #{'%6d' % pair.last}"
48
+ end
49
+ end
50
+
51
+ def read_words_to_file(sl)
52
+ write_data_to_file(read_data_words(sl))
53
+ end
54
+
55
+ def read_coils_to_file(sl)
56
+ write_data_to_file(read_data_coils(sl))
57
+ end
58
+
59
+ def write_data_to_file(data)
60
+ File.open(output, 'w') do |file|
61
+ file.puts({ :host => host, :slave => slave, :offset => address_to_s(addr_offset, :modicon), :data => data }.to_yaml)
62
+ end
63
+ end
64
+
65
+ def read_coils(sl)
66
+ data = read_data_coils(sl)
67
+ read_range.zip(data) do |pair|
68
+ puts "#{ '%-10s' % address_to_s(pair.first)} #{'%d' % pair.last}"
69
+ end
70
+ end
71
+
72
+ def execute
73
+ ModBus::TCPClient.connect(host) do |cl|
74
+ cl.with_slave(slave) do |sl|
75
+ if output then
76
+ case addr_type
77
+ when :bit
78
+ read_coils_to_file(sl)
79
+ else
80
+ read_words_to_file(sl)
81
+ end
82
+ else
83
+ case addr_type
84
+ when :bit
85
+ read_coils(sl)
86
+ when :int
87
+ read_registers(sl, :int => true)
88
+ when :word
89
+ read_registers(sl)
90
+ when :float
91
+ read_floats(sl)
92
+ when :dword
93
+ read_dwords(sl)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def read_and_unpack(sl, format)
101
+ # the word ordering is wrong. calling reverse two times effectively swaps every pair
102
+ read_data_words(sl).reverse.pack('n*').unpack("#{format}*").reverse
103
+ end
104
+
105
+ def read_data_words(sl)
106
+ result = []
107
+ read_range.each_slice(MAX_READ_WORD_COUNT) {|slice| result += sl.read_holding_registers(slice.first, slice.count) }
108
+ result
109
+ end
110
+
111
+
112
+ def read_data_coils(sl)
113
+ result = []
114
+ read_range.each_slice(MAX_READ_COIL_COUNT) do |slice|
115
+ result += sl.read_coils(slice.first, slice.count)
116
+ end
117
+ result
118
+ end
119
+
120
+ def read_range
121
+ (addr_offset..(addr_offset + count * data_size - 1))
122
+ end
123
+
124
+
125
+ def nice_float(str)
126
+ m = str.match /(.*[.][0-9])0*/
127
+ if m
128
+ m[1]
129
+ else
130
+ str
131
+ end
132
+ end
133
+
134
+ def address_to_s(addr, format = addr_format)
135
+ case format
136
+ when :schneider
137
+ case addr_type
138
+ when :bit
139
+ '%M' + addr.to_s
140
+ when :word, :int
141
+ '%MW' + addr.to_s
142
+ when :dword
143
+ '%MD' + addr.to_s
144
+ when :float
145
+ '%MF' + addr.to_s
146
+ end
147
+ when :modicon
148
+ case addr_type
149
+ when :bit
150
+ (addr + 1).to_s
151
+ when :word, :int
152
+ (addr + 400001).to_s
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+
@@ -0,0 +1,5 @@
1
+ module Modbus
2
+ module Cli
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,81 @@
1
+ require 'modbus-cli/commands_common'
2
+
3
+ module Modbus
4
+ module Cli
5
+ class WriteCommand < Clamp::Command
6
+ extend CommandsCommon::ClassMethods
7
+ include CommandsCommon
8
+
9
+
10
+ datatype_options
11
+ format_options
12
+ slave_option
13
+ host_parameter
14
+ address_parameter
15
+
16
+ parameter 'VALUES ...', 'values to write, nonzero counts as true for discrete values', :attribute_name => :values do |vv|
17
+ case addr_type
18
+
19
+ when :bit
20
+ int_parameter vv, 0, 1
21
+ when :word
22
+ int_parameter vv, 0, 0xffff
23
+ when :int
24
+ int_parameter vv, -32768, 32767
25
+ when :dword
26
+ int_parameter vv, 0, 0xffffffff
27
+ when :float
28
+ vv.map {|v| Float(v) }
29
+ end
30
+ end
31
+
32
+
33
+
34
+ def execute
35
+ ModBus::TCPClient.connect(host) do |cl|
36
+ cl.with_slave(slave) do |sl|
37
+ case addr_type
38
+ when :bit
39
+ write_coils sl
40
+ when :word, :int
41
+ write_words sl
42
+ when :float
43
+ write_floats sl
44
+ when :dword
45
+ write_dwords sl
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def write_coils(sl)
52
+ sliced_write_coils sl, addr_offset, values
53
+ end
54
+
55
+ def write_words(sl)
56
+ sliced_write_registers sl, addr_offset, values.pack('S*').unpack('S*')
57
+ end
58
+
59
+ def write_floats(sl)
60
+ pack_and_write sl, 'g'
61
+ end
62
+
63
+ def write_dwords(sl)
64
+ pack_and_write sl, 'N'
65
+ end
66
+
67
+ def pack_and_write(sl, format)
68
+ # the word ordering is wrong. calling reverse two times effectively swaps every pair
69
+ sliced_write_registers(sl, addr_offset, values.reverse.pack("#{format}*").unpack('n*').reverse)
70
+ end
71
+
72
+ def int_parameter(vv, min, max)
73
+ vv.map {|v| Integer(v) }.tap do |values|
74
+ values.each do |v|
75
+ raise ArgumentError, "Value should be in the range #{min}..#{max}" unless (min..max).member? v
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "modbus-cli/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "modbus-cli"
7
+ s.version = Modbus::Cli::VERSION
8
+ s.authors = ["Tallak Tveide"]
9
+ s.email = ["tallak@tveide.net"]
10
+ s.homepage = "http://www.github.com/tallakt/modbus-cli"
11
+ s.summary = %q{Modbus command line}
12
+ s.description = %q{Command line interface to communicate over Modbus TCP}
13
+
14
+ s.rubyforge_project = "modbus-cli"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ # specify any dependencies here; for example:
22
+ # s.add_development_dependency "rspec"
23
+ s.add_runtime_dependency "rmodbus", '= 1.1.0'
24
+ s.add_runtime_dependency "clamp", '= 0.3.0'
25
+ s.add_development_dependency "rspec", '= 2.7.0'
26
+ end
@@ -0,0 +1,104 @@
1
+ require 'spec_helper'
2
+
3
+
4
+
5
+ describe Modbus::Cli::DumpCommand do
6
+ before(:each) do
7
+ stub_tcpip
8
+ end
9
+
10
+ it 'reads the file and write the contents to the original device' do
11
+ client = mock 'client'
12
+ slave = mock 'slave'
13
+ YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 400123, :data => [4, 5, 6])
14
+ ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
15
+ client.should_receive(:with_slave).with(5).and_yield(slave)
16
+ slave.should_receive(:write_holding_registers).with(122, [4, 5, 6])
17
+ cmd.run %w(dump dump.yml)
18
+ end
19
+
20
+ it 'can read two files from separate hosts' do
21
+ client1 = mock 'client1'
22
+ client2 = mock 'client2'
23
+ slave1 = mock 'slave1'
24
+ slave2 = mock 'slave2'
25
+ yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
26
+ YAML.should_receive(:load_file).with('a.yml').and_return(yml)
27
+ YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup.tap {|y| y[:host] = 'Y' })
28
+ ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
29
+ ModBus::TCPClient.should_receive(:connect).with('Y').and_yield(client2)
30
+ client1.should_receive(:with_slave).with(5).and_yield(slave1)
31
+ client2.should_receive(:with_slave).with(5).and_yield(slave2)
32
+ slave1.should_receive(:write_holding_registers).with(9, [99])
33
+ slave2.should_receive(:write_holding_registers).with(9, [99])
34
+ cmd.run %w(dump a.yml b.yml)
35
+ end
36
+
37
+ it 'can dump two files from separate slaves on same host' do
38
+ client1 = mock 'client1'
39
+ slave1 = mock 'slave1'
40
+ slave2 = mock 'slave2'
41
+ yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
42
+ YAML.should_receive(:load_file).with('a.yml').and_return(yml)
43
+ YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup.tap {|y| y[:slave] = 99 })
44
+ ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
45
+ client1.should_receive(:with_slave).with(5).and_yield(slave1)
46
+ client1.should_receive(:with_slave).with(99).and_yield(slave2)
47
+ slave1.should_receive(:write_holding_registers).with(9, [99])
48
+ slave2.should_receive(:write_holding_registers).with(9, [99])
49
+ cmd.run %w(dump a.yml b.yml)
50
+ end
51
+
52
+ it 'can dump two files from one slave' do
53
+ client1 = mock 'client1'
54
+ slave1 = mock 'slave1'
55
+ yml = {:host => 'X', :slave => 5, :offset => 400010, :data => [99]}
56
+ YAML.should_receive(:load_file).with('a.yml').and_return(yml)
57
+ YAML.should_receive(:load_file).with('b.yml').and_return(yml.dup)
58
+ ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client1)
59
+ client1.should_receive(:with_slave).with(5).and_yield(slave1)
60
+ slave1.should_receive(:write_holding_registers).with(9, [99])
61
+ slave1.should_receive(:write_holding_registers).with(9, [99])
62
+ cmd.run %w(dump a.yml b.yml)
63
+ end
64
+
65
+ it 'accepts the --host <hostname> parameter' do
66
+ YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
67
+ ModBus::TCPClient.should_receive(:connect).with('Y')
68
+ cmd.run %w(dump --host Y dump.yml)
69
+ end
70
+
71
+ it 'accepts the --slave <id> parameter' do
72
+ client = mock 'client'
73
+ YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
74
+ ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
75
+ client.should_receive(:with_slave).with(99)
76
+ cmd.run %w(dump --slave 99 dump.yml)
77
+ end
78
+
79
+ it 'accepts the --offset <n> parameter with modicon addressing' do
80
+ client = mock 'client'
81
+ slave = mock 'slave'
82
+ YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
83
+ ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
84
+ client.should_receive(:with_slave).with(5).and_yield(slave)
85
+ slave.should_receive(:write_holding_registers).with(100, [4, 5, 6])
86
+ cmd.run %w(dump --offset 400101 dump.yml)
87
+ end
88
+
89
+ it 'accepts the --offset <n> parameter with schneider addressing' do
90
+ client = mock 'client'
91
+ slave = mock 'slave'
92
+ YAML.should_receive(:load_file).with('dump.yml').and_return(:host => '1.2.3.4', :slave => 5, :offset => 123, :data => [4, 5, 6])
93
+ ModBus::TCPClient.should_receive(:connect).with('1.2.3.4').and_yield(client)
94
+ client.should_receive(:with_slave).with(5).and_yield(slave)
95
+ slave.should_receive(:write_holding_registers).with(100, [4, 5, 6])
96
+ cmd.run %w(dump --offset %MW100 dump.yml)
97
+ end
98
+ end
99
+
100
+
101
+
102
+
103
+
104
+
@@ -0,0 +1,24 @@
1
+ require 'spec_helper'
2
+
3
+
4
+
5
+ describe Modbus::Cli::CommandLineRunner do
6
+ include OutputCapture
7
+
8
+ before(:each) do
9
+ stub_tcpip
10
+ end
11
+
12
+ it 'has help describing the read and write commands' do
13
+ c = cmd
14
+ Proc.new { c.run(%w(--help)) }.should raise_exception(Clamp::HelpWanted)
15
+ c.help.should match /usage:/i
16
+ c.help.should match /read/
17
+ c.help.should match /write/
18
+ c.help.should match /dump/
19
+ end
20
+ end
21
+
22
+
23
+
24
+
@@ -0,0 +1,156 @@
1
+ require 'spec_helper'
2
+ require 'yaml'
3
+
4
+
5
+
6
+ describe Modbus::Cli::ReadCommand do
7
+ include OutputCapture
8
+
9
+ before(:each) do
10
+ stub_tcpip
11
+ end
12
+
13
+ it 'can read registers' do
14
+ client, slave = standard_connect_helper '1.2.3.4'
15
+ slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
16
+ cmd.run %w(read 1.2.3.4 %MW100 10)
17
+ stdout.should match(/^\s*%MW105\s*5$/)
18
+ end
19
+
20
+
21
+ it 'can read floating point numbers' do
22
+ client, slave = standard_connect_helper '1.2.3.4'
23
+ slave.should_receive(:read_holding_registers).with(100, 4).and_return([52429, 17095, 52429, 17095])
24
+ cmd.run %w(read 1.2.3.4 %MF100 2)
25
+ stdout.should match(/^\s*%MF102\s*99[.]9(00[0-9]*)?$/)
26
+ end
27
+
28
+ it 'can read double word numbers' do
29
+ client, slave = standard_connect_helper '1.2.3.4'
30
+ slave.should_receive(:read_holding_registers).with(100, 4).and_return([16959, 15, 16959, 15])
31
+ cmd.run %w(read 1.2.3.4 %MD100 2)
32
+ stdout.should match(/^\s*%MD102\s*999999$/)
33
+ end
34
+
35
+ it 'can read coils' do
36
+ client, slave = standard_connect_helper '1.2.3.4'
37
+ slave.should_receive(:read_coils).with(100, 10).and_return([1, 0] * 5)
38
+ cmd.run %w(read 1.2.3.4 %M100 10)
39
+ stdout.should match(/^\s*%M105\s*0$/)
40
+ end
41
+
42
+
43
+
44
+
45
+ it 'rejects illegal counts' do
46
+ lambda { cmd.run %w(read 1.2.3.4 %MW100 1+0) }.should raise_exception(Clamp::UsageError)
47
+ lambda { cmd.run %w(read 1.2.3.4 %MW100 -10) }.should raise_exception(Clamp::UsageError)
48
+ lambda { cmd.run %w(read 1.2.3.4 %MW100 9.9) }.should raise_exception(Clamp::UsageError)
49
+ end
50
+
51
+ it 'rejects illegal addresses' do
52
+ lambda { cmd.run %w(read 1.2.3.4 %MW1+00) }.should raise_exception(Clamp::UsageError)
53
+ end
54
+
55
+ it 'should split large reads into smaller chunks for words' do
56
+ client, slave = standard_connect_helper '1.2.3.4'
57
+ slave.should_receive(:read_holding_registers).with(100, 125).and_return([1, 0] * 1000)
58
+ slave.should_receive(:read_holding_registers).with(225, 25).and_return([1, 0] * 1000)
59
+ cmd.run %w(read 1.2.3.4 %MW100 150)
60
+ end
61
+
62
+ it 'should split large reads into smaller chunks for coils' do
63
+ client, slave = standard_connect_helper '1.2.3.4'
64
+ slave.should_receive(:read_coils).with(100, 2000).and_return([1, 0] * 1000)
65
+ slave.should_receive(:read_coils).with(2100, 1000).and_return([1, 0] * 500)
66
+ cmd.run %w(read 1.2.3.4 %M100 3000)
67
+ end
68
+
69
+ it 'can read registers as ints' do
70
+ client, slave = standard_connect_helper '1.2.3.4'
71
+ slave.should_receive(:read_holding_registers).with(100, 1).and_return([0xffff])
72
+ cmd.run %w(read --int 1.2.3.4 %MW100 1)
73
+ stdout.should match(/^\s*%MW100\s*-1$/)
74
+ end
75
+
76
+ it 'can read registers as floats' do
77
+ client, slave = standard_connect_helper '1.2.3.4'
78
+ slave.should_receive(:read_holding_registers).with(100, 2).and_return([0,0])
79
+ cmd.run %w(read --float 1.2.3.4 %MW100 1)
80
+ end
81
+
82
+ it 'can read registers as dwords' do
83
+ client, slave = standard_connect_helper '1.2.3.4'
84
+ slave.should_receive(:read_holding_registers).with(100, 2).and_return([0,0])
85
+ cmd.run %w(read --dword 1.2.3.4 %MW100 1)
86
+ end
87
+
88
+ it 'can read registers as words' do
89
+ client, slave = standard_connect_helper '1.2.3.4'
90
+ slave.should_receive(:read_holding_registers).with(100, 1).and_return([0])
91
+ cmd.run %w(read --word 1.2.3.4 %MD100 1)
92
+ end
93
+
94
+ it 'accepts Modicon addresses for coils' do
95
+ client, slave = standard_connect_helper '1.2.3.4'
96
+ slave.should_receive(:read_coils).with(100, 10).and_return([1, 0] * 5)
97
+ cmd.run %w(read 1.2.3.4 101 10)
98
+ stdout.should match(/^\s*106\s*0$/)
99
+ end
100
+
101
+ it 'accepts Modicon addresses for registers' do
102
+ client, slave = standard_connect_helper '1.2.3.4'
103
+ slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
104
+ cmd.run %w(read 1.2.3.4 400101 10)
105
+ stdout.should match(/^\s*400106\s*5$/)
106
+ end
107
+
108
+
109
+ it 'should accept the --modicon option to force modicon output' do
110
+ client, slave = standard_connect_helper '1.2.3.4'
111
+ slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
112
+ cmd.run %w(read --modicon 1.2.3.4 %MW100 10)
113
+ stdout.should match(/^\s*400106\s*5$/)
114
+ end
115
+
116
+ it 'should accept the --schneider option to force schneider output' do
117
+ client, slave = standard_connect_helper '1.2.3.4'
118
+ slave.should_receive(:read_holding_registers).with(100, 10).and_return((0..9).to_a)
119
+ cmd.run %w(read --schneider 1.2.3.4 400101 10)
120
+ stdout.should match(/^\s*%MW105\s*5$/)
121
+ end
122
+
123
+ it 'has a --slave parameter' do
124
+ client = mock 'client'
125
+ ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client)
126
+ client.should_receive(:with_slave).with(99)
127
+ cmd.run %w(read --slave 99 X 101 1)
128
+ end
129
+
130
+ it 'can write the output from reading registers to a yaml file using the -o <filename> parameter' do
131
+ client, slave = standard_connect_helper '1.2.3.4'
132
+ slave.should_receive(:read_holding_registers).with(100, 1).and_return([1])
133
+ file_mock = mock('file')
134
+ File.should_receive(:open).and_yield(file_mock)
135
+ file_mock.should_receive(:puts).with({:host => '1.2.3.4', :slave => 1, :offset => '400101', :data => [1]}.to_yaml)
136
+ cmd.run %w(read --output filename.yml 1.2.3.4 %MW100 1)
137
+ stdout.should_not match(/./)
138
+ end
139
+
140
+ it 'can write the output from reading coils to a yaml file using the -o <filename> parameter' do
141
+ client, slave = standard_connect_helper '1.2.3.4'
142
+ slave.should_receive(:read_coils).with(100, 1).and_return([1])
143
+ file_mock = mock('file')
144
+ File.should_receive(:open).and_yield(file_mock)
145
+ file_mock.should_receive(:puts).with({:host => '1.2.3.4', :slave => 1, :offset => '101', :data => [1]}.to_yaml)
146
+ cmd.run %w(read --output filename.yml 1.2.3.4 %M100 1)
147
+ stdout.should_not match(/./)
148
+ end
149
+
150
+
151
+ end
152
+
153
+
154
+
155
+
156
+
@@ -0,0 +1,48 @@
1
+ require 'rspec'
2
+ require 'clamp'
3
+ require 'stringio'
4
+ require 'modbus-cli'
5
+
6
+ # Borrowed from Clamp tests
7
+ module OutputCapture
8
+
9
+ def self.included(target)
10
+ target.before do
11
+ $stdout = @out = StringIO.new
12
+ $stderr = @err = StringIO.new
13
+ end
14
+ target.after do
15
+ $stdout = STDOUT
16
+ $stderr = STDERR
17
+ end
18
+ end
19
+
20
+ def stdout
21
+ @out.string
22
+ end
23
+
24
+ def stderr
25
+ @err.string
26
+ end
27
+
28
+
29
+
30
+ end
31
+
32
+ def stub_tcpip
33
+ TCPSocket.stub!(:new) # prevent comms with actual PLC or device
34
+ end
35
+
36
+
37
+ def standard_connect_helper(address)
38
+ client = mock 'client'
39
+ slave = mock 'slave'
40
+ ModBus::TCPClient.should_receive(:connect).with(address).and_yield(client)
41
+ client.should_receive(:with_slave).with(1).and_yield(slave)
42
+ return client, slave
43
+ end
44
+
45
+
46
+ def cmd
47
+ Modbus::Cli::CommandLineRunner.new('modbus-cli')
48
+ end
@@ -0,0 +1,109 @@
1
+ require 'spec_helper'
2
+
3
+
4
+
5
+ describe Modbus::Cli::WriteCommand do
6
+ include OutputCapture
7
+
8
+ before(:each) do
9
+ stub_tcpip
10
+ end
11
+
12
+ it 'can write to registers' do
13
+ client, slave = standard_connect_helper 'HOST'
14
+ slave.should_receive(:write_holding_registers).with(100, [1, 2, 3, 4])
15
+ cmd.run %w(write HOST %MW100 1 2 3 4)
16
+ end
17
+
18
+
19
+ it 'can write floating point numbers' do
20
+ client, slave = standard_connect_helper 'HOST'
21
+ slave.should_receive(:write_holding_registers).with(100, [52429, 17095, 52429, 17095])
22
+ cmd.run %w(write HOST %MF100 99.9 99.9)
23
+ end
24
+
25
+ it 'can write double word numbers' do
26
+ client, slave = standard_connect_helper 'HOST'
27
+ slave.should_receive(:write_holding_registers).with(100, [16959, 15, 16959, 15])
28
+ cmd.run %w(write HOST %MD100 999999 999999)
29
+ end
30
+
31
+ it 'can write to coils' do
32
+ client, slave = standard_connect_helper 'HOST'
33
+ slave.should_receive(:write_multiple_coils).with(100, [1, 0, 1, 0, 1, 0, 0, 1, 1])
34
+ cmd.run %w(write HOST %M100 1 0 1 0 1 0 0 1 1)
35
+ end
36
+
37
+ it 'rejects illegal values' do
38
+ lambda { cmd.run %w(write 1.2.3.4 %MW100 10 tust) }.should raise_exception(Clamp::UsageError)
39
+ lambda { cmd.run %w(write 1.2.3.4 %MW100 9999999) }.should raise_exception(Clamp::UsageError)
40
+ end
41
+
42
+ it 'rejects illegal addresses' do
43
+ lambda { cmd.run %w(write 1.2.3.4 %MW1+00 ) }.should raise_exception(Clamp::UsageError)
44
+ end
45
+
46
+
47
+ it 'should split large writes in chunks for words' do
48
+ client, slave = standard_connect_helper 'HOST'
49
+ slave.should_receive(:write_holding_registers).with(100, (1..123).to_a)
50
+ slave.should_receive(:write_holding_registers).with(223, (124..150).to_a)
51
+ cmd.run %w(write HOST %MW100) + (1..150).to_a
52
+ end
53
+
54
+ it 'should split large writes in chunks for coils' do
55
+ client, slave = standard_connect_helper 'HOST'
56
+ slave.should_receive(:write_multiple_coils).with(100, [0, 1] * 984)
57
+ slave.should_receive(:write_multiple_coils).with(2068, [0, 1] * 16)
58
+ cmd.run %w(write HOST %M100) + [0, 1] * 1000
59
+ end
60
+
61
+ it 'can write to registers as ints' do
62
+ client, slave = standard_connect_helper 'HOST'
63
+ slave.should_receive(:write_holding_registers).with(100, [0xffff])
64
+ cmd.run %w(write --int HOST %MW100 -1)
65
+ end
66
+
67
+ it 'can write to registers as floats' do
68
+ client, slave = standard_connect_helper 'HOST'
69
+ slave.should_receive(:write_holding_registers).with(100, [52429, 17095])
70
+ cmd.run %w(write --float HOST %MW100 99.9)
71
+ end
72
+
73
+ it 'can write to registers as double words' do
74
+ client, slave = standard_connect_helper 'HOST'
75
+ slave.should_receive(:write_holding_registers).with(100, [16959, 15])
76
+ cmd.run %w(write --dword HOST %MW100 999999)
77
+ end
78
+
79
+ it 'can write to registers as words' do
80
+ client, slave = standard_connect_helper 'HOST'
81
+ slave.should_receive(:write_holding_registers).with(100, [99])
82
+ cmd.run %w(write --word HOST %MF100 99)
83
+ end
84
+
85
+ it 'can write to registers using Modicon addressing' do
86
+ client, slave = standard_connect_helper 'HOST'
87
+ slave.should_receive(:write_holding_registers).with(100, [1, 2, 3, 4])
88
+ cmd.run %w(write HOST 400101 1 2 3 4)
89
+ end
90
+
91
+ it 'can write to coils using Modicon addressing' do
92
+ client, slave = standard_connect_helper 'HOST'
93
+ slave.should_receive(:write_multiple_coils).with(100, [1, 0, 1, 0, 1, 0, 0, 1, 1])
94
+ cmd.run %w(write HOST 101 1 0 1 0 1 0 0 1 1)
95
+ end
96
+
97
+ it 'has a --slave parameter' do
98
+ client = mock 'client'
99
+ ModBus::TCPClient.should_receive(:connect).with('X').and_yield(client)
100
+ client.should_receive(:with_slave).with(99)
101
+ cmd.run %w(write --slave 99 X 101 1)
102
+ end
103
+
104
+ end
105
+
106
+
107
+
108
+
109
+
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: modbus-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Tallak Tveide
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-11-30 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rmodbus
16
+ requirement: &76170020 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - =
20
+ - !ruby/object:Gem::Version
21
+ version: 1.1.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *76170020
25
+ - !ruby/object:Gem::Dependency
26
+ name: clamp
27
+ requirement: &76169740 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - =
31
+ - !ruby/object:Gem::Version
32
+ version: 0.3.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *76169740
36
+ - !ruby/object:Gem::Dependency
37
+ name: rspec
38
+ requirement: &76169500 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - =
42
+ - !ruby/object:Gem::Version
43
+ version: 2.7.0
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *76169500
47
+ description: Command line interface to communicate over Modbus TCP
48
+ email:
49
+ - tallak@tveide.net
50
+ executables:
51
+ - modbus
52
+ extensions: []
53
+ extra_rdoc_files: []
54
+ files:
55
+ - .gitignore
56
+ - Gemfile
57
+ - README.markdown
58
+ - Rakefile
59
+ - bin/modbus
60
+ - lib/modbus-cli.rb
61
+ - lib/modbus-cli/commands_common.rb
62
+ - lib/modbus-cli/dump_command.rb
63
+ - lib/modbus-cli/read_command.rb
64
+ - lib/modbus-cli/version.rb
65
+ - lib/modbus-cli/write_command.rb
66
+ - modbus-cli.gemspec
67
+ - spec/dump_command_spec.rb
68
+ - spec/modbus_cli_spec.rb
69
+ - spec/read_command_spec.rb
70
+ - spec/spec_helper.rb
71
+ - spec/write_command_spec.rb
72
+ homepage: http://www.github.com/tallakt/modbus-cli
73
+ licenses: []
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ! '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ! '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubyforge_project: modbus-cli
92
+ rubygems_version: 1.8.10
93
+ signing_key:
94
+ specification_version: 3
95
+ summary: Modbus command line
96
+ test_files: []
97
+ has_rdoc: