Initial import into Git
This project is based on [Autarco Scraper](https://git.luon.net/paul/autarco-scraper) but will support multiple services, not just My Autarco.
This commit is contained in:
commit
5bf7f5d8de
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "solar-grabber"
|
||||||
|
version = "0.1.1"
|
||||||
|
authors = ["Paul van Tilburg <paul@luon.net>"]
|
||||||
|
edition = "2021"
|
||||||
|
description = """"
|
||||||
|
Web service that provides a REST API layer over cloud sites/services/APIs to
|
||||||
|
get statistical data of your solar panels.
|
||||||
|
"""
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://git.luon.net/paul/solar-grabber"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
color-eyre = "0.6.2"
|
||||||
|
once_cell = "1.9.0"
|
||||||
|
reqwest = { version = "0.11.6", features = ["cookies", "json"] }
|
||||||
|
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||||
|
serde = "1.0.116"
|
||||||
|
toml = "0.5.6"
|
||||||
|
url = "2.2.2"
|
|
@ -0,0 +1,19 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2018 Paul van Tilburg
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Solar Grabber
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[default]
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Put your solar cloud service credentials below
|
||||||
|
username = "foo@domain.tld"
|
||||||
|
password = "secret"
|
||||||
|
site_id = "abc123de"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also change this configuration to use a different address and/or port.
|
||||||
|
(Note that Rocket listens on `127.0.0.1:8000` by default for debug builds, i.e.
|
||||||
|
builds when you don't add `--release`.)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[default]
|
||||||
|
address = "0.0.0.0"
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
This will work independent of the type of build. For more about Rocket's
|
||||||
|
configuration, see: <https://rocket.rs/v0.5-rc/guide/configuration/>.
|
||||||
|
|
||||||
|
Finally, using Cargo, it is easy to build and run Solar Grabber, just run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ cargo run --release
|
||||||
|
...
|
||||||
|
Compiling solar-grabber v0.1.0 (/path/to/solar-grabber)
|
||||||
|
Finished release [optimized] target(s) in 9m 26s
|
||||||
|
Running `/path/to/solar-grabber/target/release/solar-grabber`
|
||||||
|
```
|
||||||
|
|
||||||
|
## API endpoint
|
||||||
|
|
||||||
|
The `/` API endpoint provides the current statistical data of your solar panels
|
||||||
|
once it has successfully logged into the cloud service using your credentials.
|
||||||
|
There is no path and no query parameters, just:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
A response uses the JSON format and typically looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"current_w":23,"total_kwh":6159,"last_updated":1661194620}
|
||||||
|
```
|
||||||
|
|
||||||
|
This contains the current production power (`current_w`) in Watt,
|
||||||
|
the total of produced energy since installation (`total_kwh`) in kilowatt-hour
|
||||||
|
and the (UNIX) timestamp that indicates when the information was last updated.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Solar Grabber is licensed under the MIT license (see the `LICENSE` file or
|
||||||
|
<http://opensource.org/licenses/MIT>).
|
|
@ -0,0 +1,8 @@
|
||||||
|
[default]
|
||||||
|
address = "0.0.0.0"
|
||||||
|
port = 2356
|
||||||
|
|
||||||
|
# Put your solar cloud service settings below and uncomment them
|
||||||
|
# username = "foo@domain.tld"
|
||||||
|
# password = "secret"
|
||||||
|
# site_id = "abc123de"
|
|
@ -0,0 +1,75 @@
|
||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
#![warn(
|
||||||
|
clippy::all,
|
||||||
|
missing_debug_implementations,
|
||||||
|
rust_2018_idioms,
|
||||||
|
rustdoc::broken_intra_doc_links
|
||||||
|
)]
|
||||||
|
#![deny(missing_docs)]
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use rocket::fairing::AdHoc;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::{get, routes};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
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.
|
||||||
|
static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
|
/// The current photovoltaic invertor status.
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
|
struct Status {
|
||||||
|
/// Current power production (W)
|
||||||
|
current_w: u32,
|
||||||
|
/// Total energy produced since installation (kWh)
|
||||||
|
total_kwh: u32,
|
||||||
|
/// Timestamp of last update
|
||||||
|
last_updated: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current (last known) status.
|
||||||
|
#[get("/", format = "application/json")]
|
||||||
|
async fn status() -> Option<Json<Status>> {
|
||||||
|
let status_guard = STATUS.lock().expect("Status mutex was poisoined");
|
||||||
|
status_guard.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a Rocket and attaches the config parsing and update loop as fairings.
|
||||||
|
#[rocket::launch]
|
||||||
|
fn rocket() -> _ {
|
||||||
|
rocket::build()
|
||||||
|
.mount("/", routes![status])
|
||||||
|
.attach(AdHoc::config::<Config>())
|
||||||
|
.attach(AdHoc::on_liftoff("Updater", |rocket| {
|
||||||
|
Box::pin(async move {
|
||||||
|
// We don't care about the join handle nor error results?
|
||||||
|
let config = rocket.figment().extract().expect("Invalid configuration");
|
||||||
|
let _ = rocket::tokio::spawn(update_loop(config));
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Module for handling the status updating/retrieval via the My Autarco site/API.
|
||||||
|
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use reqwest::{Client, ClientBuilder, Error, StatusCode};
|
||||||
|
use rocket::tokio::time::sleep;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::{ParseError, Url};
|
||||||
|
|
||||||
|
use super::{Config, Status, BASE_URL, POLL_INTERVAL, STATUS};
|
||||||
|
|
||||||
|
/// Returns the login URL for the My Autarco site.
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// It updates the mutex-guarded current update [`Status`] struct which can be retrieved via
|
||||||
|
/// Rocket.
|
||||||
|
pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> {
|
||||||
|
let client = ClientBuilder::new().cookie_store(true).build()?;
|
||||||
|
|
||||||
|
// Go to the My Autarco site and login.
|
||||||
|
println!("⚡ Logging in...");
|
||||||
|
login(&config, &client).await?;
|
||||||
|
println!("⚡ Logged in successfully!");
|
||||||
|
|
||||||
|
let mut last_updated = 0;
|
||||||
|
loop {
|
||||||
|
// Wake up every 10 seconds and check if an update is due.
|
||||||
|
sleep(Duration::from_secs(10)).await;
|
||||||
|
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
if timestamp - last_updated < POLL_INTERVAL {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = match update(&config, &client, timestamp).await {
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => {
|
||||||
|
println!("✨ Update unauthorized, trying to log in again...");
|
||||||
|
login(&config, &client).await?;
|
||||||
|
println!("⚡ Logged in successfully!");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("✨ Failed to update status: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
last_updated = timestamp;
|
||||||
|
|
||||||
|
println!("⚡ Updated status to: {:#?}", status);
|
||||||
|
let mut status_guard = STATUS.lock().expect("Status mutex was poisoned");
|
||||||
|
status_guard.replace(status);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue