From 8b7410729ea0f8951e29011cbe5e026db30038c2 Mon Sep 17 00:00:00 2001 From: gak Date: Thu, 21 Jul 2022 21:00:45 +1000 Subject: [PATCH] wip: energy/powerwall api --- examples/cli.rs | 100 ++++++++---------- examples/cli_vehicle.rs | 64 ++++++++++++ src/energy.rs | 221 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 13 +-- src/powerwall.rs | 39 +++++++ src/vehicles.rs | 50 +++++---- 6 files changed, 399 insertions(+), 88 deletions(-) create mode 100644 examples/cli_vehicle.rs create mode 100644 src/energy.rs create mode 100644 src/powerwall.rs diff --git a/examples/cli.rs b/examples/cli.rs index 0bcf3df..8f46ef0 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -1,11 +1,12 @@ +mod cli_vehicle; + use clap::{Args, Parser, Subcommand}; +use cli_vehicle::VehicleArgs; use serde::{Deserialize, Serialize}; use teslatte::auth::{AccessToken, Authentication, RefreshToken}; +use teslatte::powerwall::PowerwallId; use teslatte::vehicles::{SetChargeLimit, SetChargingAmps}; -use teslatte::{Api, Id}; - -const TESLA_ACCESS_TOKEN: &str = "TESLA_ACCESS_TOKEN"; -const TESLA_REFRESH_TOKEN: &str = "TESLA_REFRESH_TOKEN"; +use teslatte::{Api, VehicleId}; /// Teslatte /// @@ -52,81 +53,54 @@ struct ApiArgs { #[derive(Debug, Subcommand)] enum ApiCommand { - /// Get a list of vehicles. + /// List of vehicles. Vehicles, /// Specific Vehicle. - Vehicle(Vehicle), + Vehicle(VehicleArgs), + + /// List of energy sites. + EnergySites, + + /// Powerwall queries. + Powerwall(PowerwallArgs), } #[derive(Debug, Args)] -struct Vehicle { - pub id: Id, +struct PowerwallArgs { + pub id: PowerwallId, #[clap(subcommand)] - pub command: VehicleCommand, + pub command: PowerwallCommand, } -impl Vehicle { - async fn run(self, api: &Api) { +impl PowerwallArgs { + pub async fn run(&self, api: &Api) -> miette::Result<()> { match self.command { - VehicleCommand::Data => { - dbg!(api.vehicle_data(&self.id).await.unwrap()); - } - VehicleCommand::ChargeState => { - dbg!(api.charge_state(&self.id).await.unwrap()); - } - VehicleCommand::SetChargeLimit { percent } => { - dbg!(api - .set_charge_limit(&self.id, &SetChargeLimit { percent }) - .await - .unwrap()); - } - VehicleCommand::SetChargingAmps { charging_amps } => { - dbg!(api - .set_charging_amps(&self.id, &SetChargingAmps { charging_amps }) - .await - .unwrap()); - } - VehicleCommand::ChargeStart => { - dbg!(api.charge_start(&self.id).await.unwrap()); - } - VehicleCommand::ChargeStop => { - dbg!(api.charge_stop(&self.id).await.unwrap()); + PowerwallCommand::Status => { + dbg!(api.powerwall_status(&self.id).await?); } } + Ok(()) } } #[derive(Debug, Subcommand)] -enum VehicleCommand { - /// Get vehicle data. - Data, - - /// Get charge state. - ChargeState, - - /// Set charge limit. - SetChargeLimit { percent: u8 }, - - /// Set charge amps. - SetChargingAmps { charging_amps: i64 }, - - /// Start charging. - ChargeStart, - - /// Stop charging. - ChargeStop, +enum PowerwallCommand { + /// Show the status of the Powerwall. + Status, } #[tokio::main] -async fn main() { +async fn main() -> miette::Result<()> { + tracing_subscriber::fmt::init(); + let args = Cli::parse(); match args.command { Command::Auth { save } => { - let auth = Authentication::new().unwrap(); - let (access_token, refresh_token) = auth.interactive_get_access_token().await.unwrap(); + let auth = Authentication::new()?; + let (access_token, refresh_token) = auth.interactive_get_access_token().await?; updated_tokens(save, access_token, refresh_token); } Command::Refresh { refresh_token } => { @@ -138,8 +112,8 @@ async fn main() { } }; - let auth = Authentication::new().unwrap(); - let response = auth.refresh_access_token(&refresh_token).await.unwrap(); + let auth = Authentication::new()?; + let response = auth.refresh_access_token(&refresh_token).await?; updated_tokens(save, response.access_token, refresh_token); } Command::Api(api_args) => { @@ -154,15 +128,21 @@ async fn main() { let api = Api::new(&access_token); match api_args.command { ApiCommand::Vehicles => { - let vehicles = api.vehicles().await.unwrap(); - dbg!(&vehicles); + dbg!(api.vehicles().await?); } ApiCommand::Vehicle(v) => { - v.run(&api).await; + v.run(&api).await?; + } + ApiCommand::EnergySites => { + dbg!(api.energy_sites().await?); + } + ApiCommand::Powerwall(p) => { + p.run(&api).await?; } } } } + Ok(()) } fn updated_tokens(save: bool, access_token: AccessToken, refresh_token: RefreshToken) { diff --git a/examples/cli_vehicle.rs b/examples/cli_vehicle.rs new file mode 100644 index 0000000..f69db8c --- /dev/null +++ b/examples/cli_vehicle.rs @@ -0,0 +1,64 @@ +use clap::{Args, Parser, Subcommand}; +use teslatte::vehicles::{SetChargeLimit, SetChargingAmps}; +use teslatte::{Api, VehicleId}; + +#[derive(Debug, Args)] +pub struct VehicleArgs { + pub id: VehicleId, + + #[clap(subcommand)] + pub command: VehicleCommand, +} + +impl VehicleArgs { + pub async fn run(self, api: &Api) -> miette::Result<()> { + match self.command { + VehicleCommand::Data => { + dbg!(api.vehicle_data(&self.id).await?); + } + VehicleCommand::ChargeState => { + dbg!(api.charge_state(&self.id).await?); + } + VehicleCommand::SetChargeLimit { percent } => { + dbg!( + api.set_charge_limit(&self.id, &SetChargeLimit { percent }) + .await? + ); + } + VehicleCommand::SetChargingAmps { charging_amps } => { + dbg!( + api.set_charging_amps(&self.id, &SetChargingAmps { charging_amps }) + .await? + ); + } + VehicleCommand::ChargeStart => { + dbg!(api.charge_start(&self.id).await?); + } + VehicleCommand::ChargeStop => { + dbg!(api.charge_stop(&self.id).await?); + } + } + Ok(()) + } +} + +#[derive(Debug, Subcommand)] +pub enum VehicleCommand { + /// Get vehicle data. + Data, + + /// Get charge state. + ChargeState, + + /// Set charge limit. + SetChargeLimit { percent: u8 }, + + /// Set charge amps. + SetChargingAmps { charging_amps: i64 }, + + /// Start charging. + ChargeStart, + + /// Stop charging. + ChargeStop, +} diff --git a/src/energy.rs b/src/energy.rs new file mode 100644 index 0000000..508a41e --- /dev/null +++ b/src/energy.rs @@ -0,0 +1,221 @@ +use crate::error::TeslatteError; +use crate::powerwall::PowerwallId; +use crate::vehicles::VehicleData; +use crate::{get, get_arg, post_arg, post_arg_empty, Api, Empty, ExternalVehicleId, VehicleId}; +use serde::{Deserialize, Serialize}; + +#[rustfmt::skip] +impl Api { + get!(energy_sites, Vec, "/products"); +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EnergySiteId(u64); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GatewayId(String); + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum EnergySite { + Vehicle(VehicleData), + Solar(SolarData), + Powerwall(PowerwallData), +} + +/// This is assumed from https://tesla-api.timdorr.com/api-basics/products +#[derive(Debug, Clone, Deserialize)] +pub struct SolarData { + // `solar_type` must be first in the struct so serde can properly decode. + pub solar_type: String, + pub energy_site_id: EnergySiteId, + /// Should always be "solar". + pub resource_type: String, + pub id: String, + pub asset_site_id: String, + pub solar_power: i64, + pub sync_grid_alert_enabled: bool, + pub breaker_alert_enabled: bool, + pub components: Components, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PowerwallData { + // `battery_type` must be first in the struct so serde can properly decode. + pub battery_type: String, + pub energy_site_id: i64, + /// Should always be "battery". + pub resource_type: String, + pub site_name: String, + pub id: PowerwallId, + pub gateway_id: GatewayId, + pub asset_site_id: String, + pub energy_left: f64, + pub total_pack_energy: i64, + pub percentage_charged: f64, + pub backup_capable: bool, + pub battery_power: i64, + pub sync_grid_alert_enabled: bool, + pub breaker_alert_enabled: bool, + pub components: Components, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Components { + pub battery: bool, + pub battery_type: Option, + pub solar: bool, + pub solar_type: Option, + pub grid: bool, + pub load_meter: bool, + pub market_type: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn energy_match_powerwall() { + let json = r#" + { + "energy_site_id": 1032748243, + "resource_type": "battery", + "site_name": "1 Railway Pde", + "id": "ABC2010-1234", + "gateway_id": "3287423824-QWE", + "asset_site_id": "123ecd-123ecd-12345-12345", + "energy_left": 4394.000000000001, + "total_pack_energy": 13494, + "percentage_charged": 32.562620423892106, + "battery_type": "ac_powerwall", + "backup_capable": true, + "battery_power": -280, + "sync_grid_alert_enabled": true, + "breaker_alert_enabled": false, + "components": { + "battery": true, + "battery_type": "ac_powerwall", + "solar": true, + "solar_type": "pv_panel", + "grid": true, + "load_meter": true, + "market_type": "residential" + } + } + "#; + + if let EnergySite::Powerwall(data) = serde_json::from_str(json).unwrap() { + assert_eq!(data.battery_type, "ac_powerwall"); + assert_eq!(data.backup_capable, true); + assert_eq!(data.battery_power, -280); + assert_eq!(data.sync_grid_alert_enabled, true); + assert_eq!(data.breaker_alert_enabled, false); + assert_eq!(data.components.battery, true); + assert_eq!( + data.components.battery_type, + Some("ac_powerwall".to_string()) + ); + assert_eq!(data.components.solar, true); + assert_eq!(data.components.solar_type, Some("pv_panel".to_string())); + assert_eq!(data.components.grid, true); + assert_eq!(data.components.load_meter, true); + assert_eq!(data.components.market_type, "residential"); + } else { + panic!("Expected PowerwallData"); + } + } + + #[test] + fn energy_match_vehicle() { + let json = r#" + { + "id": 1111193485934, + "user_id": 2222291283912, + "vehicle_id": 333331238921, + "vin": "T234567890123456789", + "display_name": "My Vehicle", + "option_codes": "ASDF,SDFG,DFGH", + "color": null, + "access_type": "OWNER", + "tokens": [ + "asdf1234" + ], + "state": "online", + "in_service": false, + "id_s": "932423", + "calendar_enabled": true, + "api_version": 42, + "backseat_token": null, + "backseat_token_updated_at": null, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 0, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32026", + "eu_vehicle": true, + "exterior_color": "MidnightSilver", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "Black2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "255,200,253,0.9,0.3", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "T15232Z", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1658390117642, + "trim_badging": "9", + "use_range_badging": true, + "utc_offset": 0, + "webcam_supported": false, + "wheel_type": "StilettoRefresh19" + }, + "command_signing": "allowed" + } + "#; + let energy_site: EnergySite = serde_json::from_str(json).unwrap(); + if let EnergySite::Vehicle(v) = energy_site { + assert_eq!(v.id.0, 1111193485934); + assert_eq!(v.user_id, 2222291283912); + assert_eq!(v.vehicle_id.0, 333331238921); + assert_eq!(v.vin, "T234567890123456789"); + assert_eq!(v.display_name, "My Vehicle"); + assert_eq!(v.option_codes, "ASDF,SDFG,DFGH"); + assert_eq!(v.color, None); + assert_eq!(v.access_type, "OWNER"); + assert_eq!(v.tokens, vec!["asdf1234"]); + assert_eq!(v.state, "online"); + assert_eq!(v.in_service, false); + assert_eq!(v.calendar_enabled, true); + assert_eq!(v.api_version, 42); + assert_eq!(v.backseat_token, None); + assert_eq!(v.backseat_token_updated_at, None); + assert_eq!(v.vehicle_config.unwrap().aux_park_lamps, "Eu"); + } else { + panic!("Wrong EnergySite"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e5110ac..8f97179 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,9 @@ use std::str::FromStr; use tracing::{debug, instrument, trace}; pub mod auth; +pub mod energy; pub mod error; +pub mod powerwall; pub mod vehicles; const API_URL: &str = "https://owner-api.teslamotors.com"; @@ -17,18 +19,18 @@ const API_URL: &str = "https://owner-api.teslamotors.com"; /// /// This data comes from [`Api::vehicles()`] `id` field. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Id(u64); +pub struct VehicleId(u64); -impl Display for Id { +impl Display for VehicleId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } -impl FromStr for Id { +impl FromStr for VehicleId { type Err = miette::Error; fn from_str(s: &str) -> Result { - Ok(Id(s.parse().into_diagnostic()?)) + Ok(VehicleId(s.parse().into_diagnostic()?)) } } @@ -36,7 +38,7 @@ impl FromStr for Id { /// /// This data comes from [`Api::vehicles()`] `vehicle_id` field. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct VehicleId(u64); +pub struct ExternalVehicleId(u64); pub struct Api { access_token: AccessToken, @@ -257,7 +259,6 @@ pub(crate) use post_arg_empty; mod tests { use super::*; use crate::vehicles::ChargeState; - use test_log::test; #[test] fn error() { diff --git a/src/powerwall.rs b/src/powerwall.rs new file mode 100644 index 0000000..7571b4c --- /dev/null +++ b/src/powerwall.rs @@ -0,0 +1,39 @@ +use crate::energy::GatewayId; +use crate::error::TeslatteError; +use crate::vehicles::VehicleData; +use crate::{get, get_arg, post_arg, post_arg_empty, Api, Empty, ExternalVehicleId, VehicleId}; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +#[rustfmt::skip] +impl Api { + get_arg!(powerwall_status, PowerwallStatus, "/powerwalls/{}/status", PowerwallId); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PowerwallId(pub String); + +impl Display for PowerwallId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for PowerwallId { + type Err = TeslatteError; + + fn from_str(s: &str) -> Result { + Ok(PowerwallId(s.to_string())) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PowerwallStatus { + pub site_name: String, + pub id: GatewayId, + pub energy_left: f64, + pub total_pack_energy: i64, + pub percentage_charged: f64, + pub battery_power: i64, +} diff --git a/src/vehicles.rs b/src/vehicles.rs index 398cd57..8a8361e 100644 --- a/src/vehicles.rs +++ b/src/vehicles.rs @@ -1,27 +1,28 @@ -use crate::error::TeslatteError; /// Please note that these structs are generated from my own responses. /// /// Sometimes the API will return a null for a field where I've put in a non Option type, which /// will cause the deserializer to fail. Please log an issue to fix these if you come across it. -use crate::{get, get_arg, post_arg, post_arg_empty, Api, Empty, Id, VehicleId}; +use crate::error::TeslatteError; +use crate::{get, get_arg, post_arg, post_arg_empty, Api, Empty, ExternalVehicleId, VehicleId}; use serde::{Deserialize, Serialize}; #[rustfmt::skip] impl Api { get!(vehicles, Vec, "/vehicles"); - get_arg!(vehicle_data, VehicleData, "/vehicles/{}/vehicle_data", Id); - get_arg!(charge_state, ChargeState, "/vehicles/{}/data_request/charge_state", Id); - post_arg!(set_charge_limit, SetChargeLimit, "/vehicles/{}/command/set_charge_limit", Id); - post_arg!(set_charging_amps, SetChargingAmps, "/vehicles/{}/command/set_charging_amps", Id); - post_arg_empty!(charge_start, "/vehicles/{}/command/charge_start", Id); - post_arg_empty!(charge_stop, "/vehicles/{}/command/charge_stop", Id); + get_arg!(vehicle_data, VehicleData, "/vehicles/{}/vehicle_data", VehicleId); + get_arg!(charge_state, ChargeState, "/vehicles/{}/data_request/charge_state", VehicleId); + post_arg!(set_charge_limit, SetChargeLimit, "/vehicles/{}/command/set_charge_limit", VehicleId); + post_arg!(set_charging_amps, SetChargingAmps, "/vehicles/{}/command/set_charging_amps", VehicleId); + post_arg_empty!(charge_start, "/vehicles/{}/command/charge_start", VehicleId); + post_arg_empty!(charge_stop, "/vehicles/{}/command/charge_stop", VehicleId); } #[derive(Debug, Clone, Deserialize)] pub struct VehicleData { - pub id: Id, + // Leave as first field for serde untagged. + pub vehicle_id: ExternalVehicleId, + pub id: VehicleId, pub user_id: i64, - pub vehicle_id: VehicleId, pub vin: String, pub display_name: String, pub option_codes: String, @@ -38,12 +39,12 @@ pub struct VehicleData { pub backseat_token: Option, /// gak: This was null for me, assuming String. pub backseat_token_updated_at: Option, - pub charge_state: ChargeState, - pub climate_state: ClimateState, - pub drive_state: DriveState, - pub gui_settings: GuiSettings, - pub vehicle_config: VehicleConfig, - pub vehicle_state: VehicleState, + pub charge_state: Option, + pub climate_state: Option, + pub drive_state: Option, + pub gui_settings: Option, + pub vehicle_config: Option, + pub vehicle_state: Option, } #[derive(Debug, Clone, Deserialize)] @@ -300,8 +301,8 @@ pub struct Vehicles(Vec); #[derive(Debug, Deserialize)] pub struct Vehicle { - pub id: Id, - pub vehicle_id: VehicleId, + pub id: VehicleId, + pub vehicle_id: ExternalVehicleId, pub vin: String, pub display_name: String, } @@ -317,9 +318,13 @@ pub struct SetChargeLimit { pub percent: u8, } -#[test] -fn json() { - let s = r#" +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json() { + let s = r#" { "response": { "battery_heater_on": false, @@ -381,5 +386,6 @@ fn json() { } } "#; - Api::parse_json::(s, || "req".to_string()).unwrap(); + Api::parse_json::(s, || "req".to_string()).unwrap(); + } }