ewelink 1.3.0 → 2.2.1

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: 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