Compare commits

...

22 Commits

Author SHA1 Message Date
Paul van Tilburg 4088c37cd2
Bump the version to 0.2.0 2023-01-13 19:47:14 +01:00
Paul van Tilburg 17491fad78
Update the changelog 2023-01-13 19:33:09 +01:00
Paul van Tilburg 745edea875
Clarify config necessary for exposing container port
If Rocket is not configured to listen on 0.0.0.0:8000, exposing port
8000 on the inside to a chosen port (2399 by default), will not work.
2023-01-13 11:57:24 +01:00
Paul van Tilburg f6a3820961
Fix typo in Docker commands 2023-01-11 21:59:47 +01:00
Paul van Tilburg 5733a6440c
Fix (example) port in HA example 2023-01-11 21:58:33 +01:00
Paul van Tilburg 11d82c4ae4
Add a changelog 2023-01-11 21:55:04 +01:00
Paul van Tilburg 1c2fc62805
Add documentation for how to use with Home Assistant 2023-01-10 17:13:52 +01:00
Paul van Tilburg 4bdaa3bdac
Switch the example port to 2399 2023-01-10 17:00:20 +01:00
Paul van Tilburg 69f34e3243
Add Debian packaging via cargo-deb (closes: #4)
* Add the required metadata to `Cargo.toml`
* Add a systemd service unit file
* Use `Rocket.toml.example` as the default configuration
2023-01-10 16:59:20 +01:00
Paul van Tilburg a0cb3dccae
Document the Docker support 2023-01-10 16:29:32 +01:00
Paul van Tilburg 5ed688f0fb
Add support for building and running a Docker image 2023-01-10 16:09:56 +01:00
Paul van Tilburg 669c9285ad
Update code styling to follow Hoymiles service 2023-01-10 15:51:53 +01:00
Paul van Tilburg 9c9a348a53 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
2023-01-10 15:50:30 +01:00
Paul van Tilburg 0e7d339682
Update documentation and example 2023-01-10 15:49:11 +01:00
Paul van Tilburg b1dfea651f
Add first version of the Hoymiles service 2023-01-10 15:38:24 +01:00
Paul van Tilburg 2883f52249
Switch to floats for the current power and total energy fields 2023-01-10 15:37:38 +01:00
Paul van Tilburg 093d062dd4
Enable more lints 2023-01-09 21:40:04 +01:00
Paul van Tilburg 55c3b91bbd
Split off a library crate 2023-01-09 21:39:11 +01:00
Paul van Tilburg 58df4bec2f
Add missing .gitignore 2023-01-09 21:35:41 +01:00
Paul van Tilburg a47198ea24 Merge pull request 'Add support for multiple services' (#3) from 2-multiple-services-support into main
Reviewed-on: #3
2023-01-09 21:33:30 +01:00
Paul van Tilburg 3690647c76 Add service-specific configuration
Switch to a section/table for the service to make it easier.
2023-01-09 21:25:35 +01:00
Paul van Tilburg 87394f9fb9 Add service implementation; split off My Autarco support 2023-01-09 21:23:43 +01:00
16 changed files with 838 additions and 164 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
# Local build and dev artifacts
target
# Docker files
Dockerfile*
docker-compose*
# Git folder
.git
# Dot files
.gitignore
# TOML files
Rocket.toml*

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
Rocket.toml

34
CHANGELOG.md Normal file
View File

@ -0,0 +1,34 @@
# Changelog
All notable changes to Solar Grabber will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.0] - 2023-01-13
### Added
* Add support for multiple services (#3)
* Add support for the Hoymiles service (#2)
* Add `Dockerfile` (and `.dockerignore`) for building Docker images
* Add `docker-compose-yml` for running using Docker Compose
* Add Debian packaging via cargo-deb (#4)
* Add documentation for how to use it with Home Assistant
### Changed
* Change the example port the webservice runs at to 2399
* Update documentation for Docker (Compose) support
* Split off a library crate
* Split off My Autarco support as a separate service
## [0.1.1] - 2023-01-08
Rename Autarco Scraper project to Solar Grabber.
[Unreleased]: https://git.luon.net/paul/solar-grabber/compare/v0.2.0...HEAD
[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

25
Cargo.lock generated
View File

@ -351,6 +351,18 @@ dependencies = [
"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]]
name = "eyre"
version = "0.6.8"
@ -802,6 +814,15 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "memchr"
version = "2.5.0"
@ -1497,9 +1518,11 @@ dependencies = [
[[package]]
name = "solar-grabber"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"color-eyre",
"enum_dispatch",
"md-5",
"once_cell",
"reqwest",
"rocket",

View File

@ -1,6 +1,6 @@
[package]
name = "solar-grabber"
version = "0.1.1"
version = "0.2.0"
authors = ["Paul van Tilburg <paul@luon.net>"]
edition = "2021"
description = """"
@ -13,9 +13,37 @@ license = "MIT"
[dependencies]
color-eyre = "0.6.2"
enum_dispatch = "0.3.9"
md-5 = "0.10.5"
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"
[package.metadata.deb]
maintainer = "Paul van Tilburg <paul@luon.net>"
copyright = "2022, Paul van Tilburg"
depends = "$auto, systemd"
extended-description = """\
Solar Grabber is web service that provides a REST API layer over various cloud
sites/services/APIs to get statistical data of your solar panels.
It currently supports the following services:
* Hoymiles: https://global.hoymiles.com
* My Autarco: https://my.autarco.com
"""
section = "net"
priority = "optional"
assets = [
["README.md", "usr/share/doc/solar-grabber/", "664"],
["Rocket.toml.example", "/etc/solar-grabber.toml", "600"],
["target/release/solar-grabber", "usr/sbin/solar-grabber", "755"]
]
conf-files = [
"/etc/solar-grabber.toml"
]
maintainer-scripts = "debian/"
systemd-units = { unit-name = "solar-grabber" }

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# Using multistage build:
# https://docs.docker.com/develop/develop-images/multistage-build/
# https://whitfin.io/speeding-up-rust-docker-builds/
########################## BUILD IMAGE ##########################
# Rust build image to build Solar Grabber's statically compiled binary
FROM docker.io/rust:1 as builder
# Build the dependencies first
RUN USER=root cargo new --bin /usr/src/solar-grabber
WORKDIR /usr/src/solar-grabber
COPY ./Cargo.* ./
RUN cargo build --release
RUN rm src/*.rs
# Add the real project files from current folder
ADD . ./
# Build the actual binary from the copied local files
RUN rm ./target/release/deps/solar_grabber*
RUN cargo build --release
########################## RUNTIME IMAGE ##########################
# Create new stage with a minimal image for the actual runtime image/container
FROM docker.io/debian:bullseye-slim
# Install CA certificates
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Copy the binary from the "builder" stage to the current stage
RUN adduser --system --disabled-login --home /app --gecos "" --shell /bin/bash solar-grabber
COPY --from=builder /usr/src/solar-grabber/target/release/solar-grabber /app
# Standard port on which Rocket launches
EXPOSE 8000
# Set user to www-data
USER solar-grabber
# Set container home directory
WORKDIR /app
# Run Solar Grabber
ENTRYPOINT [ "/app/solar-grabber" ]

View File

@ -3,18 +3,26 @@
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.
The services that are currently supported are
[Hoymiles](https://global.hoymiles.com) and
[My Autarco](https://my.autarco.com).
## 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:
You can copy and modify `Rocket.toml.example` for this and uncomment the part
relevant for the service you want to use.
For example, to configure Solar Grabber to use the My Autarco service:
```toml
[default]
# ...
# Put your solar cloud service credentials below
# Put your solar cloud service settings below and uncomment them based on the
# service you want to use.
[default.service]
kind = "MyAutarco"
username = "foo@domain.tld"
password = "secret"
site_id = "abc123de"
@ -27,7 +35,7 @@ builds when you don't add `--release`.)
```toml
[default]
address = "0.0.0.0"
port = 8080
port = 2399
# ...
```
@ -35,7 +43,9 @@ 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:
### Using Cargo
Using Cargo it is easy to build and run Solar Grabber. just run:
```shell
$ cargo run --release
@ -45,6 +55,33 @@ $ cargo run --release
Running `/path/to/solar-grabber/target/release/solar-grabber`
```
### Using Docker (Compose)
Using Docker Compose it is easy (to build and) run using a Docker image.
If you do not change `docker-compose.yml` it will use `Rocket.toml` from
the current working directory as configuration:
```console
$ docker-compose up
...
```
Ensure that `Rocket.toml` listens on address `0.0.0.0` port `8000` for the
exposing of the container port to the outside to work!
Alternatively, to use Docker directly, run to build an image and the run it:
```console
$ docker build --rm --tag solar-grabber:latest .
...
$ docker run --rm -v ./Rocket.toml:/app/Rocket.toml -p 2399:8000 solar-grabber:latest
...
```
This also uses `Rocket.toml` from the current working directory as configuration.
You can alternatively pass a set of environment variables instead. See
`docker-compose.yml` for a list.
## API endpoint
The `/` API endpoint provides the current statistical data of your solar panels
@ -60,13 +97,59 @@ GET /
A response uses the JSON format and typically looks like this:
```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,
the total of produced energy since installation (`total_kwh`) in kilowatt-hour
and the (UNIX) timestamp that indicates when the information was last updated.
## Integration in Home Assistant
To integrate the Solar Grabber service in your [Home Assistant](https://www.home-assistant.io/)
installation, add the following three sensor entity definitions to your
configuration YAML and restart:
```yaml
sensors:
# ...Already exiting sensor definitions...
- platform: rest
name: "Photovoltaic Invertor"
resource: "http://solar-grabber.domain.tld:2399"
json_attributes:
- current_w
- total_kwh
- last_updated
value_template: >
{% if value_json["current_w"] == 0 %}
off
{% elif value_json["current_w"] > 0 %}
on
{% endif %}
- platform: rest
name: "Photovoltaic Invertor Power Production"
resource: "http://solar-grabber.domain.tld:2399"
value_template: '{{ value_json.current_w }}'
unit_of_measurement: W
device_class: power
- platform: rest
name: "Photovoltaic Invertor Total Energy Production"
resource: "http://solar-grabber.domain.tld:2399"
value_template: '{{ value_json.total_kwh }}'
unit_of_measurement: kWh
device_class: energy
```
This assumes your Solar Grabber is running at <http://solar-grabber.domain.tld:2399>.
Replace this with the URL where Solar Grabber is actually running.
Also, feel free to change the names of the sensor entities.
These sensors use the RESTful sensor integration, for more information see the
[RESTful sensor documentation](https://www.home-assistant.io/integrations/sensor.rest/).
## License
Solar Grabber is licensed under the MIT license (see the `LICENSE` file or

View File

@ -1,8 +1,18 @@
[default]
address = "0.0.0.0"
port = 2356
port = 2399
# 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]
# For Hoymiles, use the following settings:
# kind = "Hoymiles"
# username = "username"
# password = "secret"
# sid = 123456
# For My Autarco, use the following settings:
# kind = "MyAutarco"
# username = "foo@domain.tld"
# password = "secret"
# site_id = "abc123de"

47
debian/solar-grabber.service vendored Normal file
View File

@ -0,0 +1,47 @@
[Unit]
Description=Solar Grabber API web server
After=network.target
[Service]
Type=simple
AmbientCapabilities=
CapabilityBoundingSet=
DynamicUser=yes
LoadCredential=solar-grabber.toml:/etc/solar-grabber.toml
LockPersonality=yes
MemoryDenyWriteExecute=yes
NoNewPrivileges=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectHome=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=noaccess
ProtectSystem=strict
PrivateDevices=yes
PrivateMounts=yes
PrivateTmp=yes
PrivateUsers=yes
RemoveIPC=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
UMask=0077
ExecStart=/usr/sbin/solar-grabber
Restart=on-failure
RestartSec=10
StartLimitInterval=1m
StartLimitBurst=5
Environment="ROCKET_CONFIG=%d/solar-grabber.toml"
[Install]
WantedBy=multi-user.target

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
version: '3'
services:
server:
image: solar-grabber:latest
build: .
restart: unless-stopped
ports:
# Ensure that Rocket listens on 0.0.0.0, port 8000 for this!
- 2399:8000
volumes:
# Use a `Rocket.toml` or configure the credentials using environment variables below
- ./Rocket.toml:/app/Rocket.toml
environment:
ROCKET_LOG_LEVEL: normal # Available levels are: off, debug, normal, critical
# For My Autarco, use the these variabels and uncomment them
# ROCKET_KIND: MyAutarco
# ROCKET_USERNAME: foo@domain.tld
# ROCKET_PASSWORD: secret
# ROCKET_SITE_ID: abc123de
# For Hoymiles, use the these variabels and uncomment them
# ROCKET_KIND: HoyMiles
# ROCKET_USERNAME: foo@domain.tld
# ROCKET_PASSWORD: secret
# ROCKET_SID: 123456
shm_size: '2gb'

75
src/lib.rs Normal file
View File

@ -0,0 +1,75 @@
#![doc = include_str!("../README.md")]
#![warn(
clippy::all,
missing_copy_implementations,
missing_debug_implementations,
rust_2018_idioms,
rustdoc::broken_intra_doc_links,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)]
#![deny(missing_docs)]
mod services;
mod update;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use rocket::fairing::AdHoc;
use rocket::serde::json::Json;
use rocket::{get, routes, Build, Rocket};
use serde::{Deserialize, Serialize};
use self::update::update_loop;
/// The global, concurrently accessible current status.
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.
#[derive(Clone, Copy, Debug, Serialize)]
struct Status {
/// Current power production (W)
current_w: f32,
/// Total energy produced since installation (kWh)
total_kwh: f32,
/// 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.
pub fn setup() -> Rocket<Build> {
rocket::build()
.mount("/", routes![status])
.attach(AdHoc::config::<Config>())
.attach(AdHoc::on_liftoff("Updater", |rocket| {
Box::pin(async move {
let config = rocket
.figment()
.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));
})
}))
}

View File

@ -1,75 +1,21 @@
#![doc = include_str!("../README.md")]
#![warn(
clippy::all,
missing_copy_implementations,
missing_debug_implementations,
rust_2018_idioms,
rustdoc::broken_intra_doc_links
rustdoc::broken_intra_doc_links,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)]
#![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.
/// Sets up and launches Rocket.
#[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));
})
}))
solar_grabber::setup()
}

51
src/services.rs Normal file
View File

@ -0,0 +1,51 @@
//! The supported cloud services.
pub(crate) mod hoymiles;
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 {
/// Hoymiles (<https://global.hoymiles.com>)
Hoymiles(hoymiles::Config),
/// My Autarco (<https://my.autarco.com>)
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::Hoymiles(config) => Ok(Services::Hoymiles(hoymiles::service(config)?)),
Config::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::service(config)?)),
}
}
/// The supported cloud services.
#[enum_dispatch(Service)]
pub(crate) enum Services {
/// Hoymiles (<https://global.hoymiles.com>)
Hoymiles(hoymiles::Service),
/// 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>;
}

236
src/services/hoymiles.rs Normal file
View File

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

126
src/services/my_autarco.rs Normal file
View File

@ -0,0 +1,126 @@
//! 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(&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(&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 = match api_response.error_for_status() {
Ok(res) => res.json::<ApiEnergy>().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 = match api_response.error_for_status() {
Ok(res) => res.json::<ApiPower>().await?,
Err(err) => return Err(err),
};
Ok(Status {
current_w: api_power.pv_now as f32,
total_kwh: api_energy.pv_to_date as f32,
last_updated,
})
}
}

View File

@ -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 reqwest::{Client, ClientBuilder, Error, StatusCode};
use reqwest::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,
})
}
use crate::{
services::{Service, Services},
STATUS,
};
/// 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.
/// It updates the mutex-guarded current update [`Status`](crate::Status) struct which can be
/// retrieved via Rocket.
pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
// Log in on the cloud service.
println!("⚡ Logging in...");
login(&config, &client).await?;
service.login().await?;
println!("⚡ Logged in successfully!");
let mut last_updated = 0;
let poll_interval = service.poll_interval();
loop {
// Wake up every 10 seconds and check if an update is due.
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)
.unwrap_or_default()
.as_secs();
if timestamp - last_updated < POLL_INTERVAL {
if timestamp - last_updated < poll_interval {
continue;
}
let status = match update(&config, &client, timestamp).await {
let status = match service.update(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?;
service.login().await?;
println!("⚡ Logged in successfully!");
continue;
}