Compare commits
22 Commits
Author | SHA1 | Date |
---|---|---|
Paul van Tilburg | 4088c37cd2 | |
Paul van Tilburg | 17491fad78 | |
Paul van Tilburg | 745edea875 | |
Paul van Tilburg | f6a3820961 | |
Paul van Tilburg | 5733a6440c | |
Paul van Tilburg | 11d82c4ae4 | |
Paul van Tilburg | 1c2fc62805 | |
Paul van Tilburg | 4bdaa3bdac | |
Paul van Tilburg | 69f34e3243 | |
Paul van Tilburg | a0cb3dccae | |
Paul van Tilburg | 5ed688f0fb | |
Paul van Tilburg | 669c9285ad | |
Paul van Tilburg | 9c9a348a53 | |
Paul van Tilburg | 0e7d339682 | |
Paul van Tilburg | b1dfea651f | |
Paul van Tilburg | 2883f52249 | |
Paul van Tilburg | 093d062dd4 | |
Paul van Tilburg | 55c3b91bbd | |
Paul van Tilburg | 58df4bec2f | |
Paul van Tilburg | a47198ea24 | |
Paul van Tilburg | 3690647c76 | |
Paul van Tilburg | 87394f9fb9 |
|
@ -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*
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Rocket.toml
|
|
@ -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
|
|
@ -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",
|
||||
|
|
30
Cargo.toml
30
Cargo.toml
|
@ -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" }
|
||||
|
|
|
@ -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" ]
|
95
README.md
95
README.md
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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'
|
|
@ -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));
|
||||
})
|
||||
}))
|
||||
}
|
74
src/main.rs
74
src/main.rs
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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(¶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 = 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,
|
||||
})
|
||||
}
|
||||
}
|
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 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(¶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,
|
||||
})
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue