ewelink 3.0.0 → 3.1.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: 558b7f50525eabfc0e0ac3cd32fd5a0ff5c0d4b6cc7658024dba4c6c0336f96a
4
- data.tar.gz: cda09a44af6247436a22af9443a1dd3c078e8b86ab228f3ce12761baca606a7b
3
+ metadata.gz: 45f904d5e72d0038360340adc26dd7eb963f664804b43eee8235ee02d48f60ff
4
+ data.tar.gz: 98ee94c2d9187ea17250c3f32c1932f8c0df8a2123c341a47159c32e2aec66e9
5
5
  SHA512:
6
- metadata.gz: 9607d6170f6e3f0cb00d599fa7a8258b25367423e6a87b2238079d336d6ee2a58389f78ffdcbc3eaf9726c1685ff3cb8b95fb0387d655ae6b81614d50d990c01
7
- data.tar.gz: 4ad312d700e40c50f20cd47f5d66dec54937633e1162e21b3d131556752dc937e41c70d128e210d344d19a8bd0cb0fb4de1606f081cba50ad1189c84655440b6
6
+ metadata.gz: 42977ef17b87c9c6710973968ca739d642108d6595861a870090ef13fbe5332b7ff99fba0dc8444e8022dfba065f4334426daf2dc423ed08314b3939cc41e69a
7
+ data.tar.gz: 4fa2068348c252aecfa1f0d55f91013a17658a3403fa7813208f34f027757b2459181323aae9a9d8c71c915ac8c83e881150c4eacee77ed85832b1adaeadb451
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.0.0
1
+ 3.1.0
@@ -16,6 +16,7 @@ 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
21
 
21
22
  s.add_development_dependency 'byebug', '>= 11.0.0', '< 12.0.0'
@@ -1,12 +1,16 @@
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'
7
9
  require 'logger'
8
10
  require 'openssl'
9
11
  require 'optparse'
12
+ require 'set'
13
+ require 'timeout'
10
14
 
11
15
  module Ewelink
12
16
 
@@ -5,12 +5,16 @@ 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
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
15
+ WEB_SOCKET_PING_TOLERANCE_FACTOR = 1.5
16
+ SWITCH_STATUS_CHANGE_CHECK_TIMEOUT = 2.seconds
17
+ WEB_SOCKET_WAIT_INTERVAL = 0.2.seconds
14
18
 
15
19
  attr_reader :email, :password, :phone_number
16
20
 
@@ -19,31 +23,73 @@ module Ewelink
19
23
  @mutexs = {}
20
24
  @password = password.presence || raise(Error.new(":password must be specified"))
21
25
  @phone_number = phone_number.presence.try(:strip)
22
- raise(Error.new(":email or :phone_number must be specified")) if email.blank? && phone_number.blank?
26
+ @web_socket_authenticated = false
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
23
32
  end
24
33
 
25
34
  def press_rf_bridge_button!(uuid)
26
35
  synchronize(:press_rf_bridge_button) do
27
36
  button = find_rf_bridge_button!(uuid)
28
- params = {
29
- 'appid' => APP_ID,
30
- 'deviceid' => button[:device_id],
31
- 'nonce' => nonce,
32
- 'params' => {
33
- 'cmd' => 'transmit',
34
- 'rfChl' => button[:channel],
35
- },
36
- 'ts' => Time.now.to_i,
37
- 'version' => VERSION,
38
- }
39
- rest_request(:post, '/api/user/device/status', body: JSON.generate(params), headers: authentication_headers)
40
- true
37
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
38
+ params = {
39
+ 'action' => 'update',
40
+ 'apikey' => button[:api_key],
41
+ 'deviceid' => button[:device_id],
42
+ 'params' => {
43
+ 'cmd' => 'transmit',
44
+ 'rfChl' => button[:channel],
45
+ },
46
+ 'sequence' => web_socket_sequence,
47
+ 'ts' => 0,
48
+ 'userAgent' => 'app',
49
+ }
50
+ Ewelink.logger.debug(self.class.name) { "Pressing RF bridge button #{button[:uuid].inspect}" }
51
+ send_to_web_socket(JSON.generate(params))
52
+ true
53
+ end
41
54
  end
42
55
  end
43
56
 
44
57
  def reload
45
- Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices & region cache)' }
46
- [:@authentication_token, :@devices, :@rf_bridge_buttons, :@region, :@switches].each do |variable|
58
+ Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, api key, devices, region, connections,...)' }
59
+
60
+ @web_socket_authenticated = false
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
+
80
+ [
81
+ :@authentication_infos,
82
+ :@devices,
83
+ :@last_web_socket_pong_at,
84
+ :@region,
85
+ :@rf_bridge_buttons,
86
+ :@switches,
87
+ :@web_socket_ping_interval,
88
+ :@web_socket_ping_thread,
89
+ :@web_socket_thread,
90
+ :@web_socket_url,
91
+ :@web_socket,
92
+ ].each do |variable|
47
93
  remove_instance_variable(variable) if instance_variable_defined?(variable)
48
94
  end
49
95
  self
@@ -56,10 +102,12 @@ module Ewelink
56
102
  Ewelink.logger.debug(self.class.name) { "Found #{devices.size} RF 433MHz bridge device(s)" }
57
103
  end
58
104
  rf_bridge_devices.each do |device|
105
+ api_key = device['apikey'].presence || next
59
106
  device_id = device['deviceid'].presence || next
60
107
  device_name = device['name'].presence || next
61
108
  buttons = device['params']['rfList'].each do |rf|
62
109
  button = {
110
+ api_key: api_key,
63
111
  channel: rf['rfChl'],
64
112
  device_id: device_id,
65
113
  device_name: device_name,
@@ -83,15 +131,23 @@ module Ewelink
83
131
 
84
132
  def switch_on?(uuid)
85
133
  switch = find_switch!(uuid)
86
- params = {
87
- 'appid' => APP_ID,
88
- 'deviceid' => switch[:device_id],
89
- 'nonce' => nonce,
90
- 'ts' => Time.now.to_i,
91
- 'version' => VERSION,
92
- }
93
- response = rest_request(:get, '/api/user/device/status', headers: authentication_headers, query: params)
94
- response['params']['switch'] == 'on'
134
+ if @web_socket_switches_statuses[switch[:uuid]].nil?
135
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
136
+ Ewelink.logger.debug(self.class.name) { "Checking switch #{switch[:uuid].inspect} status" }
137
+ params = {
138
+ 'action' => 'query',
139
+ 'apikey' => switch[:api_key],
140
+ 'deviceid' => switch[:device_id],
141
+ 'sequence' => web_socket_sequence,
142
+ 'ts' => 0,
143
+ 'userAgent' => 'app',
144
+ }
145
+ send_to_web_socket(JSON.generate(params))
146
+ end
147
+ end
148
+ web_socket_wait_for(-> { !@web_socket_switches_statuses[switch[:uuid]].nil? }, initialize_web_socket: true) do
149
+ @web_socket_switches_statuses[switch[:uuid]] == 'on'
150
+ end
95
151
  end
96
152
 
97
153
  def switches
@@ -99,9 +155,11 @@ module Ewelink
99
155
  @switches ||= [].tap do |switches|
100
156
  switch_devices = devices.select { |device| SWITCH_DEVICES_UIIDS.include?(device['uiid']) }
101
157
  switch_devices.each do |device|
158
+ api_key = device['apikey'].presence || next
102
159
  device_id = device['deviceid'].presence || next
103
160
  name = device['name'].presence || next
104
161
  switch = {
162
+ api_key: api_key,
105
163
  device_id: device_id,
106
164
  name: name,
107
165
  }
@@ -119,29 +177,56 @@ module Ewelink
119
177
  on = false
120
178
  end
121
179
  switch = find_switch!(uuid)
180
+ @web_socket_switches_statuses[switch[:uuid]] = nil
181
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
182
+ params = {
183
+ 'action' => 'update',
184
+ 'apikey' => switch[:api_key],
185
+ 'deviceid' => switch[:device_id],
186
+ 'params' => {
187
+ 'switch' => on ? 'on' : 'off',
188
+ },
189
+ 'sequence' => web_socket_sequence,
190
+ 'ts' => 0,
191
+ 'userAgent' => 'app',
192
+ }
193
+ Ewelink.logger.debug(self.class.name) { "Turning switch #{switch[:uuid].inspect} #{on ? 'on' : 'off'}" }
194
+ send_to_web_socket(JSON.generate(params))
195
+ end
196
+ sleep(SWITCH_STATUS_CHANGE_CHECK_TIMEOUT)
197
+ switch_on?(switch[:uuid]) # Waiting for switch status update
198
+ true
199
+ end
200
+
201
+ private
202
+
203
+ def api_key
204
+ authentication_infos[:api_key]
205
+ end
206
+
207
+ def authenticate_web_socket_api_key
122
208
  params = {
209
+ 'action' => 'userOnline',
210
+ 'apikey' => api_key,
123
211
  'appid' => APP_ID,
124
- 'deviceid' => switch[:device_id],
212
+ 'at' => authentication_token,
125
213
  'nonce' => nonce,
126
- 'params' => {
127
- 'switch' => on ? 'on' : 'off',
128
- },
214
+ 'sequence' => web_socket_sequence,
129
215
  'ts' => Time.now.to_i,
216
+ 'userAgent' => 'app',
130
217
  'version' => VERSION,
131
218
  }
132
- rest_request(:post, '/api/user/device/status', body: JSON.generate(params), headers: authentication_headers)
133
- true
219
+ Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" }
220
+ send_to_web_socket(JSON.generate(params))
134
221
  end
135
222
 
136
- private
137
-
138
223
  def authentication_headers
139
224
  { 'Authorization' => "Bearer #{authentication_token}" }
140
225
  end
141
226
 
142
- def authentication_token
143
- synchronize(:authentication_token) do
144
- @authentication_token ||= begin
227
+ def authentication_infos
228
+ synchronize(:authentication_infos) do
229
+ @authentication_infos ||= begin
145
230
  params = {
146
231
  'appid' => APP_ID,
147
232
  'imei' => SecureRandom.uuid.upcase,
@@ -158,11 +243,19 @@ module Ewelink
158
243
  body = JSON.generate(params)
159
244
  response = rest_request(:post, '/api/user/login', { body: body, headers: { 'Authorization' => "Sign #{Base64.encode64(OpenSSL::HMAC.digest('SHA256', APP_SECRET, body))}" } })
160
245
  raise(Error.new('Authentication token not found')) if response['at'].blank?
161
- response['at'].tap { Ewelink.logger.debug(self.class.name) { 'Authentication token found' } }
246
+ raise(Error.new('API key not found')) if response['user'].blank? || response['user']['apikey'].blank?
247
+ {
248
+ authentication_token: response['at'].tap { Ewelink.logger.debug(self.class.name) { 'Authentication token found' } },
249
+ api_key: response['user']['apikey'].tap { Ewelink.logger.debug(self.class.name) { 'API key found' } },
250
+ }
162
251
  end
163
252
  end
164
253
  end
165
254
 
255
+ def authentication_token
256
+ authentication_infos[:authentication_token]
257
+ end
258
+
166
259
  def devices
167
260
  synchronize(:devices) do
168
261
  @devices ||= begin
@@ -200,24 +293,189 @@ module Ewelink
200
293
  method = method.to_s.upcase
201
294
  headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json')
202
295
  Ewelink.logger.debug(self.class.name) { "#{method} #{url}" }
203
- response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: TIMEOUT))
296
+ response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: REQUEST_TIMEOUT))
204
297
  raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success?
205
298
  if response['error'] == 301 && response['region'].present?
206
299
  @region = response['region']
207
300
  Ewelink.logger.debug(self.class.name) { "Switched to region #{region.inspect}" }
208
301
  return rest_request(method, path, options)
209
302
  end
210
- remove_instance_variable(:@authentication_token) if instance_variable_defined?(:@authentication_token) && [401, 403].include?(response['error'])
303
+ remove_instance_variable(:@authentication_infos) if instance_variable_defined?(:@authentication_infos) && [401, 403].include?(response['error'])
211
304
  raise(Error.new("#{method} #{url}: #{response['error']} #{response['msg']}".strip)) if response['error'].present? && response['error'] != 0
212
- response
305
+ response.to_h
213
306
  rescue Errno::ECONNREFUSED, OpenSSL::OpenSSLError, SocketError, Timeout::Error => e
214
307
  raise Error.new(e)
215
308
  end
216
309
 
310
+ def send_to_web_socket(message)
311
+ web_socket.send(message)
312
+ rescue => e
313
+ reload
314
+ raise Error.new(e)
315
+ end
316
+
317
+ def start_web_socket_authentication_check_thread
318
+ raise Error.new('WebSocket authentication check must only be started once') if @web_socket_authentication_check_thread.present?
319
+
320
+ @web_socket_authentication_check_thread = Thread.new do
321
+ loop do
322
+ Ewelink.logger.debug(self.class.name) { 'Checking if WebSocket is authenticated' }
323
+ begin
324
+ web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do
325
+ Ewelink.logger.debug(self.class.name) { 'WebSocket is authenticated' }
326
+ end
327
+ rescue => e
328
+ Ewelink.logger.error(self.class.name) { e }
329
+ end
330
+ sleep(WEB_SOCKET_CHECK_AUTHENTICATION_TIMEOUT)
331
+ end
332
+ end
333
+ end
334
+
335
+ def start_web_socket_ping_thread(interval)
336
+ @last_web_socket_pong_at = Time.now
337
+ @web_socket_ping_interval = interval
338
+ Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" }
339
+ @web_socket_ping_thread = Thread.new do
340
+ loop do
341
+ break if Thread.current[:stop]
342
+ sleep(@web_socket_ping_interval)
343
+ Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' }
344
+ send_to_web_socket('ping')
345
+ end
346
+ end
347
+ end
348
+
217
349
  def synchronize(name, &block)
218
350
  (@mutexs[name] ||= Mutex.new).synchronize(&block)
219
351
  end
220
352
 
353
+ def web_socket
354
+ if web_socket_outdated_ping?
355
+ Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' }
356
+ reload
357
+ end
358
+
359
+ synchronize(:web_socket) do
360
+ next @web_socket if @web_socket
361
+
362
+ # Initializes caches before opening WebSocket: important in order to
363
+ # NOT cumulate requests Timeouts from #web_socket_wait_for.
364
+ api_key
365
+ web_socket_url
366
+
367
+ Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url.inspect}" }
368
+
369
+ @web_socket_thread = Thread.new do
370
+ EventMachine.run do
371
+ @web_socket = Faye::WebSocket::Client.new(web_socket_url)
372
+
373
+ @web_socket.on(:close) do |event|
374
+ Ewelink.logger.debug(self.class.name) { 'WebSocket closed' }
375
+ reload
376
+ end
377
+
378
+ @web_socket.on(:open) do |event|
379
+ Ewelink.logger.debug(self.class.name) { 'WebSocket opened' }
380
+ @last_web_socket_pong_at = Time.now
381
+ authenticate_web_socket_api_key
382
+ end
383
+
384
+ @web_socket.on(:message) do |event|
385
+ message = event.data
386
+
387
+ if message == 'pong'
388
+ Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.inspect} message" }
389
+ @last_web_socket_pong_at = Time.now
390
+ next
391
+ end
392
+
393
+ begin
394
+ json = JSON.parse(message)
395
+ rescue => e
396
+ Ewelink.logger.error(self.class.name) { 'WebSocket JSON parse error' }
397
+ reload
398
+ next
399
+ end
400
+
401
+ if json.key?('error') && json['error'] != 0
402
+ Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.inspect}" }
403
+ reload
404
+ next
405
+ end
406
+
407
+ if !@web_socket_ping_thread && json.key?('config') && json['config']['hb'] == 1 && json['config']['hbInterval'].present?
408
+ start_web_socket_ping_thread(json['config']['hbInterval'] + 7)
409
+ end
410
+
411
+ if json['apikey'].present? && !@web_socket_authenticated && json['apikey'] == api_key
412
+ @web_socket_authenticated = true
413
+ Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{json['apikey'].truncate(16).inspect}" }
414
+ end
415
+
416
+ if json['deviceid'].present? && json['params'].is_a?(Hash) && json['params']['switch'].present?
417
+ switch = switches.find { |switch| switch[:device_id] == json['deviceid'] }
418
+ if switch.present?
419
+ @web_socket_switches_statuses[switch[:uuid]] = json['params']['switch']
420
+ Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@web_socket_switches_statuses[switch[:uuid]]}" }
421
+ end
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ web_socket_wait_for(-> { @web_socket.present? }) do
428
+ @web_socket
429
+ end
430
+ end
431
+ end
432
+
433
+ def web_socket_authenticated?
434
+ @web_socket_authenticated.present?
435
+ end
436
+
437
+ def web_socket_outdated_ping?
438
+ @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
439
+ end
440
+
441
+ def web_socket_sequence
442
+ (Time.now.to_f * 1000).round.to_s
443
+ end
444
+
445
+ def web_socket_url
446
+ synchronize(:web_socket_url) do
447
+ @web_socket_url ||= begin
448
+ params = {
449
+ 'accept' => 'ws',
450
+ 'appid' => APP_ID,
451
+ 'nonce' => nonce,
452
+ 'ts' => Time.now.to_i,
453
+ 'version' => VERSION,
454
+ }
455
+ response = rest_request(:post, '/dispatch/app', body: JSON.generate(params), headers: authentication_headers)
456
+ raise('Error while getting WebSocket URL') unless response['error'] == 0
457
+ domain = response['domain'].presence || raise("Can't get WebSocket server domain")
458
+ port = response['port'].presence || raise("Can't get WebSocket server port")
459
+ "wss://#{domain}:#{port}/api/ws".tap { |url| Ewelink.logger.debug(self.class.name) { "WebSocket URL is: #{url.inspect}" } }
460
+ end
461
+ end
462
+ end
463
+
464
+ def web_socket_wait_for(condition, initialize_web_socket: false, &block)
465
+ web_socket if initialize_web_socket
466
+ begin
467
+ Timeout.timeout(REQUEST_TIMEOUT) do
468
+ while !condition.call
469
+ sleep(WEB_SOCKET_WAIT_INTERVAL)
470
+ end
471
+ block_given? ? yield : true
472
+ end
473
+ rescue => e
474
+ reload
475
+ raise Error.new(e)
476
+ end
477
+ end
478
+
221
479
  end
222
480
 
223
481
  end
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: 3.0.0
4
+ version: 3.1.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-10-27 00:00:00.000000000 Z
11
+ date: 2020-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -30,6 +30,26 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: 7.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: faye-websocket
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.11.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: 0.12.0
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 0.11.0
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: 0.12.0
33
53
  - !ruby/object:Gem::Dependency
34
54
  name: httparty
35
55
  requirement: !ruby/object:Gem::Requirement