initial
This commit is contained in:
commit
54108eb896
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/Cargo.lock
|
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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
44
README.md
Normal 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
20
examples/basic.rs
Normal 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
233
src/auth.rs
Normal 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
38
src/error.rs
Normal 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
421
src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue