Compare commits
67 Commits
Author | SHA1 | Date |
---|---|---|
Paul van Tilburg | 03c51e2a2c | |
Paul van Tilburg | cc1a0e4748 | |
Paul van Tilburg | d2b90c16f6 | |
Paul van Tilburg | d5eed08072 | |
Paul van Tilburg | 247e9d51f5 | |
Paul van Tilburg | 5241d90e79 | |
Paul van Tilburg | c61bbfef5b | |
Paul van Tilburg | f3ff02e4ff | |
Paul van Tilburg | f2be089fb9 | |
Paul van Tilburg | 45b3f52e71 | |
Paul van Tilburg | ff12875a08 | |
Paul van Tilburg | 1bf6a4e772 | |
Paul van Tilburg | c6f7511fc7 | |
Paul van Tilburg | 3fed86d36f | |
Paul van Tilburg | d3cc19524b | |
Paul van Tilburg | 92c75d09b9 | |
Paul van Tilburg | e1319dcfc2 | |
Paul van Tilburg | 0f1bc9d83d | |
Paul van Tilburg | 0068f6e9de | |
Paul van Tilburg | caad71389b | |
Paul van Tilburg | 0b76db96f0 | |
Paul van Tilburg | 42a43cc83d | |
Paul van Tilburg | 1aca61d3fd | |
Paul van Tilburg | 35dda781a3 | |
Paul van Tilburg | 14bda61a9e | |
Paul van Tilburg | bc22fd2d70 | |
Paul van Tilburg | fd00ef0b4f | |
Paul van Tilburg | c070877384 | |
Paul van Tilburg | 732d4b83f2 | |
Paul van Tilburg | 12a797baa9 | |
Paul van Tilburg | 5586ae4d70 | |
Paul van Tilburg | 59e3b53263 | |
Paul van Tilburg | f236499125 | |
Paul van Tilburg | 02a4d1ca9b | |
Paul van Tilburg | 3fff79a2cd | |
Paul van Tilburg | 9200a10cef | |
Paul van Tilburg | beb49373fb | |
Paul van Tilburg | 99e7e8a68c | |
Paul van Tilburg | bab9228b0f | |
Paul van Tilburg | 81e82e90da | |
Paul van Tilburg | b07bb73da4 | |
Paul van Tilburg | 58759d5309 | |
Paul van Tilburg | b1764b7fe3 | |
Paul van Tilburg | 7c704b69ed | |
Paul van Tilburg | 04e28a33c3 | |
Paul van Tilburg | de1ad37b95 | |
Paul van Tilburg | 5cbc3a04fc | |
Paul van Tilburg | ca116351db | |
Paul van Tilburg | 8d892d8619 | |
Paul van Tilburg | 35209b6303 | |
Paul van Tilburg | 6707928e37 | |
Paul van Tilburg | e6b0357670 | |
Paul van Tilburg | 365b847313 | |
Paul van Tilburg | e1d70e8a59 | |
Paul van Tilburg | e268a6ebca | |
Paul van Tilburg | 93e8295c96 | |
Paul van Tilburg | 1d35b88aba | |
Paul van Tilburg | ddcb375345 | |
Paul van Tilburg | e0151c3cde | |
Paul van Tilburg | ef13f7e4f2 | |
Paul van Tilburg | d787c8b3ab | |
Paul van Tilburg | 5a2889a0f2 | |
Paul van Tilburg | 18b52cd422 | |
Paul van Tilburg | 70b117d11d | |
Paul van Tilburg | 2b5a64b6b0 | |
Paul van Tilburg | 01416ee136 | |
Paul van Tilburg | 536b1564b9 |
|
@ -5,9 +5,6 @@ target
|
|||
Dockerfile*
|
||||
docker-compose*
|
||||
|
||||
# Git folder
|
||||
.git
|
||||
|
||||
# Dot files
|
||||
.gitignore
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
name: "Check and lint using Cargo"
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
- push
|
||||
- workflow_dispatch
|
||||
|
||||
jobs:
|
||||
check_lint:
|
||||
name: Check and lint
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Rust stable toolchain
|
||||
uses: https://github.com/actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Run cargo check
|
||||
uses: https://github.com/actions-rs/cargo@v1
|
||||
with:
|
||||
command: check
|
||||
|
||||
- name: Run cargo clippy
|
||||
uses: https://github.com/actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: -- -D warnings
|
||||
|
||||
- name: Run cargo fmt
|
||||
uses: https://github.com/actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
|
||||
# TODO: Add a test suite first!
|
||||
# - name: Run cargo test
|
||||
# uses: https://github.com/actions-rs/cargo@v1
|
||||
# with:
|
||||
# command: test
|
||||
# args: --all-features
|
|
@ -0,0 +1,122 @@
|
|||
name: "Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: "Release"
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine the version of the release
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Releasing version: $VERSION"
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Get the release notes from the changelog
|
||||
run: |
|
||||
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
|
||||
RELEASE_NOTES=$(sed -n -e "/^## \[$VERSION\]/,/^## \[/{//"'!'"p;}" CHANGELOG.md | sed -e '1d;$d')
|
||||
echo "Release notes:"
|
||||
echo
|
||||
echo "$RELEASE_NOTES"
|
||||
echo "RELEASE_NOTES<<$EOF" >> "$GITHUB_ENV"
|
||||
echo "$RELEASE_NOTES" >> "$GITHUB_ENV"
|
||||
echo "$EOF" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '>=1.20.1'
|
||||
|
||||
- name: Release to Gitea
|
||||
uses: actions/release-action@main
|
||||
with:
|
||||
# This is available by default.
|
||||
api_key: '${{ secrets.RELEASE_TOKEN }}'
|
||||
files: FIXME
|
||||
title: 'Release ${{ env.VERSION }}'
|
||||
body: '${{ env.RELEASE_NOTES }}'
|
||||
|
||||
release-crate:
|
||||
name: "Release Rust crate"
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Rust stable toolchain
|
||||
uses: https://github.com/actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Use sparse Cargo index for crates.io
|
||||
run: echo -e '[registries.crates-io]\nprotocol = "sparse"' >> /root/.cargo/config.toml
|
||||
|
||||
- name: Register the Gitea crate registry with Cargo
|
||||
run: echo -e '[registries.luon]\nindex = "https://git.luon.net/paul/_cargo-index.git"' >> /root/.cargo/config.toml
|
||||
|
||||
- name: Run cargo publish
|
||||
uses: https://github.com/actions-rs/cargo@v1
|
||||
env:
|
||||
# This needs to be provided for the repository; no login necessary as a result.
|
||||
CARGO_REGISTRIES_LUON_TOKEN: '${{ secrets.CARGO_TOKEN }}'
|
||||
with:
|
||||
command: publish
|
||||
args: --registry luon
|
||||
|
||||
release-docker-image:
|
||||
name: "Release Docker image"
|
||||
runs-on: debian-latest
|
||||
container:
|
||||
image: ghcr.io/catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: https://github.com/docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
git.luon.net/paul/solar-grabber
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to the Gitea Docker registry
|
||||
uses: https://github.com/docker/login-action@v2
|
||||
with:
|
||||
registry: git.luon.net
|
||||
username: ${{ github.repository_owner }}
|
||||
# This needs to be provided by the repository owner and have the packages scopes enabled.
|
||||
# Note that the default `GITEA_TOKEN` secret does not have this scope enabled.
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker build and push
|
||||
uses: https://github.com/docker/build-push-action@v4
|
||||
env:
|
||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
102
CHANGELOG.md
102
CHANGELOG.md
|
@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.5] - 2024-02-27
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix clippy issue
|
||||
|
||||
## [0.3.4] - 2024-02-27
|
||||
|
||||
### Security
|
||||
|
||||
* Updated dependencies, fixes security advisories:
|
||||
* [RUSTSEC-2024-0003](https://rustsec.org/advisories/RUSTSEC-2024-0003)
|
||||
* [RUSTSEC-2023-0072](https://rustsec.org/advisories/RUSTSEC-2024-0072)
|
||||
|
||||
## [0.3.3] - 2023-11-03
|
||||
|
||||
### Security
|
||||
|
||||
* Update dependencies ([RUSTSEC-2020-0071](https://rustsec.org/advisories/RUSTSEC-2020-0071.html))
|
||||
|
||||
### Changed
|
||||
|
||||
* Switch to Rocket 0.5 RC4
|
||||
|
||||
## [0.3.2] - 2023-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
* Switch to Debian bookworm Docker image for runtime; fixes Docker image
|
||||
|
||||
## [0.3.1] - 2023-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
* Fix and improve Gitea Actions workflow
|
||||
|
||||
### Security
|
||||
|
||||
* Update dependencies ([RUSTSEC-2023-0044](https://rustsec.org/advisories/RUSTSEC-2023-0044))
|
||||
|
||||
## [0.3.0] - 2023-04-15
|
||||
|
||||
### Added
|
||||
|
||||
* Implement backoff for login/update API call failures (#8)
|
||||
|
||||
### Changed
|
||||
|
||||
* Update dependencies
|
||||
* Speed up Docker image builds by using sparse Cargo index for crates.io
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fix login errors not being detected for My Autarco
|
||||
* Fix missing build script/git repository during Docker image build
|
||||
|
||||
## [0.2.2] - 2023-03-22
|
||||
|
||||
### Added
|
||||
|
||||
* Implement error catchers for all endpoints (#5)
|
||||
* Print the version on lift off (#6)
|
||||
* Add `/version` endpoint to the API (#6)
|
||||
* Add Gitea Actions workflow for cargo
|
||||
|
||||
### Fixed
|
||||
|
||||
* Fixed/tweaked documentation
|
||||
|
||||
### Security
|
||||
|
||||
* Update dependencies ([RUSTSEC-2023-0018](https://rustsec.org/advisories/RUSTSEC-2023-0018.html))
|
||||
|
||||
## [0.2.1] - 2023-01-16
|
||||
|
||||
### Changed
|
||||
|
||||
* Change poll interval for Hoymiles to 5 minutes
|
||||
* Catch and raise error when Hoymiles API data responses cannot be deserialized
|
||||
* Use stderr for error messages (and change prefix emoji)
|
||||
* Use the `serde` crate via Rocket,; drop depend on the `serde` crate itself
|
||||
|
||||
### Fixed
|
||||
|
||||
* Also set the state class in HA sensors example
|
||||
* Improve deserialization of Hoymiles API responses (#7)
|
||||
* Prevent total energy reported decreasing for Hoymiles (#7)
|
||||
* Set correct `last_updated` field in status report for Hoymiles (#7)
|
||||
* Set cookie to configure Hoymiles API language to English (#7)
|
||||
* Detect when Hoymiles (login/data) API response are not correct (#7)
|
||||
* Small formatting, error message and documentation fixes
|
||||
|
||||
## [0.2.0] - 2023-01-13
|
||||
|
||||
### Added
|
||||
|
@ -29,6 +121,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
Rename Autarco Scraper project to Solar Grabber.
|
||||
|
||||
[Unreleased]: https://git.luon.net/paul/solar-grabber/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://git.luon.net/paul/solar-grabber/compare/v0.3.5...HEAD
|
||||
[0.3.5]: https://git.luon.net/paul/solar-grabber/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://git.luon.net/paul/solar-grabber/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://git.luon.net/paul/solar-grabber/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://git.luon.net/paul/solar-grabber/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://git.luon.net/paul/solar-grabber/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://git.luon.net/paul/solar-grabber/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://git.luon.net/paul/solar-grabber/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://git.luon.net/paul/solar-grabber/compare/v0.2.0...v0.2.1
|
||||
[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
|
||||
|
|
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "solar-grabber"
|
||||
version = "0.2.0"
|
||||
version = "0.3.5"
|
||||
authors = ["Paul van Tilburg <paul@luon.net>"]
|
||||
edition = "2021"
|
||||
description = """"
|
||||
|
@ -10,18 +10,22 @@ get statistical data of your solar panels.
|
|||
readme = "README.md"
|
||||
repository = "https://git.luon.net/paul/solar-grabber"
|
||||
license = "MIT"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.23", features = ["serde"] }
|
||||
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"
|
||||
rocket = { version = "0.5.0-rc.3", features = ["json"] }
|
||||
thiserror = "1.0.38"
|
||||
url = "2.2.2"
|
||||
|
||||
[build-dependencies]
|
||||
vergen = { version = "8.1.1", features = ["build", "git", "gitcl"] }
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Paul van Tilburg <paul@luon.net>"
|
||||
copyright = "2022, Paul van Tilburg"
|
||||
|
|
|
@ -10,10 +10,12 @@ FROM docker.io/rust:1 as builder
|
|||
RUN USER=root cargo new --bin /usr/src/solar-grabber
|
||||
WORKDIR /usr/src/solar-grabber
|
||||
COPY ./Cargo.* ./
|
||||
RUN sed -i -e 's/^build =/#build =/' Cargo.toml
|
||||
RUN cargo build --release
|
||||
RUN rm src/*.rs
|
||||
|
||||
# Add the real project files from current folder
|
||||
COPY ./Cargo.toml ./
|
||||
ADD . ./
|
||||
|
||||
# Build the actual binary from the copied local files
|
||||
|
@ -22,7 +24,7 @@ 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
|
||||
FROM docker.io/debian:bookworm-slim
|
||||
|
||||
# Install CA certificates
|
||||
RUN apt-get update && \
|
||||
|
|
38
README.md
38
README.md
|
@ -82,7 +82,7 @@ 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
|
||||
## Status 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.
|
||||
|
@ -92,9 +92,9 @@ There is no path and no query parameters, just:
|
|||
GET /
|
||||
```
|
||||
|
||||
### Response
|
||||
### Status API Response
|
||||
|
||||
A response uses the JSON format and typically looks like this:
|
||||
The response uses the JSON format and typically looks like this:
|
||||
|
||||
```json
|
||||
{"current_w":23.0,"total_kwh":6159.0,"last_updated":1661194620}
|
||||
|
@ -104,6 +104,36 @@ 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.
|
||||
|
||||
### (Status) API Error Response
|
||||
|
||||
If the API endpoint is accessed before any statistical data has been retrieved,
|
||||
or if any other request than `GET /` is made, an error response is returned
|
||||
that looks like this:
|
||||
|
||||
```json
|
||||
{"error":"No status found (yet)"}
|
||||
```
|
||||
|
||||
## Version API Endpoint
|
||||
|
||||
The `/version` endpoint provides information of the current version and build
|
||||
of the service. This can be used to check if it needs to be updated.
|
||||
Again, there is no path and no query parameters, just:
|
||||
|
||||
```http
|
||||
GET /version
|
||||
```
|
||||
|
||||
### Version API Response
|
||||
|
||||
The response uses the JSON format and typically looks like this:
|
||||
|
||||
```json
|
||||
{"version":"0.2.1","timestamp":"2023-01-29T14:10:24.971748027Z","git_sha":"5cbc3a04","git_timestamp":"2023-01-16T20:18:20Z"}
|
||||
```
|
||||
|
||||
(Build and git information may be out of date.)
|
||||
|
||||
## Integration in Home Assistant
|
||||
|
||||
To integrate the Solar Grabber service in your [Home Assistant](https://www.home-assistant.io/)
|
||||
|
@ -134,6 +164,7 @@ sensors:
|
|||
value_template: '{{ value_json.current_w }}'
|
||||
unit_of_measurement: W
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
|
||||
- platform: rest
|
||||
name: "Photovoltaic Invertor Total Energy Production"
|
||||
|
@ -141,6 +172,7 @@ sensors:
|
|||
value_template: '{{ value_json.total_kwh }}'
|
||||
unit_of_measurement: kWh
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
```
|
||||
|
||||
This assumes your Solar Grabber is running at <http://solar-grabber.domain.tld:2399>.
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
use std::error::Error;
|
||||
use vergen::EmitBuilder;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Generate the `cargo:` instructions to fill the appropriate environment variables.
|
||||
EmitBuilder::builder().all_build().all_git().emit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
100
src/lib.rs
100
src/lib.rs
|
@ -20,10 +20,13 @@ 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 rocket::{
|
||||
catch, catchers,
|
||||
fairing::AdHoc,
|
||||
get, routes,
|
||||
serde::{json::Json, Deserialize, Serialize},
|
||||
Build, Request, Rocket,
|
||||
};
|
||||
|
||||
use self::update::update_loop;
|
||||
|
||||
|
@ -32,6 +35,7 @@ static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
|
|||
|
||||
/// The configuration loaded additionally by Rocket.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Config {
|
||||
/// The service-specific configuration
|
||||
service: services::Config,
|
||||
|
@ -39,26 +43,89 @@ struct Config {
|
|||
|
||||
/// The current photovoltaic invertor status.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Status {
|
||||
/// Current power production (W)
|
||||
/// The current power production (W).
|
||||
current_w: f32,
|
||||
/// Total energy produced since installation (kWh)
|
||||
/// The total energy produced since installation (kWh).
|
||||
total_kwh: f32,
|
||||
/// Timestamp of last update
|
||||
/// The (UNIX) timestamp of when the status was last updated.
|
||||
last_updated: u64,
|
||||
}
|
||||
|
||||
/// An error used as JSON response.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Error {
|
||||
/// The error message.
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Creates a new error result from a message.
|
||||
fn from(message: impl AsRef<str>) -> Self {
|
||||
let error = String::from(message.as_ref());
|
||||
|
||||
Self { error }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current (last known) status.
|
||||
#[get("/", format = "application/json")]
|
||||
async fn status() -> Option<Json<Status>> {
|
||||
async fn status() -> Result<Json<Status>, Json<Error>> {
|
||||
let status_guard = STATUS.lock().expect("Status mutex was poisoined");
|
||||
status_guard.map(Json)
|
||||
status_guard
|
||||
.map(Json)
|
||||
.ok_or_else(|| Json(Error::from("No status found (yet)")))
|
||||
}
|
||||
|
||||
/// The version information as JSON response.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct VersionInfo {
|
||||
/// The version of the build.
|
||||
version: String,
|
||||
/// The timestamp of the build.
|
||||
timestamp: String,
|
||||
/// The (most recent) git SHA used for the build.
|
||||
git_sha: String,
|
||||
/// The timestamp of the last git commit used for the build.
|
||||
git_timestamp: String,
|
||||
}
|
||||
|
||||
impl VersionInfo {
|
||||
/// Retrieves the version information from the environment variables.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version: String::from(env!("CARGO_PKG_VERSION")),
|
||||
timestamp: String::from(env!("VERGEN_BUILD_TIMESTAMP")),
|
||||
git_sha: String::from(&env!("VERGEN_GIT_SHA")[0..7]),
|
||||
git_timestamp: String::from(env!("VERGEN_GIT_COMMIT_TIMESTAMP")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the version information.
|
||||
#[get("/version", format = "application/json")]
|
||||
async fn version() -> Result<Json<VersionInfo>, Json<Error>> {
|
||||
Ok(Json(VersionInfo::new()))
|
||||
}
|
||||
|
||||
/// Default catcher for any unsuppored request
|
||||
#[catch(default)]
|
||||
fn unsupported(status: rocket::http::Status, _request: &Request<'_>) -> Json<Error> {
|
||||
let code = status.code;
|
||||
|
||||
Json(Error::from(format!(
|
||||
"Unhandled/unsupported API call or path (HTTP {code})"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Creates a Rocket and attaches the config parsing and update loop as fairings.
|
||||
pub fn setup() -> Rocket<Build> {
|
||||
rocket::build()
|
||||
.mount("/", routes![status])
|
||||
.mount("/", routes![status, version])
|
||||
.register("/", catchers![unsupported])
|
||||
.attach(AdHoc::config::<Config>())
|
||||
.attach(AdHoc::on_liftoff("Updater", |rocket| {
|
||||
Box::pin(async move {
|
||||
|
@ -68,8 +135,17 @@ pub fn setup() -> Rocket<Build> {
|
|||
.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));
|
||||
// We don't care about the join handle nor error results?
|
||||
let _service = rocket::tokio::spawn(update_loop(service));
|
||||
})
|
||||
}))
|
||||
.attach(AdHoc::on_liftoff("Version", |_| {
|
||||
Box::pin(async move {
|
||||
let name = env!("CARGO_PKG_NAME");
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let git_sha = &env!("VERGEN_GIT_SHA")[0..7];
|
||||
|
||||
println!("☀️ Started {name} v{version} (git @{git_sha})");
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -4,8 +4,7 @@ pub(crate) mod hoymiles;
|
|||
pub(crate) mod my_autarco;
|
||||
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use rocket::async_trait;
|
||||
use serde::Deserialize;
|
||||
use rocket::{async_trait, serde::Deserialize};
|
||||
|
||||
use crate::Status;
|
||||
|
||||
|
@ -27,6 +26,25 @@ pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The errors that can occur during service API transactions.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum Error {
|
||||
/// The service is not or no longer authorized to perform requests.
|
||||
///
|
||||
/// This usually indicates that the service needs to login again.
|
||||
#[error("not/no longer authorized")]
|
||||
NotAuthorized,
|
||||
/// The service encountered some other API request error.
|
||||
#[error("API request error: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
/// The service encountered an unsupported API response.
|
||||
#[error("API service error: {0}")]
|
||||
Response(String),
|
||||
}
|
||||
|
||||
/// Type alias for service results.
|
||||
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
/// The supported cloud services.
|
||||
#[enum_dispatch(Service)]
|
||||
pub(crate) enum Services {
|
||||
|
@ -44,8 +62,8 @@ pub(crate) trait Service {
|
|||
fn poll_interval(&self) -> u64;
|
||||
|
||||
/// Perfoms a login on the cloud service (if necessary).
|
||||
async fn login(&self) -> Result<(), reqwest::Error>;
|
||||
async fn login(&mut self) -> Result<()>;
|
||||
|
||||
/// Retrieves a status update using the API of the cloud service.
|
||||
async fn update(&self, timestamp: u64) -> Result<Status, reqwest::Error>;
|
||||
async fn update(&mut self, timestamp: u64) -> Result<Status>;
|
||||
}
|
||||
|
|
|
@ -6,22 +6,37 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
|
||||
use md5::{Digest, Md5};
|
||||
use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url};
|
||||
use rocket::async_trait;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use rocket::{
|
||||
async_trait,
|
||||
serde::{json::Value as JsonValue, Deserialize, Deserializer, Serialize},
|
||||
};
|
||||
use url::ParseError;
|
||||
|
||||
use crate::Status;
|
||||
use crate::{
|
||||
services::{Error, Result},
|
||||
Status,
|
||||
};
|
||||
|
||||
/// The base URL of Hoymiles API gateway.
|
||||
const BASE_URL: &str = "https://global.hoymiles.com/platform/api/gateway";
|
||||
|
||||
/// The date/time format used by the Hoymiles API.
|
||||
const DATE_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
|
||||
|
||||
/// The language to switch the API to.
|
||||
///
|
||||
/// If not set, it seems it uses `zh_cn`.
|
||||
const LANGUAGE: &str = "en_us";
|
||||
|
||||
/// The interval between data polls (in seconds).
|
||||
const POLL_INTERVAL: u64 = 900;
|
||||
const POLL_INTERVAL: u64 = 300;
|
||||
|
||||
/// The configuration necessary to access the Hoymiles.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Config {
|
||||
/// The username of the account to login with
|
||||
username: String,
|
||||
|
@ -32,15 +47,17 @@ pub(crate) struct Config {
|
|||
}
|
||||
|
||||
/// Instantiates the Hoymiles service.
|
||||
pub(crate) fn service(config: Config) -> Result<Service, reqwest::Error> {
|
||||
pub(crate) fn service(config: Config) -> Result<Service> {
|
||||
let cookie_jar = Arc::new(CookieJar::default());
|
||||
let client = ClientBuilder::new()
|
||||
.cookie_provider(Arc::clone(&cookie_jar))
|
||||
.build()?;
|
||||
let total_kwh = 0f32;
|
||||
let service = Service {
|
||||
client,
|
||||
config,
|
||||
cookie_jar,
|
||||
total_kwh,
|
||||
};
|
||||
|
||||
Ok(service)
|
||||
|
@ -55,6 +72,8 @@ pub(crate) struct Service {
|
|||
config: Config,
|
||||
/// The cookie jar used for API requests.
|
||||
cookie_jar: Arc<CookieJar>,
|
||||
/// The last known total produced energy value.
|
||||
total_kwh: f32,
|
||||
}
|
||||
|
||||
/// Returns the login URL for the Hoymiles site.
|
||||
|
@ -67,22 +86,107 @@ fn api_url() -> Result<Url, ParseError> {
|
|||
Url::parse(&format!("{BASE_URL}/pvm-data/data_count_station_real_data"))
|
||||
}
|
||||
|
||||
/// Captures JSON values that can either be a string or an object.
|
||||
///
|
||||
/// This is used for the API responses where the data field is either an object or an empty string
|
||||
/// instead of `null`. If the response is not deserializable object, the JSON value is preserved
|
||||
/// for debugging purposes.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde", untagged)]
|
||||
enum StringOrObject<'a, T> {
|
||||
/// The value is an object (deserializable as type `T`).
|
||||
Object(T),
|
||||
/// The value is a string.
|
||||
String(&'a str),
|
||||
/// The value is not some JSON value not deserializable as type `T`.
|
||||
Value(JsonValue),
|
||||
}
|
||||
|
||||
/// Deserialize either a string or an object as an option of type `T`.
|
||||
fn from_empty_str_or_object<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
D::Error: rocket::serde::de::Error,
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
use rocket::serde::de::Error;
|
||||
|
||||
match <StringOrObject<'_, T>>::deserialize(deserializer) {
|
||||
Ok(StringOrObject::String("")) => Ok(None),
|
||||
Ok(StringOrObject::String(_)) => Err(Error::custom("Non-empty string not allowed here")),
|
||||
Ok(StringOrObject::Object(t)) => Ok(Some(t)),
|
||||
Ok(StringOrObject::Value(j)) => Err(Error::custom(&format!(
|
||||
"Undeserializable JSON object: {}",
|
||||
j
|
||||
))),
|
||||
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a string ([`&str`]) into a date/time ([`DateTime<Local>`]).
|
||||
fn from_date_time_str<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
D::Error: rocket::serde::de::Error,
|
||||
{
|
||||
use rocket::serde::de::Error;
|
||||
|
||||
let s = <&str>::deserialize(deserializer)?;
|
||||
let dt = NaiveDateTime::parse_from_str(s, DATE_TIME_FORMAT).map_err(D::Error::custom)?;
|
||||
|
||||
Local
|
||||
.from_local_datetime(&dt)
|
||||
.latest()
|
||||
.ok_or_else(|| D::Error::custom("time representation is invalid for server time zone"))
|
||||
}
|
||||
|
||||
/// Deserializes a string ([`&str`]) into a float ([`f32`]).
|
||||
///
|
||||
/// This is used for the API responses where the value is a float put into a string.
|
||||
fn from_float_str<'de, D>(deserializer: D) -> Result<f32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
D::Error: rocket::serde::de::Error,
|
||||
{
|
||||
use rocket::serde::de::Error;
|
||||
|
||||
let s = <&str>::deserialize(deserializer)?;
|
||||
s.parse::<f32>().map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
/// Deserializes a string ([`&str`]) into an integer ([`u16`]).
|
||||
///
|
||||
/// This is used for the API responses where the value is an integer put into a string.
|
||||
fn from_integer_str<'de, D>(deserializer: D) -> Result<u16, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
D::Error: rocket::serde::de::Error,
|
||||
{
|
||||
use rocket::serde::de::Error;
|
||||
|
||||
let s = <&str>::deserialize(deserializer)?;
|
||||
s.parse::<u16>().map_err(D::Error::custom)
|
||||
}
|
||||
|
||||
/// The request passed to the API login endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiLoginRequest {
|
||||
/// The body of the API login request.
|
||||
body: ApiLoginRequestBody,
|
||||
}
|
||||
|
||||
impl ApiLoginRequest {
|
||||
/// Creates a new API login request.
|
||||
fn new(username: &str, password: &str) -> Self {
|
||||
let user_name = username.to_owned();
|
||||
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(),
|
||||
user_name,
|
||||
password,
|
||||
};
|
||||
|
||||
|
@ -92,23 +196,32 @@ impl ApiLoginRequest {
|
|||
|
||||
/// The request body passed to the API login endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiLoginRequestBody {
|
||||
/// The username to login with.
|
||||
password: String,
|
||||
/// The password to login with.
|
||||
user_name: String,
|
||||
}
|
||||
|
||||
/// The response returned by the API login endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiLoginResponse {
|
||||
// status: String,
|
||||
// message: String,
|
||||
/// The embedded response data
|
||||
data: ApiLoginResponseData,
|
||||
/// The status (error) code as a string: 0 for OK, another number for error.
|
||||
#[serde(deserialize_with = "from_integer_str")]
|
||||
status: u16,
|
||||
/// The status message.
|
||||
message: String,
|
||||
/// The embedded response data.
|
||||
#[serde(deserialize_with = "from_empty_str_or_object")]
|
||||
data: Option<ApiLoginResponseData>,
|
||||
// systemNotice: Option<String>,
|
||||
}
|
||||
|
||||
/// The response data returned by the API login endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiLoginResponseData {
|
||||
/// The token to be used as cookie for API data requests.
|
||||
token: String,
|
||||
|
@ -116,7 +229,9 @@ struct ApiLoginResponseData {
|
|||
|
||||
/// The request passed to the API data endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiDataRequest {
|
||||
/// The body of the API data request.
|
||||
body: ApiDataRequestBody,
|
||||
}
|
||||
|
||||
|
@ -131,34 +246,30 @@ impl ApiDataRequest {
|
|||
|
||||
/// The request body passed to the API data endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiDataRequestBody {
|
||||
/// The ID of the Hoymiles station.
|
||||
sid: u32,
|
||||
}
|
||||
|
||||
/// The response returned by the API data endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiDataResponse {
|
||||
// status: String,
|
||||
// message: String,
|
||||
// /// The embedded response data
|
||||
data: ApiDataResponseData,
|
||||
/// The status (error) code as a string: 0 for OK, another number for error.
|
||||
#[serde(deserialize_with = "from_integer_str")]
|
||||
status: u16,
|
||||
/// The status message.
|
||||
message: String,
|
||||
/// The embedded response data.
|
||||
#[serde(deserialize_with = "from_empty_str_or_object")]
|
||||
data: Option<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)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiDataResponseData {
|
||||
/// Energy produced today (Wh)
|
||||
#[serde(deserialize_with = "from_float_str")]
|
||||
|
@ -174,7 +285,8 @@ struct ApiDataResponseData {
|
|||
// co2_emission_reducation: f32,
|
||||
// plant_tree: u32,
|
||||
// data_time: String,
|
||||
// last_data_time: String,
|
||||
#[serde(deserialize_with = "from_date_time_str")]
|
||||
last_data_time: DateTime<Local>,
|
||||
// capacitor: f32,
|
||||
// is_balance: bool,
|
||||
// is_reflux: bool,
|
||||
|
@ -185,7 +297,8 @@ struct ApiDataResponseData {
|
|||
impl super::Service for Service {
|
||||
/// The interval between data polls (in seconds).
|
||||
///
|
||||
/// Hoymiles processes provides information from the invertor every 15 minutes.
|
||||
/// Hoymiles processes information from the invertor about every 15 minutes. Since this is not
|
||||
/// really exact, we need to poll at a higher rate to detect changes faster!
|
||||
fn poll_interval(&self) -> u64 {
|
||||
POLL_INTERVAL
|
||||
}
|
||||
|
@ -195,18 +308,40 @@ impl super::Service for Service {
|
|||
/// 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> {
|
||||
async fn login(&mut self) -> Result<()> {
|
||||
let base_url = Url::parse(BASE_URL).expect("valid base URL");
|
||||
let login_url = login_url().expect("valid login URL");
|
||||
|
||||
// Insert the cookie `hm_token_language` to specific the API language into the cookie jar.
|
||||
let lang_cookie = format!("hm_token_language={}", LANGUAGE);
|
||||
self.cookie_jar.add_cookie_str(&lang_cookie, &base_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 = 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),
|
||||
Ok(res) => {
|
||||
let login_response = res.json::<ApiLoginResponse>().await?;
|
||||
match login_response.status {
|
||||
0 => login_response.data.expect("No API response data found"),
|
||||
1 => return Err(Error::NotAuthorized),
|
||||
_ => {
|
||||
return Err(Error::Response(format!(
|
||||
"{} ({})",
|
||||
login_response.message, login_response.status
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
// 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);
|
||||
let token_cookie = format!("hm_token={}", login_response_data.token);
|
||||
self.cookie_jar.add_cookie_str(&token_cookie, &base_url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -216,16 +351,43 @@ impl super::Service for Service {
|
|||
/// 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> {
|
||||
async fn update(&mut self, _last_updated: u64) -> Result<Status> {
|
||||
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_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),
|
||||
Ok(res) => {
|
||||
let api_response = res.json::<ApiDataResponse>().await?;
|
||||
match api_response.status {
|
||||
0 => api_response.data.expect("No API response data found"),
|
||||
1 | 100 => return Err(Error::NotAuthorized),
|
||||
_ => {
|
||||
return Err(Error::Response(format!(
|
||||
"{} ({})",
|
||||
api_response.message, api_response.status
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
let current_w = api_data.real_power;
|
||||
let total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0;
|
||||
let mut total_kwh = (api_data.total_eq + api_data.today_eq) / 1000.0;
|
||||
let last_updated = api_data.last_data_time.timestamp() as u64;
|
||||
|
||||
// Sometimes it can be that `today_eq` is reset when the day switches but it has not been
|
||||
// added to `total_eq` yet. The `total_eq` should always be non-decreasing, so return the
|
||||
// last known value until this is corrected (this most suredly happens during the night).
|
||||
if total_kwh <= self.total_kwh {
|
||||
total_kwh = self.total_kwh
|
||||
} else {
|
||||
self.total_kwh = total_kwh;
|
||||
}
|
||||
|
||||
Ok(Status {
|
||||
current_w,
|
||||
|
|
|
@ -4,12 +4,14 @@
|
|||
//! 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 reqwest::{Client, ClientBuilder, StatusCode, Url};
|
||||
use rocket::{async_trait, serde::Deserialize};
|
||||
use url::ParseError;
|
||||
|
||||
use crate::Status;
|
||||
use crate::{
|
||||
services::{Error, Result},
|
||||
Status,
|
||||
};
|
||||
|
||||
/// The base URL of My Autarco site.
|
||||
const BASE_URL: &str = "https://my.autarco.com";
|
||||
|
@ -19,6 +21,7 @@ const POLL_INTERVAL: u64 = 300;
|
|||
|
||||
/// The configuration necessary to access the My Autarco API.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Config {
|
||||
/// The username of the account to login with
|
||||
username: String,
|
||||
|
@ -29,7 +32,7 @@ pub(crate) struct Config {
|
|||
}
|
||||
|
||||
/// Instantiates the My Autarco service.
|
||||
pub(crate) fn service(config: Config) -> Result<Service, reqwest::Error> {
|
||||
pub(crate) fn service(config: Config) -> Result<Service> {
|
||||
let client = ClientBuilder::new().cookie_store(true).build()?;
|
||||
let service = Service { client, config };
|
||||
|
||||
|
@ -57,6 +60,7 @@ fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
|
|||
|
||||
/// The energy data returned by the energy API endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiEnergy {
|
||||
/// Total energy produced today (kWh)
|
||||
// pv_today: u32,
|
||||
|
@ -68,6 +72,7 @@ struct ApiEnergy {
|
|||
|
||||
/// The power data returned by the power API endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct ApiPower {
|
||||
/// Current power production (W)
|
||||
pv_now: u32,
|
||||
|
@ -86,35 +91,41 @@ impl super::Service for Service {
|
|||
///
|
||||
/// 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> {
|
||||
async fn login(&mut self) -> Result<()> {
|
||||
let login_url = login_url().expect("valid login URL");
|
||||
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(())
|
||||
let response = self.client.post(login_url).form(¶ms).send().await?;
|
||||
match response.error_for_status() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => Err(Error::NotAuthorized),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
async fn update(&mut self, last_updated: u64) -> Result<Status> {
|
||||
// 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),
|
||||
Err(err) if err.status() == Some(StatusCode::UNAUTHORIZED) => {
|
||||
return Err(Error::NotAuthorized)
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
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),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
Ok(Status {
|
||||
|
|
|
@ -2,19 +2,36 @@
|
|||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use rocket::tokio::time::sleep;
|
||||
|
||||
use crate::{
|
||||
services::{Service, Services},
|
||||
services::{Error, Service, Services},
|
||||
STATUS,
|
||||
};
|
||||
|
||||
/// The default sleep interval to use between checks.
|
||||
const DEFAULT_SLEEP_INTERVAL: u64 = 10;
|
||||
|
||||
/// The sleep interval upper limit when applying exponential backoff.
|
||||
const MAX_SLEEP_INTERVAL: u64 = 320;
|
||||
|
||||
/// The backoff factor.
|
||||
const BACKOFF_FACTOR: f64 = 2.0;
|
||||
|
||||
/// Calculates the new interval by applying the backoff factor and taking the maximum into account.
|
||||
fn back_off(interval: u64) -> u64 {
|
||||
let new_interval = (interval as f64 * BACKOFF_FACTOR) as u64;
|
||||
|
||||
new_interval.min(MAX_SLEEP_INTERVAL)
|
||||
}
|
||||
|
||||
/// Main update loop that logs in and periodically acquires updates from the API.
|
||||
///
|
||||
/// 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<()> {
|
||||
let mut service = service;
|
||||
|
||||
// Log in on the cloud service.
|
||||
println!("⚡ Logging in...");
|
||||
service.login().await?;
|
||||
|
@ -22,9 +39,10 @@ pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
|||
|
||||
let mut last_updated = 0;
|
||||
let poll_interval = service.poll_interval();
|
||||
let mut sleep_interval = DEFAULT_SLEEP_INTERVAL;
|
||||
loop {
|
||||
// Wake up every 10 seconds and check if an update is due.
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
sleep(Duration::from_secs(sleep_interval)).await;
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
|
@ -36,17 +54,26 @@ pub(super) async fn update_loop(service: Services) -> color_eyre::Result<()> {
|
|||
|
||||
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...");
|
||||
service.login().await?;
|
||||
Err(Error::NotAuthorized) => {
|
||||
eprintln!("💥 Update unauthorized, trying to log in again...");
|
||||
if let Err(e) = service.login().await {
|
||||
eprintln!("💥 Login failed: {e}; will retry in {sleep_interval} seconds...");
|
||||
sleep_interval = back_off(sleep_interval);
|
||||
continue;
|
||||
};
|
||||
println!("⚡ Logged in successfully!");
|
||||
sleep_interval = DEFAULT_SLEEP_INTERVAL;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
println!("✨ Failed to update status: {}", e);
|
||||
eprintln!(
|
||||
"💥 Failed to update status: {e}; will retry in {sleep_interval} seconds..."
|
||||
);
|
||||
sleep_interval = back_off(sleep_interval);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
sleep_interval = DEFAULT_SLEEP_INTERVAL;
|
||||
last_updated = timestamp;
|
||||
|
||||
println!("⚡ Updated status to: {:#?}", status);
|
||||
|
|
Loading…
Reference in New Issue