From 87394f9fb95861540700608a4c2f0e33cda8a4cd Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Mon, 9 Jan 2023 20:23:55 +0100 Subject: [PATCH 1/2] Add service implementation; split off My Autarco support --- Cargo.lock | 13 +++++ Cargo.toml | 1 + src/main.rs | 21 +++---- src/services.rs | 37 ++++++++++++ src/services/my_autarco.rs | 116 +++++++++++++++++++++++++++++++++++++ src/update.rs | 105 +++++---------------------------- 6 files changed, 190 insertions(+), 103 deletions(-) create mode 100644 src/services.rs create mode 100644 src/services/my_autarco.rs diff --git a/Cargo.lock b/Cargo.lock index d528ae0..17c2541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum_dispatch" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1693044dcf452888dd3a6a6a0dab67f0652094e3920dfe029a54d2f37d9b7394" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "eyre" version = "0.6.8" @@ -1500,6 +1512,7 @@ name = "solar-grabber" version = "0.1.1" dependencies = [ "color-eyre", + "enum_dispatch", "once_cell", "reqwest", "rocket", diff --git a/Cargo.toml b/Cargo.toml index 5c536db..09a3a31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ license = "MIT" [dependencies] color-eyre = "0.6.2" +enum_dispatch = "0.3.9" once_cell = "1.9.0" reqwest = { version = "0.11.6", features = ["cookies", "json"] } rocket = { version = "0.5.0-rc.2", features = ["json"] } diff --git a/src/main.rs b/src/main.rs index 032e2e7..482b251 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,9 @@ )] #![deny(missing_docs)] +mod services; +mod update; + use std::sync::Mutex; use once_cell::sync::Lazy; @@ -17,17 +20,7 @@ use serde::{Deserialize, Serialize}; use self::update::update_loop; -mod update; - -/// The base URL of My Autarco site. -const BASE_URL: &str = "https://my.autarco.com"; - -/// The interval between data polls. -/// -/// This depends on with which interval Autaurco processes new information from the invertor. -const POLL_INTERVAL: u64 = 300; - -/// The extra configuration necessary to access the My Autarco site. +/// The configuration necessary to access a cloud service API. #[derive(Debug, Deserialize)] struct Config { /// The username of the account to login with @@ -67,9 +60,11 @@ fn rocket() -> _ { .attach(AdHoc::config::()) .attach(AdHoc::on_liftoff("Updater", |rocket| { Box::pin(async move { - // We don't care about the join handle nor error results? let config = rocket.figment().extract().expect("Invalid configuration"); - let _ = rocket::tokio::spawn(update_loop(config)); + let service = services::get("my_autarco", config).expect("Invalid service"); + + // We don't care about the join handle nor error results?t + let _ = rocket::tokio::spawn(update_loop(service)); }) })) } diff --git a/src/services.rs b/src/services.rs new file mode 100644 index 0000000..1174f1d --- /dev/null +++ b/src/services.rs @@ -0,0 +1,37 @@ +//! The supported cloud services. + +pub(crate) mod my_autarco; + +use enum_dispatch::enum_dispatch; +use rocket::async_trait; + +use crate::{Status, Config}; + +/// Retrieves the service for the provided name (if supported). +pub(crate) fn get(service: &str, config: Config) -> color_eyre::Result { + match service { + "my_autarco" => Ok(Services::MyAutarco(my_autarco::service(config)?)), + _ => panic!("Unsupported service: {service}"), + } +} + +/// The supported cloud services. +#[enum_dispatch(Service)] +pub(crate) enum Services { + /// My Autarco () + MyAutarco(my_autarco::Service), +} + +/// Functionality trait of a cloud service. +#[async_trait] +#[enum_dispatch] +pub(crate) trait Service { + /// The interval between data polls (in seconds). + fn poll_interval(&self) -> u64; + + /// Perfoms a login on the cloud service (if necessary). + async fn login(&self) -> Result<(), reqwest::Error>; + + /// Retrieves a status update using the API of the cloud service. + async fn update(&self, timestamp: u64) -> Result; +} diff --git a/src/services/my_autarco.rs b/src/services/my_autarco.rs new file mode 100644 index 0000000..2d75e6f --- /dev/null +++ b/src/services/my_autarco.rs @@ -0,0 +1,116 @@ +//! The My Autarco service. +//! +//! It uses the private My Autarco API to login (and obtain the session cookies) and +//! to retrieve the energy data (using the session cookies). +//! See also: + +use reqwest::{Client, ClientBuilder, Url}; +use rocket::async_trait; +use serde::Deserialize; +use url::ParseError; + +use crate::{Status, Config}; + +/// The base URL of My Autarco site. +const BASE_URL: &str = "https://my.autarco.com"; + +/// The interval between data polls (in seconds). +const POLL_INTERVAL: u64 = 300; + +/// Instantiates the My Autarco service. +pub(crate) fn service(config: Config) -> Result { + let client = ClientBuilder::new().cookie_store(true).build()?; + let service = Service { client, config }; + + Ok(service) +} + +/// The My Autarco service. +#[derive(Debug)] +pub(crate) struct Service { + /// The client used to do API requests using a cookie jar. + client: Client, + /// The configuration used to access the API. + config: Config, +} + +/// Returns the login URL for the My Autarco site. +fn login_url() -> Result { + Url::parse(&format!("{BASE_URL}/auth/login")) +} + +/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site. +fn api_url(site_id: &str, endpoint: &str) -> Result { + Url::parse(&format!("{BASE_URL}/api/site/{site_id}/kpis/{endpoint}",)) +} + +/// The energy data returned by the energy API endpoint. +#[derive(Debug, Deserialize)] +struct ApiEnergy { + /// Total energy produced today (kWh) + // pv_today: u32, + /// Total energy produced this month (kWh) + // pv_month: u32, + /// Total energy produced since installation (kWh) + pv_to_date: u32, +} + +/// The power data returned by the power API endpoint. +#[derive(Debug, Deserialize)] +struct ApiPower { + /// Current power production (W) + pv_now: u32, +} + +#[async_trait] +impl super::Service for Service { + /// The interval between data polls (in seconds). + /// + /// Autaurco processes provides information from the invertor every 5 minutes. + fn poll_interval(&self) -> u64 { + POLL_INTERVAL + } + + /// Performs a login on the My Autarco site. + /// + /// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come + /// from the loaded configuration (see [`Config`]). + async fn login(&self) -> Result<(), reqwest::Error> { + let params = [ + ("username", &self.config.username), + ("password", &self.config.password), + ]; + let login_url = login_url().expect("valid login URL"); + + self.client.post(login_url).form(¶ms).send().await?; + + Ok(()) + } + + /// Retrieves a status update from the API of the My Autarco site. + /// + /// It needs the cookie from the login to be able to perform the action. It uses both the `energy` + /// and `power` endpoint to construct the [`Status`] struct. + async fn update(&self, last_updated: u64) -> Result { + // Retrieve the data from the API endpoints. + let api_energy_url = api_url(&self.config.site_id, "energy").expect("valid API energy URL"); + let api_response = self.client.get(api_energy_url).send().await?; + let api_energy: ApiEnergy = match api_response.error_for_status() { + Ok(res) => res.json().await?, + Err(err) => return Err(err), + }; + + let api_power_url = api_url(&self.config.site_id, "power").expect("valid API power URL"); + let api_response = self.client.get(api_power_url).send().await?; + let api_power: ApiPower = match api_response.error_for_status() { + Ok(res) => res.json().await?, + Err(err) => return Err(err), + }; + + Ok(Status { + current_w: api_power.pv_now, + total_kwh: api_energy.pv_to_date, + last_updated, + }) + } +} diff --git a/src/update.rs b/src/update.rs index 0e66f00..7ecc354 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,102 +1,27 @@ -//! Module for handling the status updating/retrieval via the My Autarco site/API. +//! Module for handling the status updating/retrieval via the cloud service API. use std::time::{Duration, SystemTime}; -use reqwest::{Client, ClientBuilder, Error, StatusCode}; +use reqwest::StatusCode; use rocket::tokio::time::sleep; -use serde::Deserialize; -use url::{ParseError, Url}; -use super::{Config, Status, BASE_URL, POLL_INTERVAL, STATUS}; - -/// Returns the login URL for the My Autarco site. -fn login_url() -> Result { - Url::parse(&format!("{}/auth/login", BASE_URL)) -} - -/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site. -fn api_url(site_id: &str, endpoint: &str) -> Result { - Url::parse(&format!( - "{}/api/site/{}/kpis/{}", - BASE_URL, site_id, endpoint - )) -} - -/// The energy data returned by the energy API endpoint. -#[derive(Debug, Deserialize)] -struct ApiEnergy { - /// Total energy produced today (kWh) - // pv_today: u32, - /// Total energy produced this month (kWh) - // pv_month: u32, - /// Total energy produced since installation (kWh) - pv_to_date: u32, -} - -/// The power data returned by the power API endpoint. -#[derive(Debug, Deserialize)] -struct ApiPower { - /// Current power production (W) - pv_now: u32, -} - -/// Performs a login on the My Autarco site. -/// -/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come -/// from the loaded configuration (see [`Config`]). -async fn login(config: &Config, client: &Client) -> Result<(), Error> { - let params = [ - ("username", &config.username), - ("password", &config.password), - ]; - let login_url = login_url().expect("valid login URL"); - - client.post(login_url).form(¶ms).send().await?; - - Ok(()) -} - -/// Retrieves a status update from the API of the My Autarco site. -/// -/// It needs the cookie from the login to be able to perform the action. It uses both the `energy` -/// and `power` endpoint to construct the [`Status`] struct. -async fn update(config: &Config, client: &Client, last_updated: u64) -> Result { - // Retrieve the data from the API endpoints. - let api_energy_url = api_url(&config.site_id, "energy").expect("valid API energy URL"); - let api_response = client.get(api_energy_url).send().await?; - let api_energy: ApiEnergy = match api_response.error_for_status() { - Ok(res) => res.json().await?, - Err(err) => return Err(err), - }; - - let api_power_url = api_url(&config.site_id, "power").expect("valid API power URL"); - let api_response = client.get(api_power_url).send().await?; - let api_power: ApiPower = match api_response.error_for_status() { - Ok(res) => res.json().await?, - Err(err) => return Err(err), - }; - - // Update the status. - Ok(Status { - current_w: api_power.pv_now, - total_kwh: api_energy.pv_to_date, - last_updated, - }) -} +use crate::{ + services::{Service, Services}, + STATUS, +}; /// Main update loop that logs in and periodically acquires updates from the API. /// -/// It updates the mutex-guarded current update [`Status`] struct which can be retrieved via -/// Rocket. -pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> { - let client = ClientBuilder::new().cookie_store(true).build()?; - - // Go to the My Autarco site and login. +/// It updates the mutex-guarded current update [`Status`](crate::Status) struct which can be +/// retrieved via Rocket. +pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> { + // Log in on the cloud service. println!("⚡ Logging in..."); - login(&config, &client).await?; + service.login().await?; println!("⚡ Logged in successfully!"); let mut last_updated = 0; + let poll_interval = service.poll_interval(); loop { // Wake up every 10 seconds and check if an update is due. sleep(Duration::from_secs(10)).await; @@ -105,15 +30,15 @@ pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> { .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - if timestamp - last_updated < POLL_INTERVAL { + if timestamp - last_updated < poll_interval { continue; } - let status = match update(&config, &client, timestamp).await { + let status = match service.update(timestamp).await { Ok(status) => status, Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => { println!("✨ Update unauthorized, trying to log in again..."); - login(&config, &client).await?; + service.login().await?; println!("⚡ Logged in successfully!"); continue; } From 3690647c76310ee527a2f6d4fb35820656bb5bea Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Mon, 9 Jan 2023 21:25:35 +0100 Subject: [PATCH 2/2] Add service-specific configuration Switch to a section/table for the service to make it easier. --- README.md | 4 +++- Rocket.toml.example | 2 ++ src/main.rs | 25 ++++++++++++------------- src/services.rs | 18 +++++++++++++----- src/services/my_autarco.rs | 13 ++++++++++++- 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7f55299..68a039d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ For example for My Autarco: [default] # ... -# Put your solar cloud service credentials below +# Put your solar cloud service settings below and uncomment them +[default.service] +kind = "MyAutarco" username = "foo@domain.tld" password = "secret" site_id = "abc123de" diff --git a/Rocket.toml.example b/Rocket.toml.example index 65c5a0e..f069df7 100644 --- a/Rocket.toml.example +++ b/Rocket.toml.example @@ -3,6 +3,8 @@ address = "0.0.0.0" port = 2356 # Put your solar cloud service settings below and uncomment them +[default.service] +# kind = "MyAutarco" # username = "foo@domain.tld" # password = "secret" # site_id = "abc123de" diff --git a/src/main.rs b/src/main.rs index 482b251..f237ffe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,20 +20,16 @@ use serde::{Deserialize, Serialize}; use self::update::update_loop; -/// The configuration necessary to access a cloud service API. -#[derive(Debug, Deserialize)] -struct Config { - /// The username of the account to login with - username: String, - /// The password of the account to login with - password: String, - /// The Autarco site ID to track - site_id: String, -} - /// The global, concurrently accessible current status. static STATUS: Lazy>> = Lazy::new(|| Mutex::new(None)); +/// The configuration loaded additionally by Rocket. +#[derive(Debug, Deserialize)] +struct Config { + /// The service-specific configuration + service: services::Config, +} + /// The current photovoltaic invertor status. #[derive(Clone, Copy, Debug, Serialize)] struct Status { @@ -60,8 +56,11 @@ fn rocket() -> _ { .attach(AdHoc::config::()) .attach(AdHoc::on_liftoff("Updater", |rocket| { Box::pin(async move { - let config = rocket.figment().extract().expect("Invalid configuration"); - let service = services::get("my_autarco", config).expect("Invalid service"); + let config = rocket + .figment() + .extract::() + .expect("Invalid configuration"); + let service = services::get(config.service).expect("Invalid service"); // We don't care about the join handle nor error results?t let _ = rocket::tokio::spawn(update_loop(service)); diff --git a/src/services.rs b/src/services.rs index 1174f1d..1d824a9 100644 --- a/src/services.rs +++ b/src/services.rs @@ -4,14 +4,22 @@ pub(crate) mod my_autarco; use enum_dispatch::enum_dispatch; use rocket::async_trait; +use serde::Deserialize; -use crate::{Status, Config}; +use crate::Status; + +/// The service-specific configuration necessary to access a cloud service API. +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde", tag = "kind")] +pub(crate) enum Config { + /// The configuration of the My Autarco service + MyAutarco(my_autarco::Config), +} /// Retrieves the service for the provided name (if supported). -pub(crate) fn get(service: &str, config: Config) -> color_eyre::Result { - match service { - "my_autarco" => Ok(Services::MyAutarco(my_autarco::service(config)?)), - _ => panic!("Unsupported service: {service}"), +pub(crate) fn get(config: Config) -> color_eyre::Result { + match config { + Config::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::service(config)?)), } } diff --git a/src/services/my_autarco.rs b/src/services/my_autarco.rs index 2d75e6f..24d26d4 100644 --- a/src/services/my_autarco.rs +++ b/src/services/my_autarco.rs @@ -9,7 +9,7 @@ use rocket::async_trait; use serde::Deserialize; use url::ParseError; -use crate::{Status, Config}; +use crate::Status; /// The base URL of My Autarco site. const BASE_URL: &str = "https://my.autarco.com"; @@ -17,6 +17,17 @@ const BASE_URL: &str = "https://my.autarco.com"; /// The interval between data polls (in seconds). const POLL_INTERVAL: u64 = 300; +/// The configuration necessary to access the My Autarco API. +#[derive(Debug, Deserialize)] +pub(crate) struct Config { + /// The username of the account to login with + username: String, + /// The password of the account to login with + password: String, + /// The Autarco site ID to track + site_id: String, +} + /// Instantiates the My Autarco service. pub(crate) fn service(config: Config) -> Result { let client = ClientBuilder::new().cookie_store(true).build()?;