Compare commits

...

89 Commits
v0.1.1 ... 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
Paul van Tilburg 4088c37cd2
Bump the version to 0.2.0 2023-01-13 19:47:14 +01:00
Paul van Tilburg 17491fad78
Update the changelog 2023-01-13 19:33:09 +01:00
Paul van Tilburg 745edea875
Clarify config necessary for exposing container port
If Rocket is not configured to listen on 0.0.0.0:8000, exposing port
8000 on the inside to a chosen port (2399 by default), will not work.
2023-01-13 11:57:24 +01:00
Paul van Tilburg f6a3820961
Fix typo in Docker commands 2023-01-11 21:59:47 +01:00
Paul van Tilburg 5733a6440c
Fix (example) port in HA example 2023-01-11 21:58:33 +01:00
Paul van Tilburg 11d82c4ae4
Add a changelog 2023-01-11 21:55:04 +01:00
Paul van Tilburg 1c2fc62805
Add documentation for how to use with Home Assistant 2023-01-10 17:13:52 +01:00
Paul van Tilburg 4bdaa3bdac
Switch the example port to 2399 2023-01-10 17:00:20 +01:00
Paul van Tilburg 69f34e3243
Add Debian packaging via cargo-deb (closes: #4)
* Add the required metadata to `Cargo.toml`
* Add a systemd service unit file
* Use `Rocket.toml.example` as the default configuration
2023-01-10 16:59:20 +01:00
Paul van Tilburg a0cb3dccae
Document the Docker support 2023-01-10 16:29:32 +01:00
Paul van Tilburg 5ed688f0fb
Add support for building and running a Docker image 2023-01-10 16:09:56 +01:00
Paul van Tilburg 669c9285ad
Update code styling to follow Hoymiles service 2023-01-10 15:51:53 +01:00
Paul van Tilburg 9c9a348a53 Merge pull request 'Implement Hoymiles service' (#6) from 1-hoymiles-service into main
Add support for retrieving solar panel data from Hoymiles.

* Add the Hoymiles service
* Update the documentation
* Add a depend on the `md-5` crate for password hashing

Reviewed-on: #6
2023-01-10 15:50:30 +01:00
Paul van Tilburg 0e7d339682
Update documentation and example 2023-01-10 15:49:11 +01:00
Paul van Tilburg b1dfea651f
Add first version of the Hoymiles service 2023-01-10 15:38:24 +01:00
Paul van Tilburg 2883f52249
Switch to floats for the current power and total energy fields 2023-01-10 15:37:38 +01:00
Paul van Tilburg 093d062dd4
Enable more lints 2023-01-09 21:40:04 +01:00
Paul van Tilburg 55c3b91bbd
Split off a library crate 2023-01-09 21:39:11 +01:00
Paul van Tilburg 58df4bec2f
Add missing .gitignore 2023-01-09 21:35:41 +01:00
Paul van Tilburg a47198ea24 Merge pull request 'Add support for multiple services' (#3) from 2-multiple-services-support into main
Reviewed-on: #3
2023-01-09 21:33:30 +01:00
Paul van Tilburg 3690647c76 Add service-specific configuration
Switch to a section/table for the service to make it easier.
2023-01-09 21:25:35 +01:00
Paul van Tilburg 87394f9fb9 Add service implementation; split off My Autarco support 2023-01-09 21:23:43 +01:00
19 changed files with 2176 additions and 694 deletions

12
.dockerignore Normal file
View File

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

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

2
.gitignore vendored Normal file
View File

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

134
CHANGELOG.md Normal file
View File

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

1281
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.1.1"
version = "0.3.5"
authors = ["Paul van Tilburg <paul@luon.net>"]
edition = "2021"
description = """"
@ -10,12 +10,44 @@ 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"
depends = "$auto, systemd"
extended-description = """\
Solar Grabber is web service that provides a REST API layer over various cloud
sites/services/APIs to get statistical data of your solar panels.
It currently supports the following services:
* Hoymiles: https://global.hoymiles.com
* My Autarco: https://my.autarco.com
"""
section = "net"
priority = "optional"
assets = [
["README.md", "usr/share/doc/solar-grabber/", "664"],
["Rocket.toml.example", "/etc/solar-grabber.toml", "600"],
["target/release/solar-grabber", "usr/sbin/solar-grabber", "755"]
]
conf-files = [
"/etc/solar-grabber.toml"
]
maintainer-scripts = "debian/"
systemd-units = { unit-name = "solar-grabber" }

49
Dockerfile Normal file
View File

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

133
README.md
View File

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

View File

@ -1,8 +1,18 @@
[default]
address = "0.0.0.0"
port = 2356
port = 2399
# Put your solar cloud service settings below and uncomment them
# Put your solar cloud service settings below and uncomment them based on the
# service you want to use.
[default.service]
# For Hoymiles, use the following settings:
# kind = "Hoymiles"
# username = "username"
# password = "secret"
# sid = 123456
# For My Autarco, use the following settings:
# kind = "MyAutarco"
# username = "foo@domain.tld"
# password = "secret"
# site_id = "abc123de"

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

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

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

26
docker-compose.yml Normal file
View File

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

151
src/lib.rs Normal file
View File

@ -0,0 +1,151 @@
#![doc = include_str!("../README.md")]
#![warn(
clippy::all,
missing_copy_implementations,
missing_debug_implementations,
rust_2018_idioms,
rustdoc::broken_intra_doc_links,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)]
#![deny(missing_docs)]
mod services;
mod update;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use rocket::{
catch, catchers,
fairing::AdHoc,
get, routes,
serde::{json::Json, Deserialize, Serialize},
Build, Request, Rocket,
};
use self::update::update_loop;
/// The global, concurrently accessible current status.
static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
/// The configuration loaded additionally by Rocket.
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
struct Config {
/// The service-specific configuration
service: services::Config,
}
/// The current photovoltaic invertor status.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(crate = "rocket::serde")]
struct Status {
/// The current power production (W).
current_w: f32,
/// The total energy produced since installation (kWh).
total_kwh: f32,
/// 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() -> Result<Json<Status>, Json<Error>> {
let status_guard = STATUS.lock().expect("Status mutex was poisoined");
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, version])
.register("/", catchers![unsupported])
.attach(AdHoc::config::<Config>())
.attach(AdHoc::on_liftoff("Updater", |rocket| {
Box::pin(async move {
let config = rocket
.figment()
.extract::<Config>()
.expect("Invalid configuration");
let service = services::get(config.service).expect("Invalid service");
// We don't care about the join handle nor error results?
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

@ -1,75 +1,21 @@
#![doc = include_str!("../README.md")]
#![warn(
clippy::all,
missing_copy_implementations,
missing_debug_implementations,
rust_2018_idioms,
rustdoc::broken_intra_doc_links
rustdoc::broken_intra_doc_links,
trivial_numeric_casts,
renamed_and_removed_lints,
unsafe_code,
unstable_features,
unused_import_braces,
unused_qualifications
)]
#![deny(missing_docs)]
use std::sync::Mutex;
use once_cell::sync::Lazy;
use rocket::fairing::AdHoc;
use rocket::serde::json::Json;
use rocket::{get, routes};
use serde::{Deserialize, Serialize};
use self::update::update_loop;
mod update;
/// The base URL of My Autarco site.
const BASE_URL: &str = "https://my.autarco.com";
/// The interval between data polls.
///
/// This depends on with which interval Autaurco processes new information from the invertor.
const POLL_INTERVAL: u64 = 300;
/// The extra configuration necessary to access the My Autarco site.
#[derive(Debug, Deserialize)]
struct Config {
/// The username of the account to login with
username: String,
/// The password of the account to login with
password: String,
/// The Autarco site ID to track
site_id: String,
}
/// The global, concurrently accessible current status.
static STATUS: Lazy<Mutex<Option<Status>>> = Lazy::new(|| Mutex::new(None));
/// The current photovoltaic invertor status.
#[derive(Clone, Copy, Debug, Serialize)]
struct Status {
/// Current power production (W)
current_w: u32,
/// Total energy produced since installation (kWh)
total_kwh: u32,
/// Timestamp of last update
last_updated: u64,
}
/// Returns the current (last known) status.
#[get("/", format = "application/json")]
async fn status() -> Option<Json<Status>> {
let status_guard = STATUS.lock().expect("Status mutex was poisoined");
status_guard.map(Json)
}
/// Creates a Rocket and attaches the config parsing and update loop as fairings.
/// Sets up and launches Rocket.
#[rocket::launch]
fn rocket() -> _ {
rocket::build()
.mount("/", routes![status])
.attach(AdHoc::config::<Config>())
.attach(AdHoc::on_liftoff("Updater", |rocket| {
Box::pin(async move {
// We don't care about the join handle nor error results?
let config = rocket.figment().extract().expect("Invalid configuration");
let _ = rocket::tokio::spawn(update_loop(config));
})
}))
solar_grabber::setup()
}

69
src/services.rs Normal file
View File

@ -0,0 +1,69 @@
//! The supported cloud services.
pub(crate) mod hoymiles;
pub(crate) mod my_autarco;
use enum_dispatch::enum_dispatch;
use rocket::{async_trait, serde::Deserialize};
use crate::Status;
/// The service-specific configuration necessary to access a cloud service API.
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde", tag = "kind")]
pub(crate) enum Config {
/// Hoymiles (<https://global.hoymiles.com>)
Hoymiles(hoymiles::Config),
/// My Autarco (<https://my.autarco.com>)
MyAutarco(my_autarco::Config),
}
/// Retrieves the service for the provided name (if supported).
pub(crate) fn get(config: Config) -> color_eyre::Result<Services> {
match config {
Config::Hoymiles(config) => Ok(Services::Hoymiles(hoymiles::service(config)?)),
Config::MyAutarco(config) => Ok(Services::MyAutarco(my_autarco::service(config)?)),
}
}
/// The 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 {
/// Hoymiles (<https://global.hoymiles.com>)
Hoymiles(hoymiles::Service),
/// My Autarco (<https://my.autarco.com>)
MyAutarco(my_autarco::Service),
}
/// Functionality trait of a cloud service.
#[async_trait]
#[enum_dispatch]
pub(crate) trait Service {
/// The interval between data polls (in seconds).
fn poll_interval(&self) -> u64;
/// Perfoms a login on the cloud service (if necessary).
async fn login(&mut self) -> Result<()>;
/// Retrieves a status update using the API of the cloud service.
async fn update(&mut self, timestamp: u64) -> Result<Status>;
}

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

@ -0,0 +1,398 @@
//! The Hoymiles service.
//!
//! It uses the private Hoymiles API to login (and obtain the session cookies) and
//! to retrieve the energy data (using the session cookies).
//! See also: <https://global.hoymiles.com>.
use std::sync::Arc;
use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
use md5::{Digest, Md5};
use reqwest::{cookie::Jar as CookieJar, Client, ClientBuilder, Url};
use rocket::{
async_trait,
serde::{json::Value as JsonValue, Deserialize, Deserializer, Serialize},
};
use url::ParseError;
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 = 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,
/// The password of the account to login with
password: String,
/// The ID of the Hoymiles station to track
sid: u32,
}
/// Instantiates the Hoymiles service.
pub(crate) fn service(config: Config) -> Result<Service> {
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)
}
/// The Hoymiles service.
#[derive(Debug)]
pub(crate) struct Service {
/// The client used to do API requests using a cookie jar.
client: Client,
/// The configuration used to access the API.
config: Config,
/// The cookie jar used for API requests.
cookie_jar: Arc<CookieJar>,
/// The last known total produced energy value.
total_kwh: f32,
}
/// Returns the login URL for the Hoymiles site.
fn login_url() -> Result<Url, ParseError> {
Url::parse(&format!("{BASE_URL}/iam/auth_login"))
}
/// Returns an API endpoint URL for for the Hoymiles site.
fn api_url() -> Result<Url, ParseError> {
Url::parse(&format!("{BASE_URL}/pvm-data/data_count_station_real_data"))
}
/// 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());
let body = ApiLoginRequestBody {
user_name,
password,
};
Self { body }
}
}
/// 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 {
/// 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,
}
/// 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,
}
impl ApiDataRequest {
/// Creates a new API data request.
fn new(sid: u32) -> Self {
let body = ApiDataRequestBody { sid };
Self { body }
}
}
/// The request body passed to the API data endpoint.
#[derive(Debug, Serialize)]
#[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 {
/// 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>,
}
/// 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")]
today_eq: f32,
// month_eq: f32,
// year_eq: f32,
/// Total energy produced since installation, excluding today's (Wh)
#[serde(deserialize_with = "from_float_str")]
total_eq: f32,
/// Current power production
#[serde(deserialize_with = "from_float_str")]
real_power: f32,
// co2_emission_reducation: f32,
// plant_tree: u32,
// data_time: String,
#[serde(deserialize_with = "from_date_time_str")]
last_data_time: DateTime<Local>,
// capacitor: f32,
// is_balance: bool,
// is_reflux: bool,
// reflux_station_data: Option<_>,
}
#[async_trait]
impl super::Service for Service {
/// The interval between data polls (in seconds).
///
/// Hoymiles processes 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
}
/// Performs a login on the Hoymiles site.
///
/// It mainly stores the acquired cookies in the client's cookie jar and adds the token cookie
/// provided by the logins response. The login credentials come from the loaded configuration
/// (see [`Config`]).
async fn login(&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_data = match login_response.error_for_status() {
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 token_cookie = format!("hm_token={}", login_response_data.token);
self.cookie_jar.add_cookie_str(&token_cookie, &base_url);
Ok(())
}
/// Retrieves a status update from the API of the Hoymiles site.
///
/// It needs the cookies from the login to be able to perform the action.
/// It uses a endpoint to construct the [`Status`] struct, but it needs to summarize the today
/// value with the total value because Hoymiles only includes it after the day has finished.
async fn update(&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_data = match api_response.error_for_status() {
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 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,
total_kwh,
last_updated,
})
}
}

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

@ -0,0 +1,137 @@
//! The My Autarco service.
//!
//! It uses the private My Autarco API to login (and obtain the session cookies) and
//! to retrieve the energy data (using the session cookies).
//! See also: <https://my.autarco.com>.
use reqwest::{Client, ClientBuilder, StatusCode, Url};
use rocket::{async_trait, serde::Deserialize};
use url::ParseError;
use crate::{
services::{Error, Result},
Status,
};
/// The base URL of My Autarco site.
const BASE_URL: &str = "https://my.autarco.com";
/// The interval between data polls (in seconds).
const POLL_INTERVAL: u64 = 300;
/// The configuration necessary to access the My Autarco API.
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub(crate) struct Config {
/// The username of the account to login with
username: String,
/// The password of the account to login with
password: String,
/// The Autarco site ID to track
site_id: String,
}
/// Instantiates the My Autarco service.
pub(crate) fn service(config: Config) -> Result<Service> {
let client = ClientBuilder::new().cookie_store(true).build()?;
let service = Service { client, config };
Ok(service)
}
/// The My Autarco service.
#[derive(Debug)]
pub(crate) struct Service {
/// The client used to do API requests using a cookie jar.
client: Client,
/// The configuration used to access the API.
config: Config,
}
/// Returns the login URL for the My Autarco site.
fn login_url() -> Result<Url, ParseError> {
Url::parse(&format!("{BASE_URL}/auth/login"))
}
/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site.
fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
Url::parse(&format!("{BASE_URL}/api/site/{site_id}/kpis/{endpoint}",))
}
/// The energy data returned by the energy API endpoint.
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
struct ApiEnergy {
/// Total energy produced today (kWh)
// pv_today: u32,
/// Total energy produced this month (kWh)
// pv_month: u32,
/// Total energy produced since installation (kWh)
pv_to_date: u32,
}
/// The power data returned by the power API endpoint.
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
struct ApiPower {
/// Current power production (W)
pv_now: u32,
}
#[async_trait]
impl super::Service for Service {
/// The interval between data polls (in seconds).
///
/// Autaurco processes provides information from the invertor every 5 minutes.
fn poll_interval(&self) -> u64 {
POLL_INTERVAL
}
/// Performs a login on the My Autarco site.
///
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
/// from the loaded configuration (see [`Config`]).
async fn login(&mut self) -> Result<()> {
let login_url = login_url().expect("valid login URL");
let params = [
("username", &self.config.username),
("password", &self.config.password),
];
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(&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) 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.into()),
};
Ok(Status {
current_w: api_power.pv_now as f32,
total_kwh: api_energy.pv_to_date as f32,
last_updated,
})
}
}

View File

@ -1,127 +1,79 @@
//! Module for handling the status updating/retrieval via the My Autarco site/API.
//! Module for handling the status updating/retrieval via the cloud service API.
use std::time::{Duration, SystemTime};
use reqwest::{Client, ClientBuilder, Error, StatusCode};
use rocket::tokio::time::sleep;
use serde::Deserialize;
use url::{ParseError, Url};
use super::{Config, Status, BASE_URL, POLL_INTERVAL, STATUS};
use crate::{
services::{Error, Service, Services},
STATUS,
};
/// Returns the login URL for the My Autarco site.
fn login_url() -> Result<Url, ParseError> {
Url::parse(&format!("{}/auth/login", BASE_URL))
}
/// The default sleep interval to use between checks.
const DEFAULT_SLEEP_INTERVAL: u64 = 10;
/// Returns an API endpoint URL for the given site ID and endpoint of the My Autarco site.
fn api_url(site_id: &str, endpoint: &str) -> Result<Url, ParseError> {
Url::parse(&format!(
"{}/api/site/{}/kpis/{}",
BASE_URL, site_id, endpoint
))
}
/// The sleep interval upper limit when applying exponential backoff.
const MAX_SLEEP_INTERVAL: u64 = 320;
/// The energy data returned by the energy API endpoint.
#[derive(Debug, Deserialize)]
struct ApiEnergy {
/// Total energy produced today (kWh)
// pv_today: u32,
/// Total energy produced this month (kWh)
// pv_month: u32,
/// Total energy produced since installation (kWh)
pv_to_date: u32,
}
/// The backoff factor.
const BACKOFF_FACTOR: f64 = 2.0;
/// The power data returned by the power API endpoint.
#[derive(Debug, Deserialize)]
struct ApiPower {
/// Current power production (W)
pv_now: u32,
}
/// 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;
/// Performs a login on the My Autarco site.
///
/// It mainly stores the acquired cookie in the client's cookie jar. The login credentials come
/// from the loaded configuration (see [`Config`]).
async fn login(config: &Config, client: &Client) -> Result<(), Error> {
let params = [
("username", &config.username),
("password", &config.password),
];
let login_url = login_url().expect("valid login URL");
client.post(login_url).form(&params).send().await?;
Ok(())
}
/// Retrieves a status update from the API of the My Autarco site.
///
/// It needs the cookie from the login to be able to perform the action. It uses both the `energy`
/// and `power` endpoint to construct the [`Status`] struct.
async fn update(config: &Config, client: &Client, last_updated: u64) -> Result<Status, Error> {
// Retrieve the data from the API endpoints.
let api_energy_url = api_url(&config.site_id, "energy").expect("valid API energy URL");
let api_response = client.get(api_energy_url).send().await?;
let api_energy: ApiEnergy = match api_response.error_for_status() {
Ok(res) => res.json().await?,
Err(err) => return Err(err),
};
let api_power_url = api_url(&config.site_id, "power").expect("valid API power URL");
let api_response = client.get(api_power_url).send().await?;
let api_power: ApiPower = match api_response.error_for_status() {
Ok(res) => res.json().await?,
Err(err) => return Err(err),
};
// Update the status.
Ok(Status {
current_w: api_power.pv_now,
total_kwh: api_energy.pv_to_date,
last_updated,
})
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`] struct which can be retrieved via
/// Rocket.
pub(super) async fn update_loop(config: Config) -> color_eyre::Result<()> {
let client = ClientBuilder::new().cookie_store(true).build()?;
/// 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;
// Go to the My Autarco site and login.
// Log in on the cloud service.
println!("⚡ Logging in...");
login(&config, &client).await?;
service.login().await?;
println!("⚡ Logged in successfully!");
let mut last_updated = 0;
let poll_interval = service.poll_interval();
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)
.unwrap_or_default()
.as_secs();
if timestamp - last_updated < POLL_INTERVAL {
if timestamp - last_updated < poll_interval {
continue;
}
let status = match update(&config, &client, timestamp).await {
let status = match service.update(timestamp).await {
Ok(status) => status,
Err(e) if e.status() == Some(StatusCode::UNAUTHORIZED) => {
println!("✨ Update unauthorized, trying to log in again...");
login(&config, &client).await?;
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);