Compare commits

...

67 Commits
v0.2.0 ... main

Author SHA1 Message Date
Paul van Tilburg 03c51e2a2c
Bump the version to 0.3.5
Check and lint using Cargo / Check and lint (push) Successful in 2m10s Details
Release / Release (push) Successful in 1m14s Details
Release / Release Rust crate (push) Successful in 2m53s Details
Release / Release Docker image (push) Successful in 8m41s Details
2024-02-27 15:37:56 +01:00
Paul van Tilburg cc1a0e4748
Update the changelog 2024-02-27 15:37:35 +01:00
Paul van Tilburg d2b90c16f6
Fix clippy issue 2024-02-27 15:37:05 +01:00
Paul van Tilburg d5eed08072
Bump the version to 0.3.4
Check and lint using Cargo / Check and lint (push) Has been cancelled Details
Release / Release (push) Has been cancelled Details
Release / Release Rust crate (push) Has been cancelled Details
Release / Release Docker image (push) Has been cancelled Details
2024-02-27 15:28:09 +01:00
Paul van Tilburg 247e9d51f5
Update the changelog 2024-02-27 15:27:21 +01:00
Paul van Tilburg 5241d90e79
Cargo update; fixes several security advisories
Fixes RUSTSEC-2024-0003 and RUSTSEC-2023-0072
2024-02-27 15:25:21 +01:00
Paul van Tilburg c61bbfef5b
Bump the version to 0.3.3
Check and lint using Cargo / Check and lint (push) Successful in 2m35s Details
Release / Release (push) Successful in 1m14s Details
Release / Release Rust crate (push) Successful in 3m28s Details
Release / Release Docker image (push) Successful in 11m19s Details
2023-11-03 21:25:03 +01:00
Paul van Tilburg f3ff02e4ff
Update the changelog 2023-11-03 21:24:22 +01:00
Paul van Tilburg f2be089fb9
Add missing dates 2023-11-03 21:16:26 +01:00
Paul van Tilburg 45b3f52e71
Cargo update; fixes RUSTSEC-2020-0071
Fix the usage of a deprecated method.
2023-11-03 21:12:04 +01:00
Paul van Tilburg ff12875a08
Bump the version to 0.3.2
Check and lint using Cargo / Check and lint (push) Successful in 2m41s Details
Release / Release (push) Successful in 1m20s Details
Release / Release Rust crate (push) Successful in 3m46s Details
Release / Release Docker image (push) Successful in 8m18s Details
2023-08-27 13:30:11 +02:00
Paul van Tilburg 1bf6a4e772
Update the changelog 2023-08-27 13:29:48 +02:00
Paul van Tilburg c6f7511fc7
Switch to Debian bookworm Docker image for runtime
The Rust Docker build image is also based on Bookworm and would lead to
a binary that is linked against OpenSSL 3 which would then not be
available in the bullseye runtime Docker image.
2023-08-27 13:28:28 +02:00
Paul van Tilburg 3fed86d36f
Bump the version to 0.3.1
Check and lint using Cargo / Check and lint (push) Successful in 3m15s Details
Release / Release (push) Successful in 1m26s Details
Release / Release Rust crate (push) Successful in 4m15s Details
Release / Release Docker image (push) Successful in 11m37s Details
2023-08-26 11:50:26 +02:00
Paul van Tilburg d3cc19524b
Update the changelog 2023-08-26 11:48:55 +02:00
Paul van Tilburg 92c75d09b9
Fix and improve the release workflow
Ad the relevant part of the changelog as release notes to the release
and fix some schema-related issues.
2023-08-26 11:47:31 +02:00
Paul van Tilburg e1319dcfc2
Cargo update 2023-08-26 11:44:56 +02:00
Paul van Tilburg 0f1bc9d83d
Fix typo in comment
Check and lint using Cargo / Check and lint (push) Successful in 5m4s Details
2023-07-17 21:50:13 +02:00
Paul van Tilburg 0068f6e9de
Cargo update
Check and lint using Cargo / Check and lint (push) Successful in 3m9s Details
2023-06-08 11:10:06 +02:00
Paul van Tilburg caad71389b
Also here no longer set sparse Cargo index for crates.io
Check and lint using Cargo / Check and lint (push) Failing after 26s Details
2023-06-08 11:01:13 +02:00
Paul van Tilburg 0b76db96f0
Use the personal Cargo token
Check and lint using Cargo / Check and lint (push) Successful in 2m57s Details
2023-06-08 10:58:33 +02:00
Paul van Tilburg 42a43cc83d
No longer configure using a sparse Cargo index for crates.io
Check and lint using Cargo / Check and lint (push) Successful in 2m53s Details
This is the default since Rust 1.70.
2023-06-06 07:47:23 +02:00
Paul van Tilburg 1aca61d3fd
Add a full release workflow
Check and lint using Cargo / Check and lint (push) Successful in 2m45s Details
2023-05-22 20:02:03 +02:00
Paul van Tilburg 35dda781a3
Tweak workflow step name 2023-05-22 19:56:52 +02:00
Paul van Tilburg 14bda61a9e
Fix name of Gitea Actions workflow
Check and lint using Cargo / Check and lint (push) Successful in 3m2s Details
2023-04-25 16:30:05 +02:00
Paul van Tilburg bc22fd2d70
Run cargo clippy right after check; install missing components
Check, test and lint using Cargo / Check and lint (push) Successful in 2m57s Details
2023-04-25 16:28:02 +02:00
Paul van Tilburg fd00ef0b4f
Simplify Gitea Actions check and lint workflow
Check, test and lint using Cargo / Check and lint (push) Failing after 2m50s Details
2023-04-25 16:23:32 +02:00
Paul van Tilburg c070877384
Bump the version to 0.3.0
Check, Test and Lint Using Cargo / Lints (push) Successful in 3m8s Details
Check, Test and Lint Using Cargo / Check (push) Successful in 2m39s Details
2023-04-15 12:16:30 +02:00
Paul van Tilburg 732d4b83f2
Update the changelog 2023-04-15 12:15:40 +02:00
Paul van Tilburg 12a797baa9
Cargo update
Check, Test and Lint Using Cargo / Check (push) Successful in 2m53s Details
Check, Test and Lint Using Cargo / Lints (push) Successful in 3m3s Details
2023-04-14 23:26:07 +02:00
Paul van Tilburg 5586ae4d70
Update build dependecy on the vergen crate to 8.1.1
This change allows for dropping the dependency on the `anyhow` crate.
2023-04-14 23:25:25 +02:00
Paul van Tilburg 59e3b53263
Implement backoff for login/update API calls (closes: #8)
Check, Test and Lint Using Cargo / Check (push) Successful in 3m45s Details
Check, Test and Lint Using Cargo / Lints (push) Successful in 3m35s Details
Start from an interval of 10 seconds, increase with a factor of 2.0 on
each failure up to a maximum of 320 seconds.

This commit also fixes an issue where the update loop would be aborted
if a relogin fails.
2023-04-14 23:02:21 +02:00
Paul van Tilburg f236499125
Fix login errors not being detected 2023-04-14 22:54:54 +02:00
Paul van Tilburg 02a4d1ca9b
Update to Rocket 0.5.0-rc.3
Check Details
Lints Details
2023-03-24 14:38:42 +01:00
Paul van Tilburg 3fff79a2cd
Fix missing build script/git repo during build
Check Details
Lints Details
When building the dependencies, the build script should not be
considered. When building the actual binary, the git repository needs to
be present and the build script should be run.
2023-03-22 15:27:12 +01:00
Paul van Tilburg 9200a10cef
Speed up build by using sparse Cargo index for crates.io 2023-03-22 15:26:45 +01:00
Paul van Tilburg beb49373fb
Bump the version to 0.2.2
Check Details
Lints Details
2023-03-22 15:00:35 +01:00
Paul van Tilburg 99e7e8a68c
Update the changelog 2023-03-22 14:59:18 +01:00
Paul van Tilburg bab9228b0f
Drop unused dependency on the toml crate 2023-03-22 13:38:16 +01:00
Paul van Tilburg 81e82e90da
Cargo update; fixes RUSTSEC-2023-0018 2023-03-22 13:37:56 +01:00
Paul van Tilburg b07bb73da4
Fix clippy issue
Check Details
Lints Details
2023-03-21 12:10:52 +01:00
Paul van Tilburg 58759d5309
Add Gitea Actions workflow for cargo
Check Details
Lints Details
2023-03-21 11:53:05 +01:00
Paul van Tilburg b1764b7fe3
Use 7 chars for the git SHA 2023-01-29 15:44:29 +01:00
Paul van Tilburg 7c704b69ed Merge pull request 'Print the version on lift off and add version endpoint' (#10) from 6-print-version-add-endpoint into main
Reviewed-on: #10
2023-01-29 15:37:14 +01:00
Paul van Tilburg 04e28a33c3
Add a /version API endpoint
* Introduce the `VersionInfo` struct, build from the vergen environment
  variables
* Add the `version` handler to construct and return the version info
* Update the README
2023-01-29 15:29:32 +01:00
Paul van Tilburg de1ad37b95
Use the vergen crate to generate version information
* Add depend on the `vergen` crate (only use the `build` and `git`
  features)
* Add the build script `build.rs` to setup the environment variables
  from the build system
* Update the printed version information to use these environment
  variables
2023-01-29 15:22:47 +01:00
Paul van Tilburg 5cbc3a04fc
Print the version on lift off 2023-01-16 21:18:20 +01:00
Paul van Tilburg ca116351db
Implement error catchers for all requests (closes: #5)
* Introduce an error JSON output
* Return error JSON output if the status data is not there (yet)
* Introduce a default catcher to return error JSON output in all other
  unsupported/unhandled cases
* Update the documentation
2023-01-16 21:08:03 +01:00
Paul van Tilburg 8d892d8619
Tweak documentation (to look like the rest) 2023-01-16 21:05:15 +01:00
Paul van Tilburg 35209b6303
Bump the version to 0.2.1 2023-01-16 20:16:30 +01:00
Paul van Tilburg 6707928e37
Update the changelog 2023-01-16 20:15:54 +01:00
Paul van Tilburg e6b0357670
Add serde from rocket; drop depend on serde 2023-01-16 20:07:56 +01:00
Paul van Tilburg 365b847313
Use stderr and a different emoji for error log messages 2023-01-16 20:03:16 +01:00
Paul van Tilburg e1d70e8a59
Catch and raise when API response data cannot be deserialized
* Introduce a `StringOrObject::Value` variant that captures the
  undeserializable JSON value
* Generate an error with the undeserializable JSON value when
  deserialization is attempted
2023-01-16 20:00:53 +01:00
Paul van Tilburg e268a6ebca
Detect when API (login) responses are not correct
* Introduce the `Error::Response` variant so services can raise errors
  if the API response are not valid but a relogin will not help
* Indicate that a login failed for status (error) code 1
* Indicate that an API request failed and relogin is necessary for
  status code 1 or 100
* Raise an error on any non-zero status code otherwise with the message
2023-01-16 19:57:05 +01:00
Paul van Tilburg 93e8295c96
Small formatting and error message fixes 2023-01-15 16:44:45 +01:00
Paul van Tilburg 1d35b88aba
Set a cookie to configure the API language
It will be Simplified Chinese (`zh_cn`) otherwise.
2023-01-15 16:34:40 +01:00
Paul van Tilburg ddcb375345
Introduce an error type for services
As a result, services don't always have to provide a `reqwest::Error`
but also return other errors. The error variant `Error::NotAuthorized`
in particular specifies that requests are not or no longer allowed and a
login should be (re)attempted. This way, services can indicate that it
is in this state and not have to provided a 403 status code
`reqwest::Error` to show this.

Add a depend on the `thiserror` crate for this.
2023-01-15 16:31:37 +01:00
Paul van Tilburg e0151c3cde
Set last updated field to what is returned by API
In Hoymiles, the date of the last update is part of the API response.
Parse it and use that in `Status` instead of the timestamp provided by
the update loop.

Add a depend on the `chrono` crate for this.
2023-01-15 14:48:24 +01:00
Paul van Tilburg ef13f7e4f2
Update documentation about Hoymiles poll interval 2023-01-15 14:16:11 +01:00
Paul van Tilburg d787c8b3ab
Fix issue in Hoymiles where total energy decreases
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).

Also, allow for `login` and `update` to mutate the state of the service
to be able to update things like the last known total produced energy
value.
2023-01-15 13:41:40 +01:00
Paul van Tilburg 5a2889a0f2
Improve deserialization for Hoymiles
* Also deserialize the status (error) code and message
* Handle `data` fields having the value `""` in API responses if there
  is an error
* Add missing documentation for API struct fields
2023-01-15 13:23:34 +01:00
Paul van Tilburg 18b52cd422
Small simplification; remove already solved TODO 2023-01-15 12:26:20 +01:00
Paul van Tilburg 70b117d11d
Cargo update 2023-01-14 15:55:48 +01:00
Paul van Tilburg 2b5a64b6b0
FIx some formatting 2023-01-14 15:54:29 +01:00
Paul van Tilburg 01416ee136
Reduce poll interval for Hoymiles to 5 minutes 2023-01-14 13:03:26 +01:00
Paul van Tilburg 536b1564b9
Also set the state class in HA sensors example 2023-01-13 23:22:41 +01:00
14 changed files with 1431 additions and 623 deletions

View File

@ -5,9 +5,6 @@ target
Dockerfile*
docker-compose*
# Git folder
.git
# Dot files
.gitignore

View File

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

View File

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

View File

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

1272
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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 && \

View File

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

9
build.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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(&params).send().await?;
Ok(())
let response = self.client.post(login_url).form(&params).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 {

View File

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