diff --git a/Cargo.toml b/Cargo.toml index 38899c6..82d53fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,9 @@ sha256 = "1.0" base64 = "0.13" rand = "0.8" regex = "1.5" +chrono = { version = "0.4", features = ["serde"] } strum = { version = "0.24.1", features = ["derive"] } +urlencoding = "2.1.0" [dev-dependencies] test-log = { version = "0.2", default-features = false, features = ["trace"] } diff --git a/examples/cli.rs b/examples/cli.rs index 8f46ef0..c838e87 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -1,9 +1,13 @@ mod cli_vehicle; +use chrono::DateTime; use clap::{Args, Parser, Subcommand}; use cli_vehicle::VehicleArgs; +use miette::{miette, IntoDiagnostic, WrapErr}; use serde::{Deserialize, Serialize}; use teslatte::auth::{AccessToken, Authentication, RefreshToken}; +use teslatte::calendar_history::{CalendarHistoryValues, HistoryKind, HistoryPeriod}; +use teslatte::energy::EnergySiteId; use teslatte::powerwall::PowerwallId; use teslatte::vehicles::{SetChargeLimit, SetChargingAmps}; use teslatte::{Api, VehicleId}; @@ -62,10 +66,71 @@ enum ApiCommand { /// List of energy sites. EnergySites, + /// Specific energy site. + EnergySite(EnergySiteArgs), + /// Powerwall queries. Powerwall(PowerwallArgs), } +#[derive(Debug, Args)] +struct EnergySiteArgs { + pub id: EnergySiteId, + + #[clap(subcommand)] + pub command: EnergySiteCommand, +} + +impl EnergySiteArgs { + pub async fn run(&self, api: &Api) -> miette::Result<()> { + match &self.command { + EnergySiteCommand::CalendarHistory(args) => { + let start_date = args + .start + .as_ref() + .map(|s| DateTime::parse_from_rfc3339(&s).into_diagnostic()) + .transpose() + .wrap_err("start_date")?; + let end_date = args + .end + .as_ref() + .map(|s| DateTime::parse_from_rfc3339(&s).into_diagnostic()) + .transpose() + .wrap_err("end_date")?; + let values = CalendarHistoryValues { + site_id: self.id.clone(), + kind: args.kind.clone(), + period: args.period.clone(), + start_date, + end_date, + }; + let history = api.energy_sites_calendar_history(&values).await?; + println!("{:#?}", history); + } + } + Ok(()) + } +} + +#[derive(Debug, Subcommand)] +enum EnergySiteCommand { + CalendarHistory(CalendarHistoryArgs), +} + +#[derive(Debug, Args)] +struct CalendarHistoryArgs { + pub kind: HistoryKind, + + #[clap(short, long, default_value = "day")] + pub period: HistoryPeriod, + + #[clap(short, long)] + start: Option, + + #[clap(short, long)] + end: Option, +} + #[derive(Debug, Args)] struct PowerwallArgs { pub id: PowerwallId, @@ -136,6 +201,9 @@ async fn main() -> miette::Result<()> { ApiCommand::EnergySites => { dbg!(api.energy_sites().await?); } + ApiCommand::EnergySite(e) => { + e.run(&api).await?; + } ApiCommand::Powerwall(p) => { p.run(&api).await?; } diff --git a/src/calendar_history.rs b/src/calendar_history.rs new file mode 100644 index 0000000..2a47107 --- /dev/null +++ b/src/calendar_history.rs @@ -0,0 +1,109 @@ +use crate::energy::EnergySiteId; +use crate::{get_args, rfc3339, Api}; +use crate::{TeslatteError, Values}; +use chrono::{DateTime, FixedOffset}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; +use urlencoding::encode; + +#[rustfmt::skip] +impl Api { + get_args!(energy_sites_calendar_history, CalendarHistory, "/energy_sites/{}/calendar_history", CalendarHistoryValues); +} + +#[derive(Debug, Clone, Display, EnumString)] +#[strum(serialize_all = "snake_case")] +pub enum HistoryKind { + Power, + Energy, +} + +#[derive(Debug, Clone, Display, EnumString)] +#[strum(serialize_all = "snake_case")] +pub enum HistoryPeriod { + Day, + Month, + Year, + Lifetime, +} + +pub struct CalendarHistoryValues { + pub site_id: EnergySiteId, + pub period: HistoryPeriod, + pub kind: HistoryKind, + pub start_date: Option>, + pub end_date: Option>, +} + +impl Values for CalendarHistoryValues { + fn format(&self, url: &str) -> String { + let url = url.replace("{}", &format!("{}", self.site_id.0)); + let mut pairs = vec![ + ("period", self.period.to_string()), + ("kind", self.kind.to_string()), + ]; + if let Some(start_date) = self.start_date { + pairs.push(("start_date", rfc3339(&start_date))); + } + if let Some(end_date) = self.end_date { + pairs.push(("end_date", rfc3339(&end_date))); + } + format!( + "{}?{}", + url, + pairs + .iter() + .map(|(k, v)| format!("{}={}", k, v.replace("+", "%2B"))) + .collect::>() + .join("&") + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CalendarHistory { + pub serial_number: String, + /// Only appears in energy kind. + pub period: Option, + pub installation_time_zone: String, + /// Optional because if there are no `Series` fields, this field is omitted. + pub time_series: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Series { + Power(PowerSeries), + Energy(EnergySeries), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PowerSeries { + pub timestamp: DateTime, + pub solar_power: f64, + pub battery_power: f64, + pub grid_power: f64, + pub grid_services_power: i64, + pub generator_power: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EnergySeries { + pub timestamp: DateTime, + pub solar_energy_exported: f64, + pub generator_energy_exported: f64, + pub grid_energy_imported: f64, + pub grid_services_energy_imported: f64, + pub grid_services_energy_exported: f64, + pub grid_energy_exported_from_solar: f64, + pub grid_energy_exported_from_generator: f64, + pub grid_energy_exported_from_battery: f64, + pub battery_energy_exported: f64, + pub battery_energy_imported_from_grid: f64, + pub battery_energy_imported_from_solar: f64, + pub battery_energy_imported_from_generator: f64, + pub consumer_energy_imported_from_grid: f64, + pub consumer_energy_imported_from_solar: f64, + pub consumer_energy_imported_from_battery: f64, + pub consumer_energy_imported_from_generator: f64, +} diff --git a/src/energy.rs b/src/energy.rs index de26798..1e11fbe 100644 --- a/src/energy.rs +++ b/src/energy.rs @@ -4,68 +4,30 @@ use crate::vehicles::VehicleData; use crate::{ get, get_arg, get_args, post_arg, post_arg_empty, Api, Empty, ExternalVehicleId, VehicleId, }; +use chrono::{DateTime, FixedOffset}; use serde::{Deserialize, Serialize}; -use url::Url; +use std::str::FromStr; +use strum::{Display, EnumString}; +use url::{Url, UrlQuery}; #[rustfmt::skip] impl Api { get!(energy_sites, Vec, "/products"); - // https://owner-api.teslamotors.com/api/1/energy_sites/1370797147/calendar_history?period=day&kind=power - get_args!(energy_sites_calendar_history, CalendarHistory, "/energy_sites/{}/calendar_history", CalendarHistoryValues); } #[derive(Debug, Clone, Deserialize)] -pub struct CalendarHistory {} +pub struct EnergySiteId(pub u64); -trait Values { - fn format(&self, url: &str) -> String; -} +impl FromStr for EnergySiteId { + type Err = TeslatteError; -#[derive(Debug, Clone, strum::Display)] -#[strum(serialize_all = "snake_case")] -pub enum HistoryKind { - Power, - Energy, -} - -#[derive(Debug, Clone, strum::Display)] -#[strum(serialize_all = "snake_case")] -pub enum HistoryPeriod { - Day, - Week, - Month, - Year, -} - -pub struct CalendarHistoryValues { - site_id: EnergySiteId, - period: HistoryPeriod, - kind: HistoryKind, - start_date: Option>, - end_date: Option>, -} - -impl Values for CalendarHistoryValues { - fn format(&self, url: &str) -> String { - let url = url.replace("{}", &format!("{}", self.site_id.0)); - let mut url = Url::parse(&url).unwrap(); - let mut pairs = url.query_pairs_mut(); - pairs.append_pair("period", &self.period.to_string()); - pairs.append_pair("kind", &self.kind.to_string()); - if let Some(start_date) = self.start_date { - pairs.append_pair("start_date", &start_date.to_rfc3339()); - } - if let Some(end_date) = self.end_date { - pairs.append_pair("end_date", &end_date.to_rfc3339()); - } - drop(pairs); - url.to_string() + fn from_str(s: &str) -> Result { + Ok(EnergySiteId(s.parse().map_err(|_| { + TeslatteError::DecodeEnergySiteIdError(s.to_string()) + })?)) } } -#[derive(Debug, Clone, Deserialize)] -pub struct EnergySiteId(u64); - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GatewayId(String); @@ -128,6 +90,8 @@ pub struct Components { #[cfg(test)] mod tests { use super::*; + use crate::calendar_history::{CalendarHistoryValues, HistoryKind, HistoryPeriod}; + use crate::Values; #[test] fn energy_match_powerwall() { @@ -288,4 +252,20 @@ mod tests { "https://base.com/e/123/history?period=month&kind=energy" ); } + + #[test] + fn calendar_history_values_dates() { + let v = CalendarHistoryValues { + site_id: EnergySiteId(123), + period: HistoryPeriod::Month, + kind: HistoryKind::Energy, + start_date: Some(DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z").unwrap()), + end_date: Some(DateTime::parse_from_rfc3339("2020-01-31T23:59:59Z").unwrap()), + }; + let url = v.format("https://base.com/e/{}/history"); + assert_eq!( + url, + "https://base.com/e/123/history?period=month&kind=energy&start_date=2020-01-01T00:00:00Z&end_date=2020-01-31T23:59:59Z" + ); + } } diff --git a/src/error.rs b/src/error.rs index 94f7574..229aa84 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,4 +35,7 @@ pub enum TeslatteError { #[error("Callback URL did not contain a callback code.")] CouldNotFindCallbackCode, + + #[error("Could not convert \"{0}\" to an EnergySiteId.")] + DecodeEnergySiteIdError(String), } diff --git a/src/lib.rs b/src/lib.rs index e5539d0..4b78a41 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ use crate::auth::AccessToken; use crate::error::TeslatteError; +use chrono::{DateTime, SecondsFormat, TimeZone}; use miette::IntoDiagnostic; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::str::FromStr; use tracing::{debug, instrument, trace}; pub mod auth; +pub mod calendar_history; pub mod energy; pub mod error; pub mod powerwall; @@ -15,6 +17,10 @@ pub mod vehicles; const API_URL: &str = "https://owner-api.teslamotors.com"; +trait Values { + fn format(&self, url: &str) -> String; +} + /// Vehicle ID used by the owner-api endpoint. /// /// This data comes from [`Api::vehicles()`] `id` field. @@ -267,6 +273,14 @@ macro_rules! post_arg_empty { } pub(crate) use post_arg_empty; +pub(crate) fn rfc3339(d: &DateTime) -> String +where + Tz: TimeZone, + Tz::Offset: Display, +{ + d.to_rfc3339_opts(SecondsFormat::Secs, true) +} + #[cfg(test)] mod tests { use super::*;