ewelink 1.3.0 → 2.2.1

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: 9f2548dd14bcfce1ce4cacb1a2768687d88e15bb4dae14223cadccd61176ab3d
4
- data.tar.gz: 995f4281dd22f168ffe90dc2f072209bd25b13487bb171d5dbcc4efc94380811
3
+ metadata.gz: 957d00b0d8171519a48b1cf828cbd78c21dbde34b93f2c1930273ad460d5a770
4
+ data.tar.gz: 0c636a47ac05f8428d9e099d952a0b89a512f8e199a3a85a72624c10a76590b3
5
5
  SHA512:
6
- metadata.gz: 74821997ef9c625b2506ebda9229ddac0605f7e4dda4ac836c60974fa6d17a2ebcb9cfd306947c60d958409e5277b6d2ebe1189effef32f91730bfa0be2a9883
7
- data.tar.gz: 0d1999216c2006a5f70102190196a9c890ef9ab0c9aa6464402f8c843a51c8211852c560e62778d901eb91bf19f331f1008dd500b998cb59daf6ebe72477e5b1
6
+ metadata.gz: c20dfab19c133d80913716e7be2b20e47958e841e61d7b401f28b73e642358d80ce698d5fd22410ede0bb783114c0e91dfc7a1b4760ebab3738aaba3081275af
7
+ data.tar.gz: 07664117dfacf9f32fa7e0965199bed938da619b75b313207d702d5ad83ebc2ceacbb76b266e40f3e995114542a110061ad271630a3b007fc2e4f1cbb2bccc58
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.3.0
1
+ 2.2.1
@@ -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,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
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
16
+ WEB_SOCKET_WAIT_INTERVAL = 0.2.seconds
14
17
 
15
18
  attr_reader :email, :password, :phone_number
16
19
 
@@ -19,31 +22,45 @@ module Ewelink
19
22
  @mutexs = {}
20
23
  @password = password.presence || raise(Error.new(":password must be specified"))
21
24
  @phone_number = phone_number.presence.try(:strip)
25
+ @web_socket_authenticated_api_keys = Set.new
26
+ @web_socket_switches_statuses = {}
22
27
  raise(Error.new(":email or :phone_number must be specified")) if email.blank? && phone_number.blank?
23
28
  end
24
29
 
25
30
  def press_rf_bridge_button!(uuid)
26
31
  synchronize(:press_rf_bridge_button) do
27
32
  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
33
+ web_socket_wait_for(-> { web_socket_authenticated? }) do
34
+ params = {
35
+ 'action' => 'update',
36
+ 'apikey' => button[:api_key],
37
+ 'deviceid' => button[:device_id],
38
+ 'params' => {
39
+ 'cmd' => 'transmit',
40
+ 'rfChl' => button[:channel],
41
+ },
42
+ 'sequence' => web_socket_sequence,
43
+ 'ts' => 0,
44
+ 'userAgent' => 'app',
45
+ }
46
+ Ewelink.logger.debug(self.class.name) { "Pressing RF bridge button #{button[:uuid].inspect}" }
47
+ send_to_web_socket(JSON.generate(params))
48
+ true
49
+ end
41
50
  end
42
51
  end
43
52
 
44
53
  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|
54
+ Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices, region,...)' }
55
+ dispose_web_socket
56
+ [
57
+ :@api_keys,
58
+ :@authentication_token,
59
+ :@devices,
60
+ :@region,
61
+ :@rf_bridge_buttons,
62
+ :@switches,
63
+ ].each do |variable|
47
64
  remove_instance_variable(variable) if instance_variable_defined?(variable)
48
65
  end
49
66
  self
@@ -56,10 +73,12 @@ module Ewelink
56
73
  Ewelink.logger.debug(self.class.name) { "Found #{devices.size} RF 433MHz bridge device(s)" }
57
74
  end
58
75
  rf_bridge_devices.each do |device|
76
+ api_key = device['apikey'].presence || next
59
77
  device_id = device['deviceid'].presence || next
60
78
  device_name = device['name'].presence || next
61
79
  buttons = device['params']['rfList'].each do |rf|
62
80
  button = {
81
+ api_key: api_key,
63
82
  channel: rf['rfChl'],
64
83
  device_id: device_id,
65
84
  device_name: device_name,
@@ -83,15 +102,23 @@ module Ewelink
83
102
 
84
103
  def switch_on?(uuid)
85
104
  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'
105
+ if @web_socket_switches_statuses[switch[:uuid]].nil?
106
+ web_socket_wait_for(-> { web_socket_authenticated? }) do
107
+ Ewelink.logger.debug(self.class.name) { "Checking switch #{switch[:uuid].inspect} status" }
108
+ params = {
109
+ 'action' => 'query',
110
+ 'apikey' => switch[:api_key],
111
+ 'deviceid' => switch[:device_id],
112
+ 'sequence' => web_socket_sequence,
113
+ 'ts' => 0,
114
+ 'userAgent' => 'app',
115
+ }
116
+ send_to_web_socket(JSON.generate(params))
117
+ end
118
+ end
119
+ web_socket_wait_for(-> { !@web_socket_switches_statuses[switch[:uuid]].nil? }) do
120
+ @web_socket_switches_statuses[switch[:uuid]] == 'on'
121
+ end
95
122
  end
96
123
 
97
124
  def switches
@@ -99,9 +126,11 @@ module Ewelink
99
126
  @switches ||= [].tap do |switches|
100
127
  switch_devices = devices.select { |device| SWITCH_DEVICES_UIIDS.include?(device['uiid']) }
101
128
  switch_devices.each do |device|
129
+ api_key = device['apikey'].presence || next
102
130
  device_id = device['deviceid'].presence || next
103
131
  name = device['name'].presence || next
104
132
  switch = {
133
+ api_key: api_key,
105
134
  device_id: device_id,
106
135
  name: name,
107
136
  }
@@ -119,22 +148,53 @@ module Ewelink
119
148
  on = false
120
149
  end
121
150
  switch = find_switch!(uuid)
122
- params = {
123
- 'appid' => APP_ID,
124
- 'deviceid' => switch[:device_id],
125
- 'nonce' => nonce,
126
- 'params' => {
127
- 'switch' => on ? 'on' : 'off',
128
- },
129
- 'ts' => Time.now.to_i,
130
- 'version' => VERSION,
131
- }
132
- rest_request(:post, '/api/user/device/status', body: JSON.generate(params), headers: authentication_headers)
151
+ @web_socket_switches_statuses[switch[:uuid]] = nil
152
+ web_socket_wait_for(-> { web_socket_authenticated? }) do
153
+ params = {
154
+ 'action' => 'update',
155
+ 'apikey' => switch[:api_key],
156
+ 'deviceid' => switch[:device_id],
157
+ 'params' => {
158
+ 'switch' => on ? 'on' : 'off',
159
+ },
160
+ 'sequence' => web_socket_sequence,
161
+ 'ts' => 0,
162
+ 'userAgent' => 'app',
163
+ }
164
+ Ewelink.logger.debug(self.class.name) { "Turning switch #{switch[:uuid].inspect} #{on ? 'on' : 'off'}" }
165
+ send_to_web_socket(JSON.generate(params))
166
+ end
167
+ sleep(SWITCH_STATUS_CHANGE_CHECK_TIMEOUT)
168
+ switch_on?(switch[:uuid]) # Waiting for switch status update
133
169
  true
134
170
  end
135
171
 
136
172
  private
137
173
 
174
+ def api_keys
175
+ synchronize(:api_keys) do
176
+ @api_keys ||= Set.new(devices.map { |device| device['apikey'] })
177
+ end
178
+ end
179
+
180
+ def authenticate_web_socket_api_keys
181
+ api_keys.each do |api_key|
182
+ params = {
183
+ 'action' => 'userOnline',
184
+ 'apikey' => api_key,
185
+ 'appid' => APP_ID,
186
+ 'at' => authentication_token,
187
+ 'nonce' => nonce,
188
+ 'sequence' => web_socket_sequence,
189
+ 'ts' => Time.now.to_i,
190
+ 'userAgent' => 'app',
191
+ 'version' => VERSION,
192
+ }
193
+ Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" }
194
+ send_to_web_socket(JSON.generate(params))
195
+ end
196
+ end
197
+
138
198
  def authentication_headers
139
199
  { 'Authorization' => "Bearer #{authentication_token}" }
140
200
  end
@@ -179,6 +239,40 @@ module Ewelink
179
239
  end
180
240
  end
181
241
 
242
+ def dispose_web_socket
243
+ Ewelink.logger.debug(self.class.name) { 'Dispose WebSocket' }
244
+ @web_socket_authenticated_api_keys.clear
245
+ @web_socket_switches_statuses.clear
246
+
247
+ [@web_socket_ping_thread, @web_socket_thread].each do |thread|
248
+ next unless thread
249
+ if Thread.current == thread
250
+ thread[:stop] = true
251
+ else
252
+ thread.kill
253
+ end
254
+ end
255
+
256
+ if @web_socket.present?
257
+ begin
258
+ @web_socket.close if @web_socket.open?
259
+ rescue
260
+ # Ignoring close errors
261
+ end
262
+ end
263
+
264
+ [
265
+ :@last_web_socket_pong_at,
266
+ :@web_socket_ping_interval,
267
+ :@web_socket_ping_thread,
268
+ :@web_socket_thread,
269
+ :@web_socket_url,
270
+ :@web_socket,
271
+ ].each do |variable|
272
+ remove_instance_variable(variable) if instance_variable_defined?(variable)
273
+ end
274
+ end
275
+
182
276
  def find_rf_bridge_button!(uuid)
183
277
  rf_bridge_buttons.find { |button| button[:uuid] == uuid } || raise(Error.new("No such RF bridge button with UUID: #{uuid.inspect}"))
184
278
  end
@@ -200,7 +294,7 @@ module Ewelink
200
294
  method = method.to_s.upcase
201
295
  headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json')
202
296
  Ewelink.logger.debug(self.class.name) { "#{method} #{url}" }
203
- response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: TIMEOUT))
297
+ response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: REQUEST_TIMEOUT))
204
298
  raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success?
205
299
  if response['error'] == 301 && response['region'].present?
206
300
  @region = response['region']
@@ -209,15 +303,155 @@ module Ewelink
209
303
  end
210
304
  remove_instance_variable(:@authentication_token) if instance_variable_defined?(:@authentication_token) && [401, 403].include?(response['error'])
211
305
  raise(Error.new("#{method} #{url}: #{response['error']} #{response['msg']}".strip)) if response['error'].present? && response['error'] != 0
212
- response
306
+ response.to_h
213
307
  rescue Errno::ECONNREFUSED, OpenSSL::OpenSSLError, SocketError, Timeout::Error => e
214
308
  raise Error.new(e)
215
309
  end
216
310
 
311
+ def send_to_web_socket(message)
312
+ if web_socket_outdated_ping?
313
+ Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' }
314
+ dispose_web_socket
315
+ end
316
+ web_socket.send(message)
317
+ rescue => e
318
+ dispose_web_socket
319
+ raise Error.new(e)
320
+ end
321
+
322
+ def start_web_socket_ping_thread(interval)
323
+ @last_web_socket_pong_at = Time.now
324
+ @web_socket_ping_interval = interval
325
+ Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" }
326
+ @web_socket_ping_thread = Thread.new do
327
+ loop do
328
+ break if Thread.current[:stop]
329
+ sleep(@web_socket_ping_interval)
330
+ Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' }
331
+ send_to_web_socket('ping')
332
+ end
333
+ end
334
+ end
335
+
217
336
  def synchronize(name, &block)
218
337
  (@mutexs[name] ||= Mutex.new).synchronize(&block)
219
338
  end
220
339
 
340
+ def web_socket
341
+ synchronize(:web_socket) do
342
+ next @web_socket if @web_socket
343
+
344
+ @web_socket_thread = Thread.new do
345
+ EventMachine.run do
346
+ Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url.inspect}" }
347
+
348
+ @web_socket = Faye::WebSocket::Client.new('wss://as-pconnect3.coolkit.cc:8080/api/ws')
349
+
350
+ @web_socket.on(:close) do |event|
351
+ Ewelink.logger.debug(self.class.name) { 'WebSocket closed' }
352
+ dispose_web_socket
353
+ end
354
+
355
+ @web_socket.on(:open) do |event|
356
+ Ewelink.logger.debug(self.class.name) { 'WebSocket opened' }
357
+ @last_web_socket_pong_at = Time.now
358
+ authenticate_web_socket_api_keys
359
+ end
360
+
361
+ @web_socket.on(:message) do |event|
362
+ message = event.data
363
+
364
+ if message == 'pong'
365
+ Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.inspect} message" }
366
+ @last_web_socket_pong_at = Time.now
367
+ next
368
+ end
369
+
370
+ begin
371
+ json = JSON.parse(message)
372
+ rescue => e
373
+ Ewelink.logger.error(self.class.name) { 'WebSocket JSON parse error' }
374
+ next
375
+ end
376
+
377
+ if json.key?('error') && json['error'] != 0
378
+ Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.inspect}" }
379
+ next
380
+ end
381
+
382
+ if !@web_socket_ping_thread && json.key?('config') && json['config']['hb'] == 1 && json['config']['hbInterval'].present?
383
+ start_web_socket_ping_thread(json['config']['hbInterval'] + 7)
384
+ end
385
+
386
+ if json['apikey'].present? && !@web_socket_authenticated_api_keys.include?(json['apikey'])
387
+ @web_socket_authenticated_api_keys << json['apikey']
388
+ Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{json['apikey'].truncate(16).inspect}" }
389
+ end
390
+
391
+ if json['deviceid'].present? && json['params'].is_a?(Hash) && json['params']['switch'].present?
392
+ switch = switches.find { |switch| switch[:device_id] == json['deviceid'] }
393
+ if switch.present?
394
+ @web_socket_switches_statuses[switch[:uuid]] = json['params']['switch']
395
+ Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@web_socket_switches_statuses[switch[:uuid]]}" }
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
401
+
402
+ Timeout.timeout(REQUEST_TIMEOUT) do
403
+ while @web_socket.blank?
404
+ sleep(WEB_SOCKET_WAIT_INTERVAL)
405
+ end
406
+ end
407
+
408
+ @web_socket
409
+ end
410
+ end
411
+
412
+ def web_socket_authenticated?
413
+ api_keys == @web_socket_authenticated_api_keys
414
+ end
415
+
416
+ def web_socket_outdated_ping?
417
+ @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
418
+ end
419
+
420
+ def web_socket_sequence
421
+ (Time.now.to_f * 1000).round.to_s
422
+ end
423
+
424
+ def web_socket_url
425
+ synchronize(:web_socket_url) do
426
+ @web_socket_url ||= begin
427
+ params = {
428
+ 'accept' => 'ws',
429
+ 'appid' => APP_ID,
430
+ 'nonce' => nonce,
431
+ 'ts' => Time.now.to_i,
432
+ 'version' => VERSION,
433
+ }
434
+ response = rest_request(:post, '/dispatch/app', body: JSON.generate(params), headers: authentication_headers)
435
+ raise('Error while getting WebSocket URL') unless response['error'] == 0
436
+ domain = response['domain'].presence || raise("Can't get WebSocket server domain")
437
+ port = response['port'].presence || raise("Can't get WebSocket server port")
438
+ "wss://#{domain}:#{port}/api/ws".tap { |url| Ewelink.logger.debug(self.class.name) { "WebSocket URL is: #{url.inspect}" } }
439
+ end
440
+ end
441
+ end
442
+
443
+ def web_socket_wait_for(condition, &block)
444
+ web_socket # Initializes WebSocket
445
+ Timeout.timeout(REQUEST_TIMEOUT) do
446
+ while !condition.call
447
+ sleep(WEB_SOCKET_WAIT_INTERVAL)
448
+ end
449
+ block_given? ? yield : true
450
+ rescue
451
+ dispose_web_socket
452
+ end
453
+ end
454
+
221
455
  end
222
456
 
223
457
  end
@@ -9,7 +9,7 @@ module Ewelink
9
9
  options[:turn_switches_on_uuids].each { |uuid| api.turn_switch!(uuid, :on) }
10
10
  options[:turn_switches_off_uuids].each { |uuid| api.turn_switch!(uuid, :off) }
11
11
  options[:press_rf_bridge_buttons_uuids].each { |uuid| api.press_rf_bridge_button!(uuid) }
12
- puts(JSON.pretty_generate(options[:switch_status_uuids].map { |uuid| [uuid, api.switch_on?(uuid) ? 'on' : 'off'] }.to_h))
12
+ puts(JSON.pretty_generate(options[:switch_status_uuids].map { |uuid| [uuid, api.switch_on?(uuid) ? 'on' : 'off'] }.to_h)) if options[:switch_status_uuids].present?
13
13
  end
14
14
 
15
15
  private
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: 1.3.0
4
+ version: 2.2.1
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-08-31 00:00:00.000000000 Z
11
+ date: 2020-09-03 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