ewelink 3.0.0 → 3.1.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.
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