ruze 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fa802b8dceb6e72c4e9c04b3a0ee3fd0a1c70d2063b0ffae6d46398057b317d
4
- data.tar.gz: 9976f664f420a5b351023f3d4bb8ef44947849c752aebbc0c0d8de402dd0a507
3
+ metadata.gz: 328fbd59ffa961117d42c42029132b42d2109858f2a6f365d067019430d20dba
4
+ data.tar.gz: eb41527948e38b6d26a713e33afc1a7cf54c8616f1f0360e1e9394eb1e8cba39
5
5
  SHA512:
6
- metadata.gz: 5b5e04039d1562360d0f237179555b5660e8f0d5369269723f343d689dd9f747c2ff184a6bd68331fa493bb9ae5f75e7801fdf55b8924928efe7fea1525f5691
7
- data.tar.gz: 708613986c1f2f35bd215cc2a4099bc2487d7f456200643c8fba86ffef83b132e30a2cf2878337a15d0eb3236d8172a03bb97489f34abb152d2bbb55c7ecf08d
6
+ metadata.gz: 30b962007b6fb90f95c50ced022dbc381f23f10a015627c0550521e01366a80b74f53b8aa18d79635590b77b0f57f307bc5af838b44209d3dff8b8e1aa72a4ed
7
+ data.tar.gz: 433d7097c27ea567c3a19387e8d04f6d0f167bac0be43d52ccaf3363ad7df7a1fb6cb76bfbfcf01178f392e55783ef4d8ac447a09f4d80e1da1ab5b8ab891803
data/.rubocop.yml CHANGED
@@ -4,13 +4,15 @@ AllCops:
4
4
  SuggestExtensions: false
5
5
 
6
6
  Metrics/ClassLength:
7
- Max: 170
7
+ Max: 180
8
8
 
9
9
  Metrics/MethodLength:
10
10
  Max: 20
11
11
 
12
12
  Metrics/BlockLength:
13
13
  Max: 90
14
+ Exclude:
15
+ - 'spec/**/*'
14
16
 
15
17
  Metrics/AbcSize:
16
18
  Max: 18
data/README.md CHANGED
@@ -68,6 +68,33 @@ car.location
68
68
  # }
69
69
  ```
70
70
 
71
+ ### Repeated queries
72
+
73
+ A `Ruze::Car` keeps the data it fetched. To get new data, call `reload`
74
+ and query the car again:
75
+
76
+ ```ruby
77
+ loop do
78
+ car.reload
79
+ puts car.battery['batteryLevel']
80
+ sleep 600
81
+ end
82
+ ```
83
+
84
+ Use one car for all queries. The car logs in once and keeps the login. It
85
+ gets a new token from Renault when the old token expires (after 15 minutes).
86
+ If Renault rejects the login later (HTTP 401 or 403), the car logs in again
87
+ and repeats the query once. Other errors, like a failing server, raise
88
+ `Ruze::Error` without a new login. A new `Ruze::Car` for each query makes 4 more requests, because
89
+ it logs in again and looks up the account and the VIN.
90
+
91
+ To log each login and each new token, give a logger. Any object with an
92
+ `info` method works:
93
+
94
+ ```ruby
95
+ car = Ruze::Car.new(email, password, logger: Logger.new($stdout))
96
+ ```
97
+
71
98
 
72
99
  ## Two-factor authentication
73
100
 
data/lib/ruze/car.rb CHANGED
@@ -2,22 +2,62 @@ require_relative 'gigya'
2
2
  require_relative 'kamereon'
3
3
 
4
4
  module Ruze
5
+ # One Car can be queried repeatedly: it keeps the login and renews the JWT
6
+ # when needed. Call #reload to fetch fresh vehicle data.
7
+ #
8
+ # logger is any object with #info. It gets a message for each login and
9
+ # each new token.
5
10
  class Car
6
- def initialize(email, password, vin = nil, device: Ruze::Device.new)
7
- gigya = Ruze::Gigya.new(email, password, device: device)
8
- @kamereon = Ruze::Kamereon.new(gigya.person_id, gigya.jwt, vin)
11
+ def initialize(email, password, vin = nil, device: Ruze::Device.new, logger: nil)
12
+ @email = email
13
+ @password = password
14
+ @vin = vin
15
+ @device = device
16
+ @logger = logger
17
+ @gigya = new_gigya
9
18
  end
10
19
 
11
20
  def battery
12
- @kamereon.battery
21
+ with_session { kamereon.battery }
13
22
  end
14
23
 
15
24
  def cockpit
16
- @kamereon.cockpit
25
+ with_session { kamereon.cockpit }
17
26
  end
18
27
 
19
28
  def location
20
- @kamereon.location
29
+ with_session { kamereon.location }
30
+ end
31
+
32
+ # Forgets the vehicle data, but keeps the login
33
+ def reload
34
+ @kamereon&.reload
35
+ self
36
+ end
37
+
38
+ private
39
+
40
+ def kamereon
41
+ @kamereon ||= Ruze::Kamereon.new(@gigya.person_id, -> { @gigya.jwt }, @vin)
42
+ end
43
+
44
+ def new_gigya
45
+ Ruze::Gigya.new(@email, @password, device: @device, logger: @logger)
46
+ end
47
+
48
+ # A reused login can expire. If Renault rejects it, log in again and retry
49
+ # once. A fresh login gets no retry, because it would fail the same way.
50
+ # Other errors (like a failing server) get no new login.
51
+ def with_session
52
+ reused = !@kamereon.nil?
53
+ yield
54
+ rescue AuthenticationError => e
55
+ raise unless reused
56
+
57
+ @logger&.info("Login expired (#{e.message}), logging in again")
58
+ @gigya = new_gigya
59
+ @kamereon = nil
60
+ yield
21
61
  end
22
62
  end
23
63
  end
data/lib/ruze/errors.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  module Ruze
2
2
  class Error < StandardError; end
3
3
 
4
+ # Raised when Renault rejects the login or the token (HTTP 401 or 403).
5
+ # A new login can help.
6
+ class AuthenticationError < Error; end
7
+
4
8
  # Raised when Renault/Gigya requires two-factor authentication and no trusted
5
9
  # device is available. Run the bootstrap flow (request_verification_code +
6
10
  # verify_code) to establish a trusted device.
data/lib/ruze/gigya.rb CHANGED
@@ -8,12 +8,19 @@ module Ruze
8
8
  BASE_URL = 'https://accounts.eu1.gigya.com'.freeze
9
9
  SOCIALIZE_URL = 'https://socialize.eu1.gigya.com'.freeze
10
10
 
11
- def initialize(email, password, device: Device.new)
11
+ # Lifetime of a JWT in seconds. A new one is fetched shortly before the
12
+ # old one expires.
13
+ JWT_LIFETIME = 900
14
+ JWT_MARGIN = 60
15
+
16
+ # logger is any object with #info
17
+ def initialize(email, password, device: Device.new, logger: nil)
12
18
  raise ArgumentError unless email.is_a?(String) && password.is_a?(String)
13
19
 
14
20
  @email = email
15
21
  @password = password
16
22
  @device = device
23
+ @logger = logger
17
24
  end
18
25
  attr_reader :email, :password, :device
19
26
 
@@ -25,13 +32,20 @@ module Ruze
25
32
  end
26
33
 
27
34
  def jwt
28
- @jwt ||= dig_from post(
35
+ return @jwt if @jwt && now < @jwt_expires_at
36
+
37
+ login_token = session_cookie_value
38
+ @logger&.info('Fetching new token')
39
+ fetched_at = now
40
+ @jwt = dig_from post(
29
41
  "#{BASE_URL}/accounts.getJWT",
30
42
  { 'apiKey' => api_key,
31
- 'login_token' => session_cookie_value,
43
+ 'login_token' => login_token,
32
44
  'fields' => 'data.personId,data.gigyaDataCenter',
33
- 'expiration' => 900 }
45
+ 'expiration' => JWT_LIFETIME }
34
46
  ), label: 'jwt', keys: %w[id_token]
47
+ @jwt_expires_at = fetched_at + JWT_LIFETIME - JWT_MARGIN
48
+ @jwt
35
49
  end
36
50
 
37
51
  # Logs in using the trusted device and returns the session cookie value.
@@ -88,6 +102,7 @@ module Ruze
88
102
  private
89
103
 
90
104
  def login(ids = device_ids)
105
+ @logger&.info('Logging in')
91
106
  json = parse post(
92
107
  "#{BASE_URL}/accounts.login",
93
108
  login_params(ids), cookie: cookie(ids)
@@ -191,6 +206,10 @@ module Ruze
191
206
  { gmid: json['gmid'], ucid: json['ucid'] }
192
207
  end
193
208
 
209
+ def now
210
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ end
212
+
194
213
  def cookie(ids)
195
214
  "gmid=#{ids[:gmid]}; ucid=#{ids[:ucid]}"
196
215
  end
@@ -215,11 +234,16 @@ module Ruze
215
234
 
216
235
  def dig_from(response, label:, keys:)
217
236
  json = parse(response)
218
- raise Error, "Error in #{label}: #{error_detail(json)}" unless json['errorCode']&.zero?
237
+ raise error_class(json), "Error in #{label}: #{error_detail(json)}" unless json['errorCode']&.zero?
219
238
 
220
239
  json.dig(*keys)
221
240
  end
222
241
 
242
+ # The first three digits of a Gigya error code are the HTTP status
243
+ def error_class(json)
244
+ json['errorCode'].to_s.start_with?('401', '403') ? AuthenticationError : Error
245
+ end
246
+
223
247
  def error_detail(json)
224
248
  json['errorDetails'] || json['errorMessage'] || "errorCode #{json['errorCode']}"
225
249
  end
data/lib/ruze/kamereon.rb CHANGED
@@ -6,14 +6,19 @@ module Ruze
6
6
  BASE_URL = 'https://api-wired-prod-1-euw1.wrd-aws.com/commerce/v1'.freeze
7
7
  COUNTRY = 'DE'.freeze
8
8
 
9
+ # gigya_token is a String or a callable that returns a valid token
9
10
  def initialize(person_id, gigya_token, vin = nil)
10
- raise ArgumentError unless person_id.is_a?(String) && gigya_token.is_a?(String)
11
+ raise ArgumentError unless person_id.is_a?(String) && (gigya_token.is_a?(String) || gigya_token.respond_to?(:call))
11
12
 
12
13
  @person_id = person_id
13
14
  @gigya_token = gigya_token
14
15
  @vin = vin
15
16
  end
16
- attr_reader :person_id, :gigya_token
17
+ attr_reader :person_id
18
+
19
+ def gigya_token
20
+ @gigya_token.respond_to?(:call) ? @gigya_token.call : @gigya_token
21
+ end
17
22
 
18
23
  def account_id
19
24
  accounts.first['accountId']
@@ -62,6 +67,12 @@ module Ruze
62
67
  {}
63
68
  end
64
69
 
70
+ # Forgets the vehicle data, but keeps account and VIN
71
+ def reload
72
+ @battery = @cockpit = @location = nil
73
+ self
74
+ end
75
+
65
76
  private
66
77
 
67
78
  def api_key
@@ -90,7 +101,8 @@ module Ruze
90
101
  def return_from(response, keys:)
91
102
  unless response.is_a?(Net::HTTPOK)
92
103
  caller = caller_locations(1, 1)[0].base_label
93
- raise Error, "Error in #{caller}: #{response.message} (#{response.code})"
104
+ error = %w[401 403].include?(response.code) ? AuthenticationError : Error
105
+ raise error, "Error in #{caller}: #{response.message} (#{response.code})"
94
106
  end
95
107
 
96
108
  json = JSON.parse(response.body)
data/lib/ruze/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Ruze
2
- VERSION = '0.2.0'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruze
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Georg Ledermann
@@ -58,7 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
58
58
  - !ruby/object:Gem::Version
59
59
  version: '0'
60
60
  requirements: []
61
- rubygems_version: 4.0.12
61
+ rubygems_version: 4.0.21
62
62
  specification_version: 4
63
63
  summary: Unofficial Ruby client for the Renault ZE API
64
64
  test_files: []