ewelink 2.0.0 → 2.2.2
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 +4 -4
- data/VERSION +1 -1
- data/ewelink.gemspec +1 -1
- data/lib/ewelink.rb +2 -1
- data/lib/ewelink/api.rb +167 -93
- metadata +12 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b7b1ea698c178aeed55aa90970659bb230f84e611a1bff87d135e301a81bca0d
|
4
|
+
data.tar.gz: 73e86511d022f0481d0156b130ac08f679c5e06a1549ab1c903bb9b795187ee9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 141e43b9082542fe3c6a598754b9d0e9e69f6b5612da8df2e56921e1b1df8c96517ac97059d782c7539177bf42889dbfd70b6f94186e99f8d22649071066ecf7
|
7
|
+
data.tar.gz: 445ce9af569e5b997eff1ff20fcafec709cc3edf0a60e278262ba66c56341cf389f6d133c4650f8916af995e42f61a132a56ba822455e8a6595003aca6e92a48
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.
|
1
|
+
2.2.2
|
data/ewelink.gemspec
CHANGED
@@ -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'
|
data/lib/ewelink.rb
CHANGED
@@ -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
|
|
data/lib/ewelink/api.rb
CHANGED
@@ -5,12 +5,14 @@ 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_PING_TOLERANCE_FACTOR = 1.5
|
15
|
+
SWITCH_STATUS_CHANGE_CHECK_TIMEOUT = 2.seconds
|
14
16
|
WEB_SOCKET_WAIT_INTERVAL = 0.2.seconds
|
15
17
|
|
16
18
|
attr_reader :email, :password, :phone_number
|
@@ -20,15 +22,15 @@ module Ewelink
|
|
20
22
|
@mutexs = {}
|
21
23
|
@password = password.presence || raise(Error.new(":password must be specified"))
|
22
24
|
@phone_number = phone_number.presence.try(:strip)
|
23
|
-
@switches_statuses = {}
|
24
25
|
@web_socket_authenticated_api_keys = Set.new
|
26
|
+
@web_socket_switches_statuses = {}
|
25
27
|
raise(Error.new(":email or :phone_number must be specified")) if email.blank? && phone_number.blank?
|
26
28
|
end
|
27
29
|
|
28
30
|
def press_rf_bridge_button!(uuid)
|
29
31
|
synchronize(:press_rf_bridge_button) do
|
30
32
|
button = find_rf_bridge_button!(uuid)
|
31
|
-
web_socket_wait_for(-> { web_socket_authenticated? }) do
|
33
|
+
web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
|
32
34
|
params = {
|
33
35
|
'action' => 'update',
|
34
36
|
'apikey' => button[:api_key],
|
@@ -41,6 +43,7 @@ module Ewelink
|
|
41
43
|
'ts' => 0,
|
42
44
|
'userAgent' => 'app',
|
43
45
|
}
|
46
|
+
Ewelink.logger.debug(self.class.name) { "Pressing RF bridge button #{button[:uuid].inspect}" }
|
44
47
|
send_to_web_socket(JSON.generate(params))
|
45
48
|
true
|
46
49
|
end
|
@@ -48,10 +51,42 @@ module Ewelink
|
|
48
51
|
end
|
49
52
|
|
50
53
|
def reload
|
51
|
-
Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices, region,...)' }
|
52
|
-
|
53
|
-
@
|
54
|
-
|
54
|
+
Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices, region, connections,...)' }
|
55
|
+
|
56
|
+
@web_socket_authenticated_api_keys.clear
|
57
|
+
@web_socket_switches_statuses.clear
|
58
|
+
|
59
|
+
[@web_socket_ping_thread, @web_socket_thread].each do |thread|
|
60
|
+
next unless thread
|
61
|
+
if Thread.current == thread
|
62
|
+
thread[:stop] = true
|
63
|
+
else
|
64
|
+
thread.kill
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
if @web_socket.present?
|
69
|
+
begin
|
70
|
+
@web_socket.close if @web_socket.open?
|
71
|
+
rescue
|
72
|
+
# Ignoring close errors
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
[
|
77
|
+
:@api_keys,
|
78
|
+
:@authentication_token,
|
79
|
+
:@devices,
|
80
|
+
:@last_web_socket_pong_at,
|
81
|
+
:@region,
|
82
|
+
:@rf_bridge_buttons,
|
83
|
+
:@switches,
|
84
|
+
:@web_socket_ping_interval,
|
85
|
+
:@web_socket_ping_thread,
|
86
|
+
:@web_socket_thread,
|
87
|
+
:@web_socket_url,
|
88
|
+
:@web_socket,
|
89
|
+
].each do |variable|
|
55
90
|
remove_instance_variable(variable) if instance_variable_defined?(variable)
|
56
91
|
end
|
57
92
|
self
|
@@ -93,21 +128,22 @@ module Ewelink
|
|
93
128
|
|
94
129
|
def switch_on?(uuid)
|
95
130
|
switch = find_switch!(uuid)
|
96
|
-
if @
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
131
|
+
if @web_socket_switches_statuses[switch[:uuid]].nil?
|
132
|
+
web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
|
133
|
+
Ewelink.logger.debug(self.class.name) { "Checking switch #{switch[:uuid].inspect} status" }
|
134
|
+
params = {
|
135
|
+
'action' => 'query',
|
136
|
+
'apikey' => switch[:api_key],
|
137
|
+
'deviceid' => switch[:device_id],
|
138
|
+
'sequence' => web_socket_sequence,
|
139
|
+
'ts' => 0,
|
140
|
+
'userAgent' => 'app',
|
141
|
+
}
|
106
142
|
send_to_web_socket(JSON.generate(params))
|
107
143
|
end
|
108
144
|
end
|
109
|
-
web_socket_wait_for(-> { !@
|
110
|
-
@
|
145
|
+
web_socket_wait_for(-> { !@web_socket_switches_statuses[switch[:uuid]].nil? }, initialize_web_socket: true) do
|
146
|
+
@web_socket_switches_statuses[switch[:uuid]] == 'on'
|
111
147
|
end
|
112
148
|
end
|
113
149
|
|
@@ -138,8 +174,8 @@ module Ewelink
|
|
138
174
|
on = false
|
139
175
|
end
|
140
176
|
switch = find_switch!(uuid)
|
141
|
-
@
|
142
|
-
web_socket_wait_for(-> { web_socket_authenticated? }) do
|
177
|
+
@web_socket_switches_statuses[switch[:uuid]] = nil
|
178
|
+
web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
|
143
179
|
params = {
|
144
180
|
'action' => 'update',
|
145
181
|
'apikey' => switch[:api_key],
|
@@ -151,9 +187,12 @@ module Ewelink
|
|
151
187
|
'ts' => 0,
|
152
188
|
'userAgent' => 'app',
|
153
189
|
}
|
190
|
+
Ewelink.logger.debug(self.class.name) { "Turning switch #{switch[:uuid].inspect} #{on ? 'on' : 'off'}" }
|
154
191
|
send_to_web_socket(JSON.generate(params))
|
155
|
-
true
|
156
192
|
end
|
193
|
+
sleep(SWITCH_STATUS_CHANGE_CHECK_TIMEOUT)
|
194
|
+
switch_on?(switch[:uuid]) # Waiting for switch status update
|
195
|
+
true
|
157
196
|
end
|
158
197
|
|
159
198
|
private
|
@@ -164,6 +203,24 @@ module Ewelink
|
|
164
203
|
end
|
165
204
|
end
|
166
205
|
|
206
|
+
def authenticate_web_socket_api_keys
|
207
|
+
api_keys.each do |api_key|
|
208
|
+
params = {
|
209
|
+
'action' => 'userOnline',
|
210
|
+
'apikey' => api_key,
|
211
|
+
'appid' => APP_ID,
|
212
|
+
'at' => authentication_token,
|
213
|
+
'nonce' => nonce,
|
214
|
+
'sequence' => web_socket_sequence,
|
215
|
+
'ts' => Time.now.to_i,
|
216
|
+
'userAgent' => 'app',
|
217
|
+
'version' => VERSION,
|
218
|
+
}
|
219
|
+
Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" }
|
220
|
+
send_to_web_socket(JSON.generate(params))
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
167
224
|
def authentication_headers
|
168
225
|
{ 'Authorization' => "Bearer #{authentication_token}" }
|
169
226
|
end
|
@@ -208,18 +265,6 @@ module Ewelink
|
|
208
265
|
end
|
209
266
|
end
|
210
267
|
|
211
|
-
def dispose_web_socket
|
212
|
-
@web_socket_authenticated_api_keys = Set.new
|
213
|
-
if instance_variable_defined?(:@web_socket)
|
214
|
-
begin
|
215
|
-
@web_socket.close if @web_socket.open?
|
216
|
-
rescue
|
217
|
-
# Ignoring close errors
|
218
|
-
end
|
219
|
-
remove_instance_variable(:@web_socket) if instance_variable_defined?(:@web_socket)
|
220
|
-
end
|
221
|
-
end
|
222
|
-
|
223
268
|
def find_rf_bridge_button!(uuid)
|
224
269
|
rf_bridge_buttons.find { |button| button[:uuid] == uuid } || raise(Error.new("No such RF bridge button with UUID: #{uuid.inspect}"))
|
225
270
|
end
|
@@ -241,7 +286,7 @@ module Ewelink
|
|
241
286
|
method = method.to_s.upcase
|
242
287
|
headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json')
|
243
288
|
Ewelink.logger.debug(self.class.name) { "#{method} #{url}" }
|
244
|
-
response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout:
|
289
|
+
response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: REQUEST_TIMEOUT))
|
245
290
|
raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success?
|
246
291
|
if response['error'] == 301 && response['region'].present?
|
247
292
|
@region = response['region']
|
@@ -255,11 +300,29 @@ module Ewelink
|
|
255
300
|
raise Error.new(e)
|
256
301
|
end
|
257
302
|
|
258
|
-
def send_to_web_socket(
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
303
|
+
def send_to_web_socket(message)
|
304
|
+
if web_socket_outdated_ping?
|
305
|
+
Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' }
|
306
|
+
reload
|
307
|
+
end
|
308
|
+
web_socket.send(message)
|
309
|
+
rescue => e
|
310
|
+
reload
|
311
|
+
raise Error.new(e)
|
312
|
+
end
|
313
|
+
|
314
|
+
def start_web_socket_ping_thread(interval)
|
315
|
+
@last_web_socket_pong_at = Time.now
|
316
|
+
@web_socket_ping_interval = interval
|
317
|
+
Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" }
|
318
|
+
@web_socket_ping_thread = Thread.new do
|
319
|
+
loop do
|
320
|
+
break if Thread.current[:stop]
|
321
|
+
sleep(@web_socket_ping_interval)
|
322
|
+
Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' }
|
323
|
+
send_to_web_socket('ping')
|
324
|
+
end
|
325
|
+
end
|
263
326
|
end
|
264
327
|
|
265
328
|
def synchronize(name, &block)
|
@@ -268,69 +331,71 @@ module Ewelink
|
|
268
331
|
|
269
332
|
def web_socket
|
270
333
|
synchronize(:web_socket) do
|
271
|
-
@web_socket
|
272
|
-
api = self
|
334
|
+
next @web_socket if @web_socket
|
273
335
|
|
274
|
-
|
275
|
-
|
336
|
+
@web_socket_thread = Thread.new do
|
337
|
+
EventMachine.run do
|
338
|
+
Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url.inspect}" }
|
276
339
|
|
277
|
-
web_socket.
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
340
|
+
@web_socket = Faye::WebSocket::Client.new('wss://as-pconnect3.coolkit.cc:8080/api/ws')
|
341
|
+
|
342
|
+
@web_socket.on(:close) do |event|
|
343
|
+
Ewelink.logger.debug(self.class.name) { 'WebSocket closed' }
|
344
|
+
reload
|
282
345
|
end
|
283
346
|
|
284
|
-
web_socket.on(:
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
end
|
347
|
+
@web_socket.on(:open) do |event|
|
348
|
+
Ewelink.logger.debug(self.class.name) { 'WebSocket opened' }
|
349
|
+
@last_web_socket_pong_at = Time.now
|
350
|
+
authenticate_web_socket_api_keys
|
289
351
|
end
|
290
352
|
|
291
|
-
web_socket.on(:message) do |
|
292
|
-
|
293
|
-
response = JSON.parse(message.data)
|
353
|
+
@web_socket.on(:message) do |event|
|
354
|
+
message = event.data
|
294
355
|
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
356
|
+
if message == 'pong'
|
357
|
+
Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.inspect} message" }
|
358
|
+
@last_web_socket_pong_at = Time.now
|
359
|
+
next
|
360
|
+
end
|
299
361
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
362
|
+
begin
|
363
|
+
json = JSON.parse(message)
|
364
|
+
rescue => e
|
365
|
+
Ewelink.logger.error(self.class.name) { 'WebSocket JSON parse error' }
|
366
|
+
reload
|
367
|
+
next
|
368
|
+
end
|
304
369
|
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
370
|
+
if json.key?('error') && json['error'] != 0
|
371
|
+
Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.inspect}" }
|
372
|
+
reload
|
373
|
+
next
|
374
|
+
end
|
375
|
+
|
376
|
+
if !@web_socket_ping_thread && json.key?('config') && json['config']['hb'] == 1 && json['config']['hbInterval'].present?
|
377
|
+
start_web_socket_ping_thread(json['config']['hbInterval'] + 7)
|
309
378
|
end
|
310
|
-
end
|
311
379
|
|
312
|
-
|
313
|
-
|
314
|
-
Ewelink.logger.debug(self.class.name) {
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
'sequence' => web_socket_sequence,
|
323
|
-
'ts' => Time.now.to_i,
|
324
|
-
'userAgent' => 'app',
|
325
|
-
'version' => VERSION,
|
326
|
-
}
|
327
|
-
Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.inspect}" }
|
328
|
-
send_to_web_socket(JSON.generate(params))
|
380
|
+
if json['apikey'].present? && !@web_socket_authenticated_api_keys.include?(json['apikey'])
|
381
|
+
@web_socket_authenticated_api_keys << json['apikey']
|
382
|
+
Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{json['apikey'].truncate(16).inspect}" }
|
383
|
+
end
|
384
|
+
|
385
|
+
if json['deviceid'].present? && json['params'].is_a?(Hash) && json['params']['switch'].present?
|
386
|
+
switch = switches.find { |switch| switch[:device_id] == json['deviceid'] }
|
387
|
+
if switch.present?
|
388
|
+
@web_socket_switches_statuses[switch[:uuid]] = json['params']['switch']
|
389
|
+
Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@web_socket_switches_statuses[switch[:uuid]]}" }
|
329
390
|
end
|
330
391
|
end
|
331
392
|
end
|
332
393
|
end
|
333
394
|
end
|
395
|
+
|
396
|
+
web_socket_wait_for(-> { @web_socket.present? }) do
|
397
|
+
@web_socket
|
398
|
+
end
|
334
399
|
end
|
335
400
|
end
|
336
401
|
|
@@ -338,6 +403,10 @@ module Ewelink
|
|
338
403
|
api_keys == @web_socket_authenticated_api_keys
|
339
404
|
end
|
340
405
|
|
406
|
+
def web_socket_outdated_ping?
|
407
|
+
@last_web_socket_pong_at.present? && @web_socket_ping_interval.present? && @last_web_socket_pong_at < (@web_socket_ping_interval * WEB_SOCKET_PING_TOLERANCE_FACTOR).seconds.ago
|
408
|
+
end
|
409
|
+
|
341
410
|
def web_socket_sequence
|
342
411
|
(Time.now.to_f * 1000).round.to_s
|
343
412
|
end
|
@@ -361,13 +430,18 @@ module Ewelink
|
|
361
430
|
end
|
362
431
|
end
|
363
432
|
|
364
|
-
def web_socket_wait_for(condition, &block)
|
365
|
-
web_socket
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
433
|
+
def web_socket_wait_for(condition, initialize_web_socket: false, &block)
|
434
|
+
web_socket if initialize_web_socket
|
435
|
+
begin
|
436
|
+
Timeout.timeout(REQUEST_TIMEOUT) do
|
437
|
+
while !condition.call
|
438
|
+
sleep(WEB_SOCKET_WAIT_INTERVAL)
|
439
|
+
end
|
440
|
+
block_given? ? yield : true
|
370
441
|
end
|
442
|
+
rescue => e
|
443
|
+
reload
|
444
|
+
raise Error.new(e)
|
371
445
|
end
|
372
446
|
end
|
373
447
|
|
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.
|
4
|
+
version: 2.2.2
|
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-
|
11
|
+
date: 2020-09-04 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:
|
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.
|
39
|
+
version: 0.11.0
|
40
40
|
- - "<"
|
41
41
|
- !ruby/object:Gem::Version
|
42
|
-
version: 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.
|
49
|
+
version: 0.11.0
|
50
50
|
- - "<"
|
51
51
|
- !ruby/object:Gem::Version
|
52
|
-
version: 0.
|
52
|
+
version: 0.12.0
|
53
53
|
- !ruby/object:Gem::Dependency
|
54
|
-
name:
|
54
|
+
name: httparty
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
57
|
- - ">="
|
58
58
|
- !ruby/object:Gem::Version
|
59
|
-
version: 0.
|
59
|
+
version: 0.18.0
|
60
60
|
- - "<"
|
61
61
|
- !ruby/object:Gem::Version
|
62
|
-
version: 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.
|
69
|
+
version: 0.18.0
|
70
70
|
- - "<"
|
71
71
|
- !ruby/object:Gem::Version
|
72
|
-
version: 0.
|
72
|
+
version: 0.19.0
|
73
73
|
- !ruby/object:Gem::Dependency
|
74
74
|
name: byebug
|
75
75
|
requirement: !ruby/object:Gem::Requirement
|