local tesla status cache

This commit is contained in:
Alex Janka 2024-01-08 12:00:09 +11:00
parent 375a27bd96
commit f9dba75462
10 changed files with 236 additions and 162 deletions

1
Cargo.lock generated
View file

@ -1994,6 +1994,7 @@ name = "tesla-charge-controller"
version = "0.1.4" version = "0.1.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"clap", "clap",
"include_dir", "include_dir",
"rocket", "rocket",

View file

@ -22,3 +22,4 @@ thiserror = "1.0"
rocket = { version = "0.5", features = ["json"] } rocket = { version = "0.5", features = ["json"] }
anyhow = "1.0" anyhow = "1.0"
include_dir = "0.7" include_dir = "0.7"
chrono = "0.4"

136
src/api_interface.rs Normal file
View file

@ -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<RwLock<CarState>>,
api: FleetApi,
vehicle: Box<teslatte::vehicles::VehicleData>,
last_refresh: Instant,
auth_path: PathBuf,
}
#[derive(Serialize, Deserialize, Clone)]
struct AuthInfo {
access_token: AccessToken,
refresh_token: Option<RefreshToken>,
}
const REFRESH_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60);
impl TeslaInterface {
pub async fn load(auth_path: PathBuf) -> Result<Self, AuthLoadError> {
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<CarState> {
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,
})
}

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::Coords; use crate::types::Coords;
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Config { pub struct Config {

View file

@ -27,6 +27,8 @@ pub enum AuthLoadError {
Teslatte(#[from] teslatte::error::TeslatteError), Teslatte(#[from] teslatte::error::TeslatteError),
#[error("save error")] #[error("save error")]
Save(#[from] SaveError), Save(#[from] SaveError),
#[error("other error")]
Anyhow(#[from] anyhow::Error),
} }
impl AuthLoadError { impl AuthLoadError {
@ -36,6 +38,7 @@ impl AuthLoadError {
AuthLoadError::StdIo(e) => format!("Error reading access token from disk: {e:?}"), AuthLoadError::StdIo(e) => format!("Error reading access token from disk: {e:?}"),
AuthLoadError::RonSpanned(e) => format!("Error deserialising access token: {e:?}"), AuthLoadError::RonSpanned(e) => format!("Error deserialising access token: {e:?}"),
AuthLoadError::Save(e) => e.error_string(), AuthLoadError::Save(e) => e.error_string(),
AuthLoadError::Anyhow(e) => e.to_string(),
} }
} }
} }

View file

@ -1,23 +1,15 @@
#![feature(async_closure)] #![feature(never_type)]
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
use anyhow::Result; use api_interface::TeslaInterface;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use crate::config::Config;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use teslatte::{
auth::{AccessToken, RefreshToken},
FleetApi,
};
use crate::{config::Config, errors::*};
mod api_interface;
mod config; mod config;
mod errors; mod errors;
mod server; mod server;
@ -54,108 +46,27 @@ async fn main() {
ron::ser::to_string_pretty(&Config::default(), Default::default()).unwrap() ron::ser::to_string_pretty(&Config::default(), Default::default()).unwrap()
); );
} }
Commands::Watch => match get_auth(auth_path).await { Commands::Watch => match TeslaInterface::load(auth_path).await {
Ok(auth) => { Ok(mut interface) => {
let config: Config = let config: Config =
ron::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); ron::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let products = auth.api().products().await;
match products { let server_handle = server::launch_server(server::ServerState {
Ok(res) => match res.first() {
Some(teslatte::products::Product::Vehicle(vehicle)) => {
server::launch_server(server::ServerState {
config, config,
auth, state: interface.state.clone(),
vehicle: vehicle.clone(), });
})
.await; tokio::task::spawn(async move {
} let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
_ => println!("No first item"), loop {
}, interval.tick().await;
Err(e) => println!("Error getting products: {e:#?}"), interface.refresh().await;
} }
});
server_handle.await;
} }
Err(e) => println!("{}", e.error_string()), 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<FleetApiAuth, AuthLoadError> {
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<RefreshToken>,
}
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(())
}

View file

@ -1,4 +1,5 @@
use anyhow::{Context, Result}; use std::sync::{Arc, RwLock};
use rocket::{ use rocket::{
fairing::{Fairing, Info, Kind}, fairing::{Fairing, Info, Kind},
http::Header, http::Header,
@ -6,21 +7,18 @@ use rocket::{
Request, Response, State, Request, Response, State,
}; };
use teslatte::{ use crate::{
vehicles::{Endpoint, GetVehicleData, VehicleData}, config::Config,
FleetVehicleApi, types::{CarState, ChargeState},
}; };
use crate::{config::Config, types::ChargeState, Coords, FleetApiAuth};
use self::static_handler::UiStatic; use self::static_handler::UiStatic;
mod static_handler; mod static_handler;
pub struct ServerState { pub struct ServerState {
pub config: Config, pub config: Config,
pub auth: FleetApiAuth, pub state: Arc<RwLock<CarState>>,
pub vehicle: Box<VehicleData>,
} }
pub async fn launch_server(state: ServerState) { pub async fn launch_server(state: ServerState) {
@ -45,19 +43,14 @@ fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
} }
#[get("/home")] #[get("/home")]
async fn home(state: &State<ServerState>) -> Option<String> { async fn home(state: &State<ServerState>) -> Option<Json<bool>> {
let coords = state.get_coords().await.ok()?; let location_data = &state.state.read().ok()?.location_data?;
Some(if coords.overlaps(&state.config.coords) { Some(Json(location_data.coords.overlaps(&state.config.coords)))
String::from("At home")
} else {
String::from("Not home")
})
} }
#[get("/charge-state")] #[get("/charge-state")]
async fn charge_state(state: &State<ServerState>) -> Option<Json<ChargeState>> { async fn charge_state(state: &State<ServerState>) -> Option<Json<ChargeState>> {
let charge_state = state.get_charge_state().await.ok()?; Some(Json(state.state.read().ok()?.charge_state?))
Some(Json(charge_state))
} }
#[post("/flash")] #[post("/flash")]
@ -66,39 +59,9 @@ async fn flash(state: &State<ServerState>) {
} }
impl ServerState { impl ServerState {
async fn get_coords(&self) -> Result<Coords> {
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<ChargeState> {
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) { 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;
} }
} }

View file

@ -1,5 +1,13 @@
use anyhow::Context;
use chrono::DateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Default)]
pub struct CarState {
pub charge_state: Option<ChargeState>,
pub location_data: Option<LocationData>,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)] #[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct ChargeState { pub struct ChargeState {
pub battery_level: i64, pub battery_level: i64,
@ -33,3 +41,39 @@ impl From<teslatte::vehicles::ChargeState> for ChargeState {
} }
} }
} }
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LocationData {
pub coords: Coords,
pub gps_as_of: DateTime<chrono::Utc>,
}
impl TryFrom<teslatte::vehicles::DriveState> for LocationData {
type Error = anyhow::Error;
fn try_from(value: teslatte::vehicles::DriveState) -> Result<Self, Self::Error> {
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
}
}

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
( (
trap 'kill 0' SIGINT 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" cargo watch --no-vcs-ignores -w .trigger -x "run -- --config-dir test-config watch"
) )

View file

@ -38,6 +38,8 @@
fetch(api_url + "/charge-state") fetch(api_url + "/charge-state")
.then((response) => response.json()) .then((response) => response.json())
.then((json) => update_charge_state(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) { function update_charge_state(charge_state) {
@ -67,6 +69,19 @@
info_div.appendChild(el); 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) { function get_emoji(charge_state) {
if (charge_state.charge_rate > 0) { if (charge_state.charge_rate > 0) {
return "🔌"; return "🔌";