tesla_api 3.0.0 → 3.0.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/docs/README.md +1 -1
- data/docs/SUMMARY.md +35 -34
- data/docs/api-basics/vehicles.md +5 -6
- data/docs/miscellaneous/endpoints.md +29 -4
- data/docs/vehicle/commands/README.md +11 -3
- data/docs/vehicle/commands/climate.md +21 -0
- data/docs/vehicle/commands/homelink.md +21 -0
- data/docs/vehicle/commands/sharing.md +38 -0
- data/docs/vehicle/commands/valet.md +7 -5
- data/docs/vehicle/commands/wake.md +4 -1
- data/docs/vehicle/commands/windows.md +26 -0
- data/docs/vehicle/optioncodes.md +22 -6
- data/docs/vehicle/state/chargestate.md +1 -0
- data/docs/vehicle/state/climatestate.md +1 -1
- data/docs/vehicle/state/data.md +27 -7
- data/docs/vehicle/state/guisettings.md +1 -0
- data/docs/vehicle/state/vehiclestate.md +46 -15
- data/lib/tesla_api.rb +4 -2
- data/lib/tesla_api/autopark.rb +23 -41
- data/lib/tesla_api/client.rb +14 -7
- data/lib/tesla_api/stream.rb +40 -34
- data/lib/tesla_api/vehicle.rb +49 -1
- data/lib/tesla_api/version.rb +1 -1
- data/spec/cassettes/client-login_timeout.yml +83 -0
- data/spec/cassettes/vehicle-media_next_fav.yml +109 -0
- data/spec/cassettes/vehicle-media_next_track.yml +109 -0
- data/spec/cassettes/vehicle-media_prev_fav.yml +109 -0
- data/spec/cassettes/vehicle-media_prev_track.yml +109 -0
- data/spec/cassettes/vehicle-media_toggle_playback.yml +109 -0
- data/spec/cassettes/vehicle-media_volume_down.yml +109 -0
- data/spec/cassettes/vehicle-media_volume_up.yml +109 -0
- data/spec/cassettes/vehicle-vehicle_config.yml +108 -0
- data/spec/lib/tesla_api/client_spec.rb +30 -0
- data/spec/lib/tesla_api/vehicle_spec.rb +57 -0
- data/tesla_api.gemspec +1 -1
- metadata +30 -10
- data/docs/vehicle/commands/navigation.md +0 -36
@@ -43,6 +43,7 @@ Information on the state of charge in the battery and its various settings.
|
|
43
43
|
"managed_charging_start_time": null,
|
44
44
|
"managed_charging_user_canceled": false,
|
45
45
|
"max_range_charge_counter": 0,
|
46
|
+
"minutes_to_full_charge": 0,
|
46
47
|
"not_enough_power_to_heat": false,
|
47
48
|
"scheduled_charging_pending": false,
|
48
49
|
"scheduled_charging_start_time": null,
|
@@ -12,6 +12,7 @@ Information on the current internal temperature and climate control system.
|
|
12
12
|
"battery_heater": false,
|
13
13
|
"battery_heater_no_power": false,
|
14
14
|
"climate_keeper_mode": "dog",
|
15
|
+
"defrost_mode": 0,
|
15
16
|
"driver_temp_setting": 21.6,
|
16
17
|
"fan_status": 0,
|
17
18
|
"inside_temp": null,
|
@@ -35,7 +36,6 @@ Information on the current internal temperature and climate control system.
|
|
35
36
|
"seat_heater_rear_right_back": 0,
|
36
37
|
"seat_heater_right": 2,
|
37
38
|
"side_mirror_heaters": false,
|
38
|
-
"smart_preconditioning": false,
|
39
39
|
"steering_wheel_heater": false,
|
40
40
|
"timestamp": 1543187641727,
|
41
41
|
"wiper_blade_heater": false
|
data/docs/vehicle/state/data.md
CHANGED
@@ -4,7 +4,7 @@
|
|
4
4
|
|
5
5
|
A rollup of all the `data_request` endpoints plus vehicle configuration.
|
6
6
|
|
7
|
-
|
7
|
+
_Note:_ all `*_range` values are in miles, irrespective of GUI configuration.
|
8
8
|
|
9
9
|
### Response
|
10
10
|
|
@@ -23,7 +23,7 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
23
23
|
"in_service": false,
|
24
24
|
"id_s": "12345678901234567",
|
25
25
|
"calendar_enabled": true,
|
26
|
-
"api_version":
|
26
|
+
"api_version": 7,
|
27
27
|
"backseat_token": null,
|
28
28
|
"backseat_token_updated_at": null,
|
29
29
|
"drive_state": {
|
@@ -44,6 +44,7 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
44
44
|
"battery_heater": false,
|
45
45
|
"battery_heater_no_power": false,
|
46
46
|
"climate_keeper_mode": "dog",
|
47
|
+
"defrost_mode": 0,
|
47
48
|
"driver_temp_setting": 21.6,
|
48
49
|
"fan_status": 0,
|
49
50
|
"inside_temp": null,
|
@@ -67,7 +68,6 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
67
68
|
"seat_heater_rear_right_back": 0,
|
68
69
|
"seat_heater_right": 2,
|
69
70
|
"side_mirror_heaters": false,
|
70
|
-
"smart_preconditioning": false,
|
71
71
|
"steering_wheel_heater": false,
|
72
72
|
"timestamp": 1543186971731,
|
73
73
|
"wiper_blade_heater": false
|
@@ -107,6 +107,7 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
107
107
|
"managed_charging_start_time": null,
|
108
108
|
"managed_charging_user_canceled": false,
|
109
109
|
"max_range_charge_counter": 0,
|
110
|
+
"minutes_to_full_charge": 0,
|
110
111
|
"not_enough_power_to_heat": false,
|
111
112
|
"scheduled_charging_pending": false,
|
112
113
|
"scheduled_charging_start_time": null,
|
@@ -122,34 +123,51 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
122
123
|
"gui_distance_units": "mi/hr",
|
123
124
|
"gui_range_display": "Rated",
|
124
125
|
"gui_temperature_units": "F",
|
126
|
+
"show_range_units": true,
|
125
127
|
"timestamp": 1543186971728
|
126
128
|
},
|
127
129
|
"vehicle_state": {
|
128
|
-
"api_version":
|
130
|
+
"api_version": 7,
|
129
131
|
"autopark_state_v2": "standby",
|
130
132
|
"autopark_style": "standard",
|
131
133
|
"calendar_supported": true,
|
132
|
-
"car_version": "
|
134
|
+
"car_version": "2019.40.2.1 38f55d9f9205",
|
133
135
|
"center_display_state": 0,
|
134
136
|
"df": 0,
|
135
137
|
"dr": 0,
|
138
|
+
"fd_window": 0,
|
139
|
+
"fp_window": 0,
|
136
140
|
"ft": 0,
|
141
|
+
"homelink_device_count": 0,
|
137
142
|
"homelink_nearby": true,
|
138
143
|
"is_user_present": false,
|
139
144
|
"last_autopark_error": "no_error",
|
140
145
|
"locked": true,
|
141
|
-
"media_state": {
|
146
|
+
"media_state": {
|
147
|
+
"remote_control_enabled": true
|
148
|
+
},
|
142
149
|
"notifications_supported": true,
|
143
150
|
"odometer": 33561.422505,
|
144
151
|
"parsed_calendar_supported": true,
|
145
152
|
"pf": 0,
|
146
153
|
"pr": 0,
|
154
|
+
"rd_window": 0,
|
147
155
|
"remote_start": false,
|
148
156
|
"remote_start_enabled": true,
|
149
157
|
"remote_start_supported": true,
|
158
|
+
"rp_window": 0,
|
150
159
|
"rt": 0,
|
151
160
|
"sentry_mode": true,
|
152
|
-
"
|
161
|
+
"sentry_mode_available": true,
|
162
|
+
"smart_summon_available": true,
|
163
|
+
"software_update": {
|
164
|
+
"download_perc": 100,
|
165
|
+
"expected_duration_sec": 2700,
|
166
|
+
"install_perc": 10,
|
167
|
+
"scheduled_time_ms": 1575689678432,
|
168
|
+
"status": "scheduled",
|
169
|
+
"version": "2019.40.2.1"
|
170
|
+
},
|
153
171
|
"speed_limit_mode": {
|
154
172
|
"active": false,
|
155
173
|
"current_limit_mph": 75.0,
|
@@ -157,6 +175,7 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
157
175
|
"min_limit_mph": 50,
|
158
176
|
"pin_code_set": false
|
159
177
|
},
|
178
|
+
"summon_standby_mode_enabled": true,
|
160
179
|
"sun_roof_percent_open": 0,
|
161
180
|
"sun_roof_state": "unknown",
|
162
181
|
"timestamp": 1538364666096,
|
@@ -188,6 +207,7 @@ A rollup of all the `data_request` endpoints plus vehicle configuration.
|
|
188
207
|
"third_row_seats": "None",
|
189
208
|
"timestamp": 1538364666096,
|
190
209
|
"trim_badging": "p90d",
|
210
|
+
"use_range_badging": false,
|
191
211
|
"wheel_type": "AeroTurbine19"
|
192
212
|
}
|
193
213
|
}
|
@@ -4,36 +4,75 @@
|
|
4
4
|
|
5
5
|
Returns the vehicle's physical state, such as which doors are open.
|
6
6
|
|
7
|
+
For the trunk (rt) and frunk (ft) fields, you should interpret a zero (0) value as closed and a non-zero value as open (partially or fully).
|
8
|
+
|
9
|
+
Here are the currently known values for the `center_display_state` field:
|
10
|
+
|
11
|
+
| State | Description |
|
12
|
+
| ----- | --------------- |
|
13
|
+
| 0 | Off |
|
14
|
+
| 2 | Normal On |
|
15
|
+
| 3 | Charging Screen |
|
16
|
+
| 7 | Sentry Mode |
|
17
|
+
| 8 | Dog Mode |
|
18
|
+
|
19
|
+
Here are the descriptions for the shorthand fields:
|
20
|
+
|
21
|
+
| Field | Description |
|
22
|
+
| ----- | --------------- |
|
23
|
+
| df | driver front |
|
24
|
+
| dr | driver rear |
|
25
|
+
| pf | passenger front |
|
26
|
+
| pr | passenger rear |
|
27
|
+
| ft | front trunk |
|
28
|
+
| rt | rear trunk |
|
29
|
+
|
7
30
|
### Response
|
8
31
|
|
9
32
|
```json
|
10
33
|
{
|
11
34
|
"response": {
|
12
|
-
"api_version":
|
13
|
-
"
|
14
|
-
"autopark_style": "
|
35
|
+
"api_version": 7,
|
36
|
+
"autopark_state_v3": "standby",
|
37
|
+
"autopark_style": "dead_man",
|
15
38
|
"calendar_supported": true,
|
16
|
-
"car_version": "
|
39
|
+
"car_version": "2019.40.2.1 38f55d9f9205",
|
17
40
|
"center_display_state": 0,
|
18
41
|
"df": 0,
|
19
42
|
"dr": 0,
|
43
|
+
"fd_window": 0,
|
44
|
+
"fp_window": 0,
|
20
45
|
"ft": 0,
|
46
|
+
"homelink_device_count": 0,
|
21
47
|
"homelink_nearby": true,
|
22
48
|
"is_user_present": false,
|
23
49
|
"last_autopark_error": "no_error",
|
24
50
|
"locked": true,
|
25
|
-
"media_state": {
|
51
|
+
"media_state": {
|
52
|
+
"remote_control_enabled": true
|
53
|
+
},
|
26
54
|
"notifications_supported": true,
|
27
55
|
"odometer": 36051.517239,
|
28
56
|
"parsed_calendar_supported": true,
|
29
57
|
"pf": 0,
|
30
58
|
"pr": 0,
|
59
|
+
"rd_window": 0,
|
31
60
|
"remote_start": false,
|
32
61
|
"remote_start_enabled": true,
|
33
62
|
"remote_start_supported": true,
|
63
|
+
"rp_window": 0,
|
34
64
|
"rt": 0,
|
35
65
|
"sentry_mode": true,
|
36
|
-
"
|
66
|
+
"sentry_mode_available": true,
|
67
|
+
"smart_summon_available": true,
|
68
|
+
"software_update": {
|
69
|
+
"download_perc": 100,
|
70
|
+
"expected_duration_sec": 2700,
|
71
|
+
"install_perc": 10,
|
72
|
+
"scheduled_time_ms": 1575689678432,
|
73
|
+
"status": "scheduled",
|
74
|
+
"version": "2019.40.2.1"
|
75
|
+
},
|
37
76
|
"speed_limit_mode": {
|
38
77
|
"active": false,
|
39
78
|
"current_limit_mph": 50.0,
|
@@ -41,6 +80,7 @@ Returns the vehicle's physical state, such as which doors are open.
|
|
41
80
|
"min_limit_mph": 50,
|
42
81
|
"pin_code_set": false
|
43
82
|
},
|
83
|
+
"summon_standby_mode_enabled": true,
|
44
84
|
"sun_roof_percent_open": 0,
|
45
85
|
"sun_roof_state": "unknown",
|
46
86
|
"timestamp": 1543187581934,
|
@@ -50,12 +90,3 @@ Returns the vehicle's physical state, such as which doors are open.
|
|
50
90
|
}
|
51
91
|
}
|
52
92
|
```
|
53
|
-
|
54
|
-
### Center Display States
|
55
|
-
| State | Description |
|
56
|
-
|-------|-----------------|
|
57
|
-
| 0 | Off |
|
58
|
-
| 2 | Normal On |
|
59
|
-
| 3 | Charging Screen |
|
60
|
-
| 7 | Sentry Mode |
|
61
|
-
| 8 | Dog Mode |
|
data/lib/tesla_api.rb
CHANGED
@@ -3,8 +3,10 @@ require 'base64'
|
|
3
3
|
|
4
4
|
require 'faraday'
|
5
5
|
require 'faraday_middleware'
|
6
|
-
|
7
|
-
require '
|
6
|
+
|
7
|
+
require 'async'
|
8
|
+
require 'async/http/endpoint'
|
9
|
+
require 'async/websocket/client'
|
8
10
|
|
9
11
|
require 'tesla_api/version'
|
10
12
|
require 'tesla_api/client'
|
data/lib/tesla_api/autopark.rb
CHANGED
@@ -1,60 +1,42 @@
|
|
1
1
|
module TeslaApi
|
2
2
|
module Autopark
|
3
3
|
def start_autopark(&handler)
|
4
|
-
|
5
|
-
|
6
|
-
message =
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
4
|
+
Async do |task|
|
5
|
+
Async::WebSocket::Client.connect(autopark_endpoint, headers: headers) do |connection|
|
6
|
+
while message = connection.read
|
7
|
+
case message[:msg_type]
|
8
|
+
when 'control:hello'
|
9
|
+
interval = message[:autopark][:heartbeat_frequency] / 1000.0
|
10
|
+
task.async do |subtask|
|
11
|
+
subtask.sleep interval
|
12
|
+
connection.write({ msg_type: 'autopark:heartbeat_app', timestamp: Time.now.to_i }.to_json)
|
13
|
+
end
|
14
|
+
end
|
15
15
|
|
16
|
-
|
17
|
-
|
18
|
-
@heartbeat && @heartbeat.cancel
|
19
|
-
EventMachine.stop
|
16
|
+
handler.call(message)
|
17
|
+
end
|
20
18
|
end
|
21
19
|
end
|
22
20
|
end
|
23
21
|
|
24
22
|
private
|
25
23
|
|
26
|
-
def
|
27
|
-
|
28
|
-
when 'control:hello'
|
29
|
-
interval = message['autopark']['heartbeat_frequency'] / 1000.0
|
30
|
-
@heartbeat = EventMachine::Timer.new(interval) do
|
31
|
-
beat = {
|
32
|
-
msg_type: 'autopark:heartbeat_app',
|
33
|
-
timestamp: Time.now.to_i
|
34
|
-
}
|
35
|
-
autopark_socket.send(beat.to_json)
|
36
|
-
end
|
37
|
-
end
|
24
|
+
def autopark_endpoint
|
25
|
+
Async::HTTP::Endpoint.parse(autopark_endpoint_url)
|
38
26
|
end
|
39
27
|
|
40
|
-
def
|
41
|
-
|
42
|
-
autopark_socket_endpoint,
|
43
|
-
nil,
|
44
|
-
{
|
45
|
-
headers: {
|
46
|
-
'Authorization' => "Basic #{socket_auth}"
|
47
|
-
}
|
48
|
-
}
|
49
|
-
)
|
28
|
+
def autopark_endpoint_url
|
29
|
+
"wss://streaming.vn.teslamotors.com/connect/#{self['vehicle_id']}"
|
50
30
|
end
|
51
31
|
|
52
|
-
def
|
53
|
-
|
32
|
+
def autopark_headers
|
33
|
+
{
|
34
|
+
'Authorization' => "Basic #{socket_auth}"
|
35
|
+
}
|
54
36
|
end
|
55
37
|
|
56
|
-
def
|
57
|
-
"
|
38
|
+
def autopark_socket_auth
|
39
|
+
Base64.strict_encode64("#{email}:#{self['tokens'].first}")
|
58
40
|
end
|
59
41
|
end
|
60
42
|
end
|
data/lib/tesla_api/client.rb
CHANGED
@@ -2,7 +2,7 @@ module TeslaApi
|
|
2
2
|
class Client
|
3
3
|
attr_reader :api, :email, :access_token, :access_token_expires_at, :refresh_token, :client_id, :client_secret
|
4
4
|
|
5
|
-
BASE_URI = 'https://owner-api.teslamotors.com
|
5
|
+
BASE_URI = 'https://owner-api.teslamotors.com'
|
6
6
|
|
7
7
|
def initialize(
|
8
8
|
email: nil,
|
@@ -10,9 +10,13 @@ module TeslaApi
|
|
10
10
|
access_token_expires_at: nil,
|
11
11
|
refresh_token: nil,
|
12
12
|
client_id: ENV['TESLA_CLIENT_ID'],
|
13
|
-
client_secret: ENV['TESLA_CLIENT_SECRET']
|
13
|
+
client_secret: ENV['TESLA_CLIENT_SECRET'],
|
14
|
+
retry_options: nil,
|
15
|
+
base_uri: nil,
|
16
|
+
client_options: {}
|
14
17
|
)
|
15
18
|
@email = email
|
19
|
+
@base_uri = base_uri || BASE_URI
|
16
20
|
|
17
21
|
@client_id = client_id
|
18
22
|
@client_secret = client_secret
|
@@ -22,19 +26,22 @@ module TeslaApi
|
|
22
26
|
@refresh_token = refresh_token
|
23
27
|
|
24
28
|
@api = Faraday.new(
|
25
|
-
|
26
|
-
|
29
|
+
@base_uri + '/api/1',
|
30
|
+
{
|
31
|
+
headers: { 'User-Agent' => "github.com/timdorr/tesla-api v:#{VERSION}" }
|
32
|
+
}.merge(client_options)
|
27
33
|
) do |conn|
|
28
34
|
conn.request :json
|
29
35
|
conn.response :json
|
30
36
|
conn.response :raise_error
|
37
|
+
conn.request :retry, retry_options if retry_options # Must be registered after :raise_error
|
31
38
|
conn.adapter Faraday.default_adapter
|
32
39
|
end
|
33
40
|
end
|
34
41
|
|
35
42
|
def refresh_access_token
|
36
43
|
response = api.post(
|
37
|
-
'
|
44
|
+
@base_uri + '/oauth/token',
|
38
45
|
{
|
39
46
|
grant_type: 'refresh_token',
|
40
47
|
client_id: client_id,
|
@@ -52,7 +59,7 @@ module TeslaApi
|
|
52
59
|
|
53
60
|
def login!(password)
|
54
61
|
response = api.post(
|
55
|
-
'
|
62
|
+
@base_uri + '/oauth/token',
|
56
63
|
{
|
57
64
|
grant_type: 'password',
|
58
65
|
client_id: client_id,
|
@@ -87,7 +94,7 @@ module TeslaApi
|
|
87
94
|
end
|
88
95
|
|
89
96
|
def vehicle(id)
|
90
|
-
Vehicle.new(self, email, id, self.
|
97
|
+
Vehicle.new(self, email, id, self.get("/vehicles/#{id}")['response'])
|
91
98
|
end
|
92
99
|
end
|
93
100
|
end
|
data/lib/tesla_api/stream.rb
CHANGED
@@ -1,54 +1,60 @@
|
|
1
1
|
module TeslaApi
|
2
2
|
module Stream
|
3
3
|
def stream(&receiver)
|
4
|
-
|
5
|
-
|
4
|
+
Async do |task|
|
5
|
+
Async::WebSocket::Client.connect(streaming_endpoint) do |connection|
|
6
|
+
on_timeout = ->(subtask) do
|
7
|
+
subtask.sleep TIMEOUT
|
8
|
+
task.stop
|
9
|
+
end
|
6
10
|
|
7
|
-
|
8
|
-
|
9
|
-
end
|
11
|
+
connection.write(streaming_connect_message)
|
12
|
+
timeout = task.async(&on_timeout)
|
10
13
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
time: DateTime.strptime((attributes[0].to_i/1000).to_s, '%s'),
|
19
|
-
speed: attributes[1].to_f,
|
20
|
-
odometer: attributes[2].to_f,
|
21
|
-
soc: attributes[3].to_f,
|
22
|
-
elevation: attributes[4].to_f,
|
23
|
-
est_heading: attributes[5].to_f,
|
24
|
-
est_lat: attributes[6].to_f,
|
25
|
-
est_lng: attributes[7].to_f,
|
26
|
-
power: attributes[8].to_f,
|
27
|
-
shift_state: attributes[9].to_s,
|
28
|
-
range: attributes[10].to_f,
|
29
|
-
est_range: attributes[11].to_f,
|
30
|
-
heading: attributes[12].to_f
|
31
|
-
})
|
32
|
-
end
|
33
|
-
end
|
14
|
+
while message = connection.read
|
15
|
+
timeout.stop
|
16
|
+
timeout = task.async(&on_timeout)
|
17
|
+
|
18
|
+
case message[:msg_type]
|
19
|
+
when 'data:update'
|
20
|
+
attributes = message[:value].split(',')
|
34
21
|
|
35
|
-
|
36
|
-
|
22
|
+
receiver.call({
|
23
|
+
time: DateTime.strptime((attributes[0].to_i/1000).to_s, '%s'),
|
24
|
+
speed: attributes[1].to_f,
|
25
|
+
odometer: attributes[2].to_f,
|
26
|
+
soc: attributes[3].to_f,
|
27
|
+
elevation: attributes[4].to_f,
|
28
|
+
est_heading: attributes[5].to_f,
|
29
|
+
est_lat: attributes[6].to_f,
|
30
|
+
est_lng: attributes[7].to_f,
|
31
|
+
power: attributes[8].to_f,
|
32
|
+
shift_state: attributes[9].to_s,
|
33
|
+
range: attributes[10].to_f,
|
34
|
+
est_range: attributes[11].to_f,
|
35
|
+
heading: attributes[12].to_f
|
36
|
+
})
|
37
|
+
when 'data:error'
|
38
|
+
task.stop
|
39
|
+
end
|
40
|
+
end
|
37
41
|
end
|
38
42
|
end
|
39
43
|
end
|
40
44
|
|
41
45
|
private
|
42
46
|
|
43
|
-
|
44
|
-
Faye::WebSocket::Client.new(streaming_endpoint)
|
45
|
-
end
|
47
|
+
TIMEOUT = 30
|
46
48
|
|
47
49
|
def streaming_endpoint
|
50
|
+
Async::HTTP::Endpoint.parse(streaming_endpoint_url)
|
51
|
+
end
|
52
|
+
|
53
|
+
def streaming_endpoint_url
|
48
54
|
'wss://streaming.vn.teslamotors.com/streaming/'
|
49
55
|
end
|
50
56
|
|
51
|
-
def
|
57
|
+
def streaming_connect_message
|
52
58
|
{
|
53
59
|
msg_type: 'data:subscribe',
|
54
60
|
token: Base64.strict_encode64("#{email}:#{self['tokens'].first}"),
|