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