This commit is contained in:
gak 2022-07-21 13:46:49 +10:00
commit 54108eb896
7 changed files with 786 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/Cargo.lock

28
Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "teslatte"
version = "0.0.1"
edition = "2021"
license = "MIT OR Apache-2.0"
[lib]
name = "teslatte"
path = "src/lib.rs"
[dependencies]
miette = { version = "5.1.1", features = ["fancy"] }
thiserror = "1.0.31"
tokio = { version = "1.17.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.2"
reqwest = { version = "0.11.9", features = ["rustls-tls", "cookies", "json"] }
url = "*"
serde = { version = "1.0.136", features = ["derive"] }
serde_json = "1.0.79"
rustls = "0.20.4"
sha256 = "1.0.3"
base64 = "0.13.0"
rand = "0.8.5"
regex = "1.5.4"
[dev-dependencies]
test-log = { version = "*", default-features = false, features = ["trace"] }

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# Teslatte 🚗🔋☕
> :warning: **Alpha Warning!** This Rust crate is still in alpha stage. It is something I put together if anyone needs it, and I'm aiming to work on it as I need more features.
A Tesla API using the `owner-api.teslamotors.com` endpoint as well as "interactive" OAuth.
Currently, it only supports some the `/api/1/vehicles` endpoint, but it will be expanded in the future.
It is fairly trivial to add in new endpoints if you feel like creating a PR. Please let me know if your PR is a massive change before spending a lot of time on it.
## Example
A basic example: [examples/basic.rs](examples/basic.rs)
## Endpoints
Here's a lazy dump of the endpoints I've implemented so far:
```rust
get!(vehicles, Vec<Vehicle>, "");
get_v!(vehicle_data, VehicleData, "/vehicle_data");
get_v!(charge_state, ChargeState, "/data_request/charge_state");
post_vd!(set_charge_limit, SetChargeLimit, "/command/set_charge_limit");
post_vd!(set_charging_amps, SetChargingAmps, "/command/set_charging_amps");
post_v!(charge_start, "/command/charge_start");
post_v!(charge_stop, "/command/charge_stop");
```
## License
Licensed under either of
* Apache License, Version 2.0
([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license
([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.

20
examples/basic.rs Normal file
View file

@ -0,0 +1,20 @@
use teslatte::auth::Authentication;
use teslatte::Api;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let api = Authentication::new().unwrap();
let (access_token, refresh_token) = api.interactive_get_access_token().await.unwrap();
println!("Access token: {}", access_token.0);
println!("Refresh token: {}", refresh_token.0);
let api = Api::new(&access_token);
let vehicles = api.vehicles().await.unwrap();
dbg!(&vehicles);
let charge_state = api.charge_state(&vehicles[0].id).await.unwrap();
dbg!(&charge_state);
}

233
src/auth.rs Normal file
View file

@ -0,0 +1,233 @@
use crate::TeslatteError;
use crate::TeslatteError::UnhandledReqwestError;
use rand::Rng;
use reqwest::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::io::{stdin, stdout, Write};
use url::Url;
const AUTHORIZE_URL: &str = "https://auth.tesla.com/oauth2/v3/authorize";
const TOKEN_URL: &str = "https://auth.tesla.com/oauth2/v3/token";
pub struct Authentication {
client: Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessToken(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshToken(pub String);
impl Authentication {
pub fn new() -> Result<Self, TeslatteError> {
let client = Client::builder()
.cookie_store(false)
.build()
.map_err(UnhandledReqwestError)?;
Ok(Self { client })
}
/// Currently the only way to get an access token via this library.
pub async fn interactive_get_access_token(
&self,
) -> Result<(AccessToken, RefreshToken), TeslatteError> {
let login_form = self.get_login_url_for_user().await;
dbg!(&login_form);
let callback_url =
ask_input("Enter the URL of the 404 error page after you've logged in: ");
let callback_code = Self::extract_callback_code_from_url(&callback_url)?;
let bearer = self
.exchange_auth_for_bearer(&login_form.code, &callback_code)
.await?;
let refresh_token = bearer.refresh_token.clone();
Ok((
AccessToken(bearer.access_token),
RefreshToken(refresh_token),
))
}
pub async fn get_login_url_for_user(&self) -> LoginForm {
let code = Code::new();
let state = random_string(8);
let url = Self::login_url(&code, &state);
LoginForm { url, code, state }
}
async fn exchange_auth_for_bearer(
&self,
code: &Code,
callback_code: &str,
) -> Result<BearerTokenResponse, TeslatteError> {
let url = TOKEN_URL;
let payload = BearerTokenRequest {
grant_type: "authorization_code".into(),
client_id: "ownerapi".into(),
code: callback_code.into(),
code_verifier: code.verifier.clone(),
redirect_uri: "https://auth.tesla.com/void/callback".into(),
};
self.post(url, &payload).await
}
pub async fn refresh_access_token(
&self,
refresh_token: &RefreshToken,
) -> Result<RefreshTokenResponse, TeslatteError> {
let url = "https://auth.tesla.com/oauth2/v3/token";
let payload = RefreshTokenRequest {
grant_type: "refresh_token".into(),
client_id: "ownerapi".into(),
refresh_token: refresh_token.0.clone(),
scope: "openid email offline_access".into(),
};
self.post(url, &payload).await
}
async fn post<'a, S, D>(&self, url: &str, payload: &S) -> Result<D, TeslatteError>
where
S: Serialize,
D: DeserializeOwned,
{
let response = self
.client
.post(url)
.header("Accept", "application/json")
.json(payload)
.send()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: url.to_string(),
})?;
let body = response
.text()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: url.to_string(),
})?;
let json =
serde_json::from_str::<D>(&body).map_err(|source| TeslatteError::DecodeJsonError {
source,
body: body.to_string(),
request: url.to_string(),
})?;
Ok(json)
}
pub fn login_url(code: &Code, state: &str) -> String {
let mut url = Url::parse(AUTHORIZE_URL).unwrap();
let mut query = url.query_pairs_mut();
query.append_pair("client_id", "ownerapi");
query.append_pair("code_challenge", &code.challenge);
query.append_pair("code_challenge_method", "S256");
query.append_pair("redirect_uri", "https://auth.tesla.com/void/callback");
query.append_pair("response_type", "code");
query.append_pair("scope", "openid email offline_access");
query.append_pair("state", state);
drop(query);
url.to_string()
}
fn extract_callback_code_from_url(callback_url: &str) -> Result<String, TeslatteError> {
Ok(Url::parse(callback_url)
.map_err(TeslatteError::UserDidNotSupplyValidCallbackUrl)?
.query_pairs()
.find(|(k, _)| k == "code")
.map(|kv| kv.1)
.ok_or(TeslatteError::CouldNotFindCallbackCode)?
.to_string())
}
}
#[derive(Debug, Serialize)]
struct RefreshTokenRequest {
grant_type: String,
client_id: String,
refresh_token: String,
scope: String,
}
#[derive(Debug, Deserialize)]
pub struct RefreshTokenResponse {
pub access_token: AccessToken,
pub refresh_token: RefreshToken,
pub id_token: String,
pub expires_in: u32,
pub token_type: String,
}
#[derive(Debug, Default)]
pub struct LoginForm {
url: String,
code: Code,
state: String,
}
// These can be probably &str.
#[derive(Debug, Serialize)]
struct BearerTokenRequest {
grant_type: String,
client_id: String,
code: String,
code_verifier: String,
redirect_uri: String,
}
#[derive(Debug, Deserialize)]
struct BearerTokenResponse {
access_token: String,
refresh_token: String,
expires_in: u32,
state: String,
token_type: String,
id_token: String,
}
#[derive(Debug, Default)]
pub struct Code {
verifier: String,
challenge: String,
}
impl Code {
fn new() -> Self {
let verifier = random_string(86);
let hex_digest = sha256::digest_bytes(verifier.as_bytes());
let challenge = base64::encode_config(&hex_digest, base64::URL_SAFE);
Self {
verifier,
challenge,
}
}
}
fn random_string(len: usize) -> String {
let mut rng = rand::thread_rng();
let mut s = String::with_capacity(len);
for _ in 0..len {
s.push(rng.gen_range(b'a'..=b'z') as char);
}
s
}
fn ask_input(prompt: &str) -> String {
print!("{}", prompt);
let mut s = String::new();
stdout()
.flush()
.expect("Failed to flush while expecting user input.");
stdin()
.read_line(&mut s)
.expect("Failed to read line of user input.");
s.trim().to_string()
}

38
src/error.rs Normal file
View file

@ -0,0 +1,38 @@
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum TeslatteError {
#[error("{request} server error: {msg}: {description:?}")]
#[diagnostic()]
ServerError {
request: String,
msg: String,
description: Option<String>,
},
#[error("{request} unhandled server response: {body}")]
#[diagnostic()]
UnhandledServerError { request: String, body: String },
#[error("{request} fetch error")]
#[diagnostic()]
FetchError {
source: reqwest::Error,
request: String,
},
#[error("{request} json decode error: {body}")]
#[diagnostic()]
DecodeJsonError {
source: serde_json::Error,
request: String,
body: String,
},
#[error("Unhandled reqwest error.")]
UnhandledReqwestError(#[source] reqwest::Error),
#[error("Did not supply a valid callback URL.")]
UserDidNotSupplyValidCallbackUrl(#[source] url::ParseError),
#[error("Callback URL did not contain a callback code.")]
CouldNotFindCallbackCode,
}

421
src/lib.rs Normal file
View file

@ -0,0 +1,421 @@
use crate::auth::AccessToken;
use crate::error::TeslatteError;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use tracing::{debug, instrument, trace};
pub mod auth;
pub mod error;
const API_URL: &str = "https://owner-api.teslamotors.com";
/// Vehicle ID used by the owner-api endpoint.
///
/// This data comes from [`Api::vehicles()`] `id` field.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Id(u64);
/// Vehicle ID used by other endpoints.
///
/// This data comes from [`Api::vehicles()`] `vehicle_id` field.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VehicleId(u64);
pub struct Api {
access_token: AccessToken,
client: Client,
}
impl Api {
pub fn new(access_token: &AccessToken) -> Self {
Api {
access_token: access_token.clone(),
client: Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap(), // TODO: unwrap
}
}
#[instrument(skip(self))]
async fn get<D>(&self, path: &str) -> Result<D, TeslatteError>
where
// I don't understand but it works: https://stackoverflow.com/a/60131725/11125
D: for<'de> Deserialize<'de> + Debug,
{
trace!("Fetching");
let url = format!("{}{}", API_URL, path);
let request = || format!("GET {url}");
debug!("Fetching");
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token.0))
.header("Accept", "application/json")
.send()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: request(),
})?;
let body = response
.text()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: request(),
})?;
trace!(?body);
let json = Self::json::<D, _>(&body, request)?;
trace!(?json);
Ok(json)
}
#[instrument(skip(self))]
async fn post<S>(&self, path: &str, body: S) -> Result<(), TeslatteError>
where
S: Serialize + Debug,
{
trace!("Fetching");
let url = format!("{}{}", API_URL, path);
let request = || {
let payload =
serde_json::to_string(&body).expect("Should not fail creating the request struct.");
format!("POST {} {payload}", &url)
};
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.access_token.0))
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: request(),
})?;
let body = response
.text()
.await
.map_err(|source| TeslatteError::FetchError {
source,
request: request(),
})?;
let json = Self::json::<PostResponse, _>(&body, request)?;
trace!(?json);
if json.result {
Ok(())
} else {
Err(TeslatteError::ServerError {
request: request(),
msg: json.reason,
description: None,
})
}
}
// The `request` argument is for additional context in the error.
fn json<T, F>(body: &str, request: F) -> Result<T, TeslatteError>
where
T: for<'de> Deserialize<'de> + Debug,
F: FnOnce() -> String + Copy,
{
trace!("{}", &body);
let r: Response<T> = serde_json::from_str::<ResponseDeserializer<T>>(body)
.map_err(|source| TeslatteError::DecodeJsonError {
source,
request: request(),
body: body.to_string(),
})?
.into();
match r {
Response::Response(r) => Ok(r),
Response::Error(e) => Err(TeslatteError::ServerError {
request: request(),
msg: e.error,
description: e.error_description,
}),
}
}
}
#[derive(Debug, Deserialize)]
struct ResponseDeserializer<T> {
error: Option<ResponseError>,
response: Option<T>,
}
#[derive(Debug)]
enum Response<T> {
Response(T),
Error(ResponseError),
}
impl<T> From<ResponseDeserializer<T>> for Response<T> {
fn from(response: ResponseDeserializer<T>) -> Self {
match response.error {
Some(error) => Response::Error(error),
None => match response.response {
Some(response) => Response::Response(response),
None => panic!("ResponseDeserializer has no error or response."),
},
}
}
}
#[derive(Debug, Deserialize)]
struct PostResponse {
reason: String,
result: bool,
}
#[derive(Debug, Deserialize)]
struct ResponseError {
error: String,
error_description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ChargeState {
pub battery_heater_on: bool,
pub battery_level: i64,
pub battery_range: f64,
pub charge_amps: i64,
pub charge_current_request: i64,
pub charge_current_request_max: i64,
pub charge_enable_request: bool,
pub charge_energy_added: f64,
pub charge_limit_soc: i64,
pub charge_limit_soc_max: i64,
pub charge_limit_soc_min: i64,
pub charge_limit_soc_std: i64,
pub charge_miles_added_ideal: f64,
pub charge_miles_added_rated: f64,
pub charge_port_cold_weather_mode: bool,
pub charge_port_color: String,
pub charge_port_door_open: bool,
pub charge_port_latch: String,
pub charge_rate: f64,
pub charge_to_max_range: bool,
pub charger_actual_current: i64,
pub charger_phases: Option<i64>,
pub charger_pilot_current: i64,
pub charger_power: i64,
pub charger_voltage: i64,
pub charging_state: String,
pub conn_charge_cable: String,
pub est_battery_range: f64,
pub fast_charger_brand: String,
pub fast_charger_present: bool,
pub fast_charger_type: String,
pub ideal_battery_range: f64,
pub managed_charging_active: bool,
pub managed_charging_start_time: Option<u64>,
pub managed_charging_user_canceled: bool,
pub max_range_charge_counter: i64,
pub minutes_to_full_charge: i64,
pub not_enough_power_to_heat: Option<bool>,
pub off_peak_charging_enabled: bool,
pub off_peak_charging_times: String,
pub off_peak_hours_end_time: i64,
pub preconditioning_enabled: bool,
pub preconditioning_times: String,
pub scheduled_charging_mode: String,
pub scheduled_charging_pending: bool,
pub scheduled_charging_start_time: Option<i64>,
pub scheduled_charging_start_time_app: Option<i64>,
pub scheduled_charging_start_time_minutes: Option<i64>,
pub scheduled_departure_time: i64,
pub scheduled_departure_time_minutes: i64,
pub supercharger_session_trip_planner: bool,
pub time_to_full_charge: f64,
pub timestamp: u64,
pub trip_charging: bool,
pub usable_battery_level: i64,
pub user_charge_enable_request: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct Vehicles(Vec<Vehicle>);
#[derive(Debug, Deserialize)]
pub struct Vehicle {
pub id: Id,
pub vehicle_id: VehicleId,
pub vin: String,
pub display_name: String,
}
#[derive(Debug, Deserialize)]
pub struct VehicleData {
id: Id,
user_id: u64,
display_name: String,
}
#[derive(Debug, Serialize)]
pub struct SetChargingAmps {
pub charging_amps: i64,
}
#[derive(Debug, Serialize)]
pub struct SetChargeLimit {
// pub percent: Percentage,
pub percent: u8,
}
#[derive(Debug, Serialize)]
struct Empty {}
/// GET /api/1/vehicles/[id]/...
macro_rules! get {
($name:ident, $struct:ty, $url:expr) => {
pub async fn $name(&self) -> Result<$struct, TeslatteError> {
let url = format!("/api/1/vehicles{}", $url);
self.get(&url).await
}
};
}
/// GET /api/1/vehicles/[id]/...
macro_rules! get_v {
($name:ident, $struct:ty, $url:expr) => {
pub async fn $name(&self, id: &Id) -> Result<$struct, TeslatteError> {
let url = format!("/api/1/vehicles/{}{}", id.0, $url);
self.get(&url).await
}
};
}
/// POST /api/1/vehicles/[id]/... without data
macro_rules! post_v {
($name:ident, $url:expr) => {
pub async fn $name(&self, id: &Id) -> miette::Result<(), TeslatteError> {
let url = format!("/api/1/vehicles/{}{}", id.0, $url);
self.post(&url, &Empty {}).await
}
};
}
/// POST /api/1/vehicles/[id]/... with data
macro_rules! post_vd {
($name:ident, $struct:ty, $url:expr) => {
pub async fn $name(&self, id: &Id, data: &$struct) -> miette::Result<(), TeslatteError> {
let url = format!("/api/1/vehicles/{}{}", id.0, $url);
self.post(&url, &data).await
}
};
}
#[rustfmt::skip]
impl Api {
get!(vehicles, Vec<Vehicle>, "");
get_v!(vehicle_data, VehicleData, "/vehicle_data");
get_v!(charge_state, ChargeState, "/data_request/charge_state");
post_vd!(set_charge_limit, SetChargeLimit, "/command/set_charge_limit");
post_vd!(set_charging_amps, SetChargingAmps, "/command/set_charging_amps");
post_v!(charge_start, "/command/charge_start");
post_v!(charge_stop, "/command/charge_stop");
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
#[test]
fn json() {
let s = r#"
{
"response": {
"battery_heater_on": false,
"battery_level": 50,
"battery_range": 176.08,
"charge_amps": 5,
"charge_current_request": 5,
"charge_current_request_max": 16,
"charge_enable_request": true,
"charge_energy_added": 1.05,
"charge_limit_soc": 75,
"charge_limit_soc_max": 100,
"charge_limit_soc_min": 50,
"charge_limit_soc_std": 90,
"charge_miles_added_ideal": 5,
"charge_miles_added_rated": 5,
"charge_port_cold_weather_mode": false,
"charge_port_color": "<invalid>",
"charge_port_door_open": true,
"charge_port_latch": "Engaged",
"charge_rate": 14.8,
"charge_to_max_range": false,
"charger_actual_current": 5,
"charger_phases": 2,
"charger_pilot_current": 16,
"charger_power": 4,
"charger_voltage": 241,
"charging_state": "Charging",
"conn_charge_cable": "IEC",
"est_battery_range": 163.81,
"fast_charger_brand": "<invalid>",
"fast_charger_present": false,
"fast_charger_type": "ACSingleWireCAN",
"ideal_battery_range": 176.08,
"managed_charging_active": false,
"managed_charging_start_time": null,
"managed_charging_user_canceled": false,
"max_range_charge_counter": 0,
"minutes_to_full_charge": 350,
"not_enough_power_to_heat": null,
"off_peak_charging_enabled": false,
"off_peak_charging_times": "all_week",
"off_peak_hours_end_time": 1140,
"preconditioning_enabled": false,
"preconditioning_times": "all_week",
"scheduled_charging_mode": "StartAt",
"scheduled_charging_pending": false,
"scheduled_charging_start_time": 1647045000,
"scheduled_charging_start_time_app": 690,
"scheduled_charging_start_time_minutes": 690,
"scheduled_departure_time": 1641337200,
"scheduled_departure_time_minutes": 600,
"supercharger_session_trip_planner": false,
"time_to_full_charge": 5.83,
"timestamp": 1646978638155,
"trip_charging": false,
"usable_battery_level": 50,
"user_charge_enable_request": null
}
}
"#;
Api::json::<ChargeState, _>(s, || "req".to_string()).unwrap();
}
#[test]
fn error() {
let s = r#"{
"response": null,
"error":{"error": "timeout","error_description": "s"}
}"#;
let e = Api::json::<ChargeState, _>(s, || "req".to_string());
if let Err(e) = e {
if let TeslatteError::ServerError {
msg, description, ..
} = e
{
assert_eq!(&msg, "timeout");
assert_eq!(&description.unwrap(), "s");
} else {
panic!("unexpected error: {:?}", e);
}
} else {
panic!("expected an error");
}
}
}