class Ewelink::Api
Constants
- APP_ID
- APP_SECRET
- DEFAULT_REGION
- REQUEST_TIMEOUT
- RF_BRIDGE_DEVICE_UIID
- SWITCH_DEVICES_UIIDS
- SWITCH_STATUS_CHANGE_CHECK_TIMEOUT
- URL
- UUID_NAMESPACE
- VERSION
- WEB_SOCKET_CHECK_AUTHENTICATION_TIMEOUT
- WEB_SOCKET_PING_TOLERANCE_FACTOR
- WEB_SOCKET_WAIT_INTERVAL
Attributes
email[R]
password[R]
phone_number[R]
Public Class Methods
new(async_actions: false, email: nil, password:, phone_number: nil, update_devices_status_on_connect: false)
click to toggle source
# File lib/ewelink/api.rb, line 21 def initialize(async_actions: false, email: nil, password:, phone_number: nil, update_devices_status_on_connect: false) @async_actions = async_actions.present? @email = email.presence.try(:strip) @mutexs = {} @password = password.presence || raise(Error.new(":password must be specified")) @phone_number = phone_number.presence.try(:strip) @update_devices_status_on_connect = update_devices_status_on_connect.present? @web_socket_authenticated = false @web_socket_switches_statuses = {} raise(Error.new(':email or :phone_number must be specified')) if email.blank? && phone_number.blank? start_web_socket_authentication_check_thread end
Public Instance Methods
async_actions?()
click to toggle source
# File lib/ewelink/api.rb, line 36 def async_actions? @async_actions end
reload()
click to toggle source
# File lib/ewelink/api.rb, line 65 def reload Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, api key, devices, region, connections,...)' } @web_socket_authenticated = false @web_socket_switches_statuses.clear [@web_socket_ping_thread, @web_socket_thread].each do |thread| next unless thread if Thread.current == thread thread[:stop] = true else thread.kill end end if @web_socket.present? begin @web_socket.close if @web_socket.open? rescue # Ignoring close errors end end [ :@authentication_infos, :@devices, :@last_web_socket_pong_at, :@region, :@rf_bridge_buttons, :@switches, :@web_socket_ping_interval, :@web_socket_ping_thread, :@web_socket_thread, :@web_socket_url, :@web_socket, ].each do |variable| remove_instance_variable(variable) if instance_variable_defined?(variable) end self end
switch_on?(uuid)
click to toggle source
# File lib/ewelink/api.rb, line 140 def switch_on?(uuid) switch = find_switch!(uuid) if @web_socket_switches_statuses[switch[:uuid]].nil? web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do Ewelink.logger.debug(self.class.name) { "Checking switch #{switch[:uuid].inspect} status" } params = { 'action' => 'query', 'apikey' => switch[:api_key], 'deviceid' => switch[:device_id], 'sequence' => web_socket_sequence, 'ts' => 0, 'userAgent' => 'app', } send_to_web_socket(JSON.generate(params)) end end web_socket_wait_for(-> { !@web_socket_switches_statuses[switch[:uuid]].nil? }, initialize_web_socket: true) do @web_socket_switches_statuses[switch[:uuid]] == 'on' end end
switches()
click to toggle source
# File lib/ewelink/api.rb, line 161 def switches synchronize(:switches) do @switches ||= [].tap do |switches| switch_devices = devices.select { |device| SWITCH_DEVICES_UIIDS.include?(device['uiid']) } switch_devices.each do |device| api_key = device['apikey'].presence || next device_id = device['deviceid'].presence || next name = device['name'].presence || next switch = { api_key: api_key, device_id: device_id, name: name, } switch[:uuid] = Digest::UUID.uuid_v5(UUID_NAMESPACE, switch[:device_id]) switches << switch end end.tap { |switches| Ewelink.logger.debug(self.class.name) { "Found #{switches.size} switch(es)" } } end end
turn_switch!(uuid, on)
click to toggle source
# File lib/ewelink/api.rb, line 181 def turn_switch!(uuid, on) process_action do if ['on', :on, 'true'].include?(on) on = true elsif ['off', :off, 'false'].include?(on) on = false end switch = find_switch!(uuid) @web_socket_switches_statuses[switch[:uuid]] = nil web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do params = { 'action' => 'update', 'apikey' => switch[:api_key], 'deviceid' => switch[:device_id], 'params' => { 'switch' => on ? 'on' : 'off', }, 'sequence' => web_socket_sequence, 'ts' => 0, 'userAgent' => 'app', } Ewelink.logger.debug(self.class.name) { "Turning switch #{switch[:uuid].inspect} #{on ? 'on' : 'off'}" } send_to_web_socket(JSON.generate(params)) end sleep(SWITCH_STATUS_CHANGE_CHECK_TIMEOUT) switch_on?(switch[:uuid]) # Waiting for switch status update true end end
update_devices_status_on_connect?()
click to toggle source
# File lib/ewelink/api.rb, line 211 def update_devices_status_on_connect? @update_devices_status_on_connect end
Private Instance Methods
api_key()
click to toggle source
# File lib/ewelink/api.rb, line 217 def api_key authentication_infos[:api_key] end
authenticate_web_socket_api_key()
click to toggle source
# File lib/ewelink/api.rb, line 221 def authenticate_web_socket_api_key params = { 'action' => 'userOnline', 'apikey' => api_key, 'appid' => APP_ID, 'at' => authentication_token, 'nonce' => nonce, 'sequence' => web_socket_sequence, 'ts' => Time.now.to_i, 'userAgent' => 'app', 'version' => VERSION, } Ewelink.logger.debug(self.class.name) { "Authenticating WebSocket API key: #{api_key.truncate(16).inspect}" } send_to_web_socket(JSON.generate(params)) end
authentication_headers()
click to toggle source
# File lib/ewelink/api.rb, line 237 def authentication_headers { 'Authorization' => "Bearer #{authentication_token}" } end
authentication_infos()
click to toggle source
# File lib/ewelink/api.rb, line 241 def authentication_infos synchronize(:authentication_infos) do @authentication_infos ||= begin params = { 'appid' => APP_ID, 'imei' => SecureRandom.uuid.upcase, 'nonce' => nonce, 'password' => password, 'ts' => Time.now.to_i, 'version' => VERSION, } if email.present? params['email'] = email else params['phoneNumber'] = phone_number end body = JSON.generate(params) response = rest_request(:post, '/api/user/login', { body: body, headers: { 'Authorization' => "Sign #{Base64.encode64(OpenSSL::HMAC.digest('SHA256', APP_SECRET, body))}" } }) raise(Error.new('Authentication token not found')) if response['at'].blank? raise(Error.new('API key not found')) if response['user'].blank? || response['user']['apikey'].blank? { authentication_token: response['at'].tap { Ewelink.logger.debug(self.class.name) { 'Authentication token found' } }, api_key: response['user']['apikey'].tap { Ewelink.logger.debug(self.class.name) { 'API key found' } }, } end end end
authentication_token()
click to toggle source
# File lib/ewelink/api.rb, line 269 def authentication_token authentication_infos[:authentication_token] end
devices()
click to toggle source
# File lib/ewelink/api.rb, line 273 def devices synchronize(:devices) do @devices ||= begin params = { 'appid' => APP_ID, 'getTags' => 1, 'nonce' => nonce, 'ts' => Time.now.to_i, 'version' => VERSION, } response = rest_request(:get, '/api/user/device', headers: authentication_headers, query: params) response['devicelist'].tap { |devices| Ewelink.logger.debug(self.class.name) { "Found #{devices.size} device(s)" } } end end end
find_switch!(uuid)
click to toggle source
# File lib/ewelink/api.rb, line 293 def find_switch!(uuid) switches.find { |switch| switch[:uuid] == uuid } || raise(Error.new("No such switch with UUID: #{uuid.inspect}")) end
nonce()
click to toggle source
# File lib/ewelink/api.rb, line 297 def nonce SecureRandom.hex[0, 8] end
process_action() { || ... }
click to toggle source
# File lib/ewelink/api.rb, line 301 def process_action(&block) return yield unless async_actions? @async_actions_thread_pool ||= Thread.pool(1) @async_actions_thread_pool.process(&block) nil end
region()
click to toggle source
# File lib/ewelink/api.rb, line 308 def region @region ||= DEFAULT_REGION end
rest_request(method, path, options = {})
click to toggle source
# File lib/ewelink/api.rb, line 312 def rest_request(method, path, options = {}) url = "#{URL.gsub('#{region}', region)}#{path}" method = method.to_s.upcase headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json') Ewelink.logger.debug(self.class.name) { "#{method} #{url}" } response = HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: REQUEST_TIMEOUT)) raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success? if response['error'] == 301 && response['region'].present? @region = response['region'] Ewelink.logger.debug(self.class.name) { "Switched to region #{region.inspect}" } return rest_request(method, path, options) end remove_instance_variable(:@authentication_infos) if instance_variable_defined?(:@authentication_infos) && [401, 403].include?(response['error']) raise(Error.new("#{method} #{url}: #{response['error']} #{response['msg']}".strip)) if response['error'].present? && response['error'] != 0 response.to_h rescue Errno::ECONNREFUSED, OpenSSL::OpenSSLError, SocketError, Timeout::Error => e raise Error.new(e) end
send_to_web_socket(message)
click to toggle source
# File lib/ewelink/api.rb, line 331 def send_to_web_socket(message) web_socket.send(message) rescue => e reload raise Error.new(e) end
start_web_socket_authentication_check_thread()
click to toggle source
# File lib/ewelink/api.rb, line 338 def start_web_socket_authentication_check_thread raise Error.new('WebSocket authentication check must only be started once') if @web_socket_authentication_check_thread.present? @web_socket_authentication_check_thread = Thread.new do loop do Ewelink.logger.debug(self.class.name) { 'Checking if WebSocket is authenticated' } begin web_socket_wait_for(-> { web_socket_authenticated? }, initialize_web_socket: true) do Ewelink.logger.debug(self.class.name) { 'WebSocket is authenticated' } end rescue => e Ewelink.logger.error(self.class.name) { e } end sleep(WEB_SOCKET_CHECK_AUTHENTICATION_TIMEOUT) end end end
start_web_socket_ping_thread(interval)
click to toggle source
# File lib/ewelink/api.rb, line 356 def start_web_socket_ping_thread(interval) @last_web_socket_pong_at = Time.now @web_socket_ping_interval = interval Ewelink.logger.debug(self.class.name) { "Creating thread for WebSocket ping every #{@web_socket_ping_interval} seconds" } @web_socket_ping_thread = Thread.new do loop do break if Thread.current[:stop] sleep(@web_socket_ping_interval) Ewelink.logger.debug(self.class.name) { 'Sending WebSocket ping' } send_to_web_socket('ping') end end end
synchronize(name, &block)
click to toggle source
# File lib/ewelink/api.rb, line 370 def synchronize(name, &block) (@mutexs[name] ||= Mutex.new).synchronize(&block) end
web_socket()
click to toggle source
# File lib/ewelink/api.rb, line 374 def web_socket if web_socket_outdated_ping? Ewelink.logger.warn(self.class.name) { 'WebSocket ping is outdated' } reload end synchronize(:web_socket) do next @web_socket if @web_socket # Initializes caches before opening WebSocket: important in order to # NOT cumulate requests Timeouts from #web_socket_wait_for. api_key web_socket_url Ewelink.logger.debug(self.class.name) { "Opening WebSocket to #{web_socket_url.inspect}" } @web_socket_thread = Thread.new do EventMachine.run do @web_socket = Faye::WebSocket::Client.new(web_socket_url) @web_socket.on(:close) do |event| Ewelink.logger.debug(self.class.name) { 'WebSocket closed' } reload end @web_socket.on(:open) do |event| Ewelink.logger.debug(self.class.name) { 'WebSocket opened' } @last_web_socket_pong_at = Time.now authenticate_web_socket_api_key end @web_socket.on(:message) do |event| message = event.data if message == 'pong' Ewelink.logger.debug(self.class.name) { "Received WebSocket #{message.inspect} message" } @last_web_socket_pong_at = Time.now next end begin json = JSON.parse(message) rescue => e Ewelink.logger.error(self.class.name) { 'WebSocket JSON parse error' } reload next end if json.key?('error') && json['error'] != 0 Ewelink.logger.error(self.class.name) { "WebSocket message error: #{message.inspect}" } reload next end if !@web_socket_ping_thread && json.key?('config') && json['config']['hb'] == 1 && json['config']['hbInterval'].present? start_web_socket_ping_thread(json['config']['hbInterval'] + 7) end if json['apikey'].present? && !@web_socket_authenticated && json['apikey'] == api_key @web_socket_authenticated = true Ewelink.logger.debug(self.class.name) { "WebSocket successfully authenticated API key: #{json['apikey'].truncate(16).inspect}" } Thread.new { switches.each { |switch| switch_on?(switch[:uuid]) } } if update_devices_status_on_connect? end if json['deviceid'].present? && json['params'].is_a?(Hash) && json['params']['switch'].present? switch = switches.find { |switch| switch[:device_id] == json['deviceid'] } if switch.present? @web_socket_switches_statuses[switch[:uuid]] = json['params']['switch'] Ewelink.logger.debug(self.class.name) { "Switch #{switch[:uuid].inspect} is #{@web_socket_switches_statuses[switch[:uuid]]}" } end end end end end web_socket_wait_for(-> { @web_socket.present? }) do @web_socket end end end
web_socket_authenticated?()
click to toggle source
# File lib/ewelink/api.rb, line 455 def web_socket_authenticated? @web_socket_authenticated.present? end
web_socket_outdated_ping?()
click to toggle source
# File lib/ewelink/api.rb, line 459 def web_socket_outdated_ping? @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 end
web_socket_sequence()
click to toggle source
# File lib/ewelink/api.rb, line 463 def web_socket_sequence (Time.now.to_f * 1000).round.to_s end
web_socket_url()
click to toggle source
# File lib/ewelink/api.rb, line 467 def web_socket_url synchronize(:web_socket_url) do @web_socket_url ||= begin params = { 'accept' => 'ws', 'appid' => APP_ID, 'nonce' => nonce, 'ts' => Time.now.to_i, 'version' => VERSION, } response = rest_request(:post, '/dispatch/app', body: JSON.generate(params), headers: authentication_headers) raise('Error while getting WebSocket URL') unless response['error'] == 0 domain = response['domain'].presence || raise("Can't get WebSocket server domain") port = response['port'].presence || raise("Can't get WebSocket server port") "wss://#{domain}:#{port}/api/ws".tap { |url| Ewelink.logger.debug(self.class.name) { "WebSocket URL is: #{url.inspect}" } } end end end
web_socket_wait_for(condition, initialize_web_socket: false) { |: true| ... }
click to toggle source
# File lib/ewelink/api.rb, line 486 def web_socket_wait_for(condition, initialize_web_socket: false, &block) web_socket if initialize_web_socket begin Timeout.timeout(REQUEST_TIMEOUT) do while !condition.call sleep(WEB_SOCKET_WAIT_INTERVAL) end block_given? ? yield : true end rescue => e reload raise Error.new(e) end end