diff --git a/Cargo.lock b/Cargo.lock index 385acb3..7e89ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,10 +278,12 @@ dependencies = [ "clap", "easee", "humantime", + "serde", "serde_json", "tracing", "tracing-subscriber", "tungstenite", + "ureq", ] [[package]] @@ -662,18 +664,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.204" +version = "1.0.205" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.205" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b37085d..06acf38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,9 @@ anyhow = "1.0.86" clap = { version = "4.5.11", features = ["derive", "env"] } easee = { path = "../easee", features = ["tungstenite"] } humantime = "2.1.0" +serde = { version = "1.0.205", features = ["derive"] } serde_json = "1.0.121" tracing = "0.1.40" tracing-subscriber = "0.3.18" tungstenite = { version = "0.23.0", optional = true, features = ["rustls-tls-native-roots"] } +ureq = { version = "2.10.0", features = ["json"] } diff --git a/src/main.rs b/src/main.rs index 6cc4b18..45ac927 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,16 @@ +use std::error::Error; + +use anyhow::{Context as AnyhowContext, Result}; use clap::ValueEnum; use clap::{Parser, Subcommand}; -use anyhow::{Context, Result}; -use easee::api::ChargingSession; +use easee::api::{ApiError, Charger, ChargerState, ChargingSession, Context}; use easee::stream; use tracing::info; -#[derive(Debug,Clone,Copy,ValueEnum)] +mod prom; + +#[derive(Debug, Clone, Copy, ValueEnum)] enum Command { Start, Stop, @@ -14,135 +18,172 @@ enum Command { Resume, } -#[derive(Debug,Clone,Copy,ValueEnum)] +#[derive(Debug, Clone, Copy, ValueEnum)] enum Session { Ongoing, - Latest + Latest, } -#[derive(Debug,Clone,Subcommand)] +#[derive(Debug, Clone, Subcommand)] enum Mode { Login, Status, - Session { + Session { #[arg(default_value = "ongoing")] - session: Session + session: Session, + }, + Charge { + command: Command, }, - Charge { command: Command }, Stream, + Power, } -#[derive(Debug,Parser)] +#[derive(Debug, Parser)] struct CLI { - #[arg(short,long)] + #[arg(short, long)] debug: bool, - #[arg(short,long)] + #[arg(short, long)] charger_id: Vec, + #[arg(short, long, default_value = "http://localhost:9090")] + prometheus: String, + #[command(subcommand)] mode: Mode, } const SAVED_TOKEN_PATH: &str = ".easee_token"; +fn login() -> Result<()> { + use std::io::Write; + let stdin = std::io::stdin(); + let mut stderr = std::io::stderr(); + + let mut username = String::new(); + let mut password = String::new(); + + write!(stderr, "Username: ")?; + stdin.read_line(&mut username)?; + write!(stderr, "Password: ")?; + stdin.read_line(&mut password)?; + + let username = username.trim(); + let password = password.trim(); + + let ctx = easee::api::Context::from_login(&username, &password)?; + eprintln!("Login successful."); + + std::fs::write(SAVED_TOKEN_PATH, ctx.save().as_bytes())?; + return Ok(()); +} + +fn load_context() -> Result { + let saved = std::fs::read_to_string(SAVED_TOKEN_PATH) + .context("Cannot read saved token (did you log in ?)")?; + Ok(easee::api::Context::from_saved(&saved)?) +} + +fn load_chargers(ctx: &mut Context, names: &[String]) -> Result> { + let chargers: Vec; + if names.is_empty() { + chargers = ctx.chargers()?; + info!("{} chargers available.", chargers.len()); + } else { + chargers = names + .iter() + .map(|id| ctx.charger(&id)) + .collect::>()?; + } + Ok(chargers) +} + +fn stream(names: &[String]) -> Result<()> { + let mut ctx = load_context()?; + let mut stream = stream::Stream::open(&mut ctx)?; + let chargers = load_chargers(&mut ctx, names)?; + for c in &chargers { + stream.subscribe(&c.id)?; + } + + let mut stream = easee::signalr::Stream::from_ws(stream); + loop { + println!("{:?}", stream.recv()?); + } +} + +fn loop_chargers(ctx: &mut Context, names: &[String], mut f: F) -> Result<()> +where + F: FnMut(Charger, &mut Context) -> Result<(), E>, + E: Error, +{ + let chargers = load_chargers(ctx, names)?; + for c in chargers { + if let Err(e) = f(c, ctx) { + eprintln!("{e}"); + } + } + Ok(()) +} + +fn status(id: &str, c: ChargerState) { + let cable = if c.cable_locked { "LOCK" } else { "open" }; + let mode = c.charger_op_mode; + let power = c.total_power; + println!("{id}: [{mode:?}] ({power}W) cable:{cable}"); +} + fn main() -> Result<()> { - let args = CLI::parse(); if args.debug { tracing::subscriber::set_global_default(tracing_subscriber::FmtSubscriber::new()) .expect("Tracing subscriber failed"); } - if let Mode::Login = args.mode { - use std::io::Write; - let stdin = std::io::stdin(); - let mut stderr = std::io::stderr(); + let mut ctx = load_context()?; - let mut username = String::new(); - let mut password = String::new(); - - write!(stderr, "Username: ")?; - stdin.read_line(&mut username)?; - write!(stderr, "Password: ")?; - stdin.read_line(&mut password)?; - - let username = username.trim(); - let password = password.trim(); - - let ctx = easee::api::Context::from_login(&username, &password)?; - eprintln!("Login successful."); - - std::fs::write(SAVED_TOKEN_PATH, ctx.save().as_bytes())?; - return Ok(()) - } - - let saved = std::fs::read_to_string(SAVED_TOKEN_PATH) - .context("Cannot read saved token (did you log in ?)")?; - let mut ctx = easee::api::Context::from_saved(&saved)?; - - let chargers; - if args.charger_id.is_empty() { - chargers = ctx.chargers()?; - info!("{} chargers available.", chargers.len()); - } else { - chargers = args.charger_id.iter() - .map(|id| ctx.charger(&id)) - .collect::>()?; - } - - if let Mode::Stream = args.mode { - let mut stream = stream::Stream::open(&mut ctx)?; - for c in &chargers { - stream.subscribe(&c.id)?; + match args.mode { + Mode::Login => login()?, + Mode::Status => loop_chargers(&mut ctx, &args.charger_id, |c, ctx| { + c.state(ctx).map(|s| status(&c.id, s)) + })?, + Mode::Session { session } => { + let cmd: fn(Charger, &mut Context) -> Result<(), ApiError> = match session { + Session::Latest => |c, ctx| c.latest_session(ctx).map(|s| show_session(&s)), + Session::Ongoing => |c, ctx| c.ongoing_session(ctx).map(|s| show_session(&s)), + }; + loop_chargers(&mut ctx, &args.charger_id, cmd)? } - - let mut stream = easee::signalr::Stream::from_ws(stream); - loop { - println!("{:?}", stream.recv()?); + Mode::Charge { command } => { + let cmd: fn(Charger, &mut Context) -> Result<(), ApiError> = match command { + Command::Start => |c, ctx| c.start(ctx), + Command::Stop => |c, ctx| c.stop(ctx), + Command::Pause => |c, ctx| c.pause(ctx), + Command::Resume => |c, ctx| c.resume(ctx), + }; + loop_chargers(&mut ctx, &args.charger_id, cmd)? } - } - - for c in &chargers { - match args.mode { - Mode::Status => { - println!("{}: {:?}", c.id, c.state(&mut ctx)); - }, - Mode::Session { session: Session::Ongoing }=> { - show_session(&c.ongoing_session(&mut ctx)?); - }, - Mode::Session { session: Session::Latest } => { - show_session(&c.latest_session(&mut ctx)?); - }, - Mode::Charge { command } => { - match command { - Command::Start => c.start(&mut ctx)?, - Command::Stop => c.stop(&mut ctx)?, - Command::Pause => c.pause(&mut ctx)?, - Command::Resume => c.resume(&mut ctx)?, - } - }, - _other => { - unreachable!("Stream was already ruled out above") - }, - } - } + Mode::Stream => stream(&args.charger_id)?, + Mode::Power => { + let pow = prom::current_power(&*args.prometheus)?; + println!("P1:{}W P2:{}W P3:{}W", pow.0, pow.1, pow.2); + } + }; std::fs::write(SAVED_TOKEN_PATH, ctx.save().as_bytes())?; Ok(()) - } fn show_session(s: &Option) { let Some(s) = s.as_ref() else { return }; - - let duration = std::time::Duration::from_secs( - s.charge_duration_in_seconds.unwrap_or(0) as u64 - ); - println!("{}\t{}\t{}kWh", + let duration = std::time::Duration::from_secs(s.charge_duration_in_seconds.unwrap_or(0) as u64); + + println!( + "{}\t{}\t{}kWh", s.charger_id.as_deref().unwrap_or(""), humantime::format_duration(duration), s.session_energy, ) -} \ No newline at end of file +} diff --git a/src/prom.rs b/src/prom.rs new file mode 100644 index 0000000..585fba8 --- /dev/null +++ b/src/prom.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use ureq::serde::Deserialize; + +use tracing::warn; + +const PROM_QUERY: &str = "/api/v1/query?query=ac_power_watts%7Bmeter%3D%22mains%22%2Cpower%3D%22active%22%2Cphase%21%3D%22total%22%7D"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "status")] +enum PromReply { + Success { + data: PromData, + }, + Error { + data: PromData, + error_type: String, + error: String, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "resultType", content = "result")] +enum PromData { + Vector(Vec), + Matrix(Vec), +} + +#[derive(Debug, Deserialize)] +struct VecEntry { + metric: HashMap, + value: (f64, String), +} + +#[derive(Debug, Deserialize)] +struct MatrixEntry { + metric: HashMap, + values: Vec<(f64, String)>, +} + +pub fn current_power(base: &str) -> Result<(f64, f64, f64)> { + let url = format!("{}{}", base, PROM_QUERY); + + let reply: PromReply = ureq::get(&url).call()?.into_json()?; + + let PromReply::Success { + data: PromData::Vector(v), + } = reply + else { + bail!("Could not understand Prometheus reply: {:?}", reply) + }; + + let mut r = (None, None, None); + + for entry in &v { + let val: f64 = entry.value.1.parse()?; + match entry.metric.get("phase").map(|s| &**s) { + Some("a") => r.0 = Some(val), + Some("b") => r.1 = Some(val), + Some("c") => r.2 = Some(val), + _ => warn!("Metric with unexpected phase"), + } + } + + Ok(( + r.0.ok_or_else(|| anyhow!("Missing phase a"))?, + r.1.ok_or_else(|| anyhow!("Missing phase b"))?, + r.2.ok_or_else(|| anyhow!("Missing phase c"))?, + )) +}