weconnect 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b46aa0f0215101e02ed972ad36c7a1a9a67fb08d67944d8c080b3eb10b85c2c4
4
+ data.tar.gz: 48e88d187dea1793ba0fe3293acb83c02ad614ac32c890da085fa8b9948f3ef0
5
+ SHA512:
6
+ metadata.gz: 257b77464932e392d531d71f1786a63e612fe92b056d999d814b81f06389abc7c4c9c0bbb9f0a6426c8a95d6df87f8df42732748ccffe683c39a91c4c10bc734
7
+ data.tar.gz: 75d62c8d770ce7f0655e36e996bdbdbac2c204d04a2a412694fc987921c45ce470ae1951e64376340636c2987ce64e535d8951ec0f9d76a42677cbf5e5f84a72
data/.gitignore ADDED
@@ -0,0 +1,45 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+ /data/
13
+ *.log
14
+ *.txt
15
+ *.json
16
+ *.yml
17
+ .DS_Store
18
+
19
+ # Used by dotenv library to load environment variables.
20
+ .env
21
+
22
+
23
+ ## Documentation cache and generated files:
24
+ /.yardoc/
25
+ /_yardoc/
26
+ /doc/
27
+ /rdoc/
28
+
29
+ ## Environment normalization:
30
+ /.bundle/
31
+ /vendor/bundle
32
+ /lib/bundler/man/
33
+
34
+ # for a library or gem, you might want to ignore these files since the code is
35
+ # intended to run in multiple environments; otherwise, check them in:
36
+ # Gemfile.lock
37
+ # .ruby-version
38
+ # .ruby-gemset
39
+
40
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
41
+ .rvmrc
42
+
43
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
44
+ # .rubocop-https?--*
45
+ Gemfile.lock
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-02-25
4
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in hudu.gemspec
6
+ gemspec
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'dotenv'
5
+ require 'rake/testtask'
6
+
7
+ Dotenv.load
8
+
9
+ #system './bin/cc-test-reporter before-build'
10
+ Rake::TestTask.new(:test) do |t|
11
+ t.libs << 'test'
12
+ t.libs << 'lib'
13
+ t.test_files = FileList['test/**/*_test.rb']
14
+ end
15
+
16
+ require 'rubocop/rake_task'
17
+ RuboCop::RakeTask.new
18
+ task default: %i[test rubocop]
19
+ #system './bin/cc-test-reporter after-build'
@@ -0,0 +1,36 @@
1
+ require "wrapi"
2
+ require File.expand_path('connection', __dir__)
3
+ require File.expand_path('request', __dir__)
4
+ require File.expand_path('authorization', __dir__)
5
+
6
+ module WeConnect
7
+ # @private
8
+ class API
9
+
10
+ # @private
11
+ attr_accessor *WrAPI::Configuration::VALID_OPTIONS_KEYS
12
+
13
+ # Creates a new API and copies settings from singleton
14
+ def initialize(options = {})
15
+ options = WeConnect.options.merge(options)
16
+ WrAPI::Configuration::VALID_OPTIONS_KEYS.each do |key|
17
+ send("#{key}=", options[key])
18
+ end
19
+ end
20
+
21
+ def config
22
+ conf = {}
23
+ WrAPI::Configuration::VALID_OPTIONS_KEYS.each do |key|
24
+ conf[key] = send key
25
+ end
26
+ conf
27
+ end
28
+
29
+ include WrAPI::Connection
30
+ include Connection
31
+ include WrAPI::Request
32
+ include WrAPI::Authentication
33
+ include Authentication
34
+
35
+ end
36
+ end
@@ -0,0 +1,232 @@
1
+ require 'uri'
2
+ require 'yaml'
3
+ require 'nokogiri'
4
+ require File.expand_path('error', __dir__)
5
+
6
+ module WeConnect
7
+ # Deals with authentication flow and stores it within global configuration
8
+ module Authentication
9
+ TOKENS = %w(state id_token access_token code)
10
+ TOKEN_URL = 'https://emea.bff.cariad.digital/user-login/login/v1'.freeze
11
+ REFRESH_URL = 'https://emea.bff.cariad.digital/user-login/refresh/v1'.freeze
12
+
13
+ # Authorize to the WeConnect portal
14
+ def login(options = {})
15
+ raise ConfigurationError, "username/password not set" unless username && password
16
+ # only bearer token needed
17
+ car = CarConnectInfo.new
18
+
19
+ @tokens = WebLogin.new(self,car).login
20
+ api_process_token(@tokens)
21
+ reauth_connection(self.access_token)
22
+ self.access_token
23
+ end
24
+ def auth_tokens
25
+ @tokens
26
+ end
27
+ def api_process_token(tokens)
28
+ self.access_token = tokens['access_token']
29
+ self.token_type = tokens['token_type']
30
+ self.refresh_token = tokens['refresh_token']
31
+ self.token_expires = tokens['expires_at'] if tokens['expires_at']
32
+ end
33
+
34
+ def refresh_token
35
+ raise Error.new 'not implemented'
36
+ end
37
+
38
+ # private
39
+ class CarConnectInfo
40
+ attr_reader :type, :country, :xrequest, :xclient_id, :client_id, :scope, :response_type, :redirect, :refresh_url
41
+ def initialize
42
+ @type = "Id";
43
+ @country = "DE";
44
+ @xrequest = "com.volkswagen.weconnect";
45
+ @xclientId = "";
46
+ @client_id = "a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com";
47
+
48
+ @scope = "openid profile badge cars dealers vin";
49
+ @response_type = "code id_token token";
50
+ @redirect = "weconnect://authenticated";
51
+ @refresh_url='https://identity.vwgroup.io/oidc/v1/token'
52
+ end
53
+ end
54
+ class WebLogin
55
+ def initialize(connection,car_info)
56
+ @connection = connection
57
+ @car_info = car_info
58
+ end
59
+ def login
60
+ @connection.format = 'x-www-form-urlencoded'
61
+ page = login_page_step
62
+ form = PasswordFormParser.new(page.body)
63
+ page = email_page_step(form)
64
+ idk = IDKParser.new(page.body)
65
+ page = password_page_step(idk)
66
+
67
+ raise IncompatibleAPIError.new( "#{@car_info.redirect} redirect not found" )
68
+ rescue WeconnectAuthenticated => authenticated
69
+ # weconnect://authenticatied#... extpected
70
+
71
+ @tokens = query_parameters(URI.parse(authenticated.redirect).fragment)
72
+ # fetch final tokens from login
73
+ @tokens = fetch_tokens(@tokens)
74
+ end
75
+ private
76
+ def login_page_step
77
+ params = {
78
+ nonce: nonce(),
79
+ redirect_uri: @car_info.redirect
80
+ }
81
+ r = @connection.get('https://emea.bff.cariad.digital/user-login/v1/authorize',params,true)
82
+ @login_url = r.env.url
83
+ r
84
+ end
85
+
86
+ def email_page_step(form)
87
+ fields = form.fields
88
+ fields['email'] = @connection.username
89
+ # update to login form
90
+ @login_url = URI.join(@login_url, form.action)
91
+ r = @connection.post(@login_url.to_s,fields,true) do |request|
92
+ request.headers=request.headers.merge({
93
+ 'x-requested-with': @car_info.xrequest,
94
+ 'upgrade-insecure-requests': "1"
95
+ })
96
+ end
97
+ end
98
+ def password_page_step(idk)
99
+ params = {
100
+ :email => @connection.username,
101
+ :password => @connection.password,
102
+ idk.idk['csrf_parameterName'] => idk.idk['csrf_token'],
103
+ :hmac => idk.template_model['hmac'],
104
+ 'relayState' => idk.template_model['relayState']
105
+ }
106
+
107
+ rpw = @connection.post(idk.post_action_uri(@login_url),params,true) do |request|
108
+ request.headers=request.headers.merge({
109
+ 'x-requested-with': @car_info.xrequest,
110
+ 'upgrade-insecure-requests': "1"
111
+ })
112
+ end
113
+ # should not come here, exception raised by auth redirect
114
+ if rpw.env.url.query['login.error']
115
+ params = query_parameters(rpw.env.url.query)
116
+ description = {
117
+ 'login.errors.password_invalid': 'Password is invalid',
118
+ 'login.error.throttled': 'Login throttled, probably too many wrong logins. You have to wait some minutes until a new login attempt is possible'
119
+ }
120
+ error = params['error']
121
+ error = description[error] if description[error]
122
+ raise AuthenticationError.new( "Login error #{error}" )
123
+ end
124
+ end
125
+
126
+ def fetch_tokens(tokens)
127
+ # check if all keys exist
128
+ if TOKENS.all? { |s| tokens.key? s }
129
+ params = {
130
+ 'state': tokens['state'],
131
+ 'id_token': tokens['id_token'],
132
+ 'redirect_uri': @car_info.redirect,
133
+ 'region': 'emea',
134
+ 'access_token': tokens['access_token'],
135
+ 'authorizationCode': tokens['code'],
136
+ }
137
+
138
+ @connection.format = :json
139
+ @connection.reauth_connection(tokens['id_token'])
140
+ # complete set tokens
141
+ token_response = @connection.post(TOKEN_URL, params)
142
+ # translate token names to _token suffix
143
+ token_response = translate_tokens(token_response.body, %w(accessToken idToken refreshToken))
144
+ token_response = parse_token_response(token_response)
145
+ token_response
146
+ else
147
+ raise IncompatibleAPIError.new( 'Expected tokens: #{TOKENS}, but found: #{tokens}' )
148
+ end
149
+ end
150
+
151
+ def translate_tokens(tokens, keys)
152
+ keys.each do |name|
153
+ if tokens[name]
154
+ tokens[name.gsub('Token', '_token')] = tokens.delete(name)
155
+ end
156
+ end
157
+ tokens
158
+ end
159
+ # oauthlib/oauth2/rfc6749/parameters.py
160
+ def parse_token_response(tokens)
161
+ puts "\n\nEXPIRE #{tokens['expires_in']}\n\n"
162
+ tokens['expires_at'] = Time.new() + tokens['expires_in'] if tokens['expires_in']
163
+ # validate
164
+ raise AuthenticationError.new(tokens['error']) if tokens['error']
165
+ #raise AuthenticationError.new('Missing access token error') if tokens['access_token']
166
+
167
+ tokens
168
+ end
169
+
170
+ def query_parameters(query_fragment)
171
+ parameters = query_fragment.split('&').inject({}) do |result,param|
172
+ k,v = param.split('=');
173
+ result.merge({k => v })
174
+ end
175
+ end
176
+
177
+ def nonce
178
+ rand(10 ** 30).to_s.rjust(30,'0')
179
+ end
180
+
181
+ end
182
+
183
+ class PasswordFormParser
184
+ attr_reader :action, :method, :fields
185
+ def initialize(html)
186
+ doc = Nokogiri::HTML( html )
187
+ # 1 loginform username
188
+ form = doc/'form[name="emailPasswordForm"]'
189
+ if form
190
+ @action = form.attribute('action').to_s
191
+ @method = form.attribute('method').to_s
192
+ # get hidden fields
193
+
194
+ @fields = {}
195
+ (form/'input').each do |input|
196
+ @fields[input.attribute('name').to_s] = input.attribute('value').to_s
197
+ end
198
+ else
199
+ raise IncompatibleAPIError.new( 'emailPasswordForm not found' )
200
+ end
201
+ end
202
+ end
203
+
204
+ class IDKParser
205
+ attr_reader :idk, :template_model, :post_action, :identifier, :error
206
+ def initialize(html)
207
+ doc = Nokogiri::HTML( html )
208
+ # get script with IDK
209
+ scripts = doc./'script:contains("_IDK")'
210
+ # extract json part by greedy match till last '}'
211
+ m = scripts.text.gsub("\n",'').gsub(/([\w]+):/, '"\1":').match('({.*})')
212
+ @idk = {}
213
+ if m.size > 1
214
+ # load with yam due to single quotes
215
+ @idk = YAML.load(m[1])
216
+ raise IncompatibleAPIError.new( "_IDK.templateModel not found #{@idk}" ) unless @idk['templateModel']
217
+ else
218
+ raise IncompatibleAPIError.new( "_IDK not found" )
219
+ end
220
+ # r = self.__get_url(upr.scheme+'://'+upr.netloc+form_url.replace(idk['templateModel']['identifierUrl'],idk['templateModel']['postAction']), post=post)
221
+ @template_model = @idk['templateModel']
222
+ @post_action = @template_model['postAction']
223
+ @identifier = @template_model['identifierUrl']
224
+ @error = @template_model['error']
225
+ end
226
+
227
+ def post_action_uri(base_uri)
228
+ base_uri.to_s.gsub(@identifier,@post_action)
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,60 @@
1
+ require File.expand_path('api', __dir__)
2
+ require File.expand_path('const', __dir__)
3
+ require File.expand_path('error', __dir__)
4
+
5
+ module WeConnect
6
+ # Wrapper for the WeConnect REST API
7
+ #
8
+ # @see no docs, reversed engineered
9
+ class Client < API
10
+ attr_accessor :openid_config
11
+
12
+ def initialize(options = {})
13
+ super(options)
14
+
15
+ @openid_config = openid_configuration
16
+ end
17
+
18
+ def vehicles(params={})
19
+ self.get(vehicle_api,params)
20
+ end
21
+ def vehicle_status(vin, jobs=['all'])
22
+ jobs = jobs.join(',') if jobs.is_a? Array
23
+ self.get(vehicle_api(vin,"/selectivestatus?jobs=#{jobs}"))
24
+ end
25
+
26
+ def parking(vin)
27
+ self.get(vehicle_api(vin,'/parkingposition'))
28
+ end
29
+
30
+ def trips(vin,trip_type=TripType::SHORT_TERM,period)
31
+ sef.get('/vehicle/v1/trips/#{vin}/#{trip_type.downcase}/last',params)
32
+ end
33
+
34
+ def images(vin)
35
+ self.get("/media/v2/vehicle-images/{self.vin.value}?resolution=2x")
36
+ end
37
+
38
+ def control(vin, operation, value)
39
+ self.post(vehicle_api(vin,"/#{operation}/#{value}"))
40
+ end
41
+ def control_charging(vin, value)
42
+ if ControlOperation.allowed_values.includes? value
43
+ control(vin,'charging',value)
44
+ end
45
+ end
46
+ private
47
+ def openid_configuration
48
+ get(self.endpoint)
49
+ end
50
+
51
+ PATH = "/vehicle/v1/vehicles".freeze
52
+ def vehicle_api(vin=nil,path=nil)
53
+ if vin
54
+ File.join PATH, vin, path
55
+ else
56
+ PATH
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ require 'faraday'
2
+ require 'faraday/follow_redirects'
3
+ require 'faraday-cookie_jar'
4
+ require File.expand_path('error', __dir__)
5
+
6
+ module WeConnect
7
+ class WeconnectAuthenticated < WeConnectError
8
+ attr_reader :redirect
9
+ def initialize(location)
10
+ @redirect = location
11
+ end
12
+ end
13
+ # Create connection including authorization parameters with default Accept format and User-Agent
14
+ # By default
15
+ # - Bearer authorization is access_token is not nil override with @setup_authorization
16
+ # - Headers setup for client-id and client-secret when client_id and client_secret are not nil @setup_headers
17
+ # @private
18
+ module Connection
19
+ class WeConnectMiddleware < Faraday::Middleware
20
+ def call(env)
21
+ response = @app.call(env)
22
+ if location = response['location']
23
+ raise WeconnectAuthenticated.new(location) if location['weconnect:']
24
+ end
25
+ response
26
+ end
27
+ end
28
+ def reauth_connection(token)
29
+ self.access_token = token
30
+ setup_authorization(@connection)
31
+ end
32
+ private
33
+ def connection
34
+ raise ConfigurationError, "Option for endpoint is not defined" unless endpoint
35
+
36
+ options = setup_options
37
+ @connection ||= Faraday::Connection.new(options) do |connection|
38
+ connection.use Faraday::FollowRedirects::Middleware, limit: 10
39
+
40
+ connection.use WeConnectMiddleware
41
+ connection.use :cookie_jar
42
+
43
+ connection.use Faraday::Response::RaiseError
44
+ connection.adapter Faraday.default_adapter
45
+ setup_authorization(connection)
46
+ setup_headers(connection)
47
+ connection.response :json, content_type: /\bjson$/
48
+ connection.use Faraday::Request::UrlEncoded
49
+
50
+ setup_logger_filtering(connection, logger) if logger
51
+ end
52
+ end
53
+
54
+ end
55
+ end
@@ -0,0 +1,116 @@
1
+ class String
2
+ def underscore
3
+ gsub(/::/, '/').
4
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
5
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
6
+ tr("-", "_").
7
+ downcase
8
+ end
9
+ end
10
+ module WeConnect
11
+ class Enum
12
+ def self.enum(array, proc=:to_s)
13
+ array.each do |c|
14
+ const_set c.underscore.upcase,c.send(proc)
15
+ end
16
+ end
17
+ end
18
+ class Role < Enum
19
+ enum %w[PRIMARY_USER SECONDARY_USER GUEST_USER CDIS_UNKNOWN_USER UNKNOWN]
20
+ end
21
+
22
+ class EnrollmentStatus < Enum
23
+ enum %w[STARTED NOT_STARTED COMPLETED GDC_MISSING INACTIVE UNKNOWN]
24
+ end
25
+ class UserRoleStatus < Enum
26
+ enum %w[ENABLED DISABLED_HMI DISABLED_SPIN DISABLED_PU_SPIN_RESET CDIS_UNKNOWN_USER UNKNOWN]
27
+ end
28
+ class Status
29
+ UNKNOWN = 0
30
+
31
+ DEACTIVATED = 1001
32
+ INITIALLY_DISABLED = 1003
33
+ DISABLED_BY_USER = 1004
34
+ OFFLINE_MODE = 1005
35
+ WORKSHOP_MODE = 1006
36
+ MISSING_OPERATION = 1007
37
+ MISSING_SERVICE = 1008
38
+ PLAY_PROTECTION = 1009
39
+ POWER_BUDGET_REACHED = 1010
40
+ DEEP_SLEEP = 1011
41
+ LOCATION_DATA_DISABLED = 1013
42
+
43
+ LICENSE_INACTIVE = 2001
44
+ LICENSE_EXPIRED = 2002
45
+ MISSING_LICENSE = 2003
46
+
47
+ USER_NOT_VERIFIED = 3001
48
+ TERMS_AND_CONDITIONS_NOT_ACCEPTED = 3002
49
+ INSUFFICIENT_RIGHTS = 3003
50
+ CONSENT_MISSING = 3004
51
+ LIMITED_FEATURE = 3005
52
+ AUTH_APP_CERT_ERROR = 3006
53
+
54
+ STATUS_UNSUPPORTED = 4001
55
+
56
+ KNOWN_STATUS = self.constants.inject([]){|result,const| result << self.const_get(const)}
57
+ def self.known_status? status
58
+ KNOWN_STATUS.include? status
59
+ end
60
+ end
61
+
62
+ class Badge < Enum
63
+ enum %w[charging connected cooling heating locked parking unlocked ventilating warning]
64
+ end
65
+
66
+ class DevicePlatform < Enum
67
+ enum %w[MBB MBB_ODP MBB_OFFLINE WCAR UNKNOWN]
68
+ end
69
+
70
+ class BrandCode
71
+ N = 'N'
72
+ V = 'V'
73
+ UNKNOWN = 'unknown brand code'
74
+ end
75
+ class JobDomain < Enum
76
+ enum %w[
77
+ access activeVentilation automation auxiliaryHeating
78
+ userCapabilities charging chargingProfiles batteryChargingCare
79
+ climatisation climatisationTimers departureTimers
80
+ fuelStatus vehicleLights lvBattery readiness
81
+ vehicleHealthInspection vehicleHealthWarnings oilLevel
82
+ measurements batterySupport
83
+ ]
84
+ JOB_DOMAINS = self.constants.inject([]){|result,const| result << self.const_get(const)}
85
+ end
86
+ class AllDomains < JobDomain
87
+ enum %w[
88
+ all allCapable parking trips
89
+ ]
90
+ end
91
+
92
+ class TripType < Enum
93
+ enum %w[shortTerm longTerm cyclic]
94
+ UNKNOWN = 'unkown trip type'
95
+ end
96
+
97
+ class PlugConnectionState < Enum
98
+ enum %w[connected disconnected invalid unsupported]
99
+ UNKNOWN = 'unknown unlock plug state'
100
+ end
101
+
102
+ class PlugLockState < Enum
103
+ enum %w[locked unlocked invalid unsupported]
104
+ UNKNOWN = 'unknown unlock plug state'
105
+ end
106
+
107
+ class ExternalPower < Enum
108
+ enum %w[ready active unavailable invalid unsupported]
109
+ UNKNOWN = 'unknown external power'
110
+ end
111
+
112
+ class LedColor < Enum
113
+ enum %w[nune green red]
114
+ UNKNOWN = 'unknown plug led color'
115
+ end
116
+ end
@@ -0,0 +1,26 @@
1
+ require File.expand_path('const', __dir__)
2
+
3
+ module WeConnect
4
+ module Control
5
+ # all possible operations
6
+ class Operation < Enum
7
+ enum %w[start stop settings lock unlock flash, honkandflash, timers mode profiles unknown]
8
+ end
9
+
10
+ class ControlOperation < Enum
11
+ enum %w[start stop none settings unknown]
12
+
13
+ def self.allowed_values
14
+ [START, STOP]
15
+ end
16
+ end
17
+
18
+ class AccessControlOperation < Enum
19
+ enum %w[lock unlock none unknown]
20
+ end
21
+
22
+ class HonkAndFlashControlOperation < Enum
23
+ enum %w[flash honkandflash none unknown]
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ module WeConnect
2
+
3
+ # Generic error to be able to rescue all WeConnect errors
4
+ class WeConnectError < StandardError; end
5
+
6
+ # configuration returns error
7
+ class IncompatibleAPIError < WeConnectError; end
8
+
9
+ # configuration returns error
10
+ class ConfigurationError < WeConnectError; end
11
+
12
+ # Issue authenticting
13
+ class AuthenticationError < WeConnectError; end
14
+
15
+ end
@@ -0,0 +1,22 @@
1
+ require 'uri'
2
+ require 'json'
3
+
4
+ module WeConnect
5
+ # Defines HTTP request methods
6
+ # required attributes format
7
+ module RequestPagination
8
+
9
+ # Defaut pages assumes all data retrieved in a single go.
10
+ class DefaultPager < WrAPI::RequestPagination::DefaultPager
11
+
12
+ def self.data(body)
13
+ if body['data']
14
+ body['data']
15
+ else
16
+ body
17
+ end
18
+ end
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,8 @@
1
+ require 'faraday'
2
+
3
+ module WeConnect
4
+ # Deals with requests
5
+ module Request
6
+
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WeConnect
4
+ VERSION = '0.1.0'
5
+ end