From f9dba75462643bd4d3c69425f2c0aaaa9deae77c Mon Sep 17 00:00:00 2001 From: Alex Janka Date: Mon, 8 Jan 2024 12:00:09 +1100 Subject: [PATCH] local tesla status cache --- Cargo.lock | 1 + Cargo.toml | 1 + src/api_interface.rs | 136 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 2 +- src/errors.rs | 3 + src/main.rs | 133 +++++++----------------------------------- src/server/mod.rs | 61 ++++--------------- src/types.rs | 44 ++++++++++++++ watch.sh | 2 +- webapp/index.html | 15 +++++ 10 files changed, 236 insertions(+), 162 deletions(-) create mode 100644 src/api_interface.rs diff --git a/Cargo.lock b/Cargo.lock index 4716c6f..458a974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1994,6 +1994,7 @@ name = "tesla-charge-controller" version = "0.1.4" dependencies = [ "anyhow", + "chrono", "clap", "include_dir", "rocket", diff --git a/Cargo.toml b/Cargo.toml index 3bed73f..9bc5cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ thiserror = "1.0" rocket = { version = "0.5", features = ["json"] } anyhow = "1.0" include_dir = "0.7" +chrono = "0.4" diff --git a/src/api_interface.rs b/src/api_interface.rs new file mode 100644 index 0000000..67028d7 --- /dev/null +++ b/src/api_interface.rs @@ -0,0 +1,136 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; +use teslatte::{ + auth::{AccessToken, RefreshToken}, + vehicles::{Endpoint, GetVehicleData}, + FleetApi, FleetVehicleApi, VehicleId, +}; + +use crate::{errors::*, types::CarState}; + +pub struct TeslaInterface { + pub state: Arc>, + api: FleetApi, + vehicle: Box, + last_refresh: Instant, + auth_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Clone)] +struct AuthInfo { + access_token: AccessToken, + refresh_token: Option, +} + +const REFRESH_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); + +impl TeslaInterface { + pub async fn load(auth_path: PathBuf) -> Result { + let key: AuthInfo = ron::from_str(&std::fs::read_to_string(&auth_path)?)?; + let mut api = FleetApi::new(key.access_token, key.refresh_token); + api.refresh().await?; + let last_refresh = Instant::now(); + println!("Refreshed auth key"); + let vehicle = api + .products() + .await? + .into_iter() + .filter_map(|v| match v { + teslatte::products::Product::Vehicle(vehicle) => Some(vehicle), + _ => None, + }) + .next() + .context("No vehicles attached to account!")?; + + let interface = Self { + state: Arc::new(RwLock::new(Default::default())), + api, + last_refresh, + auth_path, + vehicle, + }; + interface.save_key()?; + + Ok(interface) + } + + fn save_key(&self) -> Result<(), SaveError> { + std::fs::write( + self.auth_path.clone(), + ron::ser::to_string(&AuthInfo { + access_token: self.api.access_token.clone(), + refresh_token: self.api.refresh_token.clone(), + })?, + )?; + println!("Auth successfully saved"); + Ok(()) + } + + pub async fn refresh(&mut self) { + println!("refreshing..."); + self.refresh_keys().await; + self.refresh_state().await; + } + + async fn refresh_state(&mut self) { + match get_state(&self.api, self.vehicle.id.clone()).await { + Ok(new_state) => { + self.last_refresh = Instant::now(); + let mut state = self.state.write().expect("State handler panicked!!"); + + if let Some(new_charge_state) = new_state.charge_state { + state.charge_state = Some(new_charge_state); + } + if let Some(new_location_data) = new_state.location_data { + state.location_data = Some(new_location_data); + } + } + Err(e) => eprintln!("Error getting charge state: {e:#?}"), + } + } + + #[allow(unused)] + async fn refresh_keys(&mut self) { + if Instant::now().duration_since(self.last_refresh) >= REFRESH_INTERVAL { + match self.api.refresh().await { + Ok(_) => { + let now = Instant::now(); + match self.save_key() { + Ok(_) => self.last_refresh = now, + Err(e) => eprintln!("error saving auth token: {e:?}"), + } + } + Err(e) => eprintln!("error refreshing auth token: {e:?}"), + } + } + } +} + +async fn get_state(api: &FleetApi, vehicle_id: VehicleId) -> Result { + let vehicle_data = api + .vehicle_data(&GetVehicleData { + vehicle_id: vehicle_id.clone(), + endpoints: vec![Endpoint::ChargeState].into(), + // endpoints: vec![Endpoint::VehicleDataCombo].into(), + }) + .await?; + let charge_state = vehicle_data.charge_state.map(|v| v.into()); + + let vehicle_data = api + .vehicle_data(&GetVehicleData { + vehicle_id, + endpoints: vec![Endpoint::LocationData].into(), + }) + .await?; + let location_data = vehicle_data.drive_state.and_then(|v| v.try_into().ok()); + + Ok(CarState { + charge_state, + location_data, + }) +} diff --git a/src/config.rs b/src/config.rs index c222ec8..4506a19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,7 +2,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::Coords; +use crate::types::Coords; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Config { diff --git a/src/errors.rs b/src/errors.rs index 0fbac0f..c2c6139 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -27,6 +27,8 @@ pub enum AuthLoadError { Teslatte(#[from] teslatte::error::TeslatteError), #[error("save error")] Save(#[from] SaveError), + #[error("other error")] + Anyhow(#[from] anyhow::Error), } impl AuthLoadError { @@ -36,6 +38,7 @@ impl AuthLoadError { AuthLoadError::StdIo(e) => format!("Error reading access token from disk: {e:?}"), AuthLoadError::RonSpanned(e) => format!("Error deserialising access token: {e:?}"), AuthLoadError::Save(e) => e.error_string(), + AuthLoadError::Anyhow(e) => e.to_string(), } } } diff --git a/src/main.rs b/src/main.rs index feb9397..687838b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,15 @@ -#![feature(async_closure)] +#![feature(never_type)] #[macro_use] extern crate rocket; -use anyhow::Result; +use api_interface::TeslaInterface; use clap::{Parser, Subcommand}; +use std::path::PathBuf; -use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - time::{Duration, Instant}, -}; -use teslatte::{ - auth::{AccessToken, RefreshToken}, - FleetApi, -}; - -use crate::{config::Config, errors::*}; +use crate::config::Config; +mod api_interface; mod config; mod errors; mod server; @@ -54,108 +46,27 @@ async fn main() { ron::ser::to_string_pretty(&Config::default(), Default::default()).unwrap() ); } - Commands::Watch => match get_auth(auth_path).await { - Ok(auth) => { + Commands::Watch => match TeslaInterface::load(auth_path).await { + Ok(mut interface) => { let config: Config = ron::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); - let products = auth.api().products().await; - match products { - Ok(res) => match res.first() { - Some(teslatte::products::Product::Vehicle(vehicle)) => { - server::launch_server(server::ServerState { - config, - auth, - vehicle: vehicle.clone(), - }) - .await; - } - _ => println!("No first item"), - }, - Err(e) => println!("Error getting products: {e:#?}"), - } + + let server_handle = server::launch_server(server::ServerState { + config, + state: interface.state.clone(), + }); + + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(120)); + loop { + interval.tick().await; + interface.refresh().await; + } + }); + + server_handle.await; } Err(e) => println!("{}", e.error_string()), }, } } - -#[derive(Serialize, Deserialize, Debug, Clone, Copy)] -pub struct Coords { - pub latitude: f64, - pub longitude: f64, -} - -const COORD_PRECISION: f64 = 0.001; - -impl Coords { - fn overlaps(&self, other: &Coords) -> bool { - (self.latitude - other.latitude).abs() < COORD_PRECISION - && (self.longitude - other.longitude).abs() < COORD_PRECISION - } -} - -async fn get_auth(auth_path: PathBuf) -> Result { - let key: AuthInfo = ron::from_str(&std::fs::read_to_string(&auth_path)?)?; - let mut api = FleetApi::new(key.access_token, key.refresh_token); - api.refresh().await?; - println!("Refreshed auth key"); - save_key(&auth_path, &api)?; - // api.print_responses = teslatte::PrintResponses::Pretty; - Ok(FleetApiAuth::new(api, Instant::now(), auth_path)) -} - -struct FleetApiAuth { - api: FleetApi, - last_refresh: Instant, - auth_path: PathBuf, -} - -const REFRESH_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60); - -impl FleetApiAuth { - fn new(api: FleetApi, last_refresh: Instant, auth_path: PathBuf) -> Self { - Self { - api, - last_refresh, - auth_path, - } - } - - fn api(&self) -> &FleetApi { - &self.api - } - - #[allow(unused)] - async fn refresh(&mut self) { - if Instant::now().duration_since(self.last_refresh) >= REFRESH_INTERVAL { - match self.api.refresh().await { - Ok(_) => { - let now = Instant::now(); - match save_key(&self.auth_path, &self.api) { - Ok(_) => self.last_refresh = now, - Err(e) => eprintln!("error saving auth token: {e:?}"), - } - } - Err(e) => eprintln!("error refreshing auth token: {e:?}"), - } - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -struct AuthInfo { - access_token: AccessToken, - refresh_token: Option, -} - -fn save_key(auth_path: &PathBuf, api: &FleetApi) -> Result<(), SaveError> { - std::fs::write( - auth_path, - ron::ser::to_string(&AuthInfo { - access_token: api.access_token.clone(), - refresh_token: api.refresh_token.clone(), - })?, - )?; - println!("Auth successfully saved"); - Ok(()) -} diff --git a/src/server/mod.rs b/src/server/mod.rs index 70ff737..0007307 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result}; +use std::sync::{Arc, RwLock}; + use rocket::{ fairing::{Fairing, Info, Kind}, http::Header, @@ -6,21 +7,18 @@ use rocket::{ Request, Response, State, }; -use teslatte::{ - vehicles::{Endpoint, GetVehicleData, VehicleData}, - FleetVehicleApi, +use crate::{ + config::Config, + types::{CarState, ChargeState}, }; -use crate::{config::Config, types::ChargeState, Coords, FleetApiAuth}; - use self::static_handler::UiStatic; mod static_handler; pub struct ServerState { pub config: Config, - pub auth: FleetApiAuth, - pub vehicle: Box, + pub state: Arc>, } pub async fn launch_server(state: ServerState) { @@ -45,19 +43,14 @@ fn rocket(state: ServerState) -> rocket::Rocket { } #[get("/home")] -async fn home(state: &State) -> Option { - let coords = state.get_coords().await.ok()?; - Some(if coords.overlaps(&state.config.coords) { - String::from("At home") - } else { - String::from("Not home") - }) +async fn home(state: &State) -> Option> { + let location_data = &state.state.read().ok()?.location_data?; + Some(Json(location_data.coords.overlaps(&state.config.coords))) } #[get("/charge-state")] async fn charge_state(state: &State) -> Option> { - let charge_state = state.get_charge_state().await.ok()?; - Some(Json(charge_state)) + Some(Json(state.state.read().ok()?.charge_state?)) } #[post("/flash")] @@ -66,39 +59,9 @@ async fn flash(state: &State) { } impl ServerState { - async fn get_coords(&self) -> Result { - let vehicle_data = self - .auth - .api() - .vehicle_data(&GetVehicleData { - vehicle_id: self.vehicle.id.clone(), - endpoints: vec![Endpoint::LocationData].into(), - }) - .await?; - let drive_state = vehicle_data.drive_state.context("no drive state")?; - let latitude = drive_state.latitude.context("no latitude")?; - let longitude = drive_state.longitude.context("no longitude")?; - Ok(Coords { - latitude, - longitude, - }) - } - - async fn get_charge_state(&self) -> Result { - let vehicle_data = self - .auth - .api() - .vehicle_data(&GetVehicleData { - vehicle_id: self.vehicle.id.clone(), - endpoints: vec![Endpoint::ChargeState].into(), - }) - .await?; - let charge_state = vehicle_data.charge_state.context("no drive state")?; - Ok(charge_state.into()) - } - async fn flash(&self) { - let _ = self.auth.api().flash_lights(&self.vehicle.vin).await; + println!("stubbed flash function"); + // let _ = self.auth.api().flash_lights(&self.vehicle.vin).await; } } diff --git a/src/types.rs b/src/types.rs index f701d63..1707b6d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,13 @@ +use anyhow::Context; +use chrono::DateTime; use serde::{Deserialize, Serialize}; +#[derive(Default)] +pub struct CarState { + pub charge_state: Option, + pub location_data: Option, +} + #[derive(Clone, Copy, Serialize, Deserialize, Debug)] pub struct ChargeState { pub battery_level: i64, @@ -33,3 +41,39 @@ impl From for ChargeState { } } } + +#[derive(Clone, Copy, Serialize, Deserialize, Debug)] +pub struct LocationData { + pub coords: Coords, + pub gps_as_of: DateTime, +} + +impl TryFrom for LocationData { + type Error = anyhow::Error; + + fn try_from(value: teslatte::vehicles::DriveState) -> Result { + let gps_as_of = + chrono::DateTime::from_timestamp(value.gps_as_of.context("no gps timestamp!")?, 0) + .context("could not process timestamp!")?; + let coords = Coords { + latitude: value.latitude.context("no longitude provided!")?, + longitude: value.longitude.context("no latitude provided!")?, + }; + Ok(Self { coords, gps_as_of }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +pub struct Coords { + pub latitude: f64, + pub longitude: f64, +} + +const COORD_PRECISION: f64 = 0.001; + +impl Coords { + pub fn overlaps(&self, other: &Coords) -> bool { + (self.latitude - other.latitude).abs() < COORD_PRECISION + && (self.longitude - other.longitude).abs() < COORD_PRECISION + } +} diff --git a/watch.sh b/watch.sh index 8dcc863..60b4cac 100755 --- a/watch.sh +++ b/watch.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash ( trap 'kill 0' SIGINT - cargo watch -x check -s 'touch .trigger' & + cargo watch -w "src" -x check -s 'touch .trigger' & cargo watch --no-vcs-ignores -w .trigger -x "run -- --config-dir test-config watch" ) diff --git a/webapp/index.html b/webapp/index.html index a8c9cb3..53a9845 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -38,6 +38,8 @@ fetch(api_url + "/charge-state") .then((response) => response.json()) .then((json) => update_charge_state(json)); + + fetch(api_url + "/home").then((response) => response.json()).then((response) => update_home(response)); } function update_charge_state(charge_state) { @@ -67,6 +69,19 @@ info_div.appendChild(el); } + function update_home(response) { + var info_div = document.getElementById("info"); + + el = document.createElement('p'); + if (response) { + el.appendChild(document.createTextNode("Home")); + } else { + el.appendChild(document.createTextNode("Not home")); + } + + info_div.appendChild(el); + } + function get_emoji(charge_state) { if (charge_state.charge_rate > 0) { return "🔌";