ruze 0.1.2 → 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: c05176b082da3e8a263b95baf98b6982b0bf1563686fd9c357b575d33c860e2e
4
- data.tar.gz: a95ddfa50c47176f21bf7b36d5a6be61ec84f5ba8994abd99739f58fac2e292e
3
+ metadata.gz: 328fbd59ffa961117d42c42029132b42d2109858f2a6f365d067019430d20dba
4
+ data.tar.gz: eb41527948e38b6d26a713e33afc1a7cf54c8616f1f0360e1e9394eb1e8cba39
5
5
  SHA512:
6
- metadata.gz: c48c8ac959d98088fca9d2028e281264a37be188a2a94e5bc5233751bf3d9e4463da88b2a4e7eaf7381a16f15448b55e4e0ccfbbce23ebb94bbc6b068e655e2e
7
- data.tar.gz: 133d2f1b88778b4b01e56f03a1ba6dd629d7968a8030917ba39b14e71487ccb8e2bed733aaa60b7e402912457d75a9f24852f528e44127d93a838b455ec4e9c5
6
+ metadata.gz: 30b962007b6fb90f95c50ced022dbc381f23f10a015627c0550521e01366a80b74f53b8aa18d79635590b77b0f57f307bc5af838b44209d3dff8b8e1aa72a4ed
7
+ data.tar.gz: 433d7097c27ea567c3a19387e8d04f6d0f167bac0be43d52ccaf3363ad7df7a1fb6cb76bfbfcf01178f392e55783ef4d8ac447a09f4d80e1da1ab5b8ab891803
data/.env.test CHANGED
@@ -7,3 +7,6 @@ KAMEREON_API_KEY=bar
7
7
  RENAULT_PERSON_ID=1234567890
8
8
  RENAULT_VIN=VF1AG000X12345678
9
9
  RENAULT_ACCOUNT_ID=0987654321
10
+
11
+ RUZE_GMID=gmid.test
12
+ RUZE_UCID=ucid.test
@@ -9,7 +9,7 @@ jobs:
9
9
  strategy:
10
10
  fail-fast: false
11
11
  matrix:
12
- ruby: ['3.0', '3.1', '3.2', '3.3']
12
+ ruby: ['4.0']
13
13
 
14
14
  steps:
15
15
  - uses: actions/checkout@v3
data/.rubocop.yml CHANGED
@@ -1,13 +1,18 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.7
2
+ TargetRubyVersion: 4.0
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
6
+ Metrics/ClassLength:
7
+ Max: 180
8
+
6
9
  Metrics/MethodLength:
7
10
  Max: 20
8
11
 
9
12
  Metrics/BlockLength:
10
13
  Max: 90
14
+ Exclude:
15
+ - 'spec/**/*'
11
16
 
12
17
  Metrics/AbcSize:
13
18
  Max: 18
data/Gemfile CHANGED
@@ -20,3 +20,6 @@ gem 'dotenv'
20
20
 
21
21
  # Automatic Ruby code style checking tool. (https://github.com/rubocop/rubocop)
22
22
  gem 'rubocop'
23
+
24
+ # Interactive Ruby console for bin/console (no longer a default gem since Ruby 4.0)
25
+ gem 'irb'
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2021-2024 Georg Ledermann
3
+ Copyright (c) 2021-2026 Georg Ledermann
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -38,27 +38,25 @@ export KAMEREON_API_KEY=...
38
38
  ## Usage
39
39
 
40
40
  ```ruby
41
- car = Ruze::Car.new('me@example.com', 'my-password')
41
+ car = Ruze::Car.new('john@example.com', 'my-password')
42
42
 
43
43
  car.battery
44
44
  # {
45
- # "timestamp" => "2021-03-13T09:59:12Z",
46
- # "batteryLevel" => 66,
47
- # "batteryTemperature" => 20,
48
- # "batteryAutonomy" => 194,
49
- # "batteryCapacity" => 0,
50
- # "batteryAvailableEnergy" => 33,
51
- # "plugStatus" => 0,
52
- # "chargingStatus" => 0.0,
53
- # "chargingRemainingTime" => 55,
54
- # "chargingInstantaneousPower" => 0.0
45
+ # "timestamp" => "2026-03-13T09:59:12Z",
46
+ # "batteryLevel" => 66,
47
+ # "batteryAutonomy" => 194,
48
+ # "plugStatus" => 0,
49
+ # "chargingStatus" => 0.0,
50
+ # "chargingRemainingTime" => 55,
51
+ # "chargingRemainingTimeLastUpdateDateTime" => "2026-03-13T09:30:00Z"
55
52
  # }
56
53
 
57
54
  car.cockpit
58
55
  # {
59
56
  # "fuelAutonomy" => 0.0,
60
57
  # "fuelQuantity" => 0.0,
61
- # "totalMileage" => 12345.67
58
+ # "totalMileage" => 12345.67,
59
+ # "timestamp" => "2026-03-13T09:59:12Z"
62
60
  # }
63
61
 
64
62
  car.location
@@ -66,10 +64,77 @@ car.location
66
64
  # "gpsDirection" => nil,
67
65
  # "gpsLatitude" => 50.12345678,
68
66
  # "gpsLongitude" => 6.12345678,
69
- # "lastUpdateTime" => "2021-03-12T11:43:18Z"
67
+ # "lastUpdateTime" => "2026-03-12T11:43:18Z"
70
68
  # }
71
69
  ```
72
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
+
98
+
99
+ ## Two-factor authentication
100
+
101
+ Renault enforces two-factor authentication (2FA) on Gigya accounts, so a
102
+ password alone is no longer enough to log in. RuZE handles this by establishing
103
+ a *trusted device* once: after a single email verification, Renault remembers
104
+ the device for about 30 days, during which logins succeed without a prompt.
105
+
106
+ ### One-time setup
107
+
108
+ Trigger a verification code, confirm it with the 6-digit code Renault emails to
109
+ your account, then read the resulting trusted-device pair:
110
+
111
+ ```ruby
112
+ gigya = Ruze::Gigya.new('john@example.com', 'my-password')
113
+
114
+ gigya.request_verification_code # emails a 6-digit code (returns nil if none is needed)
115
+
116
+ gigya.verify_code('123456')
117
+
118
+ gmid = gigya.device.gmid
119
+ ucid = gigya.device.ucid
120
+ # Store this gmid/ucid pair somewhere durable (env vars, a file, a database).
121
+ ```
122
+
123
+ `Ruze::Device` keeps the pair in memory only, so persisting it is up to you. The
124
+ pair stays valid across renewals, so you store it once.
125
+
126
+ ### Normal usage
127
+
128
+ Seed a device with your stored pair and `Ruze::Car` logs in without prompting:
129
+
130
+ ```ruby
131
+ device = Ruze::Device.new(gmid: gmid, ucid: ucid)
132
+ car = Ruze::Car.new('john@example.com', 'my-password', device: device)
133
+ ```
134
+
135
+ When the device is missing or has expired, login raises
136
+ `Ruze::TwoFactorRequired` — repeat the one-time setup to renew it.
137
+
73
138
 
74
139
  ## Background
75
140
 
@@ -106,7 +171,7 @@ This project is not affiliated with, endorsed by, or connected to Renault. I acc
106
171
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
107
172
 
108
173
 
109
- Copyright (c) 2021-2024 Georg Ledermann, released under the AGPL-3.0 License
174
+ Copyright (c) 2021-2026 Georg Ledermann, released under the AGPL-3.0 License
110
175
 
111
176
  ## Code of Conduct
112
177
 
data/bin/console CHANGED
@@ -1,14 +1,24 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'bundler/setup'
4
+ require 'dotenv/load'
4
5
  require 'ruze'
5
6
 
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
7
+ # Convenience for experimenting against the real API, using the credentials and
8
+ # trusted device from your .env (RENAULT_EMAIL, RENAULT_PASSWORD, GIGYA_API_KEY,
9
+ # KAMEREON_API_KEY, RUZE_GMID, RUZE_UCID):
10
+ #
11
+ # car.battery
12
+ # car.cockpit
13
+ # car.location
14
+ #
15
+ def car
16
+ Ruze::Car.new(
17
+ ENV.fetch('RENAULT_EMAIL'),
18
+ ENV.fetch('RENAULT_PASSWORD'),
19
+ device: Ruze::Device.new(gmid: ENV.fetch('RUZE_GMID', nil), ucid: ENV.fetch('RUZE_UCID', nil))
20
+ )
21
+ end
12
22
 
13
23
  require 'irb'
14
24
  IRB.start(__FILE__)
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)
7
- gigya = Ruze::Gigya.new(email, password)
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
@@ -0,0 +1,30 @@
1
+ module Ruze
2
+ # Holds the Gigya trusted-device identity (gmid/ucid) that lets us skip the
3
+ # two-factor prompt on subsequent logins.
4
+ #
5
+ # This default implementation keeps the pair in memory only. For headless
6
+ # operation across process restarts, seed it from durable storage and persist
7
+ # the pair (see #gmid/#ucid) after a successful bootstrap:
8
+ #
9
+ # device = Ruze::Device.new(gmid: stored_gmid, ucid: stored_ucid)
10
+ # Ruze::Car.new(email, password, device: device)
11
+ class Device
12
+ def initialize(gmid: nil, ucid: nil)
13
+ @gmid = gmid
14
+ @ucid = ucid
15
+ end
16
+ attr_reader :gmid, :ucid
17
+
18
+ # Returns { gmid:, ucid: } or nil when no (complete) device is available.
19
+ def load
20
+ return nil if gmid.to_s.empty? || ucid.to_s.empty?
21
+
22
+ { gmid: gmid, ucid: ucid }
23
+ end
24
+
25
+ def save(gmid:, ucid:)
26
+ @gmid = gmid
27
+ @ucid = ucid
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,12 @@
1
+ module Ruze
2
+ class Error < StandardError; end
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
+
8
+ # Raised when Renault/Gigya requires two-factor authentication and no trusted
9
+ # device is available. Run the bootstrap flow (request_verification_code +
10
+ # verify_code) to establish a trusted device.
11
+ class TwoFactorRequired < Error; end
12
+ end
data/lib/ruze/gigya.rb CHANGED
@@ -1,68 +1,251 @@
1
1
  require 'net/http'
2
2
  require 'json'
3
+ require_relative 'errors'
4
+ require_relative 'device'
3
5
 
4
6
  module Ruze
5
7
  class Gigya
6
- BASE_URL = 'https://accounts.eu1.gigya.com'.freeze
8
+ BASE_URL = 'https://accounts.eu1.gigya.com'.freeze
9
+ SOCIALIZE_URL = 'https://socialize.eu1.gigya.com'.freeze
7
10
 
8
- def initialize(email, password)
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)
9
18
  raise ArgumentError unless email.is_a?(String) && password.is_a?(String)
10
19
 
11
20
  @email = email
12
21
  @password = password
22
+ @device = device
23
+ @logger = logger
13
24
  end
14
- attr_reader :email, :password
25
+ attr_reader :email, :password, :device
15
26
 
16
27
  def person_id
17
- @person_id ||= return_from Net::HTTP.post_form(
18
- uri('/accounts.getAccountInfo'),
19
- 'ApiKey' => api_key,
20
- 'login_token' => session_cookie_value
21
- ), keys: %w[data personId]
28
+ @person_id ||= dig_from post(
29
+ "#{BASE_URL}/accounts.getAccountInfo",
30
+ { 'apiKey' => api_key, 'login_token' => session_cookie_value }
31
+ ), label: 'person_id', keys: %w[data personId]
22
32
  end
23
33
 
24
34
  def jwt
25
- @jwt ||= return_from Net::HTTP.post_form(
26
- uri('/accounts.getJWT'),
27
- 'ApiKey' => api_key,
28
- 'login_token' => session_cookie_value,
29
- 'fields' => 'data.personId,data.gigyaDataCenter',
30
- 'expiration' => 900
31
- ), keys: %w[id_token]
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(
41
+ "#{BASE_URL}/accounts.getJWT",
42
+ { 'apiKey' => api_key,
43
+ 'login_token' => login_token,
44
+ 'fields' => 'data.personId,data.gigyaDataCenter',
45
+ 'expiration' => JWT_LIFETIME }
46
+ ), label: 'jwt', keys: %w[id_token]
47
+ @jwt_expires_at = fetched_at + JWT_LIFETIME - JWT_MARGIN
48
+ @jwt
32
49
  end
33
50
 
51
+ # Logs in using the trusted device and returns the session cookie value.
52
+ # Raises TwoFactorRequired when Renault demands a fresh 2FA verification.
34
53
  def session_cookie_value
35
- @session_cookie_value ||= return_from Net::HTTP.post_form(
36
- uri('/accounts.login'),
37
- 'ApiKey' => api_key,
38
- 'loginID' => email,
39
- 'password' => password
40
- ), keys: %w[sessionInfo cookieValue]
54
+ @session_cookie_value ||= login
55
+ end
56
+
57
+ # --- Two-factor bootstrap ------------------------------------------------
58
+ #
59
+ # The Gigya 2FA trusted-device flow below (initTFA -> getEmails ->
60
+ # sendVerificationCode -> completeVerification -> finalizeTFA -> re-login)
61
+ # was reverse-engineered by the renault-api community:
62
+ # https://github.com/hacf-fr/renault-api/issues/2132
63
+ #
64
+ # Run once interactively to establish a trusted device:
65
+ #
66
+ # gigya = Ruze::Gigya.new(email, password)
67
+ # puts "Code sent to #{gigya.request_verification_code}"
68
+ # gigya.verify_code(gets.strip)
69
+ #
70
+ # After that, session_cookie_value works headlessly for ~30 days.
71
+
72
+ # Triggers the email verification code and returns the obfuscated address it
73
+ # was sent to. Returns nil when no verification is needed -- either the
74
+ # account has no 2FA or this device is already trusted.
75
+ def request_verification_code
76
+ ids = device_ids
77
+ reg_token = login_reg_token(ids)
78
+ return nil unless reg_token
79
+
80
+ assertion = init_tfa(reg_token, ids)
81
+ mail = first_email(assertion, ids)
82
+ phv_token = send_verification_code(mail['id'], assertion, ids)
83
+
84
+ @bootstrap = { ids: ids, reg_token: reg_token, assertion: assertion, phv_token: phv_token }
85
+ mail['obfuscated']
86
+ end
87
+
88
+ # Completes the 2FA flow with the emailed code, finalizes the device as
89
+ # trusted (remembered for ~30 days) and persists it.
90
+ def verify_code(code)
91
+ raise Error, 'Call request_verification_code before verify_code' unless @bootstrap
92
+
93
+ ids = @bootstrap[:ids]
94
+ provider_assertion = complete_verification(code, ids)
95
+ finalize_tfa(provider_assertion, ids)
96
+
97
+ @session_cookie_value = login(ids)
98
+ device.save(gmid: ids[:gmid], ucid: ids[:ucid])
99
+ @session_cookie_value
41
100
  end
42
101
 
43
102
  private
44
103
 
104
+ def login(ids = device_ids)
105
+ @logger&.info('Logging in')
106
+ json = parse post(
107
+ "#{BASE_URL}/accounts.login",
108
+ login_params(ids), cookie: cookie(ids)
109
+ )
110
+
111
+ case json['errorCode']
112
+ when 0
113
+ json.dig('sessionInfo', 'cookieValue')
114
+ when 403_101
115
+ raise TwoFactorRequired,
116
+ 'Two-factor authentication required. Run the bootstrap flow to trust this device.'
117
+ else
118
+ raise Error, "Error in session_cookie_value: #{error_detail(json)}"
119
+ end
120
+ end
121
+
122
+ # Like #login but returns the regToken from a pending-2FA response instead of
123
+ # raising. Returns nil when the account is already logged in without 2FA.
124
+ def login_reg_token(ids)
125
+ json = parse post(
126
+ "#{BASE_URL}/accounts.login",
127
+ login_params(ids), cookie: cookie(ids)
128
+ )
129
+ return json['regToken'] if json['errorCode'] == 403_101
130
+ return nil if json['errorCode']&.zero?
131
+
132
+ raise Error, "Error in session_cookie_value: #{error_detail(json)}"
133
+ end
134
+
135
+ def login_params(ids)
136
+ {
137
+ 'apiKey' => api_key,
138
+ 'loginID' => email,
139
+ 'password' => password,
140
+ 'gmid' => ids[:gmid],
141
+ 'ucid' => ids[:ucid]
142
+ }
143
+ end
144
+
145
+ def init_tfa(reg_token, ids)
146
+ dig_from post(
147
+ "#{BASE_URL}/accounts.tfa.initTFA",
148
+ { 'apiKey' => api_key, 'regToken' => reg_token, 'provider' => 'gigyaEmail',
149
+ 'mode' => 'verify', 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
150
+ cookie: cookie(ids)
151
+ ), label: 'init_tfa', keys: %w[gigyaAssertion]
152
+ end
153
+
154
+ def first_email(assertion, ids)
155
+ emails = dig_from post(
156
+ "#{BASE_URL}/accounts.tfa.email.getEmails",
157
+ { 'apiKey' => api_key, 'gigyaAssertion' => assertion, 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
158
+ cookie: cookie(ids)
159
+ ), label: 'get_emails', keys: %w[emails]
160
+ raise Error, 'No verified email address available for 2FA' if emails.nil? || emails.empty?
161
+
162
+ emails.first
163
+ end
164
+
165
+ def send_verification_code(email_id, assertion, ids)
166
+ dig_from post(
167
+ "#{BASE_URL}/accounts.tfa.email.sendVerificationCode",
168
+ { 'apiKey' => api_key, 'emailID' => email_id, 'gigyaAssertion' => assertion,
169
+ 'lang' => 'en', 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
170
+ cookie: cookie(ids)
171
+ ), label: 'send_verification_code', keys: %w[phvToken]
172
+ end
173
+
174
+ def complete_verification(code, ids)
175
+ dig_from post(
176
+ "#{BASE_URL}/accounts.tfa.email.completeVerification",
177
+ { 'apiKey' => api_key, 'gigyaAssertion' => @bootstrap[:assertion],
178
+ 'phvToken' => @bootstrap[:phv_token], 'code' => code,
179
+ 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
180
+ cookie: cookie(ids)
181
+ ), label: 'complete_verification', keys: %w[providerAssertion]
182
+ end
183
+
184
+ # tempDevice=false marks the device as remembered (no 2FA for ~30 days).
185
+ def finalize_tfa(provider_assertion, ids)
186
+ json = parse post(
187
+ "#{BASE_URL}/accounts.tfa.finalizeTFA",
188
+ { 'apiKey' => api_key, 'gigyaAssertion' => @bootstrap[:assertion],
189
+ 'providerAssertion' => provider_assertion, 'tempDevice' => 'false',
190
+ 'regToken' => @bootstrap[:reg_token], 'gmid' => ids[:gmid], 'ucid' => ids[:ucid] },
191
+ cookie: cookie(ids)
192
+ )
193
+ raise Error, "Error in finalize_tfa: #{error_detail(json)}" unless json['errorCode']&.zero?
194
+
195
+ json
196
+ end
197
+
198
+ def device_ids
199
+ @device_ids ||= device.load || fetch_ids
200
+ end
201
+
202
+ def fetch_ids
203
+ json = parse post("#{SOCIALIZE_URL}/socialize.getIDs", { 'apiKey' => api_key })
204
+ raise Error, "Error in fetch_ids: #{error_detail(json)}" unless json['errorCode']&.zero?
205
+
206
+ { gmid: json['gmid'], ucid: json['ucid'] }
207
+ end
208
+
209
+ def now
210
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ end
212
+
213
+ def cookie(ids)
214
+ "gmid=#{ids[:gmid]}; ucid=#{ids[:ucid]}"
215
+ end
216
+
45
217
  def api_key
46
218
  ENV.fetch('GIGYA_API_KEY')
47
219
  end
48
220
 
49
- def uri(path)
50
- URI("#{BASE_URL}#{path}")
221
+ def post(url, params, cookie: nil)
222
+ uri = URI(url)
223
+ request = Net::HTTP::Post.new(uri)
224
+ request.set_form_data(params.merge('format' => 'json'))
225
+ request['Cookie'] = cookie if cookie
226
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
227
+ http.request(request)
228
+ end
51
229
  end
52
230
 
53
- def return_from(response, keys:)
54
- unless response.is_a?(Net::HTTPOK)
55
- caller = caller_locations(1, 1)[0].label
56
- raise Error, "Error in #{caller}: #{response.message} (#{response.code})"
57
- end
231
+ def parse(response)
232
+ JSON.parse(response.body)
233
+ end
58
234
 
59
- json = JSON.parse(response.body)
60
- unless json['errorCode']&.zero?
61
- caller = caller_locations(1, 1)[0].label
62
- raise Error, "Error in #{caller}: #{json['errorDetails']}"
63
- end
235
+ def dig_from(response, label:, keys:)
236
+ json = parse(response)
237
+ raise error_class(json), "Error in #{label}: #{error_detail(json)}" unless json['errorCode']&.zero?
64
238
 
65
239
  json.dig(*keys)
66
240
  end
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
+
247
+ def error_detail(json)
248
+ json['errorDetails'] || json['errorMessage'] || "errorCode #{json['errorCode']}"
249
+ end
67
250
  end
68
251
  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
@@ -89,8 +100,9 @@ module Ruze
89
100
 
90
101
  def return_from(response, keys:)
91
102
  unless response.is_a?(Net::HTTPOK)
92
- caller = caller_locations(1, 1)[0].label
93
- raise Error, "Error in #{caller}: #{response.message} (#{response.code})"
103
+ caller = caller_locations(1, 1)[0].base_label
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.1.2'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
data/lib/ruze.rb CHANGED
@@ -1,6 +1,3 @@
1
1
  require_relative 'ruze/version'
2
+ require_relative 'ruze/errors'
2
3
  require_relative 'ruze/car'
3
-
4
- module Ruze
5
- class Error < StandardError; end
6
- end
data/ruze.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = 'Queries vehicle data like mileage, charging state and GPS location'
11
11
  spec.homepage = 'https://github.com/solectrus/ruze'
12
12
  spec.license = 'MIT'
13
- spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0')
13
+ spec.required_ruby_version = Gem::Requirement.new('>= 4.0.0')
14
14
 
15
15
  spec.metadata['homepage_uri'] = spec.homepage
16
16
  spec.metadata['source_code_uri'] = 'https://github.com/solectrus/ruze'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruze
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Georg Ledermann
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-04-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
12
  description: Queries vehicle data like mileage, charging state and GPS location
14
13
  email:
@@ -31,6 +30,8 @@ files:
31
30
  - bin/setup
32
31
  - lib/ruze.rb
33
32
  - lib/ruze/car.rb
33
+ - lib/ruze/device.rb
34
+ - lib/ruze/errors.rb
34
35
  - lib/ruze/gigya.rb
35
36
  - lib/ruze/kamereon.rb
36
37
  - lib/ruze/version.rb
@@ -43,7 +44,6 @@ metadata:
43
44
  source_code_uri: https://github.com/solectrus/ruze
44
45
  changelog_uri: https://github.com/solectrus/ruze/releases
45
46
  rubygems_mfa_required: 'true'
46
- post_install_message:
47
47
  rdoc_options: []
48
48
  require_paths:
49
49
  - lib
@@ -51,15 +51,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: 2.7.0
54
+ version: 4.0.0
55
55
  required_rubygems_version: !ruby/object:Gem::Requirement
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
59
  version: '0'
60
60
  requirements: []
61
- rubygems_version: 3.5.9
62
- signing_key:
61
+ rubygems_version: 4.0.21
63
62
  specification_version: 4
64
63
  summary: Unofficial Ruby client for the Renault ZE API
65
64
  test_files: []