From b1dfea651f56527baf2a20c5c93c14d7403a9a97 Mon Sep 17 00:00:00 2001 From: Paul van Tilburg Date: Tue, 10 Jan 2023 15:38:24 +0100 Subject: [PATCH] Add first version of the Hoymiles service --- Cargo.lock | 10 ++ Cargo.toml | 1 + src/services.rs | 8 +- src/services/hoymiles.rs | 236 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/services/hoymiles.rs diff --git a/Cargo.lock b/Cargo.lock index 17c2541..6bdc1de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -814,6 +814,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "md-5" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.5.0" @@ -1513,6 +1522,7 @@ version = "0.1.1" dependencies = [ "color-eyre", "enum_dispatch", + "md-5", "once_cell", "reqwest", "rocket", diff --git a/Cargo.toml b/Cargo.toml index 09a3a31..b5f6cc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ license = "MIT" [dependencies] color-eyre = "0.6.2" enum_dispatch = "0.3.9" +md-5 = "0.10.5" 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/services.rs b/src/services.rs index 1d824a9..3a24ec9 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,5 +1,6 @@ //! The supported cloud services. +pub(crate) mod hoymiles; pub(crate) mod my_autarco; use enum_dispatch::enum_dispatch; @@ -12,13 +13,16 @@ use crate::Status; #[derive(Debug, Deserialize)] #[serde(crate = "rocket::serde", tag = "kind")] pub(crate) enum Config { - /// The configuration of the My Autarco service + /// Hoymiles () + Hoymiles(hoymiles::Config), + /// My Autarco () MyAutarco(my_autarco::Config), } /// Retrieves the service for the provided name (if supported). pub(crate) fn get(config: Config) -> color_eyre::Result { match config { + Config::Hoymiles(config) => Ok(Services::Hoymiles(hoymiles::service(config)?)), Config::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::service(config)?)), } } @@ -26,6 +30,8 @@ pub(crate) fn get(config: Config) -> color_eyre::Result { /// The supported cloud services. #[enum_dispatch(Service)] pub(crate) enum Services { + /// Hoymiles () + Hoymiles(hoymiles::Service), /// My Autarco () MyAutarco(my_autarco::Service), } diff --git a/src/services/hoymiles.rs b/src/services/hoymiles.rs new file mode 100644 index 0000000..be8258b --- /dev/null +++ b/src/services/hoymiles.rs @@ -0,0 +1,236 @@ +//! The Hoymiles service. +//! +//! It uses the private Hoymiles API to login (and obtain the session cookies) and +//! to retrieve the energy data (using the session cookies). +//! See also: . + +use std::sync::Arc; + +use md5::{Digest, Md5}; +use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url}; +use rocket::async_trait; +use serde::{Deserialize, Deserializer, Serialize}; +use url::ParseError; + +use crate::Status; + +/// The base URL of Hoymiles API gateway. +const BASE_URL: &str = "https://global.hoymiles.com/platform/api/gateway"; + +/// The interval between data polls (in seconds). +const POLL_INTERVAL: u64 = 900; + +/// The configuration necessary to access the Hoymiles. +#[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 ID of the Hoymiles station to track + sid: u32, +} + +/// Instantiates the Hoymiles service. +pub(crate) fn service(config: Config) -> Result { + let cookie_jar = Arc::new(CookieJar::default()); + let client = ClientBuilder::new() + .cookie_provider(Arc::clone(&cookie_jar)) + .build()?; + let service = Service { + client, + config, + cookie_jar, + }; + + Ok(service) +} + +/// The Hoymiles 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, + /// The cookie jar used for API requests. + cookie_jar: Arc, +} + +/// Returns the login URL for the Hoymiles site. +fn login_url() -> Result { + Url::parse(&format!("{BASE_URL}/iam/auth_login")) +} + +/// Returns an API endpoint URL for for the Hoymiles site. +fn api_url() -> Result { + Url::parse(&format!("{BASE_URL}/pvm-data/data_count_station_real_data")) +} + +/// The request passed to the API login endpoint. +#[derive(Debug, Serialize)] +struct ApiLoginRequest { + body: ApiLoginRequestBody, +} + +impl ApiLoginRequest { + /// Creates a new API login request. + fn new(username: &str, password: &str) -> Self { + let mut hasher = Md5::new(); + hasher.update(password.as_bytes()); + let password = format!("{:x}", hasher.finalize()); + + // TODO: Hash the password! + let body = ApiLoginRequestBody { + user_name: username.to_owned(), + password, + }; + + Self { body } + } +} + +/// The request body passed to the API login endpoint. +#[derive(Debug, Serialize)] +struct ApiLoginRequestBody { + password: String, + user_name: String, +} + +/// The response returned by the API login endpoint. +#[derive(Debug, Deserialize)] +struct ApiLoginResponse { + // status: String, + // message: String, + /// The embedded response data + data: ApiLoginResponseData, + // systemNotice: Option, +} + +/// The response data returned by the API login endpoint. +#[derive(Debug, Deserialize)] +struct ApiLoginResponseData { + /// The token to be used as cookie for API data requests. + token: String, +} + +/// The request passed to the API data endpoint. +#[derive(Debug, Serialize)] +struct ApiDataRequest { + body: ApiDataRequestBody, +} + +impl ApiDataRequest { + /// Creates a new API data request. + fn new(sid: u32) -> Self { + let body = ApiDataRequestBody { sid }; + + Self { body } + } +} + +/// The request body passed to the API data endpoint. +#[derive(Debug, Serialize)] +struct ApiDataRequestBody { + sid: u32, +} + +/// The response returned by the API data endpoint. +#[derive(Debug, Deserialize)] +struct ApiDataResponse { + // status: String, + // message: String, + // /// The embedded response data + data: ApiDataResponseData, + // systemNotice: Option, +} + +/// Deserializes a string ([`&str`]) into a float ([`f32`]). +fn from_float_str<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, + D::Error: serde::de::Error, +{ + use serde::de::Error; + + let s = <&str>::deserialize(deserializer)?; + s.parse::().map_err(D::Error::custom) +} + +/// The response data returned by the API data endpoint. +#[derive(Debug, Deserialize)] +struct ApiDataResponseData { + /// Energy produced today (Wh) + #[serde(deserialize_with = "from_float_str")] + today_eq: f32, + // month_eq: f32, + // year_eq: f32, + /// Total energy produced since installation, excluding today's (Wh) + #[serde(deserialize_with = "from_float_str")] + total_eq: f32, + /// Current power production + #[serde(deserialize_with = "from_float_str")] + real_power: f32, + // co2_emission_reducation: f32, + // plant_tree: u32, + // data_time: String, + // last_data_time: String, + // capacitor: f32, + // is_balance: bool, + // is_reflux: bool, + // reflux_station_data: Option<_>, +} + +#[async_trait] +impl super::Service for Service { + /// The interval between data polls (in seconds). + /// + /// Hoymiles processes provides information from the invertor every 15 minutes. + fn poll_interval(&self) -> u64 { + POLL_INTERVAL + } + + /// Performs a login on the Hoymiles site. + /// + /// It mainly stores the acquired cookies in the client's cookie jar and adds the token cookie + /// provided by the logins response. The login credentials come from the loaded configuration + /// (see [`Config`]). + async fn login(&self) -> Result<(), reqwest::Error> { + let base_url = Url::parse(BASE_URL).expect("valid base URL"); + let login_url = login_url().expect("valid login URL"); + let login_request = ApiLoginRequest::new(&self.config.username, &self.config.password); + let login_response = self.client.post(login_url).json(&login_request).send().await?; + let login_response_data = match login_response.error_for_status() { + Ok(res) => res.json::().await?.data, + Err(err) => return Err(err), + }; + // Insert the token in the reponse data as the cookie `hm_token` into the cookie jar. + let cookie = format!("hm_token={}", login_response_data.token); + self.cookie_jar.add_cookie_str(&cookie, &base_url); + + Ok(()) + } + + /// Retrieves a status update from the API of the Hoymiles site. + /// + /// It needs the cookies from the login to be able to perform the action. + /// It uses a endpoint to construct the [`Status`] struct, but it needs to summarize the today + /// value with the total value because Hoymiles only includes it after the day has finished. + async fn update(&self, last_updated: u64) -> Result { + let api_url = api_url().expect("valid API power URL"); + let api_data_request = ApiDataRequest::new(self.config.sid); + let api_response = self.client.post(api_url).json(&api_data_request).send().await?; + let api_data = match api_response.error_for_status() { + Ok(res) => res.json::().await?.data, + Err(err) => return Err(err), + }; + let current_w = api_data.real_power; + let total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0; + + Ok(Status { + current_w, + total_kwh, + last_updated, + }) + } +}