Add Prometheus power check

This commit is contained in:
Maxime Augier 2024-08-08 17:30:16 +02:00
parent 0705966c2e
commit 78ba0a3bac
4 changed files with 212 additions and 94 deletions

10
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -1,11 +1,15 @@
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;
mod prom;
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Command {
Start,
@ -17,7 +21,7 @@ enum Command {
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Session {
Ongoing,
Latest
Latest,
}
#[derive(Debug, Clone, Subcommand)]
@ -26,10 +30,13 @@ enum Mode {
Status,
Session {
#[arg(default_value = "ongoing")]
session: Session
session: Session,
},
Charge {
command: Command,
},
Charge { command: Command },
Stream,
Power,
}
#[derive(Debug, Parser)]
@ -40,21 +47,16 @@ struct CLI {
#[arg(short, long)]
charger_id: Vec<String>,
#[arg(short, long, default_value = "http://localhost:9090")]
prometheus: String,
#[command(subcommand)]
mode: Mode,
}
const SAVED_TOKEN_PATH: &str = ".easee_token";
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 {
fn login() -> Result<()> {
use std::io::Write;
let stdin = std::io::stdin();
let mut stderr = std::io::stderr();
@ -74,25 +76,33 @@ fn main() -> Result<()> {
eprintln!("Login successful.");
std::fs::write(SAVED_TOKEN_PATH, ctx.save().as_bytes())?;
return Ok(())
return Ok(());
}
fn load_context() -> Result<easee::api::Context> {
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)?;
Ok(easee::api::Context::from_saved(&saved)?)
}
let chargers;
if args.charger_id.is_empty() {
fn load_chargers(ctx: &mut Context, names: &[String]) -> Result<Vec<Charger>> {
let chargers: Vec<Charger>;
if names.is_empty() {
chargers = ctx.chargers()?;
info!("{} chargers available.", chargers.len());
} else {
chargers = args.charger_id.iter()
chargers = names
.iter()
.map(|id| ctx.charger(&id))
.collect::<Result<_, _>>()?;
}
Ok(chargers)
}
if let Mode::Stream = args.mode {
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)?;
}
@ -103,44 +113,75 @@ fn main() -> Result<()> {
}
}
for c in &chargers {
fn loop_chargers<F, E>(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");
}
let mut ctx = load_context()?;
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::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)?
}
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")
},
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)?
}
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<ChargingSession>) {
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
);
let duration = std::time::Duration::from_secs(s.charge_duration_in_seconds.unwrap_or(0) as u64);
println!("{}\t{}\t{}kWh",
println!(
"{}\t{}\t{}kWh",
s.charger_id.as_deref().unwrap_or("<none>"),
humantime::format_duration(duration),
s.session_energy,

73
src/prom.rs Normal file
View File

@ -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<VecEntry>),
Matrix(Vec<MatrixEntry>),
}
#[derive(Debug, Deserialize)]
struct VecEntry {
metric: HashMap<String, String>,
value: (f64, String),
}
#[derive(Debug, Deserialize)]
struct MatrixEntry {
metric: HashMap<String, String>,
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"))?,
))
}