Merge pull request 'Add support for multiple services' (#3) from 2-multiple-services-support into main
Reviewed-on: #3
This commit is contained in:
commit
a47198ea24
|
@ -351,6 +351,18 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enum_dispatch"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1693044dcf452888dd3a6a6a0dab67f0652094e3920dfe029a54d2f37d9b7394"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eyre"
|
name = "eyre"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
@ -1500,6 +1512,7 @@ name = "solar-grabber"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
|
"enum_dispatch",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rocket",
|
"rocket",
|
||||||
|
|
|
@ -13,6 +13,7 @@ license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
color-eyre = "0.6.2"
|
color-eyre = "0.6.2"
|
||||||
|
enum_dispatch = "0.3.9"
|
||||||
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,7 +14,9 @@ For example for My Autarco:
|
||||||
[default]
|
[default]
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
# Put your solar cloud service credentials below
|
# Put your solar cloud service settings below and uncomment them
|
||||||
|
[default.service]
|
||||||
|
kind = "MyAutarco"
|
||||||
username = "foo@domain.tld"
|
username = "foo@domain.tld"
|
||||||
password = "secret"
|
password = "secret"
|
||||||
site_id = "abc123de"
|
site_id = "abc123de"
|
||||||
|
|
|
@ -3,6 +3,8 @@ 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
|
||||||
|
[default.service]
|
||||||
|
# kind = "MyAutarco"
|
||||||
# username = "foo@domain.tld"
|
# username = "foo@domain.tld"
|
||||||
# password = "secret"
|
# password = "secret"
|
||||||
# site_id = "abc123de"
|
# site_id = "abc123de"
|
||||||
|
|
42
src/main.rs
42
src/main.rs
|
@ -7,6 +7,9 @@
|
||||||
)]
|
)]
|
||||||
#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
mod services;
|
||||||
|
mod update;
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
@ -17,30 +20,16 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use self::update::update_loop;
|
use self::update::update_loop;
|
||||||
|
|
||||||
mod update;
|
|
||||||
|
|
||||||
/// The base URL of My Autarco site.
|
|
||||||
const BASE_URL: &str = "https://my.autarco.com";
|
|
||||||
|
|
||||||
/// The interval between data polls.
|
|
||||||
///
|
|
||||||
/// This depends on with which interval Autaurco processes new information from the invertor.
|
|
||||||
const POLL_INTERVAL: u64 = 300;
|
|
||||||
|
|
||||||
/// The extra configuration necessary to access the My Autarco site.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct Config {
|
|
||||||
/// The username of the account to login with
|
|
||||||
username: String,
|
|
||||||
/// The password of the account to login with
|
|
||||||
password: String,
|
|
||||||
/// The Autarco site ID to track
|
|
||||||
site_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The global, concurrently accessible current status.
|
/// The global, concurrently accessible current status.
|
||||||
static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
|
static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
|
/// The configuration loaded additionally by Rocket.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Config {
|
||||||
|
/// The service-specific configuration
|
||||||
|
service: services::Config,
|
||||||
|
}
|
||||||
|
|
||||||
/// The current photovoltaic invertor status.
|
/// The current photovoltaic invertor status.
|
||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
struct Status {
|
struct Status {
|
||||||
|
@ -67,9 +56,14 @@ fn rocket() -> _ {
|
||||||
.attach(AdHoc::config::<Config>())
|
.attach(AdHoc::config::<Config>())
|
||||||
.attach(AdHoc::on_liftoff("Updater", |rocket| {
|
.attach(AdHoc::on_liftoff("Updater", |rocket| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// We don't care about the join handle nor error results?
|
let config = rocket
|
||||||
let config = rocket.figment().extract().expect("Invalid configuration");
|
.figment()
|
||||||
let _ = rocket::tokio::spawn(update_loop(config));
|
.extract::<Config>()
|
||||||
|
.expect("Invalid configuration");
|
||||||
|
let service = services::get(config.service).expect("Invalid service");
|
||||||
|
|
||||||
|
// We don't care about the join handle nor error results?t
|
||||||
|
let _ = rocket::tokio::spawn(update_loop(service));
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
//! The supported cloud services.
|
||||||
|
|
||||||
|
pub(crate) mod my_autarco;
|
||||||
|
|
||||||
|
use enum_dispatch::enum_dispatch;
|
||||||
|
use rocket::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::Status;
|
||||||
|
|
||||||
|
/// The service-specific configuration necessary to access a cloud service API.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde", tag = "kind")]
|
||||||
|
pub(crate) enum Config {
|
||||||
|
/// The configuration of the My Autarco service
|
||||||
|
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::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::service(config)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The supported cloud services.
|
||||||
|
#[enum_dispatch(Service)]
|
||||||
|
pub(crate) enum Services {
|
||||||
|
/// My Autarco (<https://my.autarco.com>)
|
||||||
|
MyAutarco(my_autarco::Service),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functionality trait of a cloud service.
|
||||||
|
#[async_trait]
|
||||||
|
#[enum_dispatch]
|
||||||
|
pub(crate) trait Service {
|
||||||
|
/// The interval between data polls (in seconds).
|
||||||
|
fn poll_interval(&self) -> u64;
|
||||||
|
|
||||||
|
/// Perfoms a login on the cloud service (if necessary).
|
||||||
|
async fn login(&self) -> Result<(), reqwest::Error>;
|
||||||
|
|
||||||
|
/// Retrieves a status update using the API of the cloud service.
|
||||||
|
async fn update(&self, timestamp: u64) -> Result<Status, reqwest::Error>;
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
//! The My Autarco service.
|
||||||
|
//!
|
||||||
|
//! It uses the private My Autarco API to login (and obtain the session cookies) and
|
||||||
|
//! to retrieve the energy data (using the session cookies).
|
||||||
|
//! See also: <https://my.autarco.com>
|
||||||
|
|
||||||
|
use reqwest::{Client, ClientBuilder, Url};
|
||||||
|
use rocket::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::ParseError;
|
||||||
|
|
||||||
|
use crate::Status;
|
||||||
|
|
||||||
|
/// The base URL of My Autarco site.
|
||||||
|
const BASE_URL: &str = "https://my.autarco.com";
|
||||||
|
|
||||||
|
/// The interval between data polls (in seconds).
|
||||||
|
const POLL_INTERVAL: u64 = 300;
|
||||||
|
|
||||||
|
/// The configuration necessary to access the My Autarco API.
|
||||||
|
#[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 Autarco site ID to track
|
||||||
|
site_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instantiates the My Autarco service.
|
||||||
|
pub(crate) fn service(config: Config) -> Result<Service, reqwest::Error> {
|
||||||
|
let client = ClientBuilder::new().cookie_store(true).build()?;
|
||||||
|
let service = Service { client, config };
|
||||||
|
|
||||||
|
Ok(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The My Autarco 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the login URL for the My Autarco site.
|
||||||
|
fn login_url() -> Result<Url, ParseError> {
|
||||||
|
Url::parse(&format!("{BASE_URL}/auth/login"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site.
|
||||||
|
fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
|
||||||
|
Url::parse(&format!("{BASE_URL}/api/site/{site_id}/kpis/{endpoint}",))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The energy data returned by the energy API endpoint.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApiEnergy {
|
||||||
|
/// Total energy produced today (kWh)
|
||||||
|
// pv_today: u32,
|
||||||
|
/// Total energy produced this month (kWh)
|
||||||
|
// pv_month: u32,
|
||||||
|
/// Total energy produced since installation (kWh)
|
||||||
|
pv_to_date: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The power data returned by the power API endpoint.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApiPower {
|
||||||
|
/// Current power production (W)
|
||||||
|
pv_now: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl super::Service for Service {
|
||||||
|
/// The interval between data polls (in seconds).
|
||||||
|
///
|
||||||
|
/// Autaurco processes provides information from the invertor every 5 minutes.
|
||||||
|
fn poll_interval(&self) -> u64 {
|
||||||
|
POLL_INTERVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a login on the My Autarco site.
|
||||||
|
///
|
||||||
|
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
|
||||||
|
/// from the loaded configuration (see [`Config`]).
|
||||||
|
async fn login(&self) -> Result<(), reqwest::Error> {
|
||||||
|
let params = [
|
||||||
|
("username", &self.config.username),
|
||||||
|
("password", &self.config.password),
|
||||||
|
];
|
||||||
|
let login_url = login_url().expect("valid login URL");
|
||||||
|
|
||||||
|
self.client.post(login_url).form(¶ms).send().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a status update from the API of the My Autarco site.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
async fn update(&self, last_updated: u64) -> Result<Status, reqwest::Error> {
|
||||||
|
// 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_response = self.client.get(api_energy_url).send().await?;
|
||||||
|
let api_energy: ApiEnergy = match api_response.error_for_status() {
|
||||||
|
Ok(res) => res.json().await?,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
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_power: ApiPower = match api_response.error_for_status() {
|
||||||
|
Ok(res) => res.json().await?,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Status {
|
||||||
|
current_w: api_power.pv_now,
|
||||||
|
total_kwh: api_energy.pv_to_date,
|
||||||
|
last_updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
105
src/update.rs
105
src/update.rs
|
@ -1,102 +1,27 @@
|
||||||
//! Module for handling the status updating/retrieval via the My Autarco site/API.
|
//! Module for handling the status updating/retrieval via the cloud service API.
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
use reqwest::{Client, ClientBuilder, Error, StatusCode};
|
use reqwest::StatusCode;
|
||||||
use rocket::tokio::time::sleep;
|
use rocket::tokio::time::sleep;
|
||||||
use serde::Deserialize;
|
|
||||||
use url::{ParseError, Url};
|
|
||||||
|
|
||||||
use super::{Config, Status, BASE_URL, POLL_INTERVAL, STATUS};
|
use crate::{
|
||||||
|
services::{Service, Services},
|
||||||
/// Returns the login URL for the My Autarco site.
|
STATUS,
|
||||||
fn login_url() -> Result<Url, ParseError> {
|
};
|
||||||
Url::parse(&format!("{}/auth/login", BASE_URL))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site.
|
|
||||||
fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
|
|
||||||
Url::parse(&format!(
|
|
||||||
"{}/api/site/{}/kpis/{}",
|
|
||||||
BASE_URL, site_id, endpoint
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The energy data returned by the energy API endpoint.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ApiEnergy {
|
|
||||||
/// Total energy produced today (kWh)
|
|
||||||
// pv_today: u32,
|
|
||||||
/// Total energy produced this month (kWh)
|
|
||||||
// pv_month: u32,
|
|
||||||
/// Total energy produced since installation (kWh)
|
|
||||||
pv_to_date: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The power data returned by the power API endpoint.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ApiPower {
|
|
||||||
/// Current power production (W)
|
|
||||||
pv_now: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Performs a login on the My Autarco site.
|
|
||||||
///
|
|
||||||
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
|
|
||||||
/// from the loaded configuration (see [`Config`]).
|
|
||||||
async fn login(config: &Config, client: &Client) -> Result<(), Error> {
|
|
||||||
let params = [
|
|
||||||
("username", &config.username),
|
|
||||||
("password", &config.password),
|
|
||||||
];
|
|
||||||
let login_url = login_url().expect("valid login URL");
|
|
||||||
|
|
||||||
client.post(login_url).form(¶ms).send().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieves a status update from the API of the My Autarco site.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
async fn update(config: &Config, client: &Client, last_updated: u64) -> Result<Status, Error> {
|
|
||||||
// Retrieve the data from the API endpoints.
|
|
||||||
let api_energy_url = api_url(&config.site_id, "energy").expect("valid API energy URL");
|
|
||||||
let api_response = client.get(api_energy_url).send().await?;
|
|
||||||
let api_energy: ApiEnergy = match api_response.error_for_status() {
|
|
||||||
Ok(res) => res.json().await?,
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
let api_power_url = api_url(&config.site_id, "power").expect("valid API power URL");
|
|
||||||
let api_response = client.get(api_power_url).send().await?;
|
|
||||||
let api_power: ApiPower = match api_response.error_for_status() {
|
|
||||||
Ok(res) => res.json().await?,
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the status.
|
|
||||||
Ok(Status {
|
|
||||||
current_w: api_power.pv_now,
|
|
||||||
total_kwh: api_energy.pv_to_date,
|
|
||||||
last_updated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main update loop that logs in and periodically acquires updates from the API.
|
/// Main update loop that logs in and periodically acquires updates from the API.
|
||||||
///
|
///
|
||||||
/// It updates the mutex-guarded current update [`Status`] struct which can be retrieved via
|
/// It updates the mutex-guarded current update [`Status`](crate::Status) struct which can be
|
||||||
/// Rocket.
|
/// retrieved via Rocket.
|
||||||
pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> {
|
pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
||||||
let client = ClientBuilder::new().cookie_store(true).build()?;
|
// Log in on the cloud service.
|
||||||
|
|
||||||
// Go to the My Autarco site and login.
|
|
||||||
println!("⚡ Logging in...");
|
println!("⚡ Logging in...");
|
||||||
login(&config, &client).await?;
|
service.login().await?;
|
||||||
println!("⚡ Logged in successfully!");
|
println!("⚡ Logged in successfully!");
|
||||||
|
|
||||||
let mut last_updated = 0;
|
let mut last_updated = 0;
|
||||||
|
let poll_interval = service.poll_interval();
|
||||||
loop {
|
loop {
|
||||||
// Wake up every 10 seconds and check if an update is due.
|
// Wake up every 10 seconds and check if an update is due.
|
||||||
sleep(Duration::from_secs(10)).await;
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
@ -105,15 +30,15 @@ pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> {
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
if timestamp - last_updated < POLL_INTERVAL {
|
if timestamp - last_updated < poll_interval {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = match update(&config, &client, timestamp).await {
|
let status = match service.update(timestamp).await {
|
||||||
Ok(status) => status,
|
Ok(status) => status,
|
||||||
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => {
|
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => {
|
||||||
println!("✨ Update unauthorized, trying to log in again...");
|
println!("✨ Update unauthorized, trying to log in again...");
|
||||||
login(&config, &client).await?;
|
service.login().await?;
|
||||||
println!("⚡ Logged in successfully!");
|
println!("⚡ Logged in successfully!");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue