ewelink 2.1.0 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c58323e838b9fbbdca7c7ee0fdf22d1e7950c627fb4fcf8851b3263e69ce883
4
- data.tar.gz: 475d05f731b079f7734fde4dd93ec8f151077d6e5b716a86e611f11bdbb8d49b
3
+ metadata.gz: d17e40d48a819572e83369500edbe6295a991626c3333ce4ff0765876f25ed51
4
+ data.tar.gz: 9caca646a1355592a276e5e4595ab486e407ecaecb105a9e7134028ecab4cb42
5
5
  SHA512:
6
- metadata.gz: 50ba5474e9012f219e380c47515d599da29a72b50cc26c36a19e253623c997e429a32c1b279619eef0e2d8b7f454fccde519500f998575c38d028ac86e6c1ba4
7
- data.tar.gz: 122f2997897893b395cd04204e3e169d90bac15fc1a9eaabbf31def8da1c7f754bd502341411727364a79011379791a89031e9d42090d22bf96be233a24a5958
6
+ metadata.gz: 6e8a2717428e2b57c7a30a7d4e9a1658032b1155a2f9bd2ba9b8b0cb2309748d248f24d66babfbe2212af9d8096e6be18ebdc3c60d799654571526edff1204d5
7
+ data.tar.gz: 9b9e517a3c8629d6a69b8195cab906d12f8f4d4231ab22311083deb75e5db97b02ec9c13a8509710017bb17cfcf1750c46919f52b393b73a0d453c423f120153
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.1.0
1
+ 2.3.0
@@ -16,8 +16,8 @@ Gem::Specification.new do |s|
16
16
  s.required_ruby_version = '>= 2.0.0'
17
17
 
18
18
  s.add_dependency 'activesupport', '>= 6.0.0', '< 7.0.0'
19
+ s.add_dependency 'faye-websocket', '>= 0.11.0', '< 0.12.0'
19
20
  s.add_dependency 'httparty', '>= 0.18.0', '< 0.19.0'
20
- s.add_dependency 'websocket-client-simple', '>= 0.3.0', '< 0.4.0'
21
21
 
22
22
  s.add_development_dependency 'byebug', '>= 11.0.0', '< 12.0.0'
23
23
  s.add_development_dependency 'rake', '>= 12.0.0', '< 13.0.0'
@@ -1,6 +1,8 @@
1
1
  require 'active_support'
2
2
  require 'active_support/core_ext'
3
3
  require 'byebug' if ENV['DEBUGGER']
4
+ require 'eventmachine'
5
+ require 'faye/websocket'
4
6
  require 'httparty'
5
7
  require 'io/console'
6
8
  require 'json'
@@ -9,7 +11,6 @@ require 'openssl'
9
11
  require 'optparse'
10
12
  require 'set'
11
13
  require 'timeout'
12
- require 'websocket-client-simple'
13
14
 
14
15
  module Ewelink
15
16
 
@@ -5,13 +5,15 @@ module Ewelink
5
5
  APP_ID = 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq'
6
6
  APP_SECRET = '6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM'
7
7
  DEFAULT_REGION = 'us'
8
+ REQUEST_TIMEOUT = 10.seconds
8
9
  RF_BRIDGE_DEVICE_UIID = 28
9
10
  SWITCH_DEVICES_UIIDS = [1, 5, 6, 24]
10
- TIMEOUT = 10.seconds
11
11
  URL = 'https://#{region}-api.coolkit.cc:8080'
12
12
  UUID_NAMESPACE = 'e25750fb-3710-41af-b831-23224f4dd609';
13
13
  VERSION = 8
14
+ WEB_SOCKET_CHECK_AUTHENTICATION_TIMEOUT = 30.seconds
14
15
  WEB_SOCKET_PING_TOLERANCE_FACTOR = 1.5
16
+ SWITCH_STATUS_CHANGE_CHECK_TIMEOUT = 2.seconds
15
17
  WEB_SOCKET_WAIT_INTERVAL = 0.2.seconds
16
18
 
17
19
  attr_reader :email, :password, :phone_number
@@ -21,15 +23,18 @@ module Ewelink
21
23
  @mutexs = {}
22
24
  @password = password.presence || raise(Error.new(":password must be specified"))
23
25
  @phone_number = phone_number.presence.try(:strip)
24
- @switches_statuses = {}
25
26
  @web_socket_authenticated_api_keys = Set.new
26
- raise(Error.new(":email or :phone_number must be specified")) if email.blank? && phone_number.blank?
27
+ @web_socket_switches_statuses = {}
28
+
29
+ raise(Error.new(':email or :phone_number must be specified')) if email.blank? && phone_number.blank?
30
+
31
+ start_web_socket_authentication_check_thread
27
32
  end
28
33
 
29
34
  def press_rf_bridge_button!(uuid)
30
35
  synchronize(:press_rf_bridge_button) do
31
36
  button = find_rf_bridge_button!(uuid)
32
- web_socket_wait_for(-> { web_socket_authenticated? }) do
37
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
33
38
  params = {
34
39
  'action' => 'update',
35
40
  'apikey' => button[:api_key],
@@ -51,15 +56,40 @@ module Ewelink
51
56
 
52
57
  def reload
53
58
  Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices, region, connections,...)' }
54
- dispose_web_socket
55
- @switches_statuses.clear
59
+
60
+ @web_socket_authenticated_api_keys.clear
61
+ @web_socket_switches_statuses.clear
62
+
63
+ [@web_socket_ping_thread, @web_socket_thread].each do |thread|
64
+ next unless thread
65
+ if Thread.current == thread
66
+ thread[:stop] = true
67
+ else
68
+ thread.kill
69
+ end
70
+ end
71
+
72
+ if @web_socket.present?
73
+ begin
74
+ @web_socket.close if @web_socket.open?
75
+ rescue
76
+ # Ignoring close errors
77
+ end
78
+ end
79
+
56
80
  [
57
81
  :@api_keys,
58
82
  :@authentication_token,
59
83
  :@devices,
84
+ :@last_web_socket_pong_at,
60
85
  :@region,
61
86
  :@rf_bridge_buttons,
62
87
  :@switches,
88
+ :@web_socket_ping_interval,
89
+ :@web_socket_ping_thread,
90
+ :@web_socket_thread,
91
+ :@web_socket_url,
92
+ :@web_socket,
63
93
  ].each do |variable|
64
94
  remove_instance_variable(variable) if instance_variable_defined?(variable)
65
95
  end
@@ -102,23 +132,22 @@ module Ewelink
102
132
 
103
133
  def switch_on?(uuid)
104
134
  switch = find_switch!(uuid)
105
- if @switches_statuses[switch[:uuid]].nil?
106
- params = {
107
- 'action' => 'query',
108
- 'apikey' => switch[:api_key],
109
- 'deviceid' => switch[:device_id],
110
- 'sequence' => web_socket_sequence,
111
- 'ts' => 0,
112
- 'userAgent' => 'app',
113
- }
114
- web_socket_wait_for(-> { web_socket_authenticated? }) do
135
+ if @web_socket_switches_statuses[switch[:uuid]].nil?
136
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
115
137
  Ewelink.logger.debug(self.class.name) { "Checking switch #{switch[:uuid].inspect} status" }
138
+ params = {
139
+ 'action' => 'query',
140
+ 'apikey' => switch[:api_key],
141
+ 'deviceid' => switch[:device_id],
142
+ 'sequence' => web_socket_sequence,
143
+ 'ts' => 0,
144
+ 'userAgent' => 'app',
145
+ }
116
146
  send_to_web_socket(JSON.generate(params))
117
147
  end
118
148
  end
119
- web_socket_wait_for(-> { !@switches_statuses[switch[:uuid]].nil? }) do
120
- Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@switches_statuses[switch[:uuid]]}" }
121
- @switches_statuses[switch[:uuid]] == 'on'
149
+ web_socket_wait_for(-> { !@web_socket_switches_statuses[switch[:uuid]].nil? }, initialize_web_socket: true) do
150
+ @web_socket_switches_statuses[switch[:uuid]] == 'on'
122
151
  end
123
152
  end
124
153
 
@@ -149,8 +178,8 @@ module Ewelink
149
178
  on = false
150
179
  end
151
180
  switch = find_switch!(uuid)
152
- @switches_statuses[switch[:uuid]] = nil
153
- web_socket_wait_for(-> { web_socket_authenticated? }) do
181
+ @web_socket_switches_statuses[switch[:uuid]] = nil
182
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
154
183
  params = {
155
184
  'action' => 'update',
156
185
  'apikey' => switch[:api_key],
@@ -165,6 +194,7 @@ module Ewelink
165
194
  Ewelink.logger.debug(self.class.name) { "Turning switch #{switch[:uuid].inspect} #{on ? 'on' : 'off'}" }
166
195
  send_to_web_socket(JSON.generate(params))
167
196
  end
197
+ sleep(SWITCH_STATUS_CHANGE_CHECK_TIMEOUT)
168
198
  switch_on?(switch[:uuid]) # Waiting for switch status update
169
199
  true
170
200
  end
@@ -177,6 +207,24 @@ module Ewelink
177
207
  end
178
208
  end
179
209
 
210
+ def authenticate_web_socket_api_keys
211
+ api_keys.each do |api_key|
212
+ params = {
213
+ 'action' => 'userOnline',
214
+ 'apikey' => api_key,
215
+ 'appid' => APP_ID,
216
+ 'at' => authentication_token,
217
+ 'nonce' => nonce,
218
+ 'sequence' => web_socket_sequence,
219
+ 'ts' => Time.now.to_i,
220
+ 'userAgent' => 'app',
221
+ 'version' => VERSION,
222
+ }
223
+ Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" }
224
+ send_to_web_socket(JSON.generate(params))
225
+ end
226
+ end
227
+
180
228
  def authentication_headers
181
229
  { 'Authorization' => "Bearer #{authentication_token}" }
182
230
  end
@@ -221,36 +269,6 @@ module Ewelink
221
269
  end
222
270
  end
223
271
 
224
- def dispose_web_socket
225
- @web_socket_authenticated_api_keys = Set.new
226
-
227
- if @web_socket_ping_thread
228
- if Thread.current == @web_socket_ping_thread
229
- Thread.current[:stop] = true
230
- else
231
- @web_socket_ping_thread.kill
232
- end
233
- end
234
-
235
- if @web_socket.present?
236
- begin
237
- @web_socket.close if @web_socket.open?
238
- rescue
239
- # Ignoring close errors
240
- end
241
- end
242
-
243
- [
244
- :@last_web_socket_pong_at,
245
- :@web_socket_ping_interval,
246
- :@web_socket_ping_thread,
247
- :@web_socket_url,
248
- :@web_socket,
249
- ].each do |variable|
250
- remove_instance_variable(variable) if instance_variable_defined?(variable)
251
- end
252
- end
253
-
254
272
  def find_rf_bridge_button!(uuid)
255
273
  rf_bridge_buttons.find { |button| button[:uuid] == uuid } || raise(Error.new("No such RF bridge button with UUID: #{uuid.inspect}"))
256
274
  end
@@ -272,7 +290,7 @@ module Ewelink
272
290
  method = method.to_s.upcase
273
291
  headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json')
274
292
  Ewelink.logger.debug(self.class.name) { "#{method} #{url}" }
275
- response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: TIMEOUT))
293
+ response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: REQUEST_TIMEOUT))
276
294
  raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success?
277
295
  if response['error'] == 301 && response['region'].present?
278
296
  @region = response['region']
@@ -286,15 +304,43 @@ module Ewelink
286
304
  raise Error.new(e)
287
305
  end
288
306
 
289
- def send_to_web_socket(data)
290
- if web_socket_outdated_ping?
291
- Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' }
292
- dispose_web_socket
307
+ def send_to_web_socket(message)
308
+ web_socket.send(message)
309
+ rescue => e
310
+ reload
311
+ raise Error.new(e)
312
+ end
313
+
314
+ def start_web_socket_authentication_check_thread
315
+ raise Error.new('WebSocket authentication check must only be started once') if @web_socket_authentication_check_thread.present?
316
+
317
+ @web_socket_authentication_check_thread = Thread.new do
318
+ loop do
319
+ Ewelink.logger.debug(self.class.name) { 'Checking if WebSocket is authenticated' }
320
+ begin
321
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
322
+ Ewelink.logger.debug(self.class.name) { 'WebSocket is authenticated' }
323
+ end
324
+ rescue => e
325
+ Ewelink.logger.error(self.class.name) { e }
326
+ end
327
+ sleep(WEB_SOCKET_CHECK_AUTHENTICATION_TIMEOUT)
328
+ end
329
+ end
330
+ end
331
+
332
+ def start_web_socket_ping_thread(interval)
333
+ @last_web_socket_pong_at = Time.now
334
+ @web_socket_ping_interval = interval
335
+ Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" }
336
+ @web_socket_ping_thread = Thread.new do
337
+ loop do
338
+ break if Thread.current[:stop]
339
+ sleep(@web_socket_ping_interval)
340
+ Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' }
341
+ send_to_web_socket('ping')
342
+ end
293
343
  end
294
- web_socket.send(data)
295
- rescue
296
- dispose_web_socket
297
- raise
298
344
  end
299
345
 
300
346
  def synchronize(name, &block)
@@ -302,96 +348,82 @@ module Ewelink
302
348
  end
303
349
 
304
350
  def web_socket
351
+ if web_socket_outdated_ping?
352
+ Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' }
353
+ reload
354
+ end
355
+
305
356
  synchronize(:web_socket) do
306
- @web_socket ||= begin
307
- api = self
357
+ next @web_socket if @web_socket
308
358
 
309
- WebSocket::Client::Simple.connect(web_socket_url) do |web_socket|
310
- Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url}" }
359
+ # Initializes caches before opening WebSocket: important in order to
360
+ # NOT cumulate requests Timeouts from #web_socket_wait_for.
361
+ api_keys
362
+ web_socket_url
311
363
 
312
- web_socket.on(:close) do
313
- api.instance_eval do
314
- Ewelink.logger.debug(self.class.name) { 'WebSocket closed' }
315
- dispose_web_socket
316
- end
364
+ Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url.inspect}" }
365
+
366
+ @web_socket_thread = Thread.new do
367
+ EventMachine.run do
368
+ @web_socket = Faye::WebSocket::Client.new(web_socket_url)
369
+
370
+ @web_socket.on(:close) do |event|
371
+ Ewelink.logger.debug(self.class.name) { 'WebSocket closed' }
372
+ reload
317
373
  end
318
374
 
319
- web_socket.on(:error) do |e|
320
- api.instance_eval do
321
- Ewelink.logger.warn(self.class.name) { "WebSocket error: #{e}" }
322
- dispose_web_socket
323
- end
375
+ @web_socket.on(:open) do |event|
376
+ Ewelink.logger.debug(self.class.name) { 'WebSocket opened' }
377
+ @last_web_socket_pong_at = Time.now
378
+ authenticate_web_socket_api_keys
324
379
  end
325
380
 
326
- web_socket.on(:message) do |message|
327
- api.instance_eval do
328
- if message.data == 'pong'
329
- Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.data.inspect} message" }
330
- @last_web_socket_pong_at = Time.now
331
- next
332
- end
381
+ @web_socket.on(:message) do |event|
382
+ message = event.data
333
383
 
334
- begin
335
- response = JSON.parse(message.data)
336
- rescue => e
337
- Ewelink.logger.error(self.class.name) { "WebSocket JSON parse error" }
338
- next
339
- end
384
+ if message == 'pong'
385
+ Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.inspect} message" }
386
+ @last_web_socket_pong_at = Time.now
387
+ next
388
+ end
340
389
 
341
- if response.key?('error') && response['error'] != 0
342
- Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.data}" }
343
- next
344
- end
390
+ begin
391
+ json = JSON.parse(message)
392
+ rescue => e
393
+ Ewelink.logger.error(self.class.name) { 'WebSocket JSON parse error' }
394
+ reload
395
+ next
396
+ end
345
397
 
346
- if !@web_socket_ping_thread && response.key?('config') && response['config'].key?('hbInterval')
347
- @last_web_socket_pong_at = Time.now
348
- # @web_socket_ping_interval = response['config']['hbInterval']
349
- @web_socket_ping_interval = 30.seconds
350
- Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" }
351
- @web_socket_ping_thread = Thread.new do
352
- loop do
353
- break if Thread.current[:stop]
354
- sleep(@web_socket_ping_interval)
355
- Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' }
356
- send_to_web_socket('ping')
357
- end
358
- end
359
- end
398
+ if json.key?('error') && json['error'] != 0
399
+ Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.inspect}" }
400
+ reload
401
+ next
402
+ end
360
403
 
361
- if response['apikey'].present? && !@web_socket_authenticated_api_keys.include?(response['apikey'])
362
- @web_socket_authenticated_api_keys << response['apikey']
363
- Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{response['apikey'].truncate(16).inspect}" }
364
- end
404
+ if !@web_socket_ping_thread && json.key?('config') && json['config']['hb'] == 1 && json['config']['hbInterval'].present?
405
+ start_web_socket_ping_thread(json['config']['hbInterval'] + 7)
406
+ end
365
407
 
366
- if response['deviceid'].present? && response['params'].is_a?(Hash) && response['params']['switch'].present?
367
- switch = switches.find { |switch| switch[:device_id] == response['deviceid'] }
368
- @switches_statuses[switch[:uuid]] = response['params']['switch'] if switch.present?
369
- end
408
+ if json['apikey'].present? && !@web_socket_authenticated_api_keys.include?(json['apikey'])
409
+ @web_socket_authenticated_api_keys << json['apikey']
410
+ Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{json['apikey'].truncate(16).inspect}" }
370
411
  end
371
- end
372
412
 
373
- web_socket.on(:open) do
374
- api.instance_eval do
375
- Ewelink.logger.debug(self.class.name) { 'WebSocket opened' }
376
- api_keys.each do |api_key|
377
- params = {
378
- 'action' => 'userOnline',
379
- 'apikey' => api_key,
380
- 'appid' => APP_ID,
381
- 'at' => authentication_token,
382
- 'nonce' => nonce,
383
- 'sequence' => web_socket_sequence,
384
- 'ts' => Time.now.to_i,
385
- 'userAgent' => 'app',
386
- 'version' => VERSION,
387
- }
388
- Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" }
389
- send_to_web_socket(JSON.generate(params))
413
+ if json['deviceid'].present? && json['params'].is_a?(Hash) && json['params']['switch'].present?
414
+ switch = switches.find { |switch| switch[:device_id] == json['deviceid'] }
415
+ if switch.present?
416
+ @web_socket_switches_statuses[switch[:uuid]] = json['params']['switch']
417
+ Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@web_socket_switches_statuses[switch[:uuid]]}" }
390
418
  end
391
419
  end
392
420
  end
393
421
  end
394
422
  end
423
+
424
+ web_socket_wait_for(-> { @web_socket.present? }) do
425
+ @web_socket
426
+ end
395
427
  end
396
428
  end
397
429
 
@@ -426,16 +458,18 @@ module Ewelink
426
458
  end
427
459
  end
428
460
 
429
- def web_socket_wait_for(condition, &block)
430
- web_socket # Initializes WebSocket
431
- Timeout.timeout(TIMEOUT) do
432
- loop do
433
- if condition.call
434
- return yield if block_given?
435
- return true
461
+ def web_socket_wait_for(condition, initialize_web_socket: false, &block)
462
+ web_socket if initialize_web_socket
463
+ begin
464
+ Timeout.timeout(REQUEST_TIMEOUT) do
465
+ while !condition.call
466
+ sleep(WEB_SOCKET_WAIT_INTERVAL)
436
467
  end
437
- sleep(WEB_SOCKET_WAIT_INTERVAL)
468
+ block_given? ? yield : true
438
469
  end
470
+ rescue => e
471
+ reload
472
+ raise Error.new(e)
439
473
  end
440
474
  end
441
475
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ewelink
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexis Toulotte
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-09-02 00:00:00.000000000 Z
11
+ date: 2020-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -31,45 +31,45 @@ dependencies:
31
31
  - !ruby/object:Gem::Version
32
32
  version: 7.0.0
33
33
  - !ruby/object:Gem::Dependency
34
- name: httparty
34
+ name: faye-websocket
35
35
  requirement: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.18.0
39
+ version: 0.11.0
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
- version: 0.19.0
42
+ version: 0.12.0
43
43
  type: :runtime
44
44
  prerelease: false
45
45
  version_requirements: !ruby/object:Gem::Requirement
46
46
  requirements:
47
47
  - - ">="
48
48
  - !ruby/object:Gem::Version
49
- version: 0.18.0
49
+ version: 0.11.0
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
- version: 0.19.0
52
+ version: 0.12.0
53
53
  - !ruby/object:Gem::Dependency
54
- name: websocket-client-simple
54
+ name: httparty
55
55
  requirement: !ruby/object:Gem::Requirement
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: 0.3.0
59
+ version: 0.18.0
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
- version: 0.4.0
62
+ version: 0.19.0
63
63
  type: :runtime
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: 0.3.0
69
+ version: 0.18.0
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
- version: 0.4.0
72
+ version: 0.19.0
73
73
  - !ruby/object:Gem::Dependency
74
74
  name: byebug
75
75
  requirement: !ruby/object:Gem::Requirement