Add first version of the Hoymiles service
This commit is contained in:
parent
2883f52249
commit
b1dfea651f
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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 (<https://global.hoymiles.com>)
|
||||
Hoymiles(hoymiles::Config),
|
||||
/// My Autarco (<https://my.autarco.com>)
|
||||
MyAutarco(my_autarco::Config),
|
||||
}
|
||||
|
||||
/// Retrieves the service for the provided name (if supported).
|
||||
pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
|
||||
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<Services> {
|
|||
/// The supported cloud services.
|
||||
#[enum_dispatch(Service)]
|
||||
pub(crate) enum Services {
|
||||
/// Hoymiles (<https://global.hoymiles.com>)
|
||||
Hoymiles(hoymiles::Service),
|
||||
/// My Autarco (<https://my.autarco.com>)
|
||||
MyAutarco(my_autarco::Service),
|
||||
}
|
||||
|
|
|
@ -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: <https://global.hoymiles.com>.
|
||||
|
||||
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<Service, reqwest::Error> {
|
||||
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<CookieJar>,
|
||||
}
|
||||
|
||||
/// Returns the login URL for the Hoymiles site.
|
||||
fn login_url() -> Result<Url, ParseError> {
|
||||
Url::parse(&format!("{BASE_URL}/iam/auth_login"))
|
||||
}
|
||||
|
||||
/// Returns an API endpoint URL for for the Hoymiles site.
|
||||
fn api_url() -> Result<Url, ParseError> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Deserializes a string ([`&str`]) into a float ([`f32`]).
|
||||
fn from_float_str<'de, D>(deserializer: D) -> Result<f32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
D::Error: serde::de::Error,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
let s = <&str>::deserialize(deserializer)?;
|
||||
s.parse::<f32>().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::<ApiLoginResponse>().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<Status, reqwest::Error> {
|
||||
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::<ApiDataResponse>().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,
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue