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:
Paul van Tilburg 2023-01-08 15:21:45 +01:00
commit 5bf7f5d8de
7 changed files with 2397 additions and 0 deletions

2070
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View file

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

19
LICENSE Normal file
View file

@ -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.

73
README.md Normal file
View file

@ -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>).

8
Rocket.toml.example Normal file
View file

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

75
src/main.rs Normal file
View file

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

131
src/update.rs Normal file
View file

@ -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(&params).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);
}
}