Merge pull request 'Implement Hoymiles service' (#6) from 1-hoymiles-service into main

Add support for retrieving solar panel data from Hoymiles.

* Add the Hoymiles service
* Update the documentation
* Add a depend on the `md-5` crate for password hashing

Reviewed-on: #6
This commit is contained in:
Paul van Tilburg 2023-01-10 15:50:30 +01:00
commit 9c9a348a53
8 changed files with 277 additions and 10 deletions

10
Cargo.lock generated
View File

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

View File

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

View File

@ -3,18 +3,24 @@
Solar Grabber is a web service that provides a REST API layer over various
cloud sites/services/APIs to get statistical data of your solar panels.
The services that are currently supported are
[Hoymiles](https://global.hoymiles.com) and
[My Autarco](https://my.autarco.com).
## Building & running
First, you need to provide settings in the file `Rocket.toml` by setting the
username, password and other cloud service-specific settings.
You can copy and modify `Rocket.toml.example` for this.
For example for My Autarco:
You can copy and modify `Rocket.toml.example` for this and uncomment the part
relevant for the service you want to use.
For example, to configure Solar Grabber to use the My Autarco service:
```toml
[default]
# ...
# Put your solar cloud service settings below and uncomment them
# Put your solar cloud service settings below and uncomment them based on the
# service you want to use.
[default.service]
kind = "MyAutarco"
username = "foo@domain.tld"
@ -62,7 +68,7 @@ GET /
A response uses the JSON format and typically looks like this:
```json
{"current_w":23,"total_kwh":6159,"last_updated":1661194620}
{"current_w":23.0,"total_kwh":6159.0,"last_updated":1661194620}
```
This contains the current production power (`current_w`) in Watt,

View File

@ -2,8 +2,16 @@
address = "0.0.0.0"
port = 2356
# Put your solar cloud service settings below and uncomment them
# Put your solar cloud service settings below and uncomment them based on the
# service you want to use.
[default.service]
# For Hoymiles, use the following settings:
# kind = "Hoymiles"
# username = "username"
# password = "secret"
# sid = 123456
# For My Autarco, use the following settings:
# kind = "MyAutarco"
# username = "foo@domain.tld"
# password = "secret"

View File

@ -41,9 +41,9 @@ struct Config {
#[derive(Clone, Copy, Debug, Serialize)]
struct Status {
/// Current power production (W)
current_w: u32,
current_w: f32,
/// Total energy produced since installation (kWh)
total_kwh: u32,
total_kwh: f32,
/// Timestamp of last update
last_updated: u64,
}

View File

@ -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),
}

236
src/services/hoymiles.rs Normal file
View File

@ -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,
})
}
}

View File

@ -119,8 +119,8 @@ impl super::Service for Service {
};
Ok(Status {
current_w: api_power.pv_now,
total_kwh: api_energy.pv_to_date,
current_w: api_power.pv_now as f32,
total_kwh: api_energy.pv_to_date as f32,
last_updated,
})
}