399 lines
13 KiB
Rust
399 lines
13 KiB
Rust
//! 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 chrono::{DateTime, Local, NaiveDateTime, TimeZone};
|
|
use md5::{Digest, Md5};
|
|
use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url};
|
|
use rocket::{
|
|
async_trait,
|
|
serde::{json::Value as JsonValue, Deserialize, Deserializer, Serialize},
|
|
};
|
|
use url::ParseError;
|
|
|
|
use crate::{
|
|
services::{Error, Result},
|
|
Status,
|
|
};
|
|
|
|
/// The base URL of Hoymiles API gateway.
|
|
const BASE_URL: &str = "https://global.hoymiles.com/platform/api/gateway";
|
|
|
|
/// The date/time format used by the Hoymiles API.
|
|
const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
|
|
|
/// The language to switch the API to.
|
|
///
|
|
/// If not set, it seems it uses `zh_cn`.
|
|
const LANGUAGE: &str = "en_us";
|
|
|
|
/// The interval between data polls (in seconds).
|
|
const POLL_INTERVAL: u64 = 300;
|
|
|
|
/// The configuration necessary to access the Hoymiles.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
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> {
|
|
let cookie_jar = Arc::new(CookieJar::default());
|
|
let client = ClientBuilder::new()
|
|
.cookie_provider(Arc::clone(&cookie_jar))
|
|
.build()?;
|
|
let total_kwh = 0f32;
|
|
let service = Service {
|
|
client,
|
|
config,
|
|
cookie_jar,
|
|
total_kwh,
|
|
};
|
|
|
|
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>,
|
|
/// The last known total produced energy value.
|
|
total_kwh: f32,
|
|
}
|
|
|
|
/// 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"))
|
|
}
|
|
|
|
/// Captures JSON values that can either be a string or an object.
|
|
///
|
|
/// This is used for the API responses where the data field is either an object or an empty string
|
|
/// instead of `null`. If the response is not deserializable object, the JSON value is preserved
|
|
/// for debugging purposes.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde", untagged)]
|
|
enum StringOrObject<'a, T> {
|
|
/// The value is an object (deserializable as type `T`).
|
|
Object(T),
|
|
/// The value is a string.
|
|
String(&'a str),
|
|
/// The value is not some JSON value not deserializable as type `T`.
|
|
Value(JsonValue),
|
|
}
|
|
|
|
/// Deserialize either a string or an object as an option of type `T`.
|
|
fn from_empty_str_or_object<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
D::Error: rocket::serde::de::Error,
|
|
T: Deserialize<'de>,
|
|
{
|
|
use rocket::serde::de::Error;
|
|
|
|
match <StringOrObject<'_, T>>::deserialize(deserializer) {
|
|
Ok(StringOrObject::String(s)) if s.is_empty() => Ok(None),
|
|
Ok(StringOrObject::String(_)) => Err(Error::custom("Non-empty string not allowed here")),
|
|
Ok(StringOrObject::Object(t)) => Ok(Some(t)),
|
|
Ok(StringOrObject::Value(j)) => Err(Error::custom(&format!(
|
|
"Undeserializable JSON object: {}",
|
|
j
|
|
))),
|
|
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
/// Deserialize a string ([`&str`]) into a date/time ([`DateTime<Local>`]).
|
|
fn from_date_time_str<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
D::Error: rocket::serde::de::Error,
|
|
{
|
|
use rocket::serde::de::Error;
|
|
|
|
let s = <&str>::deserialize(deserializer)?;
|
|
let dt = NaiveDateTime::parse_from_str(s, DATE_TIME_FORMAT).map_err(D::Error::custom)?;
|
|
|
|
Local
|
|
.from_local_datetime(&dt)
|
|
.latest()
|
|
.ok_or_else(|| D::Error::custom("time representation is invalid for server time zone"))
|
|
}
|
|
|
|
/// Deserializes a string ([`&str`]) into a float ([`f32`]).
|
|
///
|
|
/// This is used for the API responses where the value is a float put into a string.
|
|
fn from_float_str<'de, D>(deserializer: D) -> Result<f32, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
D::Error: rocket::serde::de::Error,
|
|
{
|
|
use rocket::serde::de::Error;
|
|
|
|
let s = <&str>::deserialize(deserializer)?;
|
|
s.parse::<f32>().map_err(D::Error::custom)
|
|
}
|
|
|
|
/// Deserializes a string ([`&str`]) into an integer ([`u16`]).
|
|
///
|
|
/// This is used for the API responses where the value is an integer put into a string.
|
|
fn from_integer_str<'de, D>(deserializer: D) -> Result<u16, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
D::Error: rocket::serde::de::Error,
|
|
{
|
|
use rocket::serde::de::Error;
|
|
|
|
let s = <&str>::deserialize(deserializer)?;
|
|
s.parse::<u16>().map_err(D::Error::custom)
|
|
}
|
|
|
|
/// The request passed to the API login endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiLoginRequest {
|
|
/// The body of the API login request.
|
|
body: ApiLoginRequestBody,
|
|
}
|
|
|
|
impl ApiLoginRequest {
|
|
/// Creates a new API login request.
|
|
fn new(username: &str, password: &str) -> Self {
|
|
let user_name = username.to_owned();
|
|
let mut hasher = Md5::new();
|
|
hasher.update(password.as_bytes());
|
|
let password = format!("{:x}", hasher.finalize());
|
|
|
|
let body = ApiLoginRequestBody {
|
|
user_name,
|
|
password,
|
|
};
|
|
|
|
Self { body }
|
|
}
|
|
}
|
|
|
|
/// The request body passed to the API login endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiLoginRequestBody {
|
|
/// The username to login with.
|
|
password: String,
|
|
/// The password to login with.
|
|
user_name: String,
|
|
}
|
|
|
|
/// The response returned by the API login endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiLoginResponse {
|
|
/// The status (error) code as a string: 0 for OK, another number for error.
|
|
#[serde(deserialize_with = "from_integer_str")]
|
|
status: u16,
|
|
/// The status message.
|
|
message: String,
|
|
/// The embedded response data.
|
|
#[serde(deserialize_with = "from_empty_str_or_object")]
|
|
data: Option<ApiLoginResponseData>,
|
|
// systemNotice: Option<String>,
|
|
}
|
|
|
|
/// The response data returned by the API login endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
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)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiDataRequest {
|
|
/// The body of the API data request.
|
|
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)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiDataRequestBody {
|
|
/// The ID of the Hoymiles station.
|
|
sid: u32,
|
|
}
|
|
|
|
/// The response returned by the API data endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
struct ApiDataResponse {
|
|
/// The status (error) code as a string: 0 for OK, another number for error.
|
|
#[serde(deserialize_with = "from_integer_str")]
|
|
status: u16,
|
|
/// The status message.
|
|
message: String,
|
|
/// The embedded response data.
|
|
#[serde(deserialize_with = "from_empty_str_or_object")]
|
|
data: Option<ApiDataResponseData>,
|
|
// systemNotice: Option<String>,
|
|
}
|
|
|
|
/// The response data returned by the API data endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(crate = "rocket::serde")]
|
|
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,
|
|
#[serde(deserialize_with = "from_date_time_str")]
|
|
last_data_time: DateTime<Local>,
|
|
// 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 information from the invertor about every 15 minutes. Since this is not
|
|
/// really exact, we need to poll at a higher rate to detect changes faster!
|
|
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(&mut self) -> Result<()> {
|
|
let base_url = Url::parse(BASE_URL).expect("valid base URL");
|
|
let login_url = login_url().expect("valid login URL");
|
|
|
|
// Insert the cookie `hm_token_language` to specific the API language into the cookie jar.
|
|
let lang_cookie = format!("hm_token_language={}", LANGUAGE);
|
|
self.cookie_jar.add_cookie_str(&lang_cookie, &base_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) => {
|
|
let login_response = res.json::<ApiLoginResponse>().await?;
|
|
match login_response.status {
|
|
0 => login_response.data.expect("No API response data found"),
|
|
1 => return Err(Error::NotAuthorized),
|
|
_ => {
|
|
return Err(Error::Response(format!(
|
|
"{} ({})",
|
|
login_response.message, login_response.status
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
// Insert the token in the reponse data as the cookie `hm_token` into the cookie jar.
|
|
let token_cookie = format!("hm_token={}", login_response_data.token);
|
|
self.cookie_jar.add_cookie_str(&token_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(&mut self, _last_updated: u64) -> Result<Status> {
|
|
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) => {
|
|
let api_response = res.json::<ApiDataResponse>().await?;
|
|
match api_response.status {
|
|
0 => api_response.data.expect("No API response data found"),
|
|
1 | 100 => return Err(Error::NotAuthorized),
|
|
_ => {
|
|
return Err(Error::Response(format!(
|
|
"{} ({})",
|
|
api_response.message, api_response.status
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
let current_w = api_data.real_power;
|
|
let mut total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0;
|
|
let last_updated = api_data.last_data_time.timestamp() as u64;
|
|
|
|
// Sometimes it can be that `today_eq` is reset when the day switches but it has not been
|
|
// added to `total_eq` yet. The `total_eq` should always be non-decreasing, so return the
|
|
// last known value until this is corrected (this most suredly happens during the night).
|
|
if total_kwh <= self.total_kwh {
|
|
total_kwh = self.total_kwh
|
|
} else {
|
|
self.total_kwh = total_kwh;
|
|
}
|
|
|
|
Ok(Status {
|
|
current_w,
|
|
total_kwh,
|
|
last_updated,
|
|
})
|
|
}
|
|
}
|