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:
commit
9c9a348a53
|
@ -814,6 +814,15 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
|
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]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
@ -1513,6 +1522,7 @@ version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"enum_dispatch",
|
"enum_dispatch",
|
||||||
|
"md-5",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rocket",
|
"rocket",
|
||||||
|
|
|
@ -14,6 +14,7 @@ license = "MIT"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
color-eyre = "0.6.2"
|
color-eyre = "0.6.2"
|
||||||
enum_dispatch = "0.3.9"
|
enum_dispatch = "0.3.9"
|
||||||
|
md-5 = "0.10.5"
|
||||||
once_cell = "1.9.0"
|
once_cell = "1.9.0"
|
||||||
reqwest = { version = "0.11.6", features = ["cookies", "json"] }
|
reqwest = { version = "0.11.6", features = ["cookies", "json"] }
|
||||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||||
|
|
14
README.md
14
README.md
|
@ -3,18 +3,24 @@
|
||||||
Solar Grabber is a web service that provides a REST API layer over various
|
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.
|
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
|
## Building & running
|
||||||
|
|
||||||
First, you need to provide settings in the file `Rocket.toml` by setting the
|
First, you need to provide settings in the file `Rocket.toml` by setting the
|
||||||
username, password and other cloud service-specific settings.
|
username, password and other cloud service-specific settings.
|
||||||
You can copy and modify `Rocket.toml.example` for this.
|
You can copy and modify `Rocket.toml.example` for this and uncomment the part
|
||||||
For example for My Autarco:
|
relevant for the service you want to use.
|
||||||
|
For example, to configure Solar Grabber to use the My Autarco service:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[default]
|
[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]
|
[default.service]
|
||||||
kind = "MyAutarco"
|
kind = "MyAutarco"
|
||||||
username = "foo@domain.tld"
|
username = "foo@domain.tld"
|
||||||
|
@ -62,7 +68,7 @@ GET /
|
||||||
A response uses the JSON format and typically looks like this:
|
A response uses the JSON format and typically looks like this:
|
||||||
|
|
||||||
```json
|
```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,
|
This contains the current production power (`current_w`) in Watt,
|
||||||
|
|
|
@ -2,8 +2,16 @@
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 2356
|
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]
|
[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"
|
# kind = "MyAutarco"
|
||||||
# username = "foo@domain.tld"
|
# username = "foo@domain.tld"
|
||||||
# password = "secret"
|
# password = "secret"
|
||||||
|
|
|
@ -41,9 +41,9 @@ struct Config {
|
||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
struct Status {
|
struct Status {
|
||||||
/// Current power production (W)
|
/// Current power production (W)
|
||||||
current_w: u32,
|
current_w: f32,
|
||||||
/// Total energy produced since installation (kWh)
|
/// Total energy produced since installation (kWh)
|
||||||
total_kwh: u32,
|
total_kwh: f32,
|
||||||
/// Timestamp of last update
|
/// Timestamp of last update
|
||||||
last_updated: u64,
|
last_updated: u64,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
//! The supported cloud services.
|
//! The supported cloud services.
|
||||||
|
|
||||||
|
pub(crate) mod hoymiles;
|
||||||
pub(crate) mod my_autarco;
|
pub(crate) mod my_autarco;
|
||||||
|
|
||||||
use enum_dispatch::enum_dispatch;
|
use enum_dispatch::enum_dispatch;
|
||||||
|
@ -12,13 +13,16 @@ use crate::Status;
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(crate = "rocket::serde", tag = "kind")]
|
#[serde(crate = "rocket::serde", tag = "kind")]
|
||||||
pub(crate) enum Config {
|
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),
|
MyAutarco(my_autarco::Config),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the service for the provided name (if supported).
|
/// Retrieves the service for the provided name (if supported).
|
||||||
pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
|
pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
|
||||||
match config {
|
match config {
|
||||||
|
Config::Hoymiles(config) => Ok(Services::Hoymiles(hoymiles::service(config)?)),
|
||||||
Config::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::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.
|
/// The supported cloud services.
|
||||||
#[enum_dispatch(Service)]
|
#[enum_dispatch(Service)]
|
||||||
pub(crate) enum Services {
|
pub(crate) enum Services {
|
||||||
|
/// Hoymiles (<https://global.hoymiles.com>)
|
||||||
|
Hoymiles(hoymiles::Service),
|
||||||
/// My Autarco (<https://my.autarco.com>)
|
/// My Autarco (<https://my.autarco.com>)
|
||||||
MyAutarco(my_autarco::Service),
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -119,8 +119,8 @@ impl super::Service for Service {
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Status {
|
Ok(Status {
|
||||||
current_w: api_power.pv_now,
|
current_w: api_power.pv_now as f32,
|
||||||
total_kwh: api_energy.pv_to_date,
|
total_kwh: api_energy.pv_to_date as f32,
|
||||||
last_updated,
|
last_updated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue