Compare commits
18 Commits
Author | SHA1 | Date |
---|---|---|
Paul van Tilburg | 35209b6303 | |
Paul van Tilburg | 6707928e37 | |
Paul van Tilburg | e6b0357670 | |
Paul van Tilburg | 365b847313 | |
Paul van Tilburg | e1d70e8a59 | |
Paul van Tilburg | e268a6ebca | |
Paul van Tilburg | 93e8295c96 | |
Paul van Tilburg | 1d35b88aba | |
Paul van Tilburg | ddcb375345 | |
Paul van Tilburg | e0151c3cde | |
Paul van Tilburg | ef13f7e4f2 | |
Paul van Tilburg | d787c8b3ab | |
Paul van Tilburg | 5a2889a0f2 | |
Paul van Tilburg | 18b52cd422 | |
Paul van Tilburg | 70b117d11d | |
Paul van Tilburg | 2b5a64b6b0 | |
Paul van Tilburg | 01416ee136 | |
Paul van Tilburg | 536b1564b9 |
22
CHANGELOG.md
22
CHANGELOG.md
|
@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.1] - 2023-01-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* Change poll interval for Hoymiles to 5 minutes
|
||||||
|
* Catch and raise error when Hoymiles API data responses cannot be deserialized
|
||||||
|
* Use stderr for error messages (and change prefix emoji)
|
||||||
|
* Use the `serde` crate via Rocket,; drop depend on the `serde` crate itself
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* Also set the state class in HA sensors example
|
||||||
|
* Improve deserialization of Hoymiles API responses (#7)
|
||||||
|
* Prevent total energy reported decreasing for Hoymiles (#7)
|
||||||
|
* Set correct `last_updated` field in status report for Hoymiles (#7)
|
||||||
|
* Set cookie to configure Hoymiles API language to English (#7)
|
||||||
|
* Detect when Hoymiles (login/data) API response are not correct (#7)
|
||||||
|
* Small formatting, error message and documentation fixes
|
||||||
|
|
||||||
## [0.2.0] - 2023-01-13
|
## [0.2.0] - 2023-01-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -29,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
Rename Autarco Scraper project to Solar Grabber.
|
Rename Autarco Scraper project to Solar Grabber.
|
||||||
|
|
||||||
[Unreleased]: https://git.luon.net/paul/solar-grabber/compare/v0.2.0...HEAD
|
[Unreleased]: https://git.luon.net/paul/solar-grabber/compare/v0.2.1...HEAD
|
||||||
|
[0.2.1]: https://git.luon.net/paul/solar-grabber/compare/v0.2.0...v0.2.1
|
||||||
[0.2.0]: https://git.luon.net/paul/solar-grabber/compare/v0.1.1...v0.2.0
|
[0.2.0]: https://git.luon.net/paul/solar-grabber/compare/v0.1.1...v0.2.0
|
||||||
[0.1.1]: https://git.luon.net/paul/solar-grabber/src/tag/v0.1.1
|
[0.1.1]: https://git.luon.net/paul/solar-grabber/src/tag/v0.1.1
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "solar-grabber"
|
name = "solar-grabber"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
authors = ["Paul van Tilburg <paul@luon.net>"]
|
authors = ["Paul van Tilburg <paul@luon.net>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = """"
|
description = """"
|
||||||
|
@ -12,13 +12,14 @@ repository = "https://git.luon.net/paul/solar-grabber"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.23", features = ["serde"] }
|
||||||
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"
|
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"] }
|
||||||
serde = "1.0.116"
|
thiserror = "1.0.38"
|
||||||
toml = "0.5.6"
|
toml = "0.5.6"
|
||||||
url = "2.2.2"
|
url = "2.2.2"
|
||||||
|
|
||||||
|
|
|
@ -134,6 +134,7 @@ sensors:
|
||||||
value_template: '{{ value_json.current_w }}'
|
value_template: '{{ value_json.current_w }}'
|
||||||
unit_of_measurement: W
|
unit_of_measurement: W
|
||||||
device_class: power
|
device_class: power
|
||||||
|
state_class: measurement
|
||||||
|
|
||||||
- platform: rest
|
- platform: rest
|
||||||
name: "Photovoltaic Invertor Total Energy Production"
|
name: "Photovoltaic Invertor Total Energy Production"
|
||||||
|
@ -141,6 +142,7 @@ sensors:
|
||||||
value_template: '{{ value_json.total_kwh }}'
|
value_template: '{{ value_json.total_kwh }}'
|
||||||
unit_of_measurement: kWh
|
unit_of_measurement: kWh
|
||||||
device_class: energy
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
```
|
```
|
||||||
|
|
||||||
This assumes your Solar Grabber is running at <http://solar-grabber.domain.tld:2399>.
|
This assumes your Solar Grabber is running at <http://solar-grabber.domain.tld:2399>.
|
||||||
|
|
|
@ -22,8 +22,11 @@ use std::sync::Mutex;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rocket::fairing::AdHoc;
|
use rocket::fairing::AdHoc;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::{get, routes, Build, Rocket};
|
use rocket::{
|
||||||
use serde::{Deserialize, Serialize};
|
get, routes,
|
||||||
|
serde::{Deserialize, Serialize},
|
||||||
|
Build, Rocket,
|
||||||
|
};
|
||||||
|
|
||||||
use self::update::update_loop;
|
use self::update::update_loop;
|
||||||
|
|
||||||
|
@ -32,6 +35,7 @@ static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
/// The configuration loaded additionally by Rocket.
|
/// The configuration loaded additionally by Rocket.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct Config {
|
struct Config {
|
||||||
/// The service-specific configuration
|
/// The service-specific configuration
|
||||||
service: services::Config,
|
service: services::Config,
|
||||||
|
@ -39,6 +43,7 @@ struct Config {
|
||||||
|
|
||||||
/// The current photovoltaic invertor status.
|
/// The current photovoltaic invertor status.
|
||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct Status {
|
struct Status {
|
||||||
/// Current power production (W)
|
/// Current power production (W)
|
||||||
current_w: f32,
|
current_w: f32,
|
||||||
|
|
|
@ -4,8 +4,7 @@ pub(crate) mod hoymiles;
|
||||||
pub(crate) mod my_autarco;
|
pub(crate) mod my_autarco;
|
||||||
|
|
||||||
use enum_dispatch::enum_dispatch;
|
use enum_dispatch::enum_dispatch;
|
||||||
use rocket::async_trait;
|
use rocket::{async_trait, serde::Deserialize};
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::Status;
|
use crate::Status;
|
||||||
|
|
||||||
|
@ -27,6 +26,25 @@ pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The errors that can occur during service API transactions.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub(crate) enum Error {
|
||||||
|
/// The service is not or no longer authorized to perform requests.
|
||||||
|
///
|
||||||
|
/// This usually indicates that the service needs to login again.
|
||||||
|
#[error("not/no longer authorized")]
|
||||||
|
NotAuthorized,
|
||||||
|
/// The service encountered some other API request error.
|
||||||
|
#[error("API request error: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
/// The service encountered an unsupported API response.
|
||||||
|
#[error("API service error: {0}")]
|
||||||
|
Response(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias for service results.
|
||||||
|
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
/// The supported cloud services.
|
/// The supported cloud services.
|
||||||
#[enum_dispatch(Service)]
|
#[enum_dispatch(Service)]
|
||||||
pub(crate) enum Services {
|
pub(crate) enum Services {
|
||||||
|
@ -44,8 +62,8 @@ pub(crate) trait Service {
|
||||||
fn poll_interval(&self) -> u64;
|
fn poll_interval(&self) -> u64;
|
||||||
|
|
||||||
/// Perfoms a login on the cloud service (if necessary).
|
/// Perfoms a login on the cloud service (if necessary).
|
||||||
async fn login(&self) -> Result<(), reqwest::Error>;
|
async fn login(&mut self) -> Result<()>;
|
||||||
|
|
||||||
/// Retrieves a status update using the API of the cloud service.
|
/// Retrieves a status update using the API of the cloud service.
|
||||||
async fn update(&self, timestamp: u64) -> Result<Status, reqwest::Error>;
|
async fn update(&mut self, timestamp: u64) -> Result<Status>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,22 +6,37 @@
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local, TimeZone};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url};
|
use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url};
|
||||||
use rocket::async_trait;
|
use rocket::{
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
async_trait,
|
||||||
|
serde::{json::Value as JsonValue, Deserialize, Deserializer, Serialize},
|
||||||
|
};
|
||||||
use url::ParseError;
|
use url::ParseError;
|
||||||
|
|
||||||
use crate::Status;
|
use crate::{
|
||||||
|
services::{Error, Result},
|
||||||
|
Status,
|
||||||
|
};
|
||||||
|
|
||||||
/// The base URL of Hoymiles API gateway.
|
/// The base URL of Hoymiles API gateway.
|
||||||
const BASE_URL: &str = "https://global.hoymiles.com/platform/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).
|
/// The interval between data polls (in seconds).
|
||||||
const POLL_INTERVAL: u64 = 900;
|
const POLL_INTERVAL: u64 = 300;
|
||||||
|
|
||||||
/// The configuration necessary to access the Hoymiles.
|
/// The configuration necessary to access the Hoymiles.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
pub(crate) struct Config {
|
pub(crate) struct Config {
|
||||||
/// The username of the account to login with
|
/// The username of the account to login with
|
||||||
username: String,
|
username: String,
|
||||||
|
@ -32,15 +47,17 @@ pub(crate) struct Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instantiates the Hoymiles service.
|
/// Instantiates the Hoymiles service.
|
||||||
pub(crate) fn service(config: Config) -> Result<Service, reqwest::Error> {
|
pub(crate) fn service(config: Config) -> Result<Service> {
|
||||||
let cookie_jar = Arc::new(CookieJar::default());
|
let cookie_jar = Arc::new(CookieJar::default());
|
||||||
let client = ClientBuilder::new()
|
let client = ClientBuilder::new()
|
||||||
.cookie_provider(Arc::clone(&cookie_jar))
|
.cookie_provider(Arc::clone(&cookie_jar))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
let total_kwh = 0f32;
|
||||||
let service = Service {
|
let service = Service {
|
||||||
client,
|
client,
|
||||||
config,
|
config,
|
||||||
cookie_jar,
|
cookie_jar,
|
||||||
|
total_kwh,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(service)
|
Ok(service)
|
||||||
|
@ -55,6 +72,8 @@ pub(crate) struct Service {
|
||||||
config: Config,
|
config: Config,
|
||||||
/// The cookie jar used for API requests.
|
/// The cookie jar used for API requests.
|
||||||
cookie_jar: Arc<CookieJar>,
|
cookie_jar: Arc<CookieJar>,
|
||||||
|
/// The last known total produced energy value.
|
||||||
|
total_kwh: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the login URL for the Hoymiles site.
|
/// Returns the login URL for the Hoymiles site.
|
||||||
|
@ -67,22 +86,104 @@ fn api_url() -> Result<Url, ParseError> {
|
||||||
Url::parse(&format!("{BASE_URL}/pvm-data/data_count_station_real_data"))
|
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)?;
|
||||||
|
Local
|
||||||
|
.datetime_from_str(s, DATE_TIME_FORMAT)
|
||||||
|
.map_err(D::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// The request passed to the API login endpoint.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiLoginRequest {
|
struct ApiLoginRequest {
|
||||||
|
/// The body of the API login request.
|
||||||
body: ApiLoginRequestBody,
|
body: ApiLoginRequestBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiLoginRequest {
|
impl ApiLoginRequest {
|
||||||
/// Creates a new API login request.
|
/// Creates a new API login request.
|
||||||
fn new(username: &str, password: &str) -> Self {
|
fn new(username: &str, password: &str) -> Self {
|
||||||
|
let user_name = username.to_owned();
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
hasher.update(password.as_bytes());
|
hasher.update(password.as_bytes());
|
||||||
let password = format!("{:x}", hasher.finalize());
|
let password = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
// TODO: Hash the password!
|
|
||||||
let body = ApiLoginRequestBody {
|
let body = ApiLoginRequestBody {
|
||||||
user_name: username.to_owned(),
|
user_name,
|
||||||
password,
|
password,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -92,23 +193,32 @@ impl ApiLoginRequest {
|
||||||
|
|
||||||
/// The request body passed to the API login endpoint.
|
/// The request body passed to the API login endpoint.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiLoginRequestBody {
|
struct ApiLoginRequestBody {
|
||||||
|
/// The username to login with.
|
||||||
password: String,
|
password: String,
|
||||||
|
/// The password to login with.
|
||||||
user_name: String,
|
user_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The response returned by the API login endpoint.
|
/// The response returned by the API login endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiLoginResponse {
|
struct ApiLoginResponse {
|
||||||
// status: String,
|
/// The status (error) code as a string: 0 for OK, another number for error.
|
||||||
// message: String,
|
#[serde(deserialize_with = "from_integer_str")]
|
||||||
/// The embedded response data
|
status: u16,
|
||||||
data: ApiLoginResponseData,
|
/// The status message.
|
||||||
|
message: String,
|
||||||
|
/// The embedded response data.
|
||||||
|
#[serde(deserialize_with = "from_empty_str_or_object")]
|
||||||
|
data: Option<ApiLoginResponseData>,
|
||||||
// systemNotice: Option<String>,
|
// systemNotice: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The response data returned by the API login endpoint.
|
/// The response data returned by the API login endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiLoginResponseData {
|
struct ApiLoginResponseData {
|
||||||
/// The token to be used as cookie for API data requests.
|
/// The token to be used as cookie for API data requests.
|
||||||
token: String,
|
token: String,
|
||||||
|
@ -116,7 +226,9 @@ struct ApiLoginResponseData {
|
||||||
|
|
||||||
/// The request passed to the API data endpoint.
|
/// The request passed to the API data endpoint.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiDataRequest {
|
struct ApiDataRequest {
|
||||||
|
/// The body of the API data request.
|
||||||
body: ApiDataRequestBody,
|
body: ApiDataRequestBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,34 +243,30 @@ impl ApiDataRequest {
|
||||||
|
|
||||||
/// The request body passed to the API data endpoint.
|
/// The request body passed to the API data endpoint.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiDataRequestBody {
|
struct ApiDataRequestBody {
|
||||||
|
/// The ID of the Hoymiles station.
|
||||||
sid: u32,
|
sid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The response returned by the API data endpoint.
|
/// The response returned by the API data endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiDataResponse {
|
struct ApiDataResponse {
|
||||||
// status: String,
|
/// The status (error) code as a string: 0 for OK, another number for error.
|
||||||
// message: String,
|
#[serde(deserialize_with = "from_integer_str")]
|
||||||
// /// The embedded response data
|
status: u16,
|
||||||
data: ApiDataResponseData,
|
/// The status message.
|
||||||
|
message: String,
|
||||||
|
/// The embedded response data.
|
||||||
|
#[serde(deserialize_with = "from_empty_str_or_object")]
|
||||||
|
data: Option<ApiDataResponseData>,
|
||||||
// systemNotice: Option<String>,
|
// 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.
|
/// The response data returned by the API data endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiDataResponseData {
|
struct ApiDataResponseData {
|
||||||
/// Energy produced today (Wh)
|
/// Energy produced today (Wh)
|
||||||
#[serde(deserialize_with = "from_float_str")]
|
#[serde(deserialize_with = "from_float_str")]
|
||||||
|
@ -174,7 +282,8 @@ struct ApiDataResponseData {
|
||||||
// co2_emission_reducation: f32,
|
// co2_emission_reducation: f32,
|
||||||
// plant_tree: u32,
|
// plant_tree: u32,
|
||||||
// data_time: String,
|
// data_time: String,
|
||||||
// last_data_time: String,
|
#[serde(deserialize_with = "from_date_time_str")]
|
||||||
|
last_data_time: DateTime<Local>,
|
||||||
// capacitor: f32,
|
// capacitor: f32,
|
||||||
// is_balance: bool,
|
// is_balance: bool,
|
||||||
// is_reflux: bool,
|
// is_reflux: bool,
|
||||||
|
@ -185,7 +294,8 @@ struct ApiDataResponseData {
|
||||||
impl super::Service for Service {
|
impl super::Service for Service {
|
||||||
/// The interval between data polls (in seconds).
|
/// The interval between data polls (in seconds).
|
||||||
///
|
///
|
||||||
/// Hoymiles processes provides information from the invertor every 15 minutes.
|
/// 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 {
|
fn poll_interval(&self) -> u64 {
|
||||||
POLL_INTERVAL
|
POLL_INTERVAL
|
||||||
}
|
}
|
||||||
|
@ -195,18 +305,40 @@ impl super::Service for Service {
|
||||||
/// It mainly stores the acquired cookies in the client's cookie jar and adds the token cookie
|
/// 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
|
/// provided by the logins response. The login credentials come from the loaded configuration
|
||||||
/// (see [`Config`]).
|
/// (see [`Config`]).
|
||||||
async fn login(&self) -> Result<(), reqwest::Error> {
|
async fn login(&mut self) -> Result<()> {
|
||||||
let base_url = Url::parse(BASE_URL).expect("valid base URL");
|
let base_url = Url::parse(BASE_URL).expect("valid base URL");
|
||||||
let login_url = login_url().expect("valid login 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_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 = self
|
||||||
|
.client
|
||||||
|
.post(login_url)
|
||||||
|
.json(&login_request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
let login_response_data = match login_response.error_for_status() {
|
let login_response_data = match login_response.error_for_status() {
|
||||||
Ok(res) => res.json::<ApiLoginResponse>().await?.data,
|
Ok(res) => {
|
||||||
Err(err) => return Err(err),
|
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.
|
// 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);
|
let token_cookie = format!("hm_token={}", login_response_data.token);
|
||||||
self.cookie_jar.add_cookie_str(&cookie, &base_url);
|
self.cookie_jar.add_cookie_str(&token_cookie, &base_url);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -216,16 +348,43 @@ impl super::Service for Service {
|
||||||
/// It needs the cookies from the login to be able to perform the action.
|
/// 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
|
/// 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.
|
/// 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> {
|
async fn update(&mut self, _last_updated: u64) -> Result<Status> {
|
||||||
let api_url = api_url().expect("valid API power URL");
|
let api_url = api_url().expect("valid API power URL");
|
||||||
let api_data_request = ApiDataRequest::new(self.config.sid);
|
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_response = self
|
||||||
|
.client
|
||||||
|
.post(api_url)
|
||||||
|
.json(&api_data_request)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
let api_data = match api_response.error_for_status() {
|
let api_data = match api_response.error_for_status() {
|
||||||
Ok(res) => res.json::<ApiDataResponse>().await?.data,
|
Ok(res) => {
|
||||||
Err(err) => return Err(err),
|
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 current_w = api_data.real_power;
|
||||||
let total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0;
|
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 {
|
Ok(Status {
|
||||||
current_w,
|
current_w,
|
||||||
|
|
|
@ -4,12 +4,14 @@
|
||||||
//! to retrieve the energy data (using the session cookies).
|
//! to retrieve the energy data (using the session cookies).
|
||||||
//! See also: <https://my.autarco.com>.
|
//! See also: <https://my.autarco.com>.
|
||||||
|
|
||||||
use reqwest::{Client, ClientBuilder, Url};
|
use reqwest::{Client, ClientBuilder, StatusCode, Url};
|
||||||
use rocket::async_trait;
|
use rocket::{async_trait, serde::Deserialize};
|
||||||
use serde::Deserialize;
|
|
||||||
use url::ParseError;
|
use url::ParseError;
|
||||||
|
|
||||||
use crate::Status;
|
use crate::{
|
||||||
|
services::{Error, Result},
|
||||||
|
Status,
|
||||||
|
};
|
||||||
|
|
||||||
/// The base URL of My Autarco site.
|
/// The base URL of My Autarco site.
|
||||||
const BASE_URL: &str = "https://my.autarco.com";
|
const BASE_URL: &str = "https://my.autarco.com";
|
||||||
|
@ -19,6 +21,7 @@ const POLL_INTERVAL: u64 = 300;
|
||||||
|
|
||||||
/// The configuration necessary to access the My Autarco API.
|
/// The configuration necessary to access the My Autarco API.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
pub(crate) struct Config {
|
pub(crate) struct Config {
|
||||||
/// The username of the account to login with
|
/// The username of the account to login with
|
||||||
username: String,
|
username: String,
|
||||||
|
@ -29,7 +32,7 @@ pub(crate) struct Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instantiates the My Autarco service.
|
/// Instantiates the My Autarco service.
|
||||||
pub(crate) fn service(config: Config) -> Result<Service, reqwest::Error> {
|
pub(crate) fn service(config: Config) -> Result<Service> {
|
||||||
let client = ClientBuilder::new().cookie_store(true).build()?;
|
let client = ClientBuilder::new().cookie_store(true).build()?;
|
||||||
let service = Service { client, config };
|
let service = Service { client, config };
|
||||||
|
|
||||||
|
@ -57,6 +60,7 @@ fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
|
||||||
|
|
||||||
/// The energy data returned by the energy API endpoint.
|
/// The energy data returned by the energy API endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiEnergy {
|
struct ApiEnergy {
|
||||||
/// Total energy produced today (kWh)
|
/// Total energy produced today (kWh)
|
||||||
// pv_today: u32,
|
// pv_today: u32,
|
||||||
|
@ -68,6 +72,7 @@ struct ApiEnergy {
|
||||||
|
|
||||||
/// The power data returned by the power API endpoint.
|
/// The power data returned by the power API endpoint.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
struct ApiPower {
|
struct ApiPower {
|
||||||
/// Current power production (W)
|
/// Current power production (W)
|
||||||
pv_now: u32,
|
pv_now: u32,
|
||||||
|
@ -86,7 +91,7 @@ impl super::Service for Service {
|
||||||
///
|
///
|
||||||
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
|
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
|
||||||
/// from the loaded configuration (see [`Config`]).
|
/// from the loaded configuration (see [`Config`]).
|
||||||
async fn login(&self) -> Result<(), reqwest::Error> {
|
async fn login(&mut self) -> Result<()> {
|
||||||
let params = [
|
let params = [
|
||||||
("username", &self.config.username),
|
("username", &self.config.username),
|
||||||
("password", &self.config.password),
|
("password", &self.config.password),
|
||||||
|
@ -101,20 +106,23 @@ impl super::Service for Service {
|
||||||
///
|
///
|
||||||
/// It needs the cookie from the login to be able to perform the action. It uses both the
|
/// It needs the cookie from the login to be able to perform the action. It uses both the
|
||||||
/// `energy` and `power` endpoint to construct the [`Status`] struct.
|
/// `energy` and `power` endpoint to construct the [`Status`] struct.
|
||||||
async fn update(&self, last_updated: u64) -> Result<Status, reqwest::Error> {
|
async fn update(&mut self, last_updated: u64) -> Result<Status> {
|
||||||
// Retrieve the data from the API endpoints.
|
// Retrieve the data from the API endpoints.
|
||||||
let api_energy_url = api_url(&self.config.site_id, "energy").expect("valid API energy URL");
|
let api_energy_url = api_url(&self.config.site_id, "energy").expect("valid API energy URL");
|
||||||
let api_response = self.client.get(api_energy_url).send().await?;
|
let api_response = self.client.get(api_energy_url).send().await?;
|
||||||
let api_energy = match api_response.error_for_status() {
|
let api_energy = match api_response.error_for_status() {
|
||||||
Ok(res) => res.json::<ApiEnergy>().await?,
|
Ok(res) => res.json::<ApiEnergy>().await?,
|
||||||
Err(err) => return Err(err),
|
Err(err) if err.status() == Some(StatusCode::UNAUTHORIZED) => {
|
||||||
|
return Err(Error::NotAuthorized)
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_power_url = api_url(&self.config.site_id, "power").expect("valid API power URL");
|
let api_power_url = api_url(&self.config.site_id, "power").expect("valid API power URL");
|
||||||
let api_response = self.client.get(api_power_url).send().await?;
|
let api_response = self.client.get(api_power_url).send().await?;
|
||||||
let api_power = match api_response.error_for_status() {
|
let api_power = match api_response.error_for_status() {
|
||||||
Ok(res) => res.json::<ApiPower>().await?,
|
Ok(res) => res.json::<ApiPower>().await?,
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Status {
|
Ok(Status {
|
||||||
|
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use reqwest::StatusCode;
|
|
||||||
use rocket::tokio::time::sleep;
|
use rocket::tokio::time::sleep;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
services::{Service, Services},
|
services::{Error, Service, Services},
|
||||||
STATUS,
|
STATUS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,6 +14,8 @@ use crate::{
|
||||||
/// It updates the mutex-guarded current update [`Status`](crate::Status) struct which can be
|
/// It updates the mutex-guarded current update [`Status`](crate::Status) struct which can be
|
||||||
/// retrieved via Rocket.
|
/// retrieved via Rocket.
|
||||||
pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
||||||
|
let mut service = service;
|
||||||
|
|
||||||
// Log in on the cloud service.
|
// Log in on the cloud service.
|
||||||
println!("⚡ Logging in...");
|
println!("⚡ Logging in...");
|
||||||
service.login().await?;
|
service.login().await?;
|
||||||
|
@ -36,14 +37,14 @@ pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
||||||
|
|
||||||
let status = match service.update(timestamp).await {
|
let status = match service.update(timestamp).await {
|
||||||
Ok(status) => status,
|
Ok(status) => status,
|
||||||
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => {
|
Err(Error::NotAuthorized) => {
|
||||||
println!("✨ Update unauthorized, trying to log in again...");
|
eprintln!("💥 Update unauthorized, trying to log in again...");
|
||||||
service.login().await?;
|
service.login().await?;
|
||||||
println!("⚡ Logged in successfully!");
|
println!("⚡ Logged in successfully!");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("✨ Failed to update status: {}", e);
|
eprintln!("💥 Failed to update status: {}", e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue