maxcube-client 0.4.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/Gemfile +5 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +35 -0
  6. data/Rakefile +6 -0
  7. data/bin/console +8 -0
  8. data/bin/maxcube-client +31 -0
  9. data/bin/sample_server +13 -0
  10. data/bin/sample_socket +13 -0
  11. data/bin/setup +6 -0
  12. data/data/load/del +6 -0
  13. data/data/load/meta +20 -0
  14. data/data/load/ntp +6 -0
  15. data/data/load/set_temp +13 -0
  16. data/data/load/set_temp_mode +12 -0
  17. data/data/load/set_valve +11 -0
  18. data/data/load/url +4 -0
  19. data/data/load/wake +4 -0
  20. data/lib/maxcube/messages.rb +148 -0
  21. data/lib/maxcube/messages/handler.rb +154 -0
  22. data/lib/maxcube/messages/parser.rb +34 -0
  23. data/lib/maxcube/messages/serializer.rb +59 -0
  24. data/lib/maxcube/messages/tcp.rb +18 -0
  25. data/lib/maxcube/messages/tcp/handler.rb +70 -0
  26. data/lib/maxcube/messages/tcp/parser.rb +46 -0
  27. data/lib/maxcube/messages/tcp/serializer.rb +47 -0
  28. data/lib/maxcube/messages/tcp/type/a.rb +32 -0
  29. data/lib/maxcube/messages/tcp/type/c.rb +248 -0
  30. data/lib/maxcube/messages/tcp/type/f.rb +33 -0
  31. data/lib/maxcube/messages/tcp/type/h.rb +70 -0
  32. data/lib/maxcube/messages/tcp/type/l.rb +131 -0
  33. data/lib/maxcube/messages/tcp/type/m.rb +185 -0
  34. data/lib/maxcube/messages/tcp/type/n.rb +44 -0
  35. data/lib/maxcube/messages/tcp/type/q.rb +18 -0
  36. data/lib/maxcube/messages/tcp/type/s.rb +246 -0
  37. data/lib/maxcube/messages/tcp/type/t.rb +38 -0
  38. data/lib/maxcube/messages/tcp/type/u.rb +19 -0
  39. data/lib/maxcube/messages/tcp/type/z.rb +36 -0
  40. data/lib/maxcube/messages/udp.rb +9 -0
  41. data/lib/maxcube/messages/udp/handler.rb +40 -0
  42. data/lib/maxcube/messages/udp/parser.rb +50 -0
  43. data/lib/maxcube/messages/udp/serializer.rb +30 -0
  44. data/lib/maxcube/messages/udp/type/h.rb +24 -0
  45. data/lib/maxcube/messages/udp/type/i.rb +23 -0
  46. data/lib/maxcube/messages/udp/type/n.rb +21 -0
  47. data/lib/maxcube/network.rb +14 -0
  48. data/lib/maxcube/network/tcp.rb +11 -0
  49. data/lib/maxcube/network/tcp/client.rb +174 -0
  50. data/lib/maxcube/network/tcp/client/commands.rb +286 -0
  51. data/lib/maxcube/network/tcp/sample_server.rb +96 -0
  52. data/lib/maxcube/network/udp.rb +11 -0
  53. data/lib/maxcube/network/udp/client.rb +52 -0
  54. data/lib/maxcube/network/udp/sample_socket.rb +65 -0
  55. data/lib/maxcube/version.rb +4 -0
  56. data/maxcube-client.gemspec +29 -0
  57. metadata +155 -0
@@ -0,0 +1,30 @@
1
+ require_relative 'handler'
2
+ require 'maxcube/messages/serializer'
3
+
4
+ module MaxCube
5
+ module Messages
6
+ module UDP
7
+ class Serializer
8
+ include Handler
9
+ include Messages::Serializer
10
+
11
+ MSG_TYPES = %w[I N h c R].freeze
12
+
13
+ MSG_PREFIX = (UDP::MSG_PREFIX + "*\x00").freeze
14
+
15
+ def serialize_udp_hash(hash)
16
+ check_udp_hash(hash)
17
+ serial_number = hash[:serial_number] || '*' * 10
18
+ msg = MSG_PREFIX + serial_number << @msg_type
19
+ check_udp_msg(msg)
20
+ end
21
+
22
+ private
23
+
24
+ def msg_msg_type(msg)
25
+ msg[18]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module UDP
5
+ class Parser
6
+ module MessageH
7
+ private
8
+
9
+ KEYS = (Parser::KEYS + %i[port url path]).freeze
10
+
11
+ def parse_udp_h(_body)
12
+ port = read(2, true)
13
+ url, path = read.split(',')
14
+ {
15
+ port: port,
16
+ url: url,
17
+ path: path,
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module UDP
5
+ class Parser
6
+ module MessageI
7
+ private
8
+
9
+ KEYS = (Parser::KEYS + %i[unknown
10
+ rf_address firmware_version]).freeze
11
+
12
+ def parse_udp_i(_body)
13
+ {
14
+ unknown: read(1),
15
+ rf_address: read(3, true),
16
+ firmware_version: read(2, 'H*'),
17
+ }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+
2
+ module MaxCube
3
+ module Messages
4
+ module UDP
5
+ class Parser
6
+ module MessageN
7
+ private
8
+
9
+ N_KEYS = %i[ip_address gateway subnet_mask dns1 dns2].freeze
10
+ KEYS = (Parser::KEYS + N_KEYS).freeze
11
+
12
+ def parse_udp_n(_body)
13
+ N_KEYS.map do |k|
14
+ [k, IPAddr.ntop(read(4))]
15
+ end.to_h
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ require 'socket'
2
+ require 'thread'
3
+ require 'ipaddr'
4
+ require 'pathname'
5
+
6
+ require 'pp'
7
+ require 'yaml'
8
+
9
+ module MaxCube
10
+ module Network
11
+ LOCALHOST = 'localhost'.freeze
12
+ BROADCAST = '<broadcast>'.freeze
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ require 'maxcube/network'
2
+ require 'maxcube/messages/tcp/parser'
3
+ require 'maxcube/messages/tcp/serializer'
4
+
5
+ module MaxCube
6
+ module Network
7
+ module TCP
8
+ PORT = 62_910
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,174 @@
1
+ require 'maxcube/network/tcp'
2
+ require_relative 'client/commands'
3
+
4
+ module MaxCube
5
+ module Network
6
+ module TCP
7
+ class Client
8
+ def initialize
9
+ @parser = Messages::TCP::Parser.new
10
+ @serializer = Messages::TCP::Serializer.new
11
+ @queue = Queue.new
12
+
13
+ @buffer = { recv: { hashes: [], data: [] },
14
+ sent: { hashes: [], data: [] } }
15
+ @history = { recv: { hashes: [], data: [] },
16
+ sent: { hashes: [], data: [] } }
17
+
18
+ @hash = nil
19
+ @hash_set = false
20
+
21
+ @data_dir = Pathname.new('../data')
22
+ @load_data_dir = @data_dir + 'load'
23
+ @save_data_dir = @data_dir + 'save'
24
+
25
+ @verbose = true
26
+ @persist = true
27
+ end
28
+
29
+ def connect(host = LOCALHOST, port = PORT)
30
+ @socket = TCPSocket.new(host, port)
31
+ @thread = Thread.new(self, &:receiver)
32
+ shell
33
+ end
34
+
35
+ def receiver
36
+ puts '<Starting receiver thread ...>'
37
+ while (data = @socket.gets)
38
+ hashes = @parser.parse_tcp_data(data)
39
+ if @verbose
40
+ hashes.each { |h| print_hash(h) }
41
+ puts
42
+ end
43
+ @queue << [data, hashes]
44
+ end
45
+ raise IOError
46
+ rescue IOError
47
+ STDIN.close
48
+ puts '<Closing receiver thread ...>'
49
+ rescue Messages::InvalidMessage => e
50
+ puts e.to_s.capitalize
51
+ end
52
+
53
+ def shell
54
+ puts "Welcome to interactive shell!\n" \
55
+ "Type 'help' for list of commands.\n\n"
56
+ STDIN.each do |line|
57
+ refresh_buffer
58
+ command(line)
59
+ puts
60
+ end
61
+ raise Interrupt
62
+ rescue IOError, Interrupt
63
+ puts "\nClosing shell ..."
64
+ close
65
+ end
66
+
67
+ def close
68
+ STDIN.close
69
+ send_msg('q')
70
+ @socket.close
71
+ @thread.join
72
+ end
73
+
74
+ private
75
+
76
+ def refresh_buffer
77
+ until @queue.empty?
78
+ data, hashes = @queue.pop
79
+ @buffer[:recv][:data] << data
80
+ @buffer[:recv][:hashes] << hashes
81
+ end
82
+ end
83
+
84
+ def buffer(dir_key, data_key, history = false)
85
+ return @buffer[dir_key][data_key] unless history
86
+ @history[dir_key][data_key] + @buffer[dir_key][data_key]
87
+ end
88
+
89
+ def command(line)
90
+ cmd, *args = line.chomp.split
91
+ return nil unless cmd
92
+
93
+ return send("cmd_#{cmd}", *args) if COMMANDS.key?(cmd)
94
+
95
+ keys = COMMANDS.find { |_, v| v.include?(cmd) }
96
+ return send("cmd_#{keys.first}", *args) if keys
97
+
98
+ puts "Unrecognized command: '#{cmd}'"
99
+ cmd_usage
100
+ rescue ArgumentError
101
+ puts "Invalid arguments: #{args}"
102
+ cmd_usage
103
+ end
104
+
105
+ def send_msg_hash_from_keys_args(type, *args, **opts)
106
+ keys = @serializer.msg_type_hash_keys(type) +
107
+ @serializer.msg_type_hash_opt_keys(type)
108
+ if opts[:last_array]
109
+ hash_args = args.first(keys.size - 1)
110
+ ary_args = args.drop(keys.size - 1)
111
+ ary_args = nil if opts[:array_nonempty] && ary_args.empty?
112
+ args = hash_args << ary_args
113
+ end
114
+ if keys.size < args.size
115
+ return puts 'Additional arguments: ' \
116
+ "#{args.last(args.size - keys.size)}"
117
+ end
118
+ keys.zip(args).to_h.reject { |_, v| v.nil? }
119
+ end
120
+
121
+ def send_msg_hash_from_internal(*args, **_opts)
122
+ return nil unless cmd_load(*args.drop(1))
123
+ @hash_set = false unless @persist
124
+ @hash
125
+ end
126
+
127
+ ARGS_FROM_HASH = '-'.freeze
128
+
129
+ def args_from_hash?(args)
130
+ args.first == ARGS_FROM_HASH
131
+ end
132
+
133
+ def send_msg_hash(type, *args, **opts)
134
+ if opts[:load_only] && !args_from_hash?(args)
135
+ args.unshift(ARGS_FROM_HASH)
136
+ end
137
+ return {} if args.empty?
138
+
139
+ if args_from_hash?(args)
140
+ return send_msg_hash_from_internal(*args, **opts)
141
+ end
142
+
143
+ send_msg_hash_from_keys_args(type, *args, **opts)
144
+ end
145
+
146
+ def send_msg(type, *args, **opts)
147
+ hash = send_msg_hash(type, *args, **opts)
148
+ return unless hash
149
+
150
+ if hash.key?(:type)
151
+ unless type == hash[:type]
152
+ puts "\nInternal hash message type mismatch: '#{hash[:type]}'" \
153
+ " (should be '#{type}')"
154
+ return
155
+ end
156
+ else
157
+ hash[:type] = type
158
+ end
159
+ msg = @serializer.serialize_tcp_hash(hash)
160
+
161
+ @buffer[:sent][:data] << msg
162
+ @buffer[:sent][:hashes] << [hash]
163
+ @socket.write(msg)
164
+ rescue Messages::InvalidMessage => e
165
+ puts e.to_s.capitalize
166
+ end
167
+
168
+ def print_hash(hash)
169
+ puts hash.to_yaml
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,286 @@
1
+
2
+ module MaxCube
3
+ module Network
4
+ module TCP
5
+ class Client
6
+ private
7
+
8
+ COMMANDS = {
9
+ 'usage' => %w[? h help],
10
+ 'data' => %w[B buffer d],
11
+ 'history' => %w[H hist],
12
+ 'clear' => %w[C],
13
+ 'dump' => %w[D],
14
+ 'list' => %w[l],
15
+ 'config' => %w[c],
16
+ 'send' => %w[cmd s set],
17
+ 'pair' => %w[n],
18
+ 'ntp' => %w[N f],
19
+ 'url' => %w[U u],
20
+ 'wake' => %w[w z],
21
+ 'metadata' => %w[m meta],
22
+ 'delete' => %w[del],
23
+ 'reset' => %w[],
24
+ 'verbose' => %w[V],
25
+ 'save' => %w[S],
26
+ 'load' => %w[L],
27
+ 'persist' => %w[P],
28
+ 'quit' => %w[q],
29
+ }.freeze
30
+
31
+ def usage_line(command, args, description,
32
+ message = nil, response = nil)
33
+ cmds_str = (COMMANDS[command].dup << command).join('|')
34
+ cmds_str << ' ' << args unless args.empty?
35
+
36
+ description, *rest = description.split("\n")
37
+ rest << "[#{message} message]" if message
38
+ rest << "[#{response} response]" if response
39
+ rest = if rest.empty?
40
+ ''
41
+ else
42
+ rest.map { |s| ' ' * 52 + s }.join("\n") << "\n"
43
+ end
44
+
45
+ ' ' << cmds_str << ' ' * (48 - cmds_str.size) <<
46
+ description << "\n" << rest
47
+ end
48
+
49
+ def cmd_usage
50
+ puts "\nUSAGE: <command> [<arguments...>]\nCOMMADS:\n" <<
51
+ usage_line('usage', '',
52
+ 'Prints this message') <<
53
+ usage_line('data', '',
54
+ 'Lists buffered received data (hashes)') <<
55
+ usage_line('history', '',
56
+ 'Lists all received data incl. the cleared') <<
57
+ usage_line('clear', '',
58
+ "Clears collected data\n" \
59
+ '(resp. moves it to history)') <<
60
+ usage_line('dump', '',
61
+ "Shortcut for 'data' + 'clear'") <<
62
+ usage_line('list', '',
63
+ 'Requests for new list of devices', 'l', 'L') <<
64
+ usage_line('config', '',
65
+ 'Requests for configuration message', 'c', 'C') <<
66
+ usage_line('send', '{}',
67
+ 'Sends settings to connected devices',
68
+ 's', 'S') <<
69
+ usage_line('pair', '{<timeout>}',
70
+ 'Sets device into pairing mode' \
71
+ " with optional timeout\n" \
72
+ '(request for a new device)', 'n', 'N') <<
73
+ usage_line('ntp', '{<NTP servers...>}',
74
+ 'Requests for NTP servers' \
75
+ ' and optionally updates them',
76
+ 'f', 'F') <<
77
+ usage_line('url', '{<URL> <port>}',
78
+ 'Configures Cube\'s portal URL', 'u') <<
79
+ usage_line('wake', '{<time> <scope> [<ID>]}',
80
+ 'Wake-ups the Cube',
81
+ 'z', 'A') <<
82
+ usage_line('metadata', '{}',
83
+ 'Serializes metadata for the Cube',
84
+ 'm', 'M') <<
85
+ usage_line('delete', '{<count> <force> <RF addresses...>}',
86
+ 'Deletes one or more devices from the Cube (!)',
87
+ 't', 'A') <<
88
+ usage_line('reset', '',
89
+ 'Requests for factory reset (!)', 'a', 'A') <<
90
+ usage_line('verbose', '',
91
+ "Toggles verbose mode (whether is incoming data\n" \
92
+ 'printed immediately or is not printed)') <<
93
+ usage_line('save', '[a|A|all]',
94
+ "Saves buffered [all] received and sent data\n" \
95
+ "into files at '#{@save_data_dir}'") <<
96
+ usage_line('load', '[<path>]',
97
+ 'Loads first hash from YAML file' \
98
+ " to internal variable\n" \
99
+ "-> to pass data with outgoing message\n" \
100
+ 'If path is relative,' \
101
+ " it looks in '#{@load_data_dir}'\n" \
102
+ "(loads previous valid hash if no file given)\n" \
103
+ '(command can be combined' \
104
+ " using '#{ARGS_FROM_HASH}'\n" \
105
+ " with other commands which have '{}' arguments)") <<
106
+ usage_line('persist', '',
107
+ 'Toggles persistent mode' \
108
+ "(whether is internal hash\n" \
109
+ 'not invalidated after use)') <<
110
+ usage_line('quit', '',
111
+ "Shuts the client down gracefully\n" \
112
+ '(SIGINT and EOF also work)', 'q') <<
113
+ "\n[<arg>] means optional argument <arg>" \
114
+ "\n[<args...>] means multiple arguments <args...> or none" \
115
+ "\n (<args...> requires at least one)" \
116
+ "\n{<arg>} means that either <arg>" \
117
+ " or '#{ARGS_FROM_HASH}' is expected" \
118
+ "\n (when '#{ARGS_FROM_HASH}' specified as first argument," \
119
+ ' internal hash is used' \
120
+ "\n -> 'load' command is called with rest arguments)" \
121
+ "\n ({} means that only internal hash can be used," \
122
+ "\n '#{ARGS_FROM_HASH}' is not necessary in this case)"
123
+ end
124
+
125
+ def list_hashes(history)
126
+ buffer(:recv, :hashes, history).each_with_index do |h, i|
127
+ puts "<#{i + 1}>"
128
+ print_hash(h)
129
+ puts
130
+ end
131
+ end
132
+
133
+ def cmd_data
134
+ list_hashes(false)
135
+ end
136
+
137
+ def cmd_history
138
+ list_hashes(true)
139
+ end
140
+
141
+ def cmd_clear
142
+ %i[data hashes].each do |sym|
143
+ @history[:recv][sym] += @buffer[:recv][sym]
144
+ @buffer[:recv][sym].clear
145
+ end
146
+ end
147
+
148
+ def cmd_dump
149
+ cmd_data
150
+ cmd_clear
151
+ end
152
+
153
+ def cmd_list
154
+ send_msg('l')
155
+ end
156
+
157
+ def cmd_config
158
+ send_msg('c')
159
+ end
160
+
161
+ def cmd_send(*args)
162
+ send_msg('s', *args, load_only: true)
163
+ end
164
+
165
+ def cmd_pair(*args)
166
+ send_msg('n', *args)
167
+ end
168
+
169
+ def cmd_url(*args)
170
+ send_msg('u', *args)
171
+ end
172
+
173
+ def cmd_ntp(*args)
174
+ send_msg('f', *args, last_array: true)
175
+ end
176
+
177
+ def cmd_wake(*args)
178
+ send_msg('z', *args)
179
+ end
180
+
181
+ def cmd_metadata(*args)
182
+ send_msg('m', *args, load_only: true)
183
+ end
184
+
185
+ def cmd_delete(*args)
186
+ send_msg('t', *args, last_array: true, array_nonempty: true)
187
+ end
188
+
189
+ def cmd_reset
190
+ send_msg('a')
191
+ end
192
+
193
+ def toggle(name, flag)
194
+ puts "#{name}: #{flag} -> #{!flag}"
195
+ !flag
196
+ end
197
+
198
+ def cmd_verbose
199
+ @verbose = toggle('verbose', @verbose)
200
+ end
201
+
202
+ def cmd_save(what = nil)
203
+ buffer = !what
204
+ all = %w[a A all].include?(what)
205
+ unless all || buffer
206
+ puts "Unrecognized argument: '#{what}'"
207
+ return
208
+ end
209
+
210
+ dir = @save_data_dir + Time.now.strftime('%Y%m%d-%H%M')
211
+ dir.mkpath
212
+
213
+ %i[recv sent].each do |sym|
214
+ data_fn = dir + (sym.to_s << '.data')
215
+ File.open(data_fn, 'w') do |f|
216
+ f.puts(buffer(sym, :data, all).join)
217
+ end
218
+
219
+ hashes_fn = dir + (sym.to_s << '.yaml')
220
+ File.open(hashes_fn, 'w') do |f|
221
+ buffer(sym, :hashes, all).to_yaml(f)
222
+ end
223
+ end
224
+
225
+ which = buffer ? 'Buffered' : 'All'
226
+ puts "#{which} received and sent raw data and hashes" \
227
+ " saved into '#{dir}'"
228
+ rescue SystemCallError => e
229
+ puts "Files could not been saved:\n#{e}"
230
+ end
231
+
232
+ def parse_hash(path)
233
+ unless path.file? && path.readable?
234
+ return puts "File is not readable: '#{path}'"
235
+ end
236
+
237
+ hash = YAML.load_file(path)
238
+ hash = hash.first while hash.is_a?(Array)
239
+ raise YAML::SyntaxError unless hash.is_a?(Hash)
240
+ hash
241
+ rescue YAML::SyntaxError => e
242
+ puts "File '#{path}' does not contain proper YAML hash", e
243
+ end
244
+
245
+ def load_hash(path = nil)
246
+ if path
247
+ path = Pathname.new(path)
248
+ path = @load_data_dir + path if path.relative?
249
+ return parse_hash(path)
250
+ end
251
+ return @hash if @hash && @hash_set
252
+
253
+ if @hash
254
+ puts 'Internal hash is not set'
255
+ else
256
+ puts 'No internal hash loaded yet'
257
+ cmd_usage
258
+ end
259
+ end
260
+
261
+ def assign_hash(hash)
262
+ valid_hash = !hash.nil?
263
+ @hash = hash if valid_hash
264
+ @hash_set |= valid_hash
265
+ valid_hash
266
+ end
267
+
268
+ def cmd_load(path = nil)
269
+ hash = load_hash(path)
270
+ return false unless assign_hash(hash)
271
+ print_hash(hash)
272
+ true
273
+ end
274
+
275
+ def cmd_persist
276
+ @persist = toggle('persist', @persist)
277
+ @hash_set = @persist if @hash
278
+ end
279
+
280
+ def cmd_quit
281
+ raise Interrupt
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end