ewelink 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e6cbd50164b2f25247423b0cece7baee8ec1e6bbc49faa2545ad4c7450feaf3d
4
+ data.tar.gz: cf1e6148a4cebdb5efd52acfb66feacaa096d51ca2f1cd0b06e7c45b002c6fa1
5
+ SHA512:
6
+ metadata.gz: e42fdd3244a0a9bb3a176c4ab760ba6100b8a8e9146c68b135044610075a4963d7e2d03df4cec6ae97febdcff21966d638ba9f4b6dd8e774c436acdf75174e0b
7
+ data.tar.gz: 204e83b06e6b788095352b97f5553291ec20bca0e4191695abc30f72cf898be9194bc04b3518fd61220f45250a90dac6054cfc184bdc10400639c8dca70efbaa
data/README.mdown ADDED
@@ -0,0 +1,84 @@
1
+ # Ewelink
2
+
3
+ Ruby API to manage eWeLink smart home devices.
4
+
5
+ ## Installation
6
+
7
+ Just add this into your `Gemfile`:
8
+
9
+ ```ruby
10
+ gem 'ewelink'
11
+ ```
12
+
13
+ Then, just run `bundle install`.
14
+
15
+ ## Examples
16
+
17
+ ### Displaying all switches
18
+
19
+ ```ruby
20
+ require 'ewelink'
21
+
22
+ api = Ewelink::Api.new(email: 'john@example.com', password: 'secr$t')
23
+ api.switches.each do |switch|
24
+ puts switch[:name]
25
+ puts switch[:uuid]
26
+ end
27
+ ```
28
+
29
+ `email` or `phone_number` must be specified for authentication.
30
+
31
+ ### Displaying all RF bridge buttons
32
+
33
+ ```ruby
34
+ require 'ewelink'
35
+
36
+ api = Ewelink::Api.new(email: 'john@example.com', password: 'secr$t')
37
+ api.rf_bridge_buttons.each do |button|
38
+ puts button[:name]
39
+ puts button[:uuid]
40
+ end
41
+ ```
42
+
43
+ ### Set switch on or off
44
+
45
+ ```ruby
46
+ require 'ewelink'
47
+
48
+ api = Ewelink::Api.new(phone_number: '+687 414243', password: 'secr$t')
49
+ api.switch_on!(switch[:uuid])
50
+ api.switch_off!(switch[:uuid])
51
+ ```
52
+
53
+ ### Check if switch is on or off
54
+
55
+ ```ruby
56
+ require 'ewelink'
57
+
58
+ api = Ewelink::Api.new(phone_number: '+687 414243', password: 'secr$t')
59
+ puts api.switch_on?(switch[:uuid])
60
+ puts api.switch_off?(switch[:uuid])
61
+ ```
62
+
63
+ ### Press RF bridge button
64
+
65
+ ```ruby
66
+ require 'ewelink'
67
+
68
+ api = Ewelink::Api.new(email: 'john@example.com', password: 'secr$t')
69
+ api.press_rf_bridge_button!(button[:uuid])
70
+ ```
71
+
72
+ ### Configuring logger
73
+
74
+ In order to have some debug informations about what kagu does, you could
75
+ configure its logger:
76
+
77
+ ```ruby
78
+ Ewelink.logger = Logger.new(STDERR)
79
+ ```
80
+
81
+ ### Executable
82
+
83
+ This gem also provides a `ewelink` executable, just run it with
84
+ `--help` option to get all available options.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
data/bin/ewelink ADDED
@@ -0,0 +1,17 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require_relative '../lib/ewelink'
4
+
5
+ Ewelink.logger = Logger.new(STDOUT, formatter: -> (severity, time, progname, message) {
6
+ text = ''
7
+ text << "[#{progname}] " if progname.present?
8
+ text << message.to_s << "\n"
9
+ })
10
+ Ewelink.logger.level = :warn
11
+
12
+ begin
13
+ Ewelink::Runner.new.run
14
+ rescue => e
15
+ Ewelink.logger.fatal(Ewelink::Runner.name) { e }
16
+ exit(1)
17
+ end
data/ewelink.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'ewelink'
3
+ s.version = File.read("#{File.dirname(__FILE__)}/VERSION").strip
4
+ s.platform = Gem::Platform::RUBY
5
+ s.author = 'Alexis Toulotte'
6
+ s.email = 'al@alweb.org'
7
+ s.homepage = 'https://github.com/alexistoulotte/ewelink'
8
+ s.summary = 'Manage eWeLink devices'
9
+ s.description = 'Manage eWeLink smart home devices'
10
+ s.license = 'MIT'
11
+
12
+ s.files = `git ls-files | grep -vE '^(spec/|test/|\\.|Gemfile|Rakefile)'`.split("\n")
13
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
14
+ s.require_paths = ['lib']
15
+
16
+ s.required_ruby_version = '>= 2.0.0'
17
+
18
+ s.add_dependency 'activesupport', '>= 6.0.0', '< 7.0.0'
19
+ s.add_dependency 'httparty', '>= 0.18.0', '< 0.19.0'
20
+
21
+ s.add_development_dependency 'byebug', '>= 11.0.0', '< 12.0.0'
22
+ s.add_development_dependency 'rake', '>= 12.0.0', '< 13.0.0'
23
+ end
@@ -0,0 +1,226 @@
1
+ module Ewelink
2
+
3
+ class Api
4
+
5
+ APP_ID = 'oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq'
6
+ APP_SECRET = '6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM'
7
+ DEFAULT_REGION = 'us'
8
+ RF_BRIDGE_DEVICE_UIID = 28
9
+ SWITCH_DEVICES_UIIDS = [1, 5, 6, 24]
10
+ TIMEOUT = 10
11
+ URL = 'https://#{region}-api.coolkit.cc:8080'
12
+ UUID_NAMESPACE = 'e25750fb-3710-41af-b831-23224f4dd609';
13
+ VERSION = 8
14
+
15
+ attr_reader :email, :password, :phone_number
16
+
17
+ def initialize(email: nil, password:, phone_number: nil)
18
+ @email = email.presence.try(:strip)
19
+ @mutexs = {}
20
+ @password = password.presence || raise(Error.new(":password must be specified"))
21
+ @phone_number = phone_number.presence.try(:strip)
22
+ raise(Error.new(":email or :phone_number must be specified")) if email.blank? && phone_number.blank?
23
+ end
24
+
25
+ def press_rf_bridge_button!(uuid)
26
+ button = find_rf_bridge_button!(uuid)
27
+ params = {
28
+ 'appid' => APP_ID,
29
+ 'deviceid' => button[:device_id],
30
+ 'nonce' => nonce,
31
+ 'params' => {
32
+ 'cmd' => 'transmit',
33
+ 'rfChl' => button[:channel],
34
+ },
35
+ 'ts' => Time.now.to_i,
36
+ 'version' => VERSION,
37
+ }
38
+ http_request(:post, '/api/user/device/status', body: JSON.generate(params), headers: authentication_headers)
39
+ true
40
+ end
41
+
42
+ def reload
43
+ Ewelink.logger.debug(self.class.name) { 'Reloading API (authentication token, devices & region cache)' }
44
+ [:@authentication_token, :@devices, :@rf_bridge_buttons, :@region, :@switches].each do |variable|
45
+ remove_instance_variable(variable) if instance_variable_defined?(variable)
46
+ end
47
+ self
48
+ end
49
+
50
+ def rf_bridge_buttons
51
+ @rf_bridge_buttons ||= [].tap do |buttons|
52
+ rf_bridge_devices = devices.select { |device| device['uiid'] == RF_BRIDGE_DEVICE_UIID }.tap do |devices|
53
+ Ewelink.logger.debug(self.class.name) { "Found #{devices.size} RF 433MHz Bridge device(s)" }
54
+ end
55
+ rf_bridge_devices.each do |device|
56
+ device_id = device['deviceid'].presence || next
57
+ device_name = device['name'].presence || next
58
+ buttons = device['params']['rfList'].each do |rf|
59
+ button = {
60
+ channel: rf['rfChl'],
61
+ device_id: device_id,
62
+ device_name: device_name,
63
+ }
64
+ remote_info = device['tags']['zyx_info'].find { |info| info['buttonName'].find { |data| data.key?(button[:channel].to_s) } }.presence || next
65
+ remote_name = remote_info['name'].try(:squish).presence || next
66
+ button_info = remote_info['buttonName'].find { |info| info.key?(button[:channel].to_s) }.presence || next
67
+ button_name = button_info.values.first.try(:squish).presence || next
68
+ button.merge!({
69
+ name: button_name,
70
+ remote_name: remote_name,
71
+ remote_type: remote_info['remote_type'],
72
+ })
73
+ button[:uuid] = Digest::UUID.uuid_v5(UUID_NAMESPACE, "#{button[:device_id]}/#{button[:channel]}")
74
+ buttons << button
75
+ end
76
+ end
77
+ end.tap { |buttons| Ewelink.logger.debug(self.class.name) { "Found #{buttons.size} RF 433MHz bridge button(s)" } }
78
+ end
79
+
80
+ def switch_off!(uuid)
81
+ update_switch_on!(uuid, false)
82
+ end
83
+
84
+ def switch_off?(uuid)
85
+ !switch_on?(uuid)
86
+ end
87
+
88
+ def switch_on!(uuid)
89
+ update_switch_on!(uuid, true)
90
+ end
91
+
92
+ def switch_on?(uuid)
93
+ switch = find_switch!(uuid)
94
+ params = {
95
+ 'appid' => APP_ID,
96
+ 'deviceid' => switch[:device_id],
97
+ 'nonce' => nonce,
98
+ 'ts' => Time.now.to_i,
99
+ 'version' => VERSION,
100
+ }
101
+ response = http_request(:get, '/api/user/device/status', headers: authentication_headers, query: params)
102
+ response['params']['switch'] == 'on'
103
+ end
104
+
105
+ def switches
106
+ @switches ||= [].tap do |switches|
107
+ switch_devices = devices.select { |device| SWITCH_DEVICES_UIIDS.include?(device['uiid']) }.tap do |devices|
108
+ Ewelink.logger.debug(self.class.name) { "Found #{devices.size} switch device(s)" }
109
+ end
110
+ switch_devices.each do |device|
111
+ device_id = device['deviceid'].presence || next
112
+ name = device['name'].presence || next
113
+ switch = {
114
+ device_id: device_id,
115
+ name: name,
116
+ }
117
+ switch[:uuid] = Digest::UUID.uuid_v5(UUID_NAMESPACE, switch[:device_id])
118
+ switches << switch
119
+ end
120
+ end.tap { |switches| Ewelink.logger.debug(self.class.name) { "Found #{switches.size} switch(es)" } }
121
+ end
122
+
123
+ private
124
+
125
+ def authentication_headers
126
+ { 'Authorization' => "Bearer #{authentication_token}" }
127
+ end
128
+
129
+ def authentication_token
130
+ synchronize(:authentication_token) do
131
+ @authentication_token ||= begin
132
+ params = {
133
+ 'appid' => APP_ID,
134
+ 'imei' => SecureRandom.uuid.upcase,
135
+ 'nonce' => nonce,
136
+ 'password' => password,
137
+ 'ts' => Time.now.to_i,
138
+ 'version' => VERSION,
139
+ }
140
+ if email.present?
141
+ params['email'] = email
142
+ else
143
+ params['phoneNumber'] = phone_number
144
+ end
145
+ body = JSON.generate(params)
146
+ response = http_request(:post, '/api/user/login', { body: body, headers: { 'Authorization' => "Sign #{Base64.encode64(OpenSSL::HMAC.digest('SHA256', APP_SECRET, body))}" } })
147
+ raise(Error.new('Authentication token not found')) if response['at'].blank?
148
+ response['at'].tap { Ewelink.logger.debug(self.class.name) { 'Authentication token found' } }
149
+ end
150
+ end
151
+ end
152
+
153
+ def devices
154
+ synchronize(:devices) do
155
+ @devices ||= begin
156
+ params = {
157
+ 'appid' => APP_ID,
158
+ 'getTags' => 1,
159
+ 'nonce' => nonce,
160
+ 'ts' => Time.now.to_i,
161
+ 'version' => VERSION,
162
+ }
163
+ response = http_request(:get, '/api/user/device', headers: authentication_headers, query: params)
164
+ response['devicelist'].tap { |devices| Ewelink.logger.debug(self.class.name) { "Found #{devices.size} device(s)" } }
165
+ end
166
+ end
167
+ end
168
+
169
+ def find_rf_bridge_button!(uuid)
170
+ rf_bridge_buttons.find { |button| button[:uuid] == uuid } || raise(Error.new("No such RF bridge button with UUID: #{uuid.inspect}"))
171
+ end
172
+
173
+ def find_switch!(uuid)
174
+ switches.find { |switch| switch[:uuid] == uuid } || raise(Error.new("No such switch with UUID: #{uuid.inspect}"))
175
+ end
176
+
177
+ def nonce
178
+ SecureRandom.hex[0, 8]
179
+ end
180
+
181
+ def region
182
+ @region ||= DEFAULT_REGION
183
+ end
184
+
185
+ def http_request(method, path, options = {})
186
+ url = "#{URL.gsub('#{region}', region)}#{path}"
187
+ method = method.to_s.upcase
188
+ headers = (options[:headers] || {}).reverse_merge('Content-Type' => 'application/json')
189
+ Ewelink.logger.debug(self.class.name) { "#{method} #{url}" }
190
+ response = synchronize(:http_request) { HTTParty.send(method.downcase, url, options.merge(headers: headers).reverse_merge(timeout: TIMEOUT)) }
191
+ raise(Error.new("#{method} #{url}: #{response.code}")) unless response.success?
192
+ if response['error'] == 301 && response['region'].present?
193
+ @region = response['region']
194
+ Ewelink.logger.debug(self.class.name) { "Switched to region #{region.inspect}" }
195
+ return http_request(method, path, options)
196
+ end
197
+ remove_instance_variable(:@authentication_token) if instance_variable_defined?(:@authentication_token) && [401, 403].include?(response['error'])
198
+ raise(Error.new("#{method} #{url}: #{response['error']} #{response['msg']}".strip)) if response['error'].present? && response['error'] != 0
199
+ response
200
+ rescue Errno::ECONNREFUSED, OpenSSL::OpenSSLError, SocketError, Timeout::Error => e
201
+ raise Error.new(e)
202
+ end
203
+
204
+ def synchronize(name, &block)
205
+ (@mutexs[name] ||= Mutex.new).synchronize(&block)
206
+ end
207
+
208
+ def update_switch_on!(uuid, on)
209
+ switch = find_switch!(uuid)
210
+ params = {
211
+ 'appid' => APP_ID,
212
+ 'deviceid' => switch[:device_id],
213
+ 'nonce' => nonce,
214
+ 'params' => {
215
+ 'switch' => on ? 'on' : 'off',
216
+ },
217
+ 'ts' => Time.now.to_i,
218
+ 'version' => VERSION,
219
+ }
220
+ http_request(:post, '/api/user/device/status', body: JSON.generate(params), headers: authentication_headers)
221
+ true
222
+ end
223
+
224
+ end
225
+
226
+ end
@@ -0,0 +1,6 @@
1
+ module Ewelink
2
+
3
+ class Error < StandardError
4
+ end
5
+
6
+ end
@@ -0,0 +1,78 @@
1
+ module Ewelink
2
+
3
+ class Runner
4
+
5
+ def run
6
+ api = Api.new(options.slice(:email, :password, :phone_number))
7
+ puts(JSON.pretty_generate(api.switches)) if options[:list_switches]
8
+ puts(JSON.pretty_generate(api.rf_bridge_buttons)) if options[:list_rf_bridge_buttons]
9
+ options[:switches_on_uuids].each { |uuid| api.switch_on!(uuid) }
10
+ options[:switches_off_uuids].each { |uuid| api.switch_off!(uuid) }
11
+ options[:press_rf_bridge_buttons_uuids].each { |uuid| api.press_rf_bridge_button!(uuid) }
12
+ end
13
+
14
+ private
15
+
16
+ def options
17
+ @options ||= begin
18
+ options = { press_rf_bridge_buttons_uuids: [], switches_off_uuids: [], switches_on_uuids: [] }
19
+ parser = OptionParser.new do |opts|
20
+ opts.banner = 'Manage eWeLink smart home devices'
21
+ opts.version = File.read(File.expand_path('../../VERSION', __dir__)).strip
22
+ opts.separator('')
23
+ opts.separator('Usage: ewelink [options]')
24
+ opts.separator('')
25
+ opts.on('-e', '--email EMAIL', "eWeLink account's email (mandatory if phone number is not specified)") do |email|
26
+ options[:email] = email
27
+ end
28
+ opts.on('-p', '--password PASSWORD', "eWeLink account's password (mandatory, prompted if not specified on command line)") do |password|
29
+ options[:password] = password
30
+ end
31
+ opts.on('-n', '--phone-number PHONE_NUMBER', "eWeLink account's phone number (mandatory if email is not specified)") do |phone_number|
32
+ options[:phone_number] = phone_number
33
+ end
34
+ opts.on('--list-switches', 'List all switches in JSON format') do
35
+ options[:list_switches] = true
36
+ end
37
+ opts.on('--list-rf-bridge-buttons', 'List all RF 433MHz bridge buttons in JSON format') do
38
+ options[:list_rf_bridge_buttons] = true
39
+ end
40
+ opts.on('--switch-on SWITCH_UUID', 'Set the switch with specified UUID on') do |uuid|
41
+ options[:switches_on_uuids] << uuid
42
+ end
43
+ opts.on('--switch-off SWITCH_UUID', 'Set the switch with specified UUID off') do |uuid|
44
+ options[:switches_off_uuids] << uuid
45
+ end
46
+ opts.on('--press-rf-bridge-button BUTTON_UUID', 'Press RF 433MHz bridge button with specified UUID') do |uuid|
47
+ options[:press_rf_bridge_buttons_uuids] << uuid
48
+ end
49
+ opts.on('-v', '--verbose', 'Verbose mode') do
50
+ Ewelink.logger.level = :debug
51
+ end
52
+ end
53
+ arguments = parser.parse!
54
+ if arguments.any?
55
+ STDERR.puts("Invalid option specified: #{arguments.first}")
56
+ STDERR.puts(parser.summarize)
57
+ exit(1)
58
+ end
59
+ if options[:email].blank? && options[:phone_number].blank?
60
+ STDERR.puts('Email or phone number must be specified')
61
+ STDERR.puts(parser.summarize)
62
+ exit(1)
63
+ end
64
+ if [:list_switches, :list_rf_bridge_buttons, :switches_on_uuids, :switches_off_uuids, :press_rf_bridge_buttons_uuids].map { |action| options[action] }.all?(&:blank?)
65
+ STDERR.puts('An action must be specified (listing switches, press RF bridge button, etc.)')
66
+ STDERR.puts(parser.summarize)
67
+ exit(1)
68
+ end
69
+ while options[:password].blank?
70
+ options[:password] = IO::console.getpass("Enter eWeLink account's password: ")
71
+ end
72
+ options
73
+ end
74
+ end
75
+
76
+ end
77
+
78
+ end
data/lib/ewelink.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'byebug' if ENV['DEBUGGER']
4
+ require 'httparty'
5
+ require 'io/console'
6
+ require 'json'
7
+ require 'logger'
8
+ require 'openssl'
9
+ require 'optparse'
10
+
11
+ module Ewelink
12
+
13
+ mattr_accessor :logger
14
+ self.logger = Logger.new(nil)
15
+
16
+ end
17
+
18
+ require_relative 'ewelink/api'
19
+ require_relative 'ewelink/error'
20
+ require_relative 'ewelink/runner'
metadata ADDED
@@ -0,0 +1,131 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ewelink
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexis Toulotte
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-04-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 7.0.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 6.0.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 7.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: httparty
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.18.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: 0.19.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.18.0
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: 0.19.0
53
+ - !ruby/object:Gem::Dependency
54
+ name: byebug
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 11.0.0
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: 12.0.0
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 11.0.0
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: 12.0.0
73
+ - !ruby/object:Gem::Dependency
74
+ name: rake
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 12.0.0
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: 13.0.0
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 12.0.0
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: 13.0.0
93
+ description: Manage eWeLink smart home devices
94
+ email: al@alweb.org
95
+ executables:
96
+ - ewelink
97
+ extensions: []
98
+ extra_rdoc_files: []
99
+ files:
100
+ - README.mdown
101
+ - VERSION
102
+ - bin/ewelink
103
+ - ewelink.gemspec
104
+ - lib/ewelink.rb
105
+ - lib/ewelink/api.rb
106
+ - lib/ewelink/error.rb
107
+ - lib/ewelink/runner.rb
108
+ homepage: https://github.com/alexistoulotte/ewelink
109
+ licenses:
110
+ - MIT
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 2.0.0
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 3.0.3
128
+ signing_key:
129
+ specification_version: 4
130
+ summary: Manage eWeLink devices
131
+ test_files: []