1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
# Register the Pi runner with the label solar-relay:host.
|
||||||
|
# Install the systemd unit and the restricted sudoers rule described in README.md once.
|
||||||
|
name: Deploy Solar Relay
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: solar-relay
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /var/www/html/solar-relay
|
||||||
|
SERVICE: solar-relay.service
|
||||||
|
HEALTH_URL: https://rpioffice.nest.lutran.ch/solar-relay/healthz
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Check runner and deployment setup
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v python3
|
||||||
|
command -v rsync
|
||||||
|
command -v sudo
|
||||||
|
command -v systemctl
|
||||||
|
command -v flock
|
||||||
|
command -v curl
|
||||||
|
python3 -c 'import sys, tomllib; assert sys.version_info >= (3, 11)'
|
||||||
|
test -d "$DEPLOY_PATH"
|
||||||
|
test -w "$DEPLOY_PATH"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
checkout = Path.cwd().resolve()
|
||||||
|
deployment = Path(os.environ['DEPLOY_PATH']).resolve()
|
||||||
|
if checkout == deployment or checkout in deployment.parents or deployment in checkout.parents:
|
||||||
|
raise SystemExit('The runner checkout and deployment directory must not overlap')
|
||||||
|
PY
|
||||||
|
test -r /etc/solar-relay/config.toml
|
||||||
|
cmp deploy/solar-relay.service /etc/systemd/system/solar-relay.service || {
|
||||||
|
echo 'Install the updated deploy/solar-relay.service and run systemctl daemon-reload first.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sudo -n -l "$(command -v systemctl)" restart "$SERVICE" >/dev/null
|
||||||
|
|
||||||
|
- name: Create isolated test environment
|
||||||
|
run: |
|
||||||
|
python3 -m venv .venv-ci
|
||||||
|
.venv-ci/bin/python -m pip install --only-binary=pyarrow -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: .venv-ci/bin/python -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
- name: Deploy and restart
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Also serializes deployments on Gitea versions that lack workflow concurrency.
|
||||||
|
exec 9>"$DEPLOY_PATH/.deploy.lock"
|
||||||
|
flock -w 120 9
|
||||||
|
rsync -azc --no-times --delete \
|
||||||
|
--exclude '/.git/' \
|
||||||
|
--exclude '/.gitea/' \
|
||||||
|
--exclude '/.venv/' \
|
||||||
|
--exclude '/.venv-ci/' \
|
||||||
|
--exclude '/.deploy.lock' \
|
||||||
|
--exclude '/config.toml' \
|
||||||
|
--exclude '/.env' \
|
||||||
|
--exclude '/data/' \
|
||||||
|
--exclude '__pycache__/' \
|
||||||
|
--exclude '*.parquet' \
|
||||||
|
./ "$DEPLOY_PATH/"
|
||||||
|
if [ ! -x "$DEPLOY_PATH/.venv/bin/python" ]; then
|
||||||
|
python3 -m venv "$DEPLOY_PATH/.venv"
|
||||||
|
fi
|
||||||
|
"$DEPLOY_PATH/.venv/bin/python" -c 'import sys; assert sys.version_info >= (3, 11)'
|
||||||
|
"$DEPLOY_PATH/.venv/bin/python" -m pip install --only-binary=pyarrow -r "$DEPLOY_PATH/requirements.txt"
|
||||||
|
sudo -n "$(command -v systemctl)" restart "$SERVICE"
|
||||||
|
# Catch an invalid config or missing token that triggers an immediate restart loop.
|
||||||
|
sleep 10
|
||||||
|
if ! systemctl is-active --quiet "$SERVICE"; then
|
||||||
|
systemctl status "$SERVICE" --no-pager --lines 30 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(systemctl show --property=NRestarts --value "$SERVICE")" != "0" ]; then
|
||||||
|
echo 'The collector restarted unexpectedly after deployment.'
|
||||||
|
systemctl status "$SERVICE" --no-pager --lines 30 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
systemctl status "$SERVICE" --no-pager --lines 10
|
||||||
|
curl --fail --silent --show-error --connect-timeout 3 --max-time 5 \
|
||||||
|
--retry 4 --retry-delay 2 "$HEALTH_URL"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
.venv-ci/
|
||||||
|
.deploy.lock
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
config.toml
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.parquet
|
||||||
|
|
||||||
@@ -1,3 +1,289 @@
|
|||||||
# solar-relay
|
# solar-relay
|
||||||
|
|
||||||
Fronius data extractor and relay for the solar app
|
Local Fronius GEN24 recorder and HTTPS relay for the sibling `solar` website.
|
||||||
|
Python 3.11+ and a project-local virtual environment are required. License:
|
||||||
|
AGPL-3.0-or-later, see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
The collector takes one observation every **five minutes**, aligned to the clock
|
||||||
|
(`:00`, `:05`, …). Startup and restarts wait for the next five-minute boundary.
|
||||||
|
`poll_interval_seconds` must be 300. Each observation
|
||||||
|
contains production, consumption, grid power, cumulative energy counters, boiler
|
||||||
|
switch state, individual source timestamps, errors, and the original JSON responses.
|
||||||
|
Requests to the inverter remain at least four seconds apart, so these readings are
|
||||||
|
not simultaneous. The default inverter is `http://fronius.local`, meter `0`, boiler `pin1`.
|
||||||
|
|
||||||
|
An HTTP/network request failure retries the affected endpoint up to three times
|
||||||
|
(four attempts total), keeping the same request spacing. Successful endpoints are
|
||||||
|
not repeated. If retries are exhausted, that source is recorded as unavailable.
|
||||||
|
Retries belong to the same observation and do not reset the five-minute clock.
|
||||||
|
Scheduled collection remains anchored to `:00`, `:05`, `:10`, … boundaries.
|
||||||
|
|
||||||
|
Observations are saved durably to **Parquet** before upload. Separate workers handle
|
||||||
|
delivery and optional history recovery; an internet outage does not block recording.
|
||||||
|
Acknowledged observations are deleted **30 days after acknowledgement**. Pending
|
||||||
|
observations never expire, even if their measurement dates are much older.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --only-binary=pyarrow -r requirements.txt
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
python -m solar_relay --config config.toml status
|
||||||
|
python -m solar_relay --config config.toml run
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `.venv/bin/python` directly if you prefer not to activate the environment.
|
||||||
|
All packages stay inside the venv. Parquet storage uses the pinned **PyArrow**
|
||||||
|
dependency in `requirements.txt`. Use a 64-bit Raspberry Pi OS
|
||||||
|
with an available PyArrow wheel; `--only-binary=pyarrow` avoids a large source build.
|
||||||
|
The deployment workflow installs dependencies in both its test and runtime venvs.
|
||||||
|
|
||||||
|
Configuration, credentials, `.venv` and Parquet data are ignored by Git.
|
||||||
|
Relative paths resolve against the configuration file, rather than the working directory.
|
||||||
|
Uploads default to disabled until `[upload].url` is configured.
|
||||||
|
|
||||||
|
## Sources and measurement limits
|
||||||
|
|
||||||
|
| Source | Endpoint |
|
||||||
|
| --- | --- |
|
||||||
|
| Solar API: PV, AC output, household load, grid power | `/solar_api/v1/GetPowerFlowRealtimeData.fcgi` |
|
||||||
|
| Solar API: grid meter counters | `/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System` |
|
||||||
|
| Internal GEN24 API: boiler switch | `/api/status/emrs` |
|
||||||
|
|
||||||
|
Solar API must be enabled in the inverter's Communication settings. The collector
|
||||||
|
only reads these endpoints. The boiler endpoint is an internal firmware interface;
|
||||||
|
if it stops working, boiler state is recorded as unknown rather than off.
|
||||||
|
|
||||||
|
| Reading | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `production_w` | `Site.P_PV`, PV generator DC power |
|
||||||
|
| `production_ac_w` | Sum of inverter `P`, AC output |
|
||||||
|
| `consumption_w` | `-Site.P_Load`, household load on this GEN24 |
|
||||||
|
| `grid_w` | Positive import, negative export at the feed-in meter |
|
||||||
|
| `grid_import_w`, `grid_export_w` | Nonnegative directional grid power |
|
||||||
|
| `production_ac_total_wh` | Lifetime AC production counter |
|
||||||
|
| `grid_import_total_wh`, `grid_export_total_wh` | Grid meter lifetime import/export counters |
|
||||||
|
| `boiler_enabled`, `boiler_reason` | Output state (`true`, `false`, or `null`) and reason code |
|
||||||
|
|
||||||
|
The meter must be installed at the grid feed-in point. Missing/stale readings and
|
||||||
|
source errors never become zero energy or an off boiler. DC panel power differs
|
||||||
|
from AC output by conversion losses. The website uses AC output for its energy balance.
|
||||||
|
|
||||||
|
The boiler switch enables heating but does not measure electricity use: the thermostat
|
||||||
|
can stop heating while the output remains enabled. Five-minute polling can miss short
|
||||||
|
transitions; enabled durations are estimates. A branch meter is needed for boiler kWh.
|
||||||
|
The 2000 W threshold is power (2 kW), not energy (kWh).
|
||||||
|
|
||||||
|
## Parquet archive, delivery and retention
|
||||||
|
|
||||||
|
`collector.archive` is a directory, containing one partition per **UTC date**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
archive/
|
||||||
|
.lock
|
||||||
|
date=2026-11-01/samples.parquet
|
||||||
|
date=2026-11-02/samples.parquet
|
||||||
|
```
|
||||||
|
|
||||||
|
The files contain typed numeric/boolean readings, IDs, UTC observation timestamps,
|
||||||
|
coverage flags, `uploaded_at`, and the complete original sample as JSON. They use
|
||||||
|
Zstandard compression. Each update writes a temporary file, flushes/fsyncs it,
|
||||||
|
atomically replaces the daily file, and fsyncs the directory. File permissions are
|
||||||
|
0600 and new archive directories are 0700. Thread/process locks prevent a collector,
|
||||||
|
uploader, status request or CLI replay from overwriting another operation. HTTP
|
||||||
|
requests happen outside the archive lock.
|
||||||
|
|
||||||
|
The upload request is a JSON object with `schema_version: 1` and a `samples` array,
|
||||||
|
authenticated with the bearer token named by `upload.token_env`. The website must
|
||||||
|
commit its data before responding:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"accepted_ids": ["1599c148-612c-46ab-aa9a-cf9a13271a72"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only explicitly acknowledged IDs receive an `uploaded_at` timestamp. A plain HTTP
|
||||||
|
200, malformed response, timeout or unknown ID never clears the queue. Partial
|
||||||
|
acknowledgements mark only the accepted records; remaining records retry with
|
||||||
|
exponential backoff. Delivery is **at least once**: the website deduplicates stable
|
||||||
|
UUIDs when a response is lost, so retries and replays do not double-count energy.
|
||||||
|
|
||||||
|
`collector.retention_days` defaults to 30. Automatic cleanup runs at service startup
|
||||||
|
and hourly, deleting records acknowledged more than that many days ago. Old pending
|
||||||
|
records remain. Replay clears acknowledgement and therefore prevents expiry until
|
||||||
|
another successful transfer. The main website retains its own imported history.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m solar_relay --config config.toml prune
|
||||||
|
.venv/bin/python -m solar_relay --config config.toml export \
|
||||||
|
--from 2026-10-29 --until 2026-10-31 > readings.jsonl
|
||||||
|
.venv/bin/python -m solar_relay --config config.toml replay \
|
||||||
|
--from 2026-10-29 --until 2026-10-31
|
||||||
|
.venv/bin/python -m solar_relay --config config.toml upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Date bounds use `collector.timezone`, including DST transitions. `--from` is inclusive;
|
||||||
|
`--until` is exclusive. ISO timestamps may include an explicit offset. Unbounded
|
||||||
|
export/replay covers all retained observations. Export provides a consistent snapshot;
|
||||||
|
stop the service and other CLI writers before copying the archive directory as a backup.
|
||||||
|
|
||||||
|
## Recovering history after Wi-Fi loss
|
||||||
|
|
||||||
|
GEN24 records historical data for transfer to Solar.web after reconnecting. This is
|
||||||
|
separate from its local Solar API: Fronius explicitly lists archive access for GEN24
|
||||||
|
as unavailable, and this inverter's `GetArchiveData.cgi` returns 404. Inspecting its
|
||||||
|
current web interface also found no energy-history retrieval endpoint. Its SDP support
|
||||||
|
download is an encrypted `.cry` diagnostic file, not a documented energy-history API.
|
||||||
|
|
||||||
|
See the official [Solar API manual, section 5.1.1](https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42%2C0410%2C2012.pdf)
|
||||||
|
and [Solar.web Query API manual, section 6.5](https://www.fronius.com/~/downloads/Solar%20Energy/User%20Information/SE_UI_API_InterfaceDocumentation_EN.pdf).
|
||||||
|
|
||||||
|
Optional recovery reads missing five-minute **measured energy** through the separate,
|
||||||
|
authenticated Solar.web Query API, after the inverter uploads its backlog. This uses
|
||||||
|
no public guest page. Obtain Query API access and keys from Fronius; its commercial
|
||||||
|
terms are separate from normal Solar.web access. No cloud calls occur while disabled.
|
||||||
|
|
||||||
|
Set `[history].enabled = true` and `pv_system_id` in the configuration, and put these
|
||||||
|
in the service's root-readable `/etc/solar-relay/solar-relay.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOLARWEB_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
SOLARWEB_ACCESS_KEY_VALUE=your-access-key-value
|
||||||
|
```
|
||||||
|
|
||||||
|
The recovery worker checks hourly, looking back seven days by default and allowing
|
||||||
|
a two-hour delay for the inverter's hourly uploads. It fetches only UTC days with
|
||||||
|
missing energy extractions, requests five energy channels, limits each request to
|
||||||
|
24 hours, and skips intervals already present. Requests use HTTPS, headers for
|
||||||
|
credentials, timeouts and no redirects. Missing/partial cloud data stays missing and
|
||||||
|
is tried again later. The configured lookback cannot exceed the retention period.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m solar_relay --config config.toml recover \
|
||||||
|
--from 2026-10-29 --until 2026-10-31
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual recovery accepts at most 30 days per invocation and can query dates older than
|
||||||
|
the automatic lookback. Only aligned 300-second energy records with all required Wh
|
||||||
|
channels are accepted; longer aggregates are not split into invented five-minute values.
|
||||||
|
Recovery stores stable UUIDs and `history_interval` records, which the main website
|
||||||
|
uses directly without integrating them as live power. Install the corresponding
|
||||||
|
`solar` receiver update before enabling cloud recovery.
|
||||||
|
|
||||||
|
**Boiler switch history and cumulative meter readings are not recovered.** Historical
|
||||||
|
energy intervals leave those sources unknown, and the status page shows partial
|
||||||
|
coverage. Counter differences can describe a total across an outage, but cannot
|
||||||
|
recover individual five-minute values. Fully local operation leaves Wi-Fi gaps visible.
|
||||||
|
|
||||||
|
## Raspberry Pi service
|
||||||
|
|
||||||
|
Deploy the checkout to `/var/www/html/solar-relay` and create/install its venv as above.
|
||||||
|
Place configuration outside the web directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -m 0755 /etc/solar-relay
|
||||||
|
sudo install -m 0644 config.example.toml /etc/solar-relay/config.toml
|
||||||
|
sudoedit /etc/solar-relay/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these collector settings, retaining the example's other sections:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[collector]
|
||||||
|
source_id = "home-gen24"
|
||||||
|
archive = "/var/lib/solar-relay/archive"
|
||||||
|
timezone = "Europe/Zurich"
|
||||||
|
poll_interval_seconds = 300
|
||||||
|
retention_days = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `[upload].url` to the main website's `https://your-solar-host/index.php?a=relay`.
|
||||||
|
Put `SOLAR_RELAY_TOKEN=your-shared-token` in `/etc/solar-relay/solar-relay.env` with
|
||||||
|
permissions 0600, readable by root. Configure the same token and source ID on the
|
||||||
|
main website as described in its README. Tokens and API keys stay out of TOML/Git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/solar-relay.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now solar-relay
|
||||||
|
sudo journalctl -u solar-relay -f
|
||||||
|
```
|
||||||
|
|
||||||
|
systemd provides `/var/lib/solar-relay` to a dynamic, unprivileged service user.
|
||||||
|
Recording/uploading remains separate from serving status. Running tests or `sample`
|
||||||
|
does not install/start the service. Initial systemd/configuration installation is manual.
|
||||||
|
|
||||||
|
## HTTPS reverse proxy and status page
|
||||||
|
|
||||||
|
Python listens at **`http://127.0.0.1:8765`**. Choose one proxy configuration and include
|
||||||
|
it inside the existing HTTPS virtual host for `rpioffice.nest.lutran.ch`:
|
||||||
|
|
||||||
|
- Nginx: [`deploy/nginx-solar-relay.conf`](deploy/nginx-solar-relay.conf).
|
||||||
|
- Apache: [`deploy/apache-solar-relay.conf`](deploy/apache-solar-relay.conf).
|
||||||
|
Enable `proxy`, `proxy_http` and `headers` (`sudo a2enmod proxy proxy_http headers`).
|
||||||
|
|
||||||
|
Keep your existing TLS certificates on the proxy. Proxy the entire `/solar-relay/`
|
||||||
|
prefix; do not serve the repository/archive as static files. The collector accepts
|
||||||
|
both a stripped proxy path and a preserved `/solar-relay/` prefix. Validate the web
|
||||||
|
server configuration before reloading it (`nginx -t` or `apachectl configtest`).
|
||||||
|
|
||||||
|
The status page is **https://rpioffice.nest.lutran.ch/solar-relay/**. It shows last
|
||||||
|
extraction/last success, upload backlog, retained observations, and daily five-minute
|
||||||
|
completeness with a selectable interval grid and gap ranges. Dates use the configured
|
||||||
|
timezone. DST days have 276 or 300 intervals; ordinary days have 288.
|
||||||
|
|
||||||
|
Complete means at least one fresh, successful extraction for energy, meter and boiler
|
||||||
|
in that five-minute slot. It does not mean continuously observed power or switch state.
|
||||||
|
The unfinished current slot is excluded from missing counts. Historical recovery
|
||||||
|
contributes energy only. After retention cleanup, pruned records no longer contribute
|
||||||
|
to this local page; the website keeps the transferred history. Refresh is every 15 seconds.
|
||||||
|
|
||||||
|
Endpoints under `/solar-relay/`:
|
||||||
|
|
||||||
|
- `/`: status page.
|
||||||
|
- `/api/status?days=14`: status and extraction completeness JSON.
|
||||||
|
- `/healthz`: process liveness, also used by deployment checks.
|
||||||
|
|
||||||
|
All routes are read-only and expose no raw readings, credentials or filesystem paths.
|
||||||
|
Disable `[http].enabled` and remove the workflow's health probe if HTTP is not needed.
|
||||||
|
|
||||||
|
## Gitea deployment
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml` deploys pushes to `main` or manual workflow runs to
|
||||||
|
`/var/www/html/solar-relay` and restarts `solar-relay.service`. Register the Pi runner
|
||||||
|
with **`solar-relay:host`**; its checkout must be separate from production.
|
||||||
|
|
||||||
|
The runner needs Python 3.11+, `python3-venv`/pip, a supported 64-bit PyArrow platform,
|
||||||
|
`rsync`, `flock`, `curl`, `sudo`, systemd and Node.js 20+ for `actions/checkout@v4`.
|
||||||
|
Give its account write access to the deployment directory. Install the config and unit
|
||||||
|
before the first deployment, then add a restricted sudoers rule using the actual runner
|
||||||
|
account and the path from `command -v systemctl`:
|
||||||
|
|
||||||
|
```sudoers
|
||||||
|
gitea-runner ALL=(root) NOPASSWD: /usr/bin/systemctl restart solar-relay.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow installs dependencies and runs tests in `.venv-ci`, checks the installed
|
||||||
|
unit, serializes deployment with `flock`, syncs source, installs runtime dependencies
|
||||||
|
inside `.venv`, restarts and checks the service, then probes the public HTTPS health
|
||||||
|
endpoint with certificate verification. Runtime venv/configuration, `data/` and
|
||||||
|
Parquet files are preserved. Recommended state under `/var/lib` and
|
||||||
|
configuration under `/etc` are entirely outside the sync and web directory.
|
||||||
|
|
||||||
|
If the deployment directory is initially empty, enable the unit without `--now`; the
|
||||||
|
first deployment creates the venv and starts it. Set up the proxy before deployment.
|
||||||
|
For unit changes, install the updated file and run `systemctl daemon-reload`; the
|
||||||
|
workflow detects a mismatch and does not grant itself broader sudo access.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m unittest discover -s tests -v
|
||||||
|
node --check solar_relay/web/status.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests cover normalization, real local HTTP delivery/retries, Parquet round-trips,
|
||||||
|
concurrent writers, interrupted writes, retention, DST completeness,
|
||||||
|
mocked authenticated history recovery, status routes and the deployment script.
|
||||||
|
Live Solar.web recovery requires real Query API credentials and is not exercised by tests.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[collector]
|
||||||
|
source_id = "home-gen24"
|
||||||
|
archive = "data/archive" # Parquet directory, relative to this configuration file.
|
||||||
|
timezone = "Europe/Zurich" # Used for date-only export/replay arguments.
|
||||||
|
poll_interval_seconds = 300
|
||||||
|
retention_days = 30 # Days AFTER acknowledgement; pending records never expire.
|
||||||
|
|
||||||
|
[inverter]
|
||||||
|
base_url = "http://fronius.local"
|
||||||
|
meter_id = "0" # Primary meter, as reported by Solar API.
|
||||||
|
boiler_pin = "pin1"
|
||||||
|
request_timeout_seconds = 5
|
||||||
|
min_request_interval_seconds = 4
|
||||||
|
|
||||||
|
[upload]
|
||||||
|
url = "" # Set to https://your-solar-host/index.php?a=relay to enable uploads.
|
||||||
|
token_env = "SOLAR_RELAY_TOKEN"
|
||||||
|
batch_size = 100
|
||||||
|
request_timeout_seconds = 10
|
||||||
|
retry_initial_seconds = 5
|
||||||
|
retry_max_seconds = 300
|
||||||
|
|
||||||
|
[http]
|
||||||
|
enabled = true
|
||||||
|
bind = "127.0.0.1"
|
||||||
|
port = 8765
|
||||||
|
base_path = "/solar-relay"
|
||||||
|
history_days = 14
|
||||||
|
|
||||||
|
[history]
|
||||||
|
# Optional, authenticated Solar.web Query API. No public guest link is used.
|
||||||
|
# GEN24's local Solar API has no historical-data endpoint.
|
||||||
|
enabled = false
|
||||||
|
pv_system_id = "" # Your Solar.web PV system UUID; required when enabled.
|
||||||
|
access_key_id_env = "SOLARWEB_ACCESS_KEY_ID"
|
||||||
|
access_key_value_env = "SOLARWEB_ACCESS_KEY_VALUE"
|
||||||
|
lookback_days = 7
|
||||||
|
lag_seconds = 7200 # Allow the inverter to upload its hourly historical data.
|
||||||
|
check_interval_seconds = 3600
|
||||||
|
request_timeout_seconds = 10
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Include inside the existing HTTPS VirtualHost for rpioffice.nest.lutran.ch.
|
||||||
|
# Enable modules: sudo a2enmod proxy proxy_http headers
|
||||||
|
# Keep the host's existing SSLEngine and certificate configuration.
|
||||||
|
ProxyRequests Off
|
||||||
|
ProxyPreserveHost On
|
||||||
|
RedirectMatch 308 ^/solar-relay$ /solar-relay/
|
||||||
|
ProxyPass /solar-relay/ http://127.0.0.1:8765/ connectiontimeout=3 timeout=15
|
||||||
|
ProxyPassReverse /solar-relay/ http://127.0.0.1:8765/
|
||||||
|
<Location /solar-relay/>
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
</Location>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Include these locations in the existing HTTPS server block for rpioffice.nest.lutran.ch.
|
||||||
|
# The trailing slash on proxy_pass removes /solar-relay/ before forwarding.
|
||||||
|
location = /solar-relay {
|
||||||
|
return 308 /solar-relay/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /solar-relay/ {
|
||||||
|
proxy_pass http://127.0.0.1:8765/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 3s;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Fronius local recorder and solar website relay
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
DynamicUser=yes
|
||||||
|
WorkingDirectory=/var/www/html/solar-relay
|
||||||
|
ExecStart=/var/www/html/solar-relay/.venv/bin/python -m solar_relay --config /etc/solar-relay/config.toml run
|
||||||
|
EnvironmentFile=-/etc/solar-relay/solar-relay.env
|
||||||
|
StateDirectory=solar-relay
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
UMask=0077
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStopSec=45
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/lib/solar-relay
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Runtime dependency; install inside .venv, never into the system Python.
|
||||||
|
pyarrow==26.0.0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Local Fronius recorder and durable HTTP relay (AGPL-3.0-or-later)."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Commands for recording, uploading, inspecting and replaying the local archive."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from zoneinfo import ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .config import date_bound, load_config
|
||||||
|
from .fronius import FroniusClient
|
||||||
|
from .history import HistoryRecovery
|
||||||
|
from .http import TransportError
|
||||||
|
from .storage import Store
|
||||||
|
from .status import StatusServer
|
||||||
|
from .upload import Uploader, UploadError
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(client, store, source_id, interval=300):
|
||||||
|
sample = client.collect(source_id, interval)
|
||||||
|
# Commit before attempting any upload. Even a completely failed poll is a gap marker.
|
||||||
|
store.add(sample)
|
||||||
|
if sample["errors"]:
|
||||||
|
LOG.warning("Recorded observation %s with errors: %s", sample["id"], json.dumps(sample["errors"]))
|
||||||
|
else:
|
||||||
|
LOG.info("Recorded observation %s", sample["id"])
|
||||||
|
return sample
|
||||||
|
|
||||||
|
|
||||||
|
def next_poll_at(now, interval):
|
||||||
|
"""UTC wall-clock boundaries: xx:00, xx:05, ... for the default interval."""
|
||||||
|
return (math.floor(now / interval) + 1) * interval
|
||||||
|
|
||||||
|
|
||||||
|
def _run(config, store):
|
||||||
|
stop = threading.Event()
|
||||||
|
previous = {}
|
||||||
|
uploader = Uploader(config.upload, store) if config.upload.url else None
|
||||||
|
recovery = HistoryRecovery(config.history, config.collector.source_id, store) if config.history.enabled else None
|
||||||
|
worker = None
|
||||||
|
history_worker = None
|
||||||
|
status_server = None
|
||||||
|
try:
|
||||||
|
for name in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
previous[name] = signal.signal(name, lambda *_: stop.set())
|
||||||
|
if config.http.enabled:
|
||||||
|
status_server = StatusServer(config, store)
|
||||||
|
status_server.start()
|
||||||
|
LOG.info("Status page listening on http://%s:%s", *status_server.address)
|
||||||
|
if uploader:
|
||||||
|
# Remote timeouts and retry backoff must not interrupt local recording.
|
||||||
|
worker = threading.Thread(target=uploader.run, args=(stop,), name="solar-upload", daemon=True)
|
||||||
|
worker.start()
|
||||||
|
else:
|
||||||
|
LOG.info("Upload URL is empty; recording locally only")
|
||||||
|
if recovery:
|
||||||
|
history_worker = threading.Thread(target=recovery.run, args=(stop,), name="solar-history", daemon=True)
|
||||||
|
history_worker.start()
|
||||||
|
client = FroniusClient(config.inverter)
|
||||||
|
next_prune = 0
|
||||||
|
while not stop.is_set():
|
||||||
|
if time.monotonic() >= next_prune:
|
||||||
|
deleted = store.prune()
|
||||||
|
if deleted:
|
||||||
|
LOG.info("Deleted %d acknowledged observations older than the retention period", deleted)
|
||||||
|
next_prune = time.monotonic() + 3600
|
||||||
|
interval = config.collector.poll_interval_seconds
|
||||||
|
now = time.time()
|
||||||
|
# Wait before every observation, including the first after a restart.
|
||||||
|
# Recompute after each poll so a slow poll never triggers a catch-up burst.
|
||||||
|
if stop.wait(min(interval, max(0, next_poll_at(now, interval) - now))):
|
||||||
|
break
|
||||||
|
_record(client, store, config.collector.source_id, interval)
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
if status_server:
|
||||||
|
status_server.close()
|
||||||
|
if worker:
|
||||||
|
worker.join(timeout=config.upload.request_timeout_seconds + 1)
|
||||||
|
if history_worker:
|
||||||
|
history_worker.join(timeout=config.history.request_timeout_seconds + 1)
|
||||||
|
for name, handler in previous.items():
|
||||||
|
signal.signal(name, handler)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _parser():
|
||||||
|
parser = argparse.ArgumentParser(description="Record local Fronius readings and relay them to a website")
|
||||||
|
parser.add_argument("--version", action="version", version=__version__)
|
||||||
|
parser.add_argument("--config", type=Path, default=Path("config.toml"))
|
||||||
|
parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
commands.add_parser("run", help="Record continuously, uploading in a separate worker when configured")
|
||||||
|
commands.add_parser("sample", help="Record one observation and print its normalized readings")
|
||||||
|
commands.add_parser("upload", help="Upload the pending queue, stopping on the first failure")
|
||||||
|
commands.add_parser("status", help="Show local archive/queue status without contacting the inverter")
|
||||||
|
commands.add_parser("prune", help="Delete records acknowledged more than retention_days ago")
|
||||||
|
for name, help_text in [
|
||||||
|
("export", "Export archived samples as JSON Lines, including already uploaded samples"),
|
||||||
|
("replay", "Requeue archived samples for upload, preserving their IDs"),
|
||||||
|
("recover", "Retrieve missing energy intervals from the authenticated Solar.web API"),
|
||||||
|
]:
|
||||||
|
command = commands.add_parser(name, help=help_text)
|
||||||
|
command.add_argument("--from", dest="from_date", help="Inclusive local date or ISO timestamp")
|
||||||
|
command.add_argument("--until", help="Exclusive local date or ISO timestamp")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = _parser().parse_args(argv)
|
||||||
|
logging.basicConfig(level=args.log_level, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
try:
|
||||||
|
config = load_config(args.config)
|
||||||
|
start = until = None
|
||||||
|
if args.command in ("export", "replay", "recover"):
|
||||||
|
start = date_bound(args.from_date, config.collector.timezone)
|
||||||
|
until = date_bound(args.until, config.collector.timezone)
|
||||||
|
if start is not None and until is not None and start >= until:
|
||||||
|
raise ValueError("--from must be earlier than --until")
|
||||||
|
store = Store(config.collector.archive, config.collector.retention_days)
|
||||||
|
if args.command == "run":
|
||||||
|
return _run(config, store)
|
||||||
|
if args.command == "sample":
|
||||||
|
sample = _record(FroniusClient(config.inverter), store, config.collector.source_id, config.collector.poll_interval_seconds)
|
||||||
|
print(json.dumps({key: value for key, value in sample.items() if key != "raw"}, indent=2))
|
||||||
|
return 1 if sample["errors"] else 0
|
||||||
|
if args.command == "status":
|
||||||
|
print(json.dumps(store.status(), indent=2))
|
||||||
|
elif args.command == "prune":
|
||||||
|
print(json.dumps({"deleted": store.prune()}))
|
||||||
|
elif args.command == "recover":
|
||||||
|
if not config.history.enabled:
|
||||||
|
raise ValueError("Enable history and configure Solar.web API credentials before recovery")
|
||||||
|
recovery = HistoryRecovery(config.history, config.collector.source_id, store)
|
||||||
|
print(json.dumps({"recovered": recovery.recover(start, until)}))
|
||||||
|
elif args.command == "export":
|
||||||
|
for sample in store.records(start, until):
|
||||||
|
print(json.dumps(sample, allow_nan=False, separators=(",", ":")))
|
||||||
|
elif args.command == "replay":
|
||||||
|
print(json.dumps({"requeued": store.replay(start, until)}))
|
||||||
|
elif args.command == "upload":
|
||||||
|
store.prune()
|
||||||
|
uploader = Uploader(config.upload, store)
|
||||||
|
count = 0
|
||||||
|
while uploaded := uploader.upload_once():
|
||||||
|
count += uploaded
|
||||||
|
print(json.dumps({"uploaded": count, "pending": store.status()["pending"]}))
|
||||||
|
return 0
|
||||||
|
except BrokenPipeError:
|
||||||
|
return 0
|
||||||
|
except (ValueError, TypeError, OSError, UploadError, TransportError, ZoneInfoNotFoundError) as error:
|
||||||
|
LOG.error("%s", error)
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Configuration contains environment variable names, never upload credentials."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
def utc_text(value: datetime) -> str:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
raise ValueError("A timestamp must include its timezone")
|
||||||
|
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def date_bound(value: str | None, timezone_name: str) -> str | None:
|
||||||
|
"""Date bounds are local midnights; timestamp bounds may specify an offset."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=ZoneInfo(timezone_name))
|
||||||
|
return utc_text(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollectorConfig:
|
||||||
|
source_id: str
|
||||||
|
archive: Path
|
||||||
|
timezone: str = "Europe/Zurich"
|
||||||
|
poll_interval_seconds: float = 300
|
||||||
|
retention_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InverterConfig:
|
||||||
|
base_url: str = "http://fronius.local"
|
||||||
|
meter_id: str = "0"
|
||||||
|
boiler_pin: str = "pin1"
|
||||||
|
request_timeout_seconds: float = 5
|
||||||
|
min_request_interval_seconds: float = 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UploadConfig:
|
||||||
|
url: str = ""
|
||||||
|
token_env: str = "SOLAR_RELAY_TOKEN"
|
||||||
|
batch_size: int = 100
|
||||||
|
request_timeout_seconds: float = 10
|
||||||
|
retry_initial_seconds: float = 5
|
||||||
|
retry_max_seconds: float = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HttpConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
bind: str = "127.0.0.1"
|
||||||
|
port: int = 8765
|
||||||
|
base_path: str = "/solar-relay"
|
||||||
|
history_days: int = 14
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HistoryConfig:
|
||||||
|
enabled: bool = False
|
||||||
|
pv_system_id: str = ""
|
||||||
|
access_key_id_env: str = "SOLARWEB_ACCESS_KEY_ID"
|
||||||
|
access_key_value_env: str = "SOLARWEB_ACCESS_KEY_VALUE"
|
||||||
|
lookback_days: int = 7
|
||||||
|
lag_seconds: int = 7200
|
||||||
|
check_interval_seconds: int = 3600
|
||||||
|
request_timeout_seconds: float = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
collector: CollectorConfig
|
||||||
|
inverter: InverterConfig
|
||||||
|
upload: UploadConfig
|
||||||
|
http: HttpConfig = HttpConfig()
|
||||||
|
history: HistoryConfig = HistoryConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object, name: str, minimum: float) -> float:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ValueError(f"{name} must be a number")
|
||||||
|
if not math.isfinite(value) or value < minimum:
|
||||||
|
raise ValueError(f"{name} must be at least {minimum}")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _url(value: str, name: str, *, upload: bool = False) -> None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{name} must be a URL string")
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
raise ValueError(f"{name} must be an HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password or parsed.fragment:
|
||||||
|
raise ValueError(f"{name} cannot contain credentials or a fragment")
|
||||||
|
if upload and parsed.scheme != "https" and parsed.hostname not in ("localhost", "127.0.0.1", "::1"):
|
||||||
|
raise ValueError("The upload URL must use HTTPS (HTTP is allowed only on loopback for testing)")
|
||||||
|
# Validate the port now, rather than failing inside the recorder loop.
|
||||||
|
parsed.port
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> Config:
|
||||||
|
path = path.resolve()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
data = tomllib.load(handle)
|
||||||
|
unknown = set(data) - {"collector", "inverter", "upload", "http", "history"}
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown configuration sections: {', '.join(sorted(unknown))}")
|
||||||
|
for section, keys in {
|
||||||
|
"collector": set(CollectorConfig.__dataclass_fields__),
|
||||||
|
"inverter": set(InverterConfig.__dataclass_fields__),
|
||||||
|
"upload": set(UploadConfig.__dataclass_fields__),
|
||||||
|
"http": set(HttpConfig.__dataclass_fields__),
|
||||||
|
"history": set(HistoryConfig.__dataclass_fields__),
|
||||||
|
}.items():
|
||||||
|
if not isinstance(data.get(section, {}), dict):
|
||||||
|
raise ValueError(f"{section} must be a table")
|
||||||
|
extra = set(data.get(section, {})) - keys
|
||||||
|
if extra:
|
||||||
|
raise ValueError(f"Unknown {section} settings: {', '.join(sorted(extra))}")
|
||||||
|
values = data.get("collector", {})
|
||||||
|
source_id = values.get("source_id", "home-gen24")
|
||||||
|
if not isinstance(source_id, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", source_id):
|
||||||
|
raise ValueError("collector.source_id must contain 1–80 letters, digits, dots, underscores or hyphens")
|
||||||
|
archive = Path(values.get("archive", "data/archive")).expanduser()
|
||||||
|
if not archive.is_absolute():
|
||||||
|
archive = path.parent / archive
|
||||||
|
timezone_name = values.get("timezone", "Europe/Zurich")
|
||||||
|
ZoneInfo(timezone_name)
|
||||||
|
interval = _number(values.get("poll_interval_seconds", 300), "collector.poll_interval_seconds", 10)
|
||||||
|
if interval != 300:
|
||||||
|
raise ValueError("collector.poll_interval_seconds must be 300 (one observation every five minutes)")
|
||||||
|
retention = values.get("retention_days", 30)
|
||||||
|
if isinstance(retention, bool) or not isinstance(retention, int) or retention < 1:
|
||||||
|
raise ValueError("collector.retention_days must be a positive integer")
|
||||||
|
collector = CollectorConfig(source_id, archive.resolve(), timezone_name, interval, retention)
|
||||||
|
inverter = InverterConfig(**data.get("inverter", {}))
|
||||||
|
_url(inverter.base_url, "inverter.base_url")
|
||||||
|
parsed = urlsplit(inverter.base_url)
|
||||||
|
if parsed.path not in ("", "/") or parsed.query:
|
||||||
|
raise ValueError("inverter.base_url must be the inverter origin, without a path or query")
|
||||||
|
if not isinstance(inverter.meter_id, str) or not inverter.meter_id.isdecimal():
|
||||||
|
raise ValueError('inverter.meter_id must be a string such as "0"')
|
||||||
|
if not isinstance(inverter.boiler_pin, str) or not re.fullmatch(r"pin[1-4]", inverter.boiler_pin):
|
||||||
|
raise ValueError("inverter.boiler_pin must be pin1, pin2, pin3 or pin4")
|
||||||
|
_number(inverter.request_timeout_seconds, "inverter.request_timeout_seconds", 0.1)
|
||||||
|
_number(inverter.min_request_interval_seconds, "inverter.min_request_interval_seconds", 4)
|
||||||
|
upload = UploadConfig(**data.get("upload", {}))
|
||||||
|
if not isinstance(upload.url, str):
|
||||||
|
raise ValueError("upload.url must be a string")
|
||||||
|
if upload.url:
|
||||||
|
_url(upload.url, "upload.url", upload=True)
|
||||||
|
if not isinstance(upload.token_env, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", upload.token_env):
|
||||||
|
raise ValueError("upload.token_env must be an environment variable name")
|
||||||
|
if isinstance(upload.batch_size, bool) or not isinstance(upload.batch_size, int) or not 1 <= upload.batch_size <= 500:
|
||||||
|
raise ValueError("upload.batch_size must be an integer between 1 and 500")
|
||||||
|
_number(upload.request_timeout_seconds, "upload.request_timeout_seconds", 0.1)
|
||||||
|
_number(upload.retry_initial_seconds, "upload.retry_initial_seconds", 1)
|
||||||
|
_number(upload.retry_max_seconds, "upload.retry_max_seconds", upload.retry_initial_seconds)
|
||||||
|
http = HttpConfig(**data.get("http", {}))
|
||||||
|
if not isinstance(http.enabled, bool):
|
||||||
|
raise ValueError("http.enabled must be true or false")
|
||||||
|
if http.bind != "127.0.0.1":
|
||||||
|
raise ValueError("http.bind must be 127.0.0.1; expose the page through a reverse proxy")
|
||||||
|
if isinstance(http.port, bool) or not isinstance(http.port, int) or not 1024 <= http.port <= 65535:
|
||||||
|
raise ValueError("http.port must be an integer between 1024 and 65535")
|
||||||
|
if not isinstance(http.base_path, str) or (http.base_path and not re.fullmatch(r"/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*", http.base_path)):
|
||||||
|
raise ValueError("http.base_path must be empty or a URL path without a trailing slash")
|
||||||
|
if isinstance(http.history_days, bool) or not isinstance(http.history_days, int) or not 1 <= http.history_days <= 90:
|
||||||
|
raise ValueError("http.history_days must be an integer between 1 and 90")
|
||||||
|
history = HistoryConfig(**data.get("history", {}))
|
||||||
|
if not isinstance(history.enabled, bool):
|
||||||
|
raise ValueError("history.enabled must be true or false")
|
||||||
|
if not isinstance(history.pv_system_id, str) or (history.pv_system_id and not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", history.pv_system_id)):
|
||||||
|
raise ValueError("history.pv_system_id must be a UUID")
|
||||||
|
if history.enabled and not history.pv_system_id:
|
||||||
|
raise ValueError("Set history.pv_system_id before enabling Solar.web recovery")
|
||||||
|
for name in ("access_key_id_env", "access_key_value_env"):
|
||||||
|
if not isinstance(getattr(history, name), str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", getattr(history, name)):
|
||||||
|
raise ValueError(f"history.{name} must name an environment variable")
|
||||||
|
for name, minimum in [("lookback_days", 1), ("lag_seconds", 3600), ("check_interval_seconds", 3600)]:
|
||||||
|
value = getattr(history, name)
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||||
|
raise ValueError(f"history.{name} must be an integer of at least {minimum}")
|
||||||
|
if history.lookback_days > 30 or (history.enabled and history.lookback_days > retention):
|
||||||
|
raise ValueError("history.lookback_days must be at most 30 and no longer than retention_days when enabled")
|
||||||
|
_number(history.request_timeout_seconds, "history.request_timeout_seconds", 0.1)
|
||||||
|
return Config(collector, inverter, upload, http, history)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Five-minute extraction completeness, distinct from continuous observed coverage."""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import math
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from .config import utc_text
|
||||||
|
|
||||||
|
BUCKET_MS = 300_000
|
||||||
|
MAX_SOURCE_AGE_MS = 90_000
|
||||||
|
|
||||||
|
|
||||||
|
def milliseconds(value):
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("Observation timestamp has no timezone")
|
||||||
|
return round(parsed.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _finite(value):
|
||||||
|
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_coverage(sample):
|
||||||
|
observed = milliseconds(sample["observed_at"])
|
||||||
|
if "history_interval" in sample:
|
||||||
|
# Cloud history has measured interval energy, but no cumulative meter or relay history.
|
||||||
|
return (sample["id"], sample["source_id"], observed, observed, 1, observed, 0, observed, 0)
|
||||||
|
readings = sample.get("readings", {})
|
||||||
|
result = [sample["id"], sample["source_id"], observed]
|
||||||
|
for source, fields in [
|
||||||
|
("powerflow", ["production_ac_w", "consumption_w", "grid_import_w", "grid_export_w"]),
|
||||||
|
("meter", ["grid_import_total_wh", "grid_export_total_wh"]),
|
||||||
|
("boiler", []),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
stamp = milliseconds(sample["source_timestamps"][source])
|
||||||
|
valid = source not in sample.get("errors", {}) and abs(stamp - observed) <= MAX_SOURCE_AGE_MS
|
||||||
|
except (KeyError, ValueError, TypeError, AttributeError):
|
||||||
|
stamp, valid = observed, False
|
||||||
|
valid = valid and all(_finite(readings.get(field)) for field in fields)
|
||||||
|
if source == "boiler":
|
||||||
|
valid = valid and isinstance(readings.get("boiler_enabled"), bool)
|
||||||
|
result.extend([stamp if valid else observed, int(valid)])
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def daily_coverage(rows, timezone_name, days, now=None):
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
zone = ZoneInfo(timezone_name)
|
||||||
|
today = now.astimezone(zone).date()
|
||||||
|
first = today - timedelta(days=days)
|
||||||
|
upper = round(now.timestamp() * 1000)
|
||||||
|
slots_by_time = {}
|
||||||
|
observations = Counter()
|
||||||
|
for row in rows:
|
||||||
|
observed = row[0]
|
||||||
|
if observed > upper + 1000:
|
||||||
|
continue
|
||||||
|
date = datetime.fromtimestamp(observed / 1000, zone).date().isoformat()
|
||||||
|
observations[date] += 1
|
||||||
|
bucket = observed // BUCKET_MS * BUCKET_MS
|
||||||
|
mask = sum(1 << index for index in range(3) if row[2 + index * 2])
|
||||||
|
slots_by_time[bucket] = slots_by_time.get(bucket, 0) | mask
|
||||||
|
result = []
|
||||||
|
for offset in range(days + 1):
|
||||||
|
date = today - timedelta(days=offset)
|
||||||
|
start = datetime.combine(date, datetime.min.time(), zone)
|
||||||
|
end = datetime.combine(date + timedelta(days=1), datetime.min.time(), zone)
|
||||||
|
start_ms = round(start.timestamp() * 1000)
|
||||||
|
expected = round((end.timestamp() - start.timestamp()) * 1000) // BUCKET_MS
|
||||||
|
elapsed = expected if date < today else max(0, min(expected, (upper - start_ms) // BUCKET_MS))
|
||||||
|
slots = []
|
||||||
|
for index in range(expected):
|
||||||
|
slots.append(slots_by_time.get(start_ms + index * BUCKET_MS, 0) if index < elapsed else -1)
|
||||||
|
complete = sum(mask == 7 for mask in slots)
|
||||||
|
count = observations[date.isoformat()]
|
||||||
|
status = "in_progress" if date == today else "complete" if complete == expected else "partial" if count else "missing"
|
||||||
|
result.append({
|
||||||
|
"date": date.isoformat(), "day_start": utc_text(start), "expected": expected, "elapsed": elapsed,
|
||||||
|
"complete": complete, "missing": elapsed - complete, "samples": count, "status": status,
|
||||||
|
"energy_complete": sum(mask >= 0 and bool(mask & 1) for mask in slots),
|
||||||
|
"meter_complete": sum(mask >= 0 and bool(mask & 2) for mask in slots),
|
||||||
|
"boiler_complete": sum(mask >= 0 and bool(mask & 4) for mask in slots),
|
||||||
|
"slots": slots,
|
||||||
|
})
|
||||||
|
return result
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Read Solar API and GEN24 load-management state without changing the inverter."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .config import InverterConfig, utc_text
|
||||||
|
from .http import JsonHttp, TransportError
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
REQUEST_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||||
|
raise ValueError("Invalid numeric reading")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sum_known(values):
|
||||||
|
values = list(values)
|
||||||
|
return sum(values) if values and all(v is not None for v in values) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _data(response):
|
||||||
|
head = response.get("Head", {})
|
||||||
|
if head.get("Status", {}).get("Code") != 0:
|
||||||
|
raise ValueError("Solar API reported an unsuccessful status")
|
||||||
|
data = response.get("Body", {}).get("Data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Solar API data is missing")
|
||||||
|
timestamp = datetime.fromisoformat(head["Timestamp"].replace("Z", "+00:00"))
|
||||||
|
return data, utc_text(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def powerflow_readings(response):
|
||||||
|
data, timestamp = _data(response)
|
||||||
|
site = data.get("Site")
|
||||||
|
inverters = data.get("Inverters", {})
|
||||||
|
if not isinstance(site, dict) or not isinstance(inverters, dict):
|
||||||
|
raise ValueError("Solar API site/inverter data is missing")
|
||||||
|
if not {"P_PV", "P_Load", "P_Grid"}.issubset(site):
|
||||||
|
raise ValueError("Required Solar API power fields are missing")
|
||||||
|
load = _number(site.get("P_Load"))
|
||||||
|
grid = _number(site.get("P_Grid"))
|
||||||
|
total = _number(site.get("E_Total"))
|
||||||
|
if total is None:
|
||||||
|
total = _sum_known(_number(device.get("E_Total")) for device in inverters.values())
|
||||||
|
return {
|
||||||
|
# GEN24 P_PV is DC production; keep AC output separately instead of mixing them.
|
||||||
|
"production_w": _number(site.get("P_PV")),
|
||||||
|
"production_ac_w": _sum_known(_number(device.get("P")) for device in inverters.values()),
|
||||||
|
"consumption_w": -load if load is not None else None,
|
||||||
|
"grid_w": grid, # Positive: import. Negative: export (feed-in point meter).
|
||||||
|
"grid_import_w": max(0, grid) if grid is not None else None,
|
||||||
|
"grid_export_w": max(0, -grid) if grid is not None else None,
|
||||||
|
"production_ac_total_wh": total,
|
||||||
|
}, timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def meter_readings(response, meter_id):
|
||||||
|
data, timestamp = _data(response)
|
||||||
|
meter = data.get(meter_id)
|
||||||
|
if not isinstance(meter, dict):
|
||||||
|
raise ValueError(f"Configured meter {meter_id} is missing")
|
||||||
|
if meter.get("Enable") not in (1, 1.0):
|
||||||
|
raise ValueError("Configured meter is disabled")
|
||||||
|
if meter.get("Meter_Location_Current") != 0:
|
||||||
|
raise ValueError("Configured meter must be at the grid feed-in point")
|
||||||
|
if not {"EnergyReal_WAC_Sum_Consumed", "EnergyReal_WAC_Sum_Produced"}.issubset(meter):
|
||||||
|
raise ValueError("Required meter energy counters are missing")
|
||||||
|
if meter.get("TimeStamp") is not None:
|
||||||
|
try:
|
||||||
|
timestamp = utc_text(datetime.fromtimestamp(_number(meter["TimeStamp"]), timezone.utc))
|
||||||
|
except (OverflowError, OSError):
|
||||||
|
raise ValueError("Invalid meter measurement timestamp") from None
|
||||||
|
return {
|
||||||
|
"grid_import_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Consumed")),
|
||||||
|
"grid_export_total_wh": _number(meter.get("EnergyReal_WAC_Sum_Produced")),
|
||||||
|
}, timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def boiler_readings(response, pin):
|
||||||
|
state = response.get(pin)
|
||||||
|
if not isinstance(state, dict) or not isinstance(state.get("state"), bool):
|
||||||
|
raise ValueError(f"Boiler output {pin} has no boolean state")
|
||||||
|
reason = state.get("reason")
|
||||||
|
if reason is not None and not isinstance(reason, str):
|
||||||
|
raise ValueError("Boiler reason is not a string")
|
||||||
|
return {"boiler_enabled": state["state"], "boiler_reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
class FroniusClient:
|
||||||
|
def __init__(self, config: InverterConfig, *, http=None):
|
||||||
|
self.config = config
|
||||||
|
self.http = http or JsonHttp(config.request_timeout_seconds, local=True)
|
||||||
|
self.last_request_started = None
|
||||||
|
|
||||||
|
def _read(self, path):
|
||||||
|
for attempt in range(REQUEST_RETRIES + 1):
|
||||||
|
if self.last_request_started is not None:
|
||||||
|
wait = self.config.min_request_interval_seconds - (time.monotonic() - self.last_request_started)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
self.last_request_started = time.monotonic()
|
||||||
|
try:
|
||||||
|
return self.http.request(self.config.base_url.rstrip("/") + path)
|
||||||
|
except TransportError as error:
|
||||||
|
if attempt == REQUEST_RETRIES:
|
||||||
|
raise
|
||||||
|
LOG.warning("Fronius GET %s failed (%s); retry %d/%d", path, error, attempt + 1, REQUEST_RETRIES)
|
||||||
|
|
||||||
|
def collect(self, source_id, poll_interval_seconds=300):
|
||||||
|
sample = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"source_id": source_id,
|
||||||
|
"poll_interval_seconds": poll_interval_seconds,
|
||||||
|
"observed_at": utc_text(datetime.now(timezone.utc)),
|
||||||
|
"inverter": {"meter_id": self.config.meter_id, "boiler_pin": self.config.boiler_pin},
|
||||||
|
"readings": dict.fromkeys([
|
||||||
|
"production_w", "production_ac_w", "consumption_w", "grid_w",
|
||||||
|
"grid_import_w", "grid_export_w", "production_ac_total_wh",
|
||||||
|
"grid_import_total_wh", "grid_export_total_wh",
|
||||||
|
"boiler_enabled", "boiler_reason",
|
||||||
|
]),
|
||||||
|
"source_timestamps": {},
|
||||||
|
"errors": {},
|
||||||
|
"raw": {},
|
||||||
|
}
|
||||||
|
for name, path in [
|
||||||
|
("powerflow", "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"),
|
||||||
|
("meter", "/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System"),
|
||||||
|
("boiler", "/api/status/emrs"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
response = self._read(path)
|
||||||
|
sample["raw"][name] = response
|
||||||
|
if name == "powerflow":
|
||||||
|
readings, timestamp = powerflow_readings(response)
|
||||||
|
elif name == "meter":
|
||||||
|
readings, timestamp = meter_readings(response, self.config.meter_id)
|
||||||
|
else:
|
||||||
|
readings = boiler_readings(response, self.config.boiler_pin)
|
||||||
|
timestamp = utc_text(datetime.now(timezone.utc))
|
||||||
|
sample["readings"].update(readings)
|
||||||
|
sample["source_timestamps"][name] = timestamp
|
||||||
|
except (TransportError, ValueError, KeyError, TypeError, AttributeError) as error:
|
||||||
|
sample["errors"][name] = str(error)
|
||||||
|
return sample
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Optional private Solar.web Query API recovery of missing five-minute energy.
|
||||||
|
|
||||||
|
GEN24's local Solar API exposes no archive. This reads the independent cloud API
|
||||||
|
after the inverter has uploaded its internal backlog; it never requests a guest page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
|
|
||||||
|
from .config import utc_text
|
||||||
|
from .coverage import milliseconds
|
||||||
|
from .http import JsonHttp, TransportError
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
CHANNELS = {
|
||||||
|
"production": "EnergyProductionTotal", "consumption": "EnergyConsumptionTotal",
|
||||||
|
"self_consumption": "EnergySelfConsumptionTotal", "bought": "EnergyPurchased", "sold": "EnergyFeedIn",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def history_sample(entry, source_id, system_id):
|
||||||
|
stamp = milliseconds(entry["logDateTime"])
|
||||||
|
if stamp % 300_000 or entry.get("logDuration") != 300:
|
||||||
|
raise ValueError("Historical record is not an aligned five-minute interval")
|
||||||
|
channels = {}
|
||||||
|
for channel in entry["channels"]:
|
||||||
|
name, value = channel.get("channelName"), channel.get("value")
|
||||||
|
if name not in CHANNELS.values():
|
||||||
|
continue
|
||||||
|
if name in channels or channel.get("unit") != "Wh" or channel.get("channelType") != "Energy":
|
||||||
|
raise ValueError("Unexpected historical channel metadata")
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1e18:
|
||||||
|
raise ValueError("Historical energy is missing or invalid")
|
||||||
|
channels[name] = value
|
||||||
|
energy = {field: channels[name] for field, name in CHANNELS.items()}
|
||||||
|
when = utc_text(datetime.fromtimestamp(stamp / 1000, timezone.utc))
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": str(uuid5(NAMESPACE_URL, f"https://swqapi.solarweb.com/{system_id}/{source_id}/{when}")),
|
||||||
|
"source_id": source_id, "observed_at": when,
|
||||||
|
"readings": dict.fromkeys([
|
||||||
|
"production_w", "production_ac_w", "consumption_w", "grid_w", "grid_import_w", "grid_export_w",
|
||||||
|
"production_ac_total_wh", "grid_import_total_wh", "grid_export_total_wh", "boiler_enabled", "boiler_reason",
|
||||||
|
]),
|
||||||
|
"source_timestamps": {},
|
||||||
|
"errors": {"meter": "Cumulative meter readings are unavailable in recovered energy intervals",
|
||||||
|
"boiler": "Historical boiler switch state is unavailable"},
|
||||||
|
"raw": {"solarweb_history": entry},
|
||||||
|
"history_interval": {"provider": "fronius-solarweb", "start": when, "duration_seconds": 300, "energy_wh": energy},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryRecovery:
|
||||||
|
def __init__(self, config, source_id, store, *, http=None, credentials=None):
|
||||||
|
self.config, self.source_id, self.store = config, source_id, store
|
||||||
|
names = (config.access_key_id_env, config.access_key_value_env)
|
||||||
|
values = credentials if credentials is not None else [os.environ.get(name, "") for name in names]
|
||||||
|
if len(values) != 2 or any(not isinstance(value, str) or not value or any(ord(c) < 33 or ord(c) > 126 for c in value) for value in values):
|
||||||
|
raise ValueError(f"Set {names[0]} and {names[1]} before enabling Solar.web recovery")
|
||||||
|
self.headers = dict(zip(("AccessKeyId", "AccessKeyValue"), values))
|
||||||
|
self.http = http or JsonHttp(config.request_timeout_seconds)
|
||||||
|
|
||||||
|
def recover(self, start=None, until=None, *, now=None, stop=None):
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
latest = int((now - timedelta(seconds=self.config.lag_seconds)).timestamp()) // 300 * 300
|
||||||
|
first = self.store.status()["first_observation"]
|
||||||
|
earliest = max(int((now - timedelta(days=self.config.lookback_days)).timestamp()) // 300 * 300,
|
||||||
|
milliseconds(first) // 300_000 * 300 if first else latest)
|
||||||
|
lower = milliseconds(start) // 300_000 * 300 if start else earliest
|
||||||
|
upper = min(milliseconds(until) // 300_000 * 300, latest) if until else latest
|
||||||
|
if lower >= upper:
|
||||||
|
return 0
|
||||||
|
if upper - lower > 30 * 86400:
|
||||||
|
raise ValueError("Recover at most 30 days at once")
|
||||||
|
stamp = lambda epoch: utc_text(datetime.fromtimestamp(epoch, timezone.utc))
|
||||||
|
present = self.store.energy_slots(self.source_id, stamp(lower), stamp(upper))
|
||||||
|
missing = set(range(lower, upper, 300)) - present
|
||||||
|
# Query only UTC days containing gaps. No request spans the API's 24-hour limit.
|
||||||
|
days = sorted({epoch // 86400 * 86400 for epoch in missing})
|
||||||
|
recovered = 0
|
||||||
|
for day in days:
|
||||||
|
if stop and stop.is_set():
|
||||||
|
break
|
||||||
|
begin, end = max(day, lower), min(day + 86400, upper)
|
||||||
|
page = 0
|
||||||
|
while True:
|
||||||
|
if stop and stop.is_set():
|
||||||
|
break
|
||||||
|
query = urlencode({"from": stamp(begin), "to": stamp(end), "timezone": "zulu",
|
||||||
|
"channel": ",".join(CHANNELS.values()), "offset": page * 300, "limit": 300})
|
||||||
|
url = f"https://swqapi.solarweb.com/pvsystems/{self.config.pv_system_id}/histdata?{query}"
|
||||||
|
response = self.http.request(url, extra_headers=self.headers)
|
||||||
|
system_id = response.get("pvSystemId")
|
||||||
|
if not isinstance(system_id, str) or system_id.lower() != self.config.pv_system_id.lower() or not isinstance(response.get("data"), list):
|
||||||
|
raise ValueError("Unexpected Solar.web history response")
|
||||||
|
entries = response["data"]
|
||||||
|
if len(entries) > 300:
|
||||||
|
raise ValueError("Solar.web history response exceeds the requested page size")
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
sample = history_sample(entry, self.source_id, self.config.pv_system_id)
|
||||||
|
epoch = milliseconds(sample["observed_at"]) // 1000
|
||||||
|
except (KeyError, TypeError, ValueError, AttributeError):
|
||||||
|
# Partial/unavailable cloud records remain missing and are tried again later.
|
||||||
|
continue
|
||||||
|
if begin <= epoch < end and epoch in missing:
|
||||||
|
self.store.add(sample)
|
||||||
|
missing.remove(epoch)
|
||||||
|
recovered += 1
|
||||||
|
if len(entries) < 300:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
if page > 2:
|
||||||
|
raise ValueError("Solar.web history pagination did not finish")
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
def run(self, stop):
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
recovered = self.recover(stop=stop)
|
||||||
|
if recovered:
|
||||||
|
LOG.info("Recovered %d historical energy intervals from Solar.web", recovered)
|
||||||
|
except (TransportError, OSError, ValueError) as error:
|
||||||
|
LOG.warning("Historical recovery failed (%s); retrying on the next scheduled check", type(error).__name__)
|
||||||
|
stop.wait(self.config.check_interval_seconds)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Bounded JSON requests; redirects cannot forward upload credentials."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http.client import HTTPException
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
|
||||||
|
class TransportError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirects(HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, request, response, code, message, headers, new_url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_constant(value):
|
||||||
|
raise ValueError("Non-finite JSON number")
|
||||||
|
|
||||||
|
|
||||||
|
class JsonHttp:
|
||||||
|
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
def __init__(self, timeout: float, *, local: bool = False):
|
||||||
|
handlers = [NoRedirects()]
|
||||||
|
if local:
|
||||||
|
# LAN traffic must not go through a workstation's HTTP(S) proxy.
|
||||||
|
handlers.append(ProxyHandler({}))
|
||||||
|
self.opener = build_opener(*handlers)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def request(self, url: str, *, payload: dict | None = None, token: str | None = None, extra_headers=None) -> dict:
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": f"solar-relay/{__version__}"}
|
||||||
|
headers.update(extra_headers or {})
|
||||||
|
body = None
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(payload, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
request = Request(url, data=body, headers=headers, method="POST" if body is not None else "GET")
|
||||||
|
try:
|
||||||
|
with self.opener.open(request, timeout=self.timeout) as response:
|
||||||
|
raw = response.read(self.MAX_RESPONSE_BYTES + 1)
|
||||||
|
except HTTPError as error:
|
||||||
|
# Do not log server bodies: they can echo request headers or credentials.
|
||||||
|
error.close()
|
||||||
|
raise TransportError(f"HTTP {error.code}") from None
|
||||||
|
except (URLError, OSError, TimeoutError, HTTPException) as error:
|
||||||
|
raise TransportError(f"Network request failed ({type(error).__name__})") from None
|
||||||
|
if len(raw) > self.MAX_RESPONSE_BYTES:
|
||||||
|
raise TransportError("JSON response exceeds 2 MiB")
|
||||||
|
try:
|
||||||
|
value = json.loads(raw, parse_constant=_invalid_constant)
|
||||||
|
except (ValueError, UnicodeError, RecursionError):
|
||||||
|
raise TransportError("Response is not valid JSON") from None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise TransportError("Expected a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Loopback-only status page; Nginx provides HTTPS and the public path."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .config import utc_text
|
||||||
|
from .coverage import milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
class StatusServer:
|
||||||
|
def __init__(self, config, store):
|
||||||
|
self.config, self.store = config, store
|
||||||
|
self.started = time.monotonic()
|
||||||
|
self.cache = {}
|
||||||
|
self.cache_lock = threading.Lock()
|
||||||
|
folder = Path(__file__).parent / "web"
|
||||||
|
self.assets = {
|
||||||
|
"/": ("text/html; charset=utf-8", (folder / "index.html").read_bytes()),
|
||||||
|
"/assets/status.css": ("text/css; charset=utf-8", (folder / "status.css").read_bytes()),
|
||||||
|
"/assets/status.js": ("text/javascript; charset=utf-8", (folder / "status.js").read_bytes()),
|
||||||
|
}
|
||||||
|
owner = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "solar-relay"
|
||||||
|
sys_version = ""
|
||||||
|
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send(self, status, body, kind="application/json; charset=utf-8", *, head=False, headers=None):
|
||||||
|
if not isinstance(body, bytes):
|
||||||
|
body = json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", kind)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'")
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
self.send_header(key, value)
|
||||||
|
self.end_headers()
|
||||||
|
if not head:
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def page(self, head=False):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
path = url.path
|
||||||
|
prefix = owner.config.http.base_path
|
||||||
|
if prefix and path == prefix:
|
||||||
|
self.send(308, {}, head=head, headers={"Location": prefix + "/"})
|
||||||
|
return
|
||||||
|
if prefix and path.startswith(prefix + "/"):
|
||||||
|
path = path[len(prefix):]
|
||||||
|
if path in owner.assets:
|
||||||
|
kind, body = owner.assets[path]
|
||||||
|
self.send(200, body, kind, head=head)
|
||||||
|
elif path == "/healthz":
|
||||||
|
self.send(200, {"status": "ok", "service": "solar-relay", "version": __version__}, head=head)
|
||||||
|
elif path == "/api/status":
|
||||||
|
try:
|
||||||
|
values = parse_qs(url.query).get("days", [str(owner.config.http.history_days)])
|
||||||
|
if len(values) != 1 or not values[0].isdecimal() or not 1 <= int(values[0]) <= 90:
|
||||||
|
self.send(400, {"error": "days must be between 1 and 90"}, head=head)
|
||||||
|
return
|
||||||
|
self.send(200, owner.snapshot(int(values[0])), head=head)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.send(503, {"error": "Archive status is temporarily unavailable"}, head=head)
|
||||||
|
else:
|
||||||
|
self.send(404, {"error": "Not found"}, head=head)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.page()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.page(head=True)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.send(405, {"error": "Read-only status endpoint"}, headers={"Allow": "GET, HEAD"})
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer((config.http.bind, config.http.port), Handler)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.2}, name="solar-status", daemon=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def address(self):
|
||||||
|
return self.server.server_address
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=2)
|
||||||
|
|
||||||
|
def snapshot(self, days):
|
||||||
|
# Repeated page refreshes don't repeatedly scan the historical coverage index.
|
||||||
|
with self.cache_lock:
|
||||||
|
cached = self.cache.get(days)
|
||||||
|
if cached and time.monotonic() - cached[0] < 10:
|
||||||
|
return cached[1]
|
||||||
|
archive = self.store.status()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
attempt = archive["last_observation"]
|
||||||
|
age = (now.timestamp() * 1000 - milliseconds(attempt)) / 1000 if attempt else None
|
||||||
|
errors = list(archive["last_errors"])
|
||||||
|
success = self.store.last_success(self.config.collector.source_id)
|
||||||
|
status = "waiting" if age is None else "stale" if age > max(90, self.config.collector.poll_interval_seconds * 3) or age < -90 else "partial" if errors or success != attempt else "ok"
|
||||||
|
body = {
|
||||||
|
"service": "solar-relay", "version": __version__, "generated_at": utc_text(now),
|
||||||
|
"timezone": self.config.collector.timezone, "source_id": self.config.collector.source_id,
|
||||||
|
"status": status, "uptime_seconds": int(time.monotonic() - self.started),
|
||||||
|
"collector": {"last_attempt": attempt, "last_success": success,
|
||||||
|
"age_seconds": round(max(0, age), 1) if age is not None else None, "source_errors": errors,
|
||||||
|
"poll_interval_seconds": self.config.collector.poll_interval_seconds},
|
||||||
|
"archive": {"samples": archive["samples"], "pending": archive["pending"], "last_upload": archive["last_upload"],
|
||||||
|
"upload_enabled": bool(self.config.upload.url), "format": "parquet", "retention_days": self.config.collector.retention_days},
|
||||||
|
"history": {"enabled": self.config.history.enabled, "provider": "solarweb_query_api",
|
||||||
|
"boiler_history_available": False},
|
||||||
|
"days": self.store.coverage(self.config.collector.source_id, self.config.collector.timezone, days, now),
|
||||||
|
}
|
||||||
|
self.cache[days] = time.monotonic(), body
|
||||||
|
return body
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Durable, date-partitioned Parquet archive and acknowledged upload queue.
|
||||||
|
|
||||||
|
Each mutation replaces a daily partition atomically. A process/thread lock serializes
|
||||||
|
readers and writers; it is never held during an HTTP request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pyarrow as pa
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
from .config import utc_text
|
||||||
|
from .coverage import daily_coverage, milliseconds, sample_coverage
|
||||||
|
|
||||||
|
NUMBERS = (
|
||||||
|
"production_w", "production_ac_w", "consumption_w", "grid_w",
|
||||||
|
"grid_import_w", "grid_export_w", "production_ac_total_wh",
|
||||||
|
"grid_import_total_wh", "grid_export_total_wh",
|
||||||
|
)
|
||||||
|
SCHEMA = pa.schema([
|
||||||
|
("id", pa.string()), ("source_id", pa.string()), ("observed_at", pa.string()),
|
||||||
|
("observed_ms", pa.int64()), ("recorded_ns", pa.int64()),
|
||||||
|
("uploaded_at", pa.string()), ("payload", pa.string()),
|
||||||
|
*((field, pa.float64()) for field in NUMBERS),
|
||||||
|
("boiler_enabled", pa.bool_()), ("boiler_reason", pa.string()),
|
||||||
|
("record_kind", pa.string()),
|
||||||
|
("interval_energy_wh", pa.struct([(field, pa.float64()) for field in ("production", "consumption", "self_consumption", "bought", "sold")])),
|
||||||
|
("power_ms", pa.int64()), ("power_ok", pa.bool_()),
|
||||||
|
("meter_ms", pa.int64()), ("meter_ok", pa.bool_()),
|
||||||
|
("boiler_ms", pa.int64()), ("boiler_ok", pa.bool_()),
|
||||||
|
], metadata={b"solar_relay_schema": b"1"})
|
||||||
|
COVERAGE_COLUMNS = ["observed_ms", "power_ms", "power_ok", "meter_ms", "meter_ok", "boiler_ms", "boiler_ok"]
|
||||||
|
|
||||||
|
|
||||||
|
class StoreError(OSError):
|
||||||
|
"""An archive could not be read or updated safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: Path, retention_days=30):
|
||||||
|
self.path = Path(path)
|
||||||
|
if self.path.is_file():
|
||||||
|
raise StoreError("collector.archive must be a directory")
|
||||||
|
self.path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
self.retention_days = retention_days
|
||||||
|
self._thread_lock = threading.RLock()
|
||||||
|
descriptor = os.open(self.path / ".lock", os.O_CREAT | os.O_WRONLY, 0o600)
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _locked(self, *, write=False):
|
||||||
|
with self._thread_lock, (self.path / ".lock").open("rb") as handle:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX if write else fcntl.LOCK_SH)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except pa.ArrowException as error:
|
||||||
|
raise StoreError(f"Parquet archive error ({type(error).__name__})") from error
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
def _files(self):
|
||||||
|
return sorted(self.path.glob("date=????-??-??/samples.parquet"))
|
||||||
|
|
||||||
|
def _partition(self, stamp):
|
||||||
|
date = datetime.fromtimestamp(milliseconds(stamp) / 1000, timezone.utc).date()
|
||||||
|
return self.path / f"date={date.isoformat()}" / "samples.parquet"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read(path, columns=None):
|
||||||
|
# Avoid implicit Hive partition/schema inference from the directory name.
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
parquet = pq.ParquetFile(path, page_checksum_verification=True)
|
||||||
|
if not parquet.schema_arrow.equals(SCHEMA, check_metadata=True):
|
||||||
|
raise StoreError("Unsupported Parquet archive schema")
|
||||||
|
return parquet.read(columns=columns).to_pylist()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_directory(path):
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
def _write(self, path, rows):
|
||||||
|
new_directory = not path.parent.exists()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
if new_directory:
|
||||||
|
self._sync_directory(self.path)
|
||||||
|
if not rows:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
self._sync_directory(path.parent)
|
||||||
|
return
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix=".samples-", suffix=".tmp", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as handle:
|
||||||
|
table = pa.Table.from_pylist(sorted(rows, key=lambda row: (row["observed_ms"], row["recorded_ns"], row["id"])), schema=SCHEMA)
|
||||||
|
pq.write_table(table, handle, compression="zstd", write_page_checksum=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
self._sync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row(sample, uploaded_at=None):
|
||||||
|
coverage = sample_coverage(sample)
|
||||||
|
readings = sample.get("readings", {})
|
||||||
|
return {
|
||||||
|
"id": sample["id"], "source_id": sample["source_id"], "observed_at": sample["observed_at"],
|
||||||
|
"observed_ms": coverage[2], "recorded_ns": time.time_ns(), "uploaded_at": uploaded_at,
|
||||||
|
"payload": json.dumps(sample, allow_nan=False, separators=(",", ":")),
|
||||||
|
**{name: readings.get(name) for name in NUMBERS},
|
||||||
|
"boiler_enabled": readings.get("boiler_enabled"), "boiler_reason": readings.get("boiler_reason"),
|
||||||
|
"record_kind": "history" if "history_interval" in sample else "live",
|
||||||
|
"interval_energy_wh": sample.get("history_interval", {}).get("energy_wh"),
|
||||||
|
**{name: bool(value) if name.endswith("_ok") else value
|
||||||
|
for name, value in zip(COVERAGE_COLUMNS[1:], coverage[3:])},
|
||||||
|
}
|
||||||
|
|
||||||
|
def add(self, sample):
|
||||||
|
path, row = self._partition(sample["observed_at"]), self._row(sample)
|
||||||
|
with self._locked(write=True):
|
||||||
|
rows = self._read(path)
|
||||||
|
for existing in rows:
|
||||||
|
if existing["id"] == row["id"]:
|
||||||
|
if existing["payload"] != row["payload"]:
|
||||||
|
raise StoreError("Sample ID already contains different data")
|
||||||
|
return
|
||||||
|
self._write(path, [*rows, row])
|
||||||
|
|
||||||
|
def pending(self, limit):
|
||||||
|
with self._locked():
|
||||||
|
rows = []
|
||||||
|
for path in self._files():
|
||||||
|
index = self._read(path, ["uploaded_at"])
|
||||||
|
if any(row["uploaded_at"] is None for row in index):
|
||||||
|
rows.extend(row for row in self._read(path, ["recorded_ns", "id", "payload", "uploaded_at"]) if row["uploaded_at"] is None)
|
||||||
|
rows.sort(key=lambda row: (row["recorded_ns"], row["id"]))
|
||||||
|
return [json.loads(row["payload"]) for row in rows[:limit]]
|
||||||
|
|
||||||
|
def acknowledge(self, ids, *, now=None):
|
||||||
|
wanted, stamp = set(ids), utc_text(now or datetime.now(timezone.utc))
|
||||||
|
if not wanted:
|
||||||
|
return
|
||||||
|
with self._locked(write=True):
|
||||||
|
for path in self._files():
|
||||||
|
index = self._read(path, ["id", "uploaded_at"])
|
||||||
|
if not any(row["id"] in wanted and row["uploaded_at"] is None for row in index):
|
||||||
|
continue
|
||||||
|
rows = self._read(path)
|
||||||
|
for row in rows:
|
||||||
|
if row["id"] in wanted and row["uploaded_at"] is None:
|
||||||
|
row["uploaded_at"] = stamp
|
||||||
|
self._write(path, rows)
|
||||||
|
|
||||||
|
def records(self, start=None, until=None):
|
||||||
|
lower, upper = milliseconds(start) if start else None, milliseconds(until) if until else None
|
||||||
|
with self._locked():
|
||||||
|
rows = []
|
||||||
|
for path in self._files():
|
||||||
|
rows.extend(row for row in self._read(path, ["observed_ms", "recorded_ns", "id", "payload"])
|
||||||
|
if (lower is None or row["observed_ms"] >= lower) and (upper is None or row["observed_ms"] < upper))
|
||||||
|
rows.sort(key=lambda row: (row["observed_ms"], row["recorded_ns"], row["id"]))
|
||||||
|
return (json.loads(row["payload"]) for row in rows)
|
||||||
|
|
||||||
|
def replay(self, start=None, until=None):
|
||||||
|
lower, upper = milliseconds(start) if start else None, milliseconds(until) if until else None
|
||||||
|
count = 0
|
||||||
|
with self._locked(write=True):
|
||||||
|
for path in self._files():
|
||||||
|
rows, changed = self._read(path), False
|
||||||
|
for row in rows:
|
||||||
|
if (lower is None or row["observed_ms"] >= lower) and (upper is None or row["observed_ms"] < upper):
|
||||||
|
row["uploaded_at"] = None
|
||||||
|
count += 1
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._write(path, rows)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def prune(self, *, now=None):
|
||||||
|
"""Retain pending records; age acknowledged records from their receipt time."""
|
||||||
|
cutoff = utc_text((now or datetime.now(timezone.utc)) - timedelta(days=self.retention_days))
|
||||||
|
deleted = 0
|
||||||
|
with self._locked(write=True):
|
||||||
|
for path in self._files():
|
||||||
|
index = self._read(path, ["uploaded_at"])
|
||||||
|
if not any(row["uploaded_at"] is not None and row["uploaded_at"] < cutoff for row in index):
|
||||||
|
continue
|
||||||
|
rows = self._read(path)
|
||||||
|
retained = [row for row in rows if row["uploaded_at"] is None or row["uploaded_at"] >= cutoff]
|
||||||
|
self._write(path, retained)
|
||||||
|
deleted += len(rows) - len(retained)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self._locked():
|
||||||
|
rows = []
|
||||||
|
latest = None
|
||||||
|
for path in self._files():
|
||||||
|
index = self._read(path, ["observed_at", "observed_ms", "recorded_ns", "uploaded_at"])
|
||||||
|
rows.extend(index)
|
||||||
|
if index:
|
||||||
|
candidate = max(index, key=lambda row: (row["observed_ms"], row["recorded_ns"]))
|
||||||
|
if latest is None or (candidate["observed_ms"], candidate["recorded_ns"]) > latest[0]:
|
||||||
|
latest = ((candidate["observed_ms"], candidate["recorded_ns"]), path)
|
||||||
|
errors = {}
|
||||||
|
if latest:
|
||||||
|
newest = max(self._read(latest[1], ["observed_ms", "recorded_ns", "payload"]), key=lambda row: (row["observed_ms"], row["recorded_ns"]))
|
||||||
|
errors = json.loads(newest["payload"]).get("errors", {})
|
||||||
|
return {
|
||||||
|
"archive": str(self.path), "format": "parquet", "retention_days": self.retention_days,
|
||||||
|
"samples": len(rows), "pending": sum(row["uploaded_at"] is None for row in rows),
|
||||||
|
"first_observation": min((row["observed_at"] for row in rows), default=None),
|
||||||
|
"last_observation": max((row["observed_at"] for row in rows), default=None),
|
||||||
|
"last_upload": max((row["uploaded_at"] for row in rows if row["uploaded_at"]), default=None),
|
||||||
|
"last_errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
def coverage(self, source_id, timezone_name, days, now=None):
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
first = now.astimezone(ZoneInfo(timezone_name)).date() - timedelta(days=days)
|
||||||
|
lower = round(datetime.combine(first, datetime.min.time(), ZoneInfo(timezone_name)).timestamp() * 1000)
|
||||||
|
upper = round(now.timestamp() * 1000)
|
||||||
|
with self._locked():
|
||||||
|
rows = []
|
||||||
|
for path in self._files():
|
||||||
|
if path.parent.name[5:] < (now - timedelta(days=days + 2)).date().isoformat():
|
||||||
|
continue
|
||||||
|
rows.extend(tuple(row[name] for name in COVERAGE_COLUMNS)
|
||||||
|
for row in self._read(path, ["source_id", *COVERAGE_COLUMNS])
|
||||||
|
if row["source_id"] == source_id and lower <= row["observed_ms"] <= upper)
|
||||||
|
return daily_coverage(sorted(rows), timezone_name, days, now)
|
||||||
|
|
||||||
|
def last_success(self, source_id):
|
||||||
|
with self._locked():
|
||||||
|
for path in reversed(self._files()):
|
||||||
|
rows = self._read(path, ["source_id", "observed_ms", "power_ok", "meter_ok", "boiler_ok"])
|
||||||
|
stamps = [row["observed_ms"] for row in rows if row["source_id"] == source_id and row["power_ok"] and row["meter_ok"] and row["boiler_ok"]]
|
||||||
|
if stamps:
|
||||||
|
return utc_text(datetime.fromtimestamp(max(stamps) / 1000, timezone.utc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def energy_slots(self, source_id, start, until):
|
||||||
|
"""Return UTC bucket starts already having valid local or recovered energy."""
|
||||||
|
lower, upper = milliseconds(start), milliseconds(until)
|
||||||
|
with self._locked():
|
||||||
|
return {row["observed_ms"] // 300_000 * 300
|
||||||
|
for path in self._files()
|
||||||
|
for row in self._read(path, ["source_id", "observed_ms", "power_ok"])
|
||||||
|
if row["source_id"] == source_id and row["power_ok"] and lower <= row["observed_ms"] < upper}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Retryable delivery with explicit per-sample acknowledgement."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .config import UploadConfig
|
||||||
|
from .http import JsonHttp, TransportError
|
||||||
|
from .storage import Store
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Uploader:
|
||||||
|
def __init__(self, config: UploadConfig, store: Store, *, token=None, http=None):
|
||||||
|
if not config.url:
|
||||||
|
raise ValueError("Set upload.url to the website's ingestion endpoint first")
|
||||||
|
self.config = config
|
||||||
|
self.store = store
|
||||||
|
self.token = token if token is not None else os.environ.get(config.token_env, "")
|
||||||
|
if not self.token or any(ord(c) < 33 or ord(c) > 126 for c in self.token):
|
||||||
|
raise ValueError(f"Set {config.token_env} to a nonempty bearer token without whitespace")
|
||||||
|
self.http = http or JsonHttp(config.request_timeout_seconds)
|
||||||
|
|
||||||
|
def upload_once(self) -> int:
|
||||||
|
batch = self.store.pending(self.config.batch_size)
|
||||||
|
if not batch:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
response = self.http.request(
|
||||||
|
self.config.url, payload={"schema_version": 1, "samples": batch}, token=self.token,
|
||||||
|
)
|
||||||
|
except TransportError as error:
|
||||||
|
raise UploadError(str(error)) from None
|
||||||
|
accepted = response.get("accepted_ids")
|
||||||
|
if not isinstance(accepted, list) or not all(isinstance(value, str) for value in accepted):
|
||||||
|
raise UploadError("Receiver must return an accepted_ids array")
|
||||||
|
requested = {sample["id"] for sample in batch}
|
||||||
|
if len(set(accepted)) != len(accepted) or not set(accepted).issubset(requested):
|
||||||
|
raise UploadError("Receiver acknowledged duplicate or unknown sample IDs")
|
||||||
|
self.store.acknowledge(accepted)
|
||||||
|
if set(accepted) != requested:
|
||||||
|
raise UploadError(f"Receiver accepted {len(accepted)}/{len(batch)} samples; others remain queued")
|
||||||
|
return len(accepted)
|
||||||
|
|
||||||
|
def run(self, stop):
|
||||||
|
delay = self.config.retry_initial_seconds
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
count = self.upload_once()
|
||||||
|
except (UploadError, OSError) as error:
|
||||||
|
LOG.warning("Upload failed: %s; retrying in %.0fs", error, delay)
|
||||||
|
stop.wait(delay)
|
||||||
|
delay = min(delay * 2, self.config.retry_max_seconds)
|
||||||
|
else:
|
||||||
|
delay = self.config.retry_initial_seconds
|
||||||
|
if count:
|
||||||
|
LOG.info("Uploaded and acknowledged %d samples", count)
|
||||||
|
else:
|
||||||
|
stop.wait(5)
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Solar relay · Collection status</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23171d20'/%3E%3Ccircle cx='32' cy='32' r='15' fill='%23e8ba70'/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="assets/status.css">
|
||||||
|
<script src="assets/status.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="brand"><span class="sun" aria-hidden="true">☀</span><span>SOLAR RELAY<small>RPI OFFICE</small></span></div>
|
||||||
|
<div class="header-actions"><span id="overall" class="badge neutral" role="status">Connecting</span><button id="refresh" class="refresh">↻ <span>Refresh</span></button></div>
|
||||||
|
</header>
|
||||||
|
<section class="intro">
|
||||||
|
<p class="eyebrow">COLLECTION MONITOR</p>
|
||||||
|
<h1>Solar relay status</h1>
|
||||||
|
<p class="description">Extraction, delivery and five-minute coverage of your local Fronius data.</p>
|
||||||
|
<div class="context"><span id="source">—</span><span class="separator">/</span><span id="timezone">Europe/Zurich</span><span class="separator">/</span><span id="updated">Waiting for the relay</span></div>
|
||||||
|
</section>
|
||||||
|
<p id="notice" class="notice" role="alert" hidden></p>
|
||||||
|
<section class="metrics" aria-label="Current collection status">
|
||||||
|
<article class="metric"><p class="metric-label">Last extraction</p><strong id="last-extraction">—</strong><p id="last-extraction-date">No observation received yet</p><small id="last-success">Last successful extraction: —</small></article>
|
||||||
|
<article class="metric"><p class="metric-label">Yesterday’s coverage</p><strong id="yesterday">— <span>/ —</span></strong><p id="yesterday-detail">Five-minute intervals</p><small>Energy, meter and boiler status</small></article>
|
||||||
|
<article class="metric"><p class="metric-label">Waiting to upload</p><strong id="pending">—</strong><p id="upload-detail">Checking delivery</p><small id="last-upload">Last acknowledgement: —</small></article>
|
||||||
|
<article class="metric"><p class="metric-label">Local archive</p><strong id="samples">—</strong><p>Observations retained in Parquet</p><small id="retention">Acknowledged readings kept for 30 days</small></article>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading"><div><p class="eyebrow">DAILY COVERAGE</p><h2>Five-minute completeness</h2></div><label class="range">History <select id="history"><option value="7">7 days</option><option value="14" selected>14 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label></div>
|
||||||
|
<p class="panel-note">Complete means a valid energy, meter and boiler extraction in that five-minute interval. It does not mean continuous measurement. Recovered cloud history supplies energy only.</p>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th>Day</th><th>Complete intervals</th><th class="source-column">Energy</th><th class="source-column">Meter</th><th class="source-column">Boiler</th><th>Status</th></tr></thead><tbody id="days"><tr><td colspan="6" class="empty">Waiting for the local archive…</td></tr></tbody></table></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel detail" aria-label="Selected day interval details">
|
||||||
|
<div class="panel-heading"><div><p class="eyebrow">FIVE-MINUTE INTERVALS</p><h2 id="selected-date">Select a day</h2></div><div class="legend"><span><i class="complete"></i>Complete</span><span><i class="partial"></i>Partial</span><span><i class="missing"></i>Missing</span><span><i class="future"></i>Upcoming</span></div></div>
|
||||||
|
<p id="selected-summary" class="panel-note">Each column is one hour. Each cell is a five-minute interval.</p>
|
||||||
|
<div id="slots" class="slots" aria-label="Five-minute coverage"></div>
|
||||||
|
<p id="missing-ranges" class="gap-summary"></p>
|
||||||
|
</section>
|
||||||
|
<footer><span>Read-only status · refreshes every 15 seconds</span><span id="version">solar-relay</span></footer>
|
||||||
|
<noscript><p class="notice">Enable JavaScript to load extraction and coverage status.</p></noscript>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
:root{color-scheme:dark;--bg:#101518;--panel:#171e22;--line:#2b353a;--ink:#eef1ed;--muted:#9ba9ac;--amber:#e8ba70;--green:#7ad6ac;--red:#e68d86;--radius:12px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-synthesis:none}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink)}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible{outline:2px solid var(--amber);outline-offset:4px}main{max-width:1216px;margin:auto;padding:0 32px}.masthead{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-size:13px;letter-spacing:.14em;font-weight:750}.brand small{display:block;color:var(--muted);font-size:9px;letter-spacing:.22em;margin-top:5px}.sun{font-size:36px;color:var(--amber);line-height:1}.header-actions{display:flex;align-items:center;gap:16px}.badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:20px;padding:6px 11px;font-size:11px;font-weight:650;line-height:1.3;background:#252e32;color:var(--muted)}.badge::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}.badge.ok,.badge.complete{color:var(--green);background:#193a2e}.badge.partial,.badge.in_progress{color:var(--amber);background:#3a3121}.badge.stale{color:var(--red);background:#3d2829}.badge.missing,.badge.waiting,.badge.neutral{color:var(--muted);background:#252e32}.refresh{border:1px solid var(--line);background:transparent;color:var(--ink);border-radius:7px;padding:8px 12px;font-size:13px}.refresh:hover{border-color:var(--muted)}.refresh:disabled{opacity:.45;cursor:wait}.intro{padding:42px 0 28px}.eyebrow{color:var(--amber);font-size:10px;font-weight:700;letter-spacing:.17em;margin:0 0 13px}h1{font-size:clamp(27px,3.4vw,42px);letter-spacing:-.045em;font-weight:550;line-height:1.14;margin:0 0 13px}.description{color:var(--muted);font-size:14px;line-height:1.6;margin:0}.context{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted);margin-top:20px;flex-wrap:wrap}.separator{color:#48565c}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 28px}.metric{border:1px solid var(--line);border-radius:var(--radius);padding:23px 20px;background:var(--panel)}.metric-label{font-size:11px;font-weight:550;color:var(--muted);margin:0 0 18px}.metric strong{display:block;font-size:30px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.04em;line-height:1.1;min-height:34px}.metric strong span{color:var(--muted);font-size:17px;font-weight:400;letter-spacing:normal}.metric p:not(.metric-label){font-size:11px;margin:12px 0 7px;color:#d1d9d7}.metric small{font-size:10px;color:var(--muted);line-height:1.6;display:block}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:22px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 24px 0}.panel-heading .eyebrow{margin-bottom:7px}h2{font-size:19px;font-weight:550;letter-spacing:-.025em;margin:0}.panel-note{font-size:11px;line-height:1.7;color:var(--muted);padding:0 24px;margin:12px 0 22px}.range{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:11px;white-space:nowrap}select{background:#20292e;border:1px solid #354149;border-radius:6px;color:var(--ink);padding:7px 27px 7px 9px;font-size:11px}.table-scroll{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;font-size:12px;font-variant-numeric:tabular-nums}th{background:#1c252a;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:11px 18px;font-size:9px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}th:first-child,td:first-child{padding-left:24px}td{padding:13px 18px;border-bottom:1px solid #283137;white-space:nowrap}tbody tr:last-child td{border-bottom:0}tr.selected{background:#222d32}tr:hover{background:#202a2f}.day-select{display:block;text-align:left;color:var(--ink);background:transparent;border:0;padding:0;line-height:1.55;font-size:12px}.day-select small{display:block;color:var(--muted);font-size:10px}.coverage-number{display:flex;align-items:baseline;gap:5px;font-size:12px}.coverage-number small{font-size:10px;color:var(--muted)}.progress{height:3px;width:126px;border-radius:2px;background:#303c42;margin-top:7px;overflow:hidden}.progress span{display:block;height:100%;background:var(--green)}.source-count{color:#c1ceca;font-size:11px}.source-count.incomplete{color:var(--amber)}.empty{padding:40px!important;text-align:center;color:var(--muted)}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:10px;color:var(--muted)}.legend span{display:flex;align-items:center;gap:6px}.legend i{display:block;width:7px;height:7px;border-radius:2px}.slots{display:flex;gap:5px;padding:0 24px;overflow-x:auto}.hour{display:grid;grid-template-rows:16px repeat(12,5px);gap:3px;flex:1;min-width:14px}.hour-label{font-size:9px;color:var(--muted);font-variant-numeric:tabular-nums;text-align:center}.slot{display:block;border-radius:1px;min-height:5px}.complete{background:#76c9a4}.partial{background:#bd9558}.missing{bacLine truncated
|
||||||
|
@media(max-width:900px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.metric{padding:20px}.source-column{display:none}.legend{max-width:215px;gap:10px}.slots{gap:4px}.progress{width:115px}}
|
||||||
|
@media(max-width:560px){main{padding:0 16px}.masthead{height:82px}.brand{font-size:11px;gap:8px}.sun{font-size:30px}.header-actions{gap:8px}.refresh span{display:none}.refresh{font-size:17px;padding:4px 9px}.badge{font-size:10px;padding:6px 8px}.intro{padding-top:30px}.metrics{gap:10px;margin-bottom:20px}.metric{padding:17px 14px}.metric strong{font-size:26px}.metric strong span{font-size:14px}.metric-label{margin-bottom:14px}.metric small{font-size:9px}.panel-heading{padding:20px 16px 0;align-items:flex-start}h2{font-size:16px}.panel-note{padding:0 16px;font-size:10px}th,td{padding-left:11px;padding-right:11px}th:first-child,td:first-child{padding-left:16px}.progress{width:88px}.range{flex-direction:column;align-items:flex-end;gap:5px}.legend{font-size:9px;gap:7px;max-width:150px}.slots{padding:0 16px;gap:3px}.hour{min-width:10px;grid-template-rows:16px repeat(12,5px);gap:3px}.hour-label{font-size:8px}.gap-summary{padding:16px;font-size:10px}.day-select{font-size:11px}footer{font-size:9px;flex-wrap:wrap}.detail .panel-heading{gap:8px}}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/* Read-only local archive dashboard. All URLs remain relative to the proxy prefix. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const labels = {ok:'Collecting',waiting:'Waiting for data',partial:'Partial extraction',stale:'Extraction overdue',complete:'Complete',in_progress:'In progress',missing:'No data'};
|
||||||
|
let current = null;
|
||||||
|
let selected = null;
|
||||||
|
let busy = false;
|
||||||
|
let zone = 'Europe/Zurich';
|
||||||
|
const number = value => new Intl.NumberFormat('en-GB').format(value);
|
||||||
|
const format = (value, options) => value ? new Intl.DateTimeFormat('en-GB', {timeZone:zone,...options}).format(new Date(value)) : '—';
|
||||||
|
const time = value => format(value,{hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false});
|
||||||
|
const date = value => format(value,{day:'2-digit',month:'short',year:'numeric'});
|
||||||
|
function badge(text, status) {
|
||||||
|
const element = document.createElement('span');
|
||||||
|
element.className = 'badge ' + status;
|
||||||
|
element.textContent = text;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function td(className) {
|
||||||
|
const element = document.createElement('td');
|
||||||
|
if(className) element.className = className;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function addText(parent, tag, text, className) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
if(className) element.className = className;
|
||||||
|
element.textContent = text;
|
||||||
|
parent.append(element);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function renderDays() {
|
||||||
|
$('days').replaceChildren();
|
||||||
|
for(const day of current.days) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.classList.toggle('selected',day.date===selected);
|
||||||
|
const dayCell = td();
|
||||||
|
const button = addText(dayCell,'button',format(day.date+'T12:00:00Z',{day:'2-digit',month:'short'}),'day-select');
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute('aria-expanded',String(day.date===selected));
|
||||||
|
addText(button,'small',day.status==='in_progress'?'Today':format(day.date+'T12:00:00Z',{weekday:'long'}));
|
||||||
|
button.addEventListener('click',() => {selected=day.date;renderDays();renderSlots();});
|
||||||
|
row.append(dayCell);
|
||||||
|
const amount = td();
|
||||||
|
const count = addText(amount,'div',number(day.complete),'coverage-number');
|
||||||
|
addText(count,'small','/ '+number(day.elapsed)+(day.status==='in_progress'?' so far':''));
|
||||||
|
const progress = addText(amount,'div','','progress');
|
||||||
|
const fill = addText(progress,'span','');
|
||||||
|
fill.style.width = (day.elapsed ? day.complete/day.elapsed*100:0)+'%';
|
||||||
|
row.append(amount);
|
||||||
|
for(const field of ['energy_complete','meter_complete','boiler_complete']) {
|
||||||
|
const cell = td('source-column');
|
||||||
|
addText(cell,'span',number(day[field])+'/'+number(day.elapsed),'source-count'+(day[field]<day.elapsed?' incomplete':''));
|
||||||
|
row.append(cell);
|
||||||
|
}
|
||||||
|
const state = td();
|
||||||
|
state.append(badge(labels[day.status]||day.status,day.status));
|
||||||
|
row.append(state);
|
||||||
|
$('days').append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderSlots() {
|
||||||
|
const day = current.days.find(item => item.date===selected);
|
||||||
|
if(!day) return;
|
||||||
|
$('selected-date').textContent = format(day.date+'T12:00:00Z',{weekday:'long',day:'numeric',month:'long',year:'numeric'});
|
||||||
|
$('selected-summary').textContent = `${number(day.complete)} complete of ${number(day.elapsed)} elapsed intervals · ${number(day.expected)} intervals in this calendar day. Each cell is five minutes.`;
|
||||||
|
const start = Date.parse(day.day_start);
|
||||||
|
const stamp = index => format(new Date(start+index*300000),{hour:'2-digit',minute:'2-digit',timeZoneName:'shortOffset',hour12:false});
|
||||||
|
$('slots').replaceChildren();
|
||||||
|
let hour;
|
||||||
|
for(let index=0;index<day.slots.length;index++) {
|
||||||
|
if(index%12===0) {
|
||||||
|
hour = addText($('slots'),'div','','hour');
|
||||||
|
addText(hour,'span',format(new Date(start+index*300000),{hour:'2-digit',hour12:false}),'hour-label');
|
||||||
|
}
|
||||||
|
const mask = day.slots[index];
|
||||||
|
const state = mask===7?'complete':mask===-1?'future':mask>0?'partial':'missing';
|
||||||
|
const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<<bit)));
|
||||||
|
const text = `${stamp(index)}–${stamp(index+1)} · ${mask===7?'Complete':mask===-1?'Upcoming':'Incomplete: '+missing.join(', ')}`;
|
||||||
|
const cell = addText(hour,'span','','slot '+state);
|
||||||
|
cell.title = text;
|
||||||
|
cell.setAttribute('role','img');
|
||||||
|
cell.setAttribute('aria-label',text);
|
||||||
|
}
|
||||||
|
const ranges = [];
|
||||||
|
for(let index=0;index<day.elapsed;) {
|
||||||
|
if(day.slots[index]===7) {index++;continue;}
|
||||||
|
const from = index;
|
||||||
|
while(index<day.elapsed && day.slots[index]!==7) index++;
|
||||||
|
ranges.push(`${stamp(from)}–${stamp(index)}`);
|
||||||
|
}
|
||||||
|
$('missing-ranges').textContent = ranges.length ? `${number(day.missing)} incomplete intervals. Gaps: ${ranges.slice(0,6).join('; ')}${ranges.length>6?`; plus ${number(ranges.length-6)} more ranges.`:'.'}` : day.status==='in_progress'?'All elapsed intervals are complete. The current interval is still being recorded.':'All five-minute intervals have complete data.';
|
||||||
|
}
|
||||||
|
function render(data) {
|
||||||
|
current = data;
|
||||||
|
zone = data.timezone;
|
||||||
|
$('source').textContent = data.source_id;
|
||||||
|
$('timezone').textContent = zone;
|
||||||
|
$('updated').textContent = 'Updated '+time(data.generated_at);
|
||||||
|
$('overall').textContent = labels[data.status]||data.status;
|
||||||
|
$('overall').className = 'badge '+data.status;
|
||||||
|
$('last-extraction').textContent = data.collector.last_attempt?time(data.collector.last_attempt):'Waiting';
|
||||||
|
$('last-extraction-date').textContent = data.collector.last_attempt?date(data.collector.last_attempt):'No observation received yet';
|
||||||
|
$('last-success').textContent = 'Last successful: '+(data.collector.last_success?date(data.collector.last_success)+' '+time(data.collector.last_success):'not yet recorded');
|
||||||
|
const yesterday = data.days[1];
|
||||||
|
if(yesterday) {
|
||||||
|
$('yesterday').replaceChildren(document.createTextNode(number(yesterday.complete)+' '));
|
||||||
|
addText($('yesterday'),'span','/ '+number(yesterday.expected));
|
||||||
|
$('yesterday-detail').textContent = labels[yesterday.status]+' · '+date(yesterday.date+'T12:00:00Z');
|
||||||
|
}
|
||||||
|
$('pending').textContent = number(data.archive.pending);
|
||||||
|
$('upload-detail').textContent = !data.archive.upload_enabled?'Uploads not configured':data.archive.pending?'Saved locally, awaiting delivery':'All observations acknowledged';
|
||||||
|
$('last-upload').textContent = 'Last acknowledgement: '+(data.archive.last_upload?date(data.archive.last_upload)+' '+time(data.archive.last_upload):'none yet');
|
||||||
|
$('samples').textContent = number(data.archive.samples);
|
||||||
|
$('retention').textContent = `Acknowledged readings kept for ${number(data.archive.retention_days)} days · pending readings retained`;
|
||||||
|
$('version').textContent = 'solar-relay '+data.version;
|
||||||
|
if(!selected || !data.days.some(day=>day.date===selected)) selected=(data.days.find(day=>day.samples>0)||yesterday||data.days[0]).date;
|
||||||
|
renderDays();
|
||||||
|
renderSlots();
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
if(busy) return;
|
||||||
|
busy=true;
|
||||||
|
$('refresh').disabled=true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(()=>controller.abort(),12000);
|
||||||
|
try {
|
||||||
|
const response = await fetch('api/status?days='+encodeURIComponent($('history').value),{cache:'no-store',signal:controller.signal});
|
||||||
|
if(!response.ok) throw new Error('Status request failed');
|
||||||
|
render(await response.json());
|
||||||
|
$('notice').hidden=true;
|
||||||
|
} catch {
|
||||||
|
$('notice').textContent = current?'The relay cannot be reached. Showing the last received status; automatic refresh will retry.':'Waiting for the relay status service. Check that solar-relay.service is running and the reverse proxy forwards this path to port 8765.';
|
||||||
|
$('notice').hidden=false;
|
||||||
|
$('overall').textContent='Connection lost';
|
||||||
|
$('overall').className='badge stale';
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
busy=false;
|
||||||
|
$('refresh').disabled=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('refresh').addEventListener('click',refresh);
|
||||||
|
$('history').addEventListener('change',refresh);
|
||||||
|
document.addEventListener('visibilitychange',()=>{if(!document.hidden) refresh();});
|
||||||
|
setInterval(()=>{if(!document.hidden) refresh();},15000);
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Exercise the workflow's real rsync script with a mocked service manager."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("rsync") and shutil.which("flock"), "deployment tools are not installed")
|
||||||
|
class DeploymentTests(unittest.TestCase):
|
||||||
|
def test_deploy_preserves_runtime_and_archive_and_restarts_service(self):
|
||||||
|
workflow = (Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml").read_text()
|
||||||
|
deploy_step = workflow.split("- name: Deploy and restart\n", 1)[1]
|
||||||
|
script = textwrap.dedent(deploy_step.split("run: |\n", 1)[1])
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
checkout, deployment, binaries = [root / name for name in ("checkout", "deployment", "bin")]
|
||||||
|
for path in (checkout, deployment, binaries):
|
||||||
|
path.mkdir()
|
||||||
|
(checkout / "solar_relay").mkdir()
|
||||||
|
(checkout / "solar_relay" / "new.py").write_text("# new release\n")
|
||||||
|
(checkout / "requirements.txt").write_text("pyarrow==26.0.0\n")
|
||||||
|
(deployment / "obsolete.py").write_text("# obsolete\n")
|
||||||
|
protected = {
|
||||||
|
"config.toml": "private configuration",
|
||||||
|
".env": "private token",
|
||||||
|
"data/archive/date=2026-11-01/samples.parquet": "durable parquet observations",
|
||||||
|
"archive/date=2026-11-01/samples.parquet": "archived observations",
|
||||||
|
".venv/keep.txt": "runtime venv",
|
||||||
|
".git/keep.txt": "existing checkout metadata",
|
||||||
|
}
|
||||||
|
for relative, content in protected.items():
|
||||||
|
target = deployment / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(content)
|
||||||
|
source = checkout / relative
|
||||||
|
source.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.write_text("must not replace local state")
|
||||||
|
runtime = deployment / ".venv/bin/python"
|
||||||
|
runtime.parent.mkdir(parents=True)
|
||||||
|
runtime.write_text('#!/bin/sh\nif [ "$1" = "-m" ] && [ "$2" = pip ]; then printf "pip %s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"; exit 0; fi\nexec ' + shlex.quote(sys.executable) + ' "$@"\n')
|
||||||
|
runtime.chmod(0o755)
|
||||||
|
mocks = {
|
||||||
|
"sudo": '#!/bin/sh\n[ "$1" = "-n" ] || exit 99\nshift\nexec "$@"\n',
|
||||||
|
"systemctl": '#!/bin/sh\nprintf "%s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"\nif [ "$1" = show ]; then printf "0\\n"; fi\n',
|
||||||
|
"sleep": "#!/bin/sh\nexit 0\n",
|
||||||
|
"curl": '#!/bin/sh\nprintf "curl %s\\n" "$*" >> "$SOLAR_DEPLOY_TEST_LOG"\n',
|
||||||
|
}
|
||||||
|
for name, content in mocks.items():
|
||||||
|
target = binaries / name
|
||||||
|
target.write_text(content)
|
||||||
|
target.chmod(0o755)
|
||||||
|
log = root / "systemctl.log"
|
||||||
|
env = {**os.environ, "DEPLOY_PATH": str(deployment), "SERVICE": "solar-relay.service",
|
||||||
|
"PATH": str(binaries) + os.pathsep + os.environ["PATH"], "SOLAR_DEPLOY_TEST_LOG": str(log),
|
||||||
|
"HEALTH_URL": "https://rpioffice.nest.lutran.ch/solar-relay/healthz"}
|
||||||
|
result = subprocess.run(["bash", "-c", script], cwd=checkout, env=env, capture_output=True, text=True, timeout=15)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertTrue((deployment / "solar_relay/new.py").exists())
|
||||||
|
self.assertFalse((deployment / "obsolete.py").exists())
|
||||||
|
for relative, content in protected.items():
|
||||||
|
self.assertEqual((deployment / relative).read_text(), content, relative)
|
||||||
|
self.assertIn("restart solar-relay.service", log.read_text())
|
||||||
|
self.assertIn("is-active --quiet solar-relay.service", log.read_text())
|
||||||
|
self.assertIn("pip -m pip install --only-binary=pyarrow -r", log.read_text())
|
||||||
|
self.assertLess(log.read_text().index("pip -m pip"), log.read_text().index("restart solar-relay.service"))
|
||||||
|
self.assertIn(env["HEALTH_URL"], log.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from solar_relay.config import HistoryConfig, utc_text
|
||||||
|
from solar_relay.history import CHANNELS, HistoryRecovery, history_sample
|
||||||
|
from solar_relay.http import TransportError
|
||||||
|
from solar_relay.storage import Store
|
||||||
|
from tests.test_status import observed
|
||||||
|
|
||||||
|
SYSTEM = "73733052-6f10-47a5-9746-7f7e76ebcb8c"
|
||||||
|
|
||||||
|
|
||||||
|
def entry(when):
|
||||||
|
values = {"production": 100, "consumption": 120, "self_consumption": 80, "bought": 40, "sold": 20}
|
||||||
|
return {"logDateTime": utc_text(when), "logDuration": 300,
|
||||||
|
"channels": [{"channelName": channel, "channelType": "Energy", "unit": "Wh", "value": values[field]}
|
||||||
|
for field, channel in CHANNELS.items()]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHistoryHttp:
|
||||||
|
def __init__(self, entries):
|
||||||
|
self.entries, self.requests = entries, []
|
||||||
|
|
||||||
|
def request(self, url, **kwargs):
|
||||||
|
self.requests.append((url, kwargs))
|
||||||
|
return {"pvSystemId": SYSTEM, "data": deepcopy(self.entries)}
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.store = Store(Path(self.directory.name) / "archive")
|
||||||
|
self.when = datetime(2026, 10, 30, 10, tzinfo=timezone.utc)
|
||||||
|
self.source = "test-house"
|
||||||
|
self.http = FakeHistoryHttp([entry(self.when + timedelta(minutes=offset)) for offset in [0, 5, 10]])
|
||||||
|
self.config = HistoryConfig(enabled=True, pv_system_id=SYSTEM)
|
||||||
|
self.recovery = HistoryRecovery(self.config, self.source, self.store, http=self.http, credentials=("id-test", "value-test"))
|
||||||
|
|
||||||
|
def recover(self):
|
||||||
|
return self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(minutes=15)), now=self.when + timedelta(hours=4))
|
||||||
|
|
||||||
|
def test_wifi_gap_recovers_only_missing_energy_without_inventing_boiler(self):
|
||||||
|
for offset in [0, 10]:
|
||||||
|
self.store.add(observed(self.when + timedelta(minutes=offset)))
|
||||||
|
self.assertEqual(self.recover(), 1)
|
||||||
|
records = list(self.store.records())
|
||||||
|
recovered = [s for s in records if "history_interval" in s]
|
||||||
|
self.assertEqual(len(recovered), 1)
|
||||||
|
self.assertEqual(recovered[0]["history_interval"]["energy_wh"]["production"], 100)
|
||||||
|
self.assertIsNone(recovered[0]["readings"]["boiler_enabled"])
|
||||||
|
self.assertNotIn("boiler", recovered[0]["source_timestamps"])
|
||||||
|
self.assertIn("boiler", recovered[0]["errors"])
|
||||||
|
url, kwargs = self.http.requests[0]
|
||||||
|
self.assertEqual(urlsplit(url).hostname, "swqapi.solarweb.com")
|
||||||
|
self.assertEqual(kwargs["extra_headers"], {"AccessKeyId": "id-test", "AccessKeyValue": "value-test"})
|
||||||
|
self.assertNotIn("value-test", url)
|
||||||
|
self.assertEqual(self.recover(), 0)
|
||||||
|
self.assertEqual(len(self.http.requests), 1)
|
||||||
|
|
||||||
|
def test_cloud_energy_does_not_mark_boiler_or_meter_history_complete(self):
|
||||||
|
self.assertEqual(self.recover(), 3)
|
||||||
|
rows = self.store.coverage(self.source, "UTC", 1, self.when + timedelta(days=1))
|
||||||
|
day = rows[1]
|
||||||
|
self.assertEqual(day["energy_complete"], 3)
|
||||||
|
self.assertEqual(day["boiler_complete"], 0)
|
||||||
|
self.assertEqual(day["meter_complete"], 0)
|
||||||
|
self.assertEqual(day["complete"], 0)
|
||||||
|
row = pq.ParquetFile(next(self.store.path.glob("date=*/samples.parquet"))).read().to_pylist()[0]
|
||||||
|
self.assertEqual(row["record_kind"], "history")
|
||||||
|
self.assertEqual(row["interval_energy_wh"]["production"], 100)
|
||||||
|
|
||||||
|
def test_partial_cloud_records_remain_missing_for_retry(self):
|
||||||
|
self.http.entries[1]["channels"][0]["value"] = None
|
||||||
|
self.assertEqual(self.recover(), 2)
|
||||||
|
self.assertEqual(self.recover(), 0)
|
||||||
|
self.http.entries[1] = entry(self.when + timedelta(minutes=5))
|
||||||
|
self.assertEqual(self.recover(), 1)
|
||||||
|
|
||||||
|
def test_recovered_identifiers_are_stable_for_duplicate_delivery(self):
|
||||||
|
first = history_sample(entry(self.when), self.source, SYSTEM)
|
||||||
|
second = history_sample(entry(self.when), self.source, SYSTEM)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_non_five_minute_and_bad_units_are_rejected(self):
|
||||||
|
for change in [{"logDuration": 599}, {"logDateTime": utc_text(self.when + timedelta(seconds=1))}]:
|
||||||
|
value = entry(self.when)
|
||||||
|
value.update(change)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
history_sample(value, self.source, SYSTEM)
|
||||||
|
value = entry(self.when)
|
||||||
|
value["channels"][0]["unit"] = "W"
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
history_sample(value, self.source, SYSTEM)
|
||||||
|
|
||||||
|
def test_missing_private_credentials_fail_before_requests(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "SOLARWEB_ACCESS_KEY"):
|
||||||
|
HistoryRecovery(self.config, self.source, self.store, credentials=("", ""))
|
||||||
|
|
||||||
|
def test_requests_are_split_at_utc_midnight_even_across_dst(self):
|
||||||
|
self.http.entries = []
|
||||||
|
self.recovery.recover("2026-10-24T22:00:00Z", "2026-10-26T00:00:00Z", now=datetime(2026, 10, 27, tzinfo=timezone.utc))
|
||||||
|
self.assertEqual(len(self.http.requests), 2)
|
||||||
|
for url, _ in self.http.requests:
|
||||||
|
query = parse_qs(urlsplit(url).query)
|
||||||
|
start, end = [datetime.fromisoformat(query[name][0]) for name in ("from", "to")]
|
||||||
|
self.assertLessEqual((end - start).total_seconds(), 86400)
|
||||||
|
|
||||||
|
def test_recent_intervals_wait_for_the_hourly_cloud_upload(self):
|
||||||
|
self.http.entries = []
|
||||||
|
self.recovery.recover(utc_text(self.when), utc_text(self.when + timedelta(hours=4)), now=self.when + timedelta(hours=4))
|
||||||
|
query = parse_qs(urlsplit(self.http.requests[0][0]).query)
|
||||||
|
self.assertEqual(query["to"], [utc_text(self.when + timedelta(hours=2))])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
|
||||||
|
from solar_relay.cli import next_poll_at
|
||||||
|
from solar_relay.storage import Store, StoreError
|
||||||
|
from tests.test_status import observed
|
||||||
|
|
||||||
|
|
||||||
|
class ParquetTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.path = Path(self.directory.name) / "archive"
|
||||||
|
self.store = Store(self.path)
|
||||||
|
self.now = datetime(2026, 11, 1, 12, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def test_real_parquet_has_typed_readings_and_original_payload(self):
|
||||||
|
value = observed(self.now)
|
||||||
|
value["readings"]["boiler_enabled"] = False
|
||||||
|
self.store.add(value)
|
||||||
|
path = next(self.path.glob("date=*/samples.parquet"))
|
||||||
|
self.assertEqual(path.read_bytes()[:4], b"PAR1")
|
||||||
|
row = pq.ParquetFile(path).read().to_pylist()[0]
|
||||||
|
self.assertEqual(row["production_ac_w"], 2000)
|
||||||
|
self.assertIs(row["boiler_enabled"], False)
|
||||||
|
self.assertIsNone(row["uploaded_at"])
|
||||||
|
self.assertEqual(json.loads(row["payload"]), value)
|
||||||
|
self.assertEqual(self.store.status()["format"], "parquet")
|
||||||
|
|
||||||
|
def test_retention_uses_ack_time_and_never_deletes_pending(self):
|
||||||
|
items = [observed(self.now - timedelta(days=90, minutes=index)) for index in range(4)]
|
||||||
|
for item in items:
|
||||||
|
self.store.add(item)
|
||||||
|
self.store.acknowledge([items[0]["id"]], now=self.now - timedelta(days=31))
|
||||||
|
self.store.acknowledge([items[1]["id"]], now=self.now - timedelta(days=30))
|
||||||
|
self.store.acknowledge([items[2]["id"]], now=self.now)
|
||||||
|
self.assertEqual(self.store.prune(now=self.now), 1)
|
||||||
|
self.assertEqual({s["id"] for s in self.store.records()}, {s["id"] for s in items[1:]})
|
||||||
|
self.assertEqual(self.store.pending(100), [items[3]])
|
||||||
|
self.assertEqual(self.store.prune(now=self.now + timedelta(days=31)), 2)
|
||||||
|
self.assertEqual(list(self.store.records()), [items[3]])
|
||||||
|
|
||||||
|
def test_replay_cancels_expiry_until_the_next_acknowledgement(self):
|
||||||
|
value = observed(self.now)
|
||||||
|
self.store.add(value)
|
||||||
|
self.store.acknowledge([value["id"]], now=self.now - timedelta(days=40))
|
||||||
|
self.store.replay()
|
||||||
|
self.assertEqual(self.store.prune(now=self.now), 0)
|
||||||
|
self.store.acknowledge([value["id"]], now=self.now)
|
||||||
|
self.assertEqual(self.store.prune(now=self.now), 0)
|
||||||
|
|
||||||
|
def test_interrupted_atomic_write_preserves_the_previous_partition(self):
|
||||||
|
first, second = observed(self.now), observed(self.now + timedelta(minutes=5))
|
||||||
|
self.store.add(first)
|
||||||
|
with patch("solar_relay.storage.os.replace", side_effect=OSError("simulated write failure")):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
self.store.add(second)
|
||||||
|
self.assertEqual(list(Store(self.path).records()), [first])
|
||||||
|
self.assertEqual(list(self.path.glob("date=*/.samples-*.tmp")), [])
|
||||||
|
|
||||||
|
def test_two_store_instances_cannot_lose_concurrent_records(self):
|
||||||
|
other = Store(self.path)
|
||||||
|
values = [observed(self.now + timedelta(minutes=index * 5)) for index in range(12)]
|
||||||
|
def add(pair):
|
||||||
|
index, value = pair
|
||||||
|
(other if index % 2 else self.store).add(value)
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||||
|
list(workers.map(add, enumerate(values)))
|
||||||
|
self.assertEqual({value["id"] for value in self.store.records()}, {value["id"] for value in values})
|
||||||
|
|
||||||
|
def test_pruning_an_entire_day_removes_its_parquet_file(self):
|
||||||
|
value = observed(self.now - timedelta(days=60))
|
||||||
|
self.store.add(value)
|
||||||
|
self.store.acknowledge([value["id"]], now=self.now - timedelta(days=31))
|
||||||
|
self.assertEqual(self.store.prune(now=self.now), 1)
|
||||||
|
self.assertEqual(list(self.path.glob("date=*/samples.parquet")), [])
|
||||||
|
self.assertEqual(self.store.status()["samples"], 0)
|
||||||
|
|
||||||
|
def test_corrupt_archive_fails_without_silently_dropping_records(self):
|
||||||
|
value = observed(self.now)
|
||||||
|
self.store.add(value)
|
||||||
|
next(self.path.glob("date=*/samples.parquet")).write_bytes(b"broken")
|
||||||
|
with self.assertRaises(StoreError):
|
||||||
|
self.store.pending(100)
|
||||||
|
|
||||||
|
def test_archive_path_requires_a_directory(self):
|
||||||
|
occupied = self.path.parent / "existing-file"
|
||||||
|
occupied.write_bytes(b"existing data")
|
||||||
|
with self.assertRaises(StoreError):
|
||||||
|
Store(occupied)
|
||||||
|
self.assertEqual(occupied.read_bytes(), b"existing data")
|
||||||
|
|
||||||
|
def test_next_poll_is_aligned_to_the_five_minute_clock(self):
|
||||||
|
self.assertEqual(next_poll_at(301, 300), 600)
|
||||||
|
self.assertEqual(next_poll_at(599.5, 300), 600)
|
||||||
|
self.assertEqual(next_poll_at(600, 300), 900)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from solar_relay.config import InverterConfig, UploadConfig, date_bound, load_config
|
||||||
|
from solar_relay.fronius import FroniusClient, powerflow_readings
|
||||||
|
from solar_relay.http import JsonHttp, TransportError
|
||||||
|
from solar_relay.storage import Store
|
||||||
|
from solar_relay.upload import Uploader, UploadError
|
||||||
|
|
||||||
|
|
||||||
|
def api(data):
|
||||||
|
return {
|
||||||
|
"Body": {"Data": data},
|
||||||
|
"Head": {"Status": {"Code": 0}, "Timestamp": "2026-11-01T12:00:00+00:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
POWER = api({
|
||||||
|
"Site": {"P_PV": 2100, "P_Load": -2500, "P_Grid": 500, "E_Total": 100000},
|
||||||
|
"Inverters": {"1": {"P": 2000, "E_Total": 100000}},
|
||||||
|
})
|
||||||
|
METER = api({"0": {
|
||||||
|
"Enable": 1, "Meter_Location_Current": 0,
|
||||||
|
"EnergyReal_WAC_Sum_Consumed": 50000, "EnergyReal_WAC_Sum_Produced": 80000,
|
||||||
|
}})
|
||||||
|
BOILER = {"pin1": {"state": True, "reason": "PSTR_EMRS_REASON_3"}}
|
||||||
|
RESPONSES = {
|
||||||
|
"/solar_api/v1/GetPowerFlowRealtimeData.fcgi": POWER,
|
||||||
|
"/solar_api/v1/GetMeterRealtimeData.cgi": METER,
|
||||||
|
"/api/status/emrs": BOILER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
def __init__(self, responses=None):
|
||||||
|
self.responses = deepcopy(RESPONSES if responses is None else responses)
|
||||||
|
|
||||||
|
def request(self, url):
|
||||||
|
result = self.responses[urlsplit(url).path]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return deepcopy(result)
|
||||||
|
|
||||||
|
|
||||||
|
def sample():
|
||||||
|
return FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp()).collect("test-house")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def server():
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def respond(self, status, data):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
self.server.get_counts[path] = self.server.get_counts.get(path, 0) + 1
|
||||||
|
failures = self.server.get_failures.get(path, [])
|
||||||
|
if failures:
|
||||||
|
self.respond(failures.pop(0), {"error": "temporary"})
|
||||||
|
return
|
||||||
|
if self.path == "/redirect":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "/unexpected")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
elif self.path == "/unexpected":
|
||||||
|
self.server.redirect_followed = True
|
||||||
|
self.respond(200, {})
|
||||||
|
elif self.path == "/nan":
|
||||||
|
self.respond(200, {"reading": float("nan")})
|
||||||
|
else:
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
self.respond(200, RESPONSES[path])
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.server.authorizations.append(self.headers.get("Authorization"))
|
||||||
|
data = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||||
|
self.server.requests.append(data)
|
||||||
|
ids = [s["id"] for s in data["samples"]]
|
||||||
|
# Emulate an idempotent receiver that committed before its response failed.
|
||||||
|
self.server.received_ids.update(ids)
|
||||||
|
if self.server.fail_once:
|
||||||
|
self.server.fail_once = False
|
||||||
|
self.respond(503, {"error": "temporary"})
|
||||||
|
elif self.server.ack_override is not None:
|
||||||
|
self.respond(200, self.server.ack_override)
|
||||||
|
else:
|
||||||
|
self.respond(200, {"accepted_ids": ids})
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
httpd.fail_once = False
|
||||||
|
httpd.ack_override = None
|
||||||
|
httpd.requests = []
|
||||||
|
httpd.get_counts = {}
|
||||||
|
httpd.get_failures = {}
|
||||||
|
httpd.authorizations = []
|
||||||
|
httpd.received_ids = set()
|
||||||
|
httpd.redirect_followed = False
|
||||||
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield httpd, f"http://127.0.0.1:{httpd.server_port}"
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizationTests(unittest.TestCase):
|
||||||
|
def test_dc_ac_and_meter_directions_stay_distinct(self):
|
||||||
|
result = sample()
|
||||||
|
readings = result["readings"]
|
||||||
|
self.assertEqual(readings["production_w"], 2100)
|
||||||
|
self.assertEqual(readings["production_ac_w"], 2000)
|
||||||
|
self.assertEqual(readings["consumption_w"], 2500)
|
||||||
|
self.assertEqual(readings["grid_import_w"], 500)
|
||||||
|
self.assertEqual(readings["grid_export_w"], 0)
|
||||||
|
self.assertEqual(readings["grid_import_total_wh"], 50000)
|
||||||
|
self.assertTrue(readings["boiler_enabled"])
|
||||||
|
self.assertEqual(result["errors"], {})
|
||||||
|
self.assertEqual(result["raw"]["powerflow"], POWER)
|
||||||
|
self.assertEqual(result["source_timestamps"]["powerflow"], "2026-11-01T12:00:00.000Z")
|
||||||
|
|
||||||
|
def test_export_direction_and_nighttime_nulls(self):
|
||||||
|
response = deepcopy(POWER)
|
||||||
|
response["Body"]["Data"]["Site"].update(P_Grid=-1200, P_PV=None)
|
||||||
|
response["Body"]["Data"]["Inverters"]["1"]["P"] = None
|
||||||
|
readings, _ = powerflow_readings(response)
|
||||||
|
self.assertEqual(readings["grid_import_w"], 0)
|
||||||
|
self.assertEqual(readings["grid_export_w"], 1200)
|
||||||
|
self.assertIsNone(readings["production_w"])
|
||||||
|
self.assertIsNone(readings["production_ac_w"])
|
||||||
|
|
||||||
|
def test_boiler_failure_is_unknown_and_does_not_lose_energy(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/api/status/emrs"] = TransportError("HTTP 404")
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertIsNone(result["readings"]["boiler_enabled"])
|
||||||
|
self.assertEqual(result["readings"]["production_w"], 2100)
|
||||||
|
self.assertEqual(result["errors"], {"boiler": "HTTP 404"})
|
||||||
|
|
||||||
|
def test_false_is_a_valid_boiler_state(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/api/status/emrs"]["pin1"]["state"] = False
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertIs(result["readings"]["boiler_enabled"], False)
|
||||||
|
self.assertNotIn("boiler", result["errors"])
|
||||||
|
|
||||||
|
def test_solar_api_application_error_does_not_accept_old_values(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/solar_api/v1/GetPowerFlowRealtimeData.fcgi"]["Head"]["Status"]["Code"] = 12
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertIsNone(result["readings"]["production_w"])
|
||||||
|
self.assertIn("powerflow", result["errors"])
|
||||||
|
self.assertTrue(result["readings"]["boiler_enabled"])
|
||||||
|
|
||||||
|
def test_load_branch_meter_cannot_be_mislabeled_as_grid_energy(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/solar_api/v1/GetMeterRealtimeData.cgi"]["Body"]["Data"]["0"]["Meter_Location_Current"] = 1
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertIsNone(result["readings"]["grid_import_total_wh"])
|
||||||
|
self.assertIn("meter", result["errors"])
|
||||||
|
|
||||||
|
def test_meter_measurement_timestamp_is_preserved_instead_of_request_time(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/solar_api/v1/GetMeterRealtimeData.cgi"]["Body"]["Data"]["0"]["TimeStamp"] = 1793534100
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertEqual(result["source_timestamps"]["meter"], "2026-11-01T11:55:00.000Z")
|
||||||
|
|
||||||
|
def test_missing_power_fields_are_a_source_error(self):
|
||||||
|
responses = deepcopy(RESPONSES)
|
||||||
|
responses["/solar_api/v1/GetPowerFlowRealtimeData.fcgi"]["Body"]["Data"]["Site"] = {}
|
||||||
|
result = FroniusClient(InverterConfig(min_request_interval_seconds=0), http=FakeHttp(responses)).collect("test")
|
||||||
|
self.assertIn("powerflow", result["errors"])
|
||||||
|
self.assertIsNone(result["readings"]["production_w"])
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.path = Path(self.directory.name) / "archive"
|
||||||
|
self.store = Store(self.path)
|
||||||
|
|
||||||
|
def test_restart_and_acknowledgement_preserve_archive(self):
|
||||||
|
first = sample()
|
||||||
|
self.store.add(first)
|
||||||
|
Store(self.path).acknowledge([first["id"]])
|
||||||
|
reopened = Store(self.path)
|
||||||
|
self.assertEqual(reopened.pending(100), [])
|
||||||
|
self.assertEqual(list(reopened.records()), [first])
|
||||||
|
self.assertEqual(reopened.status()["samples"], 1)
|
||||||
|
if os.name == "posix":
|
||||||
|
self.assertEqual(self.path.stat().st_mode & 0o777, 0o700)
|
||||||
|
self.assertEqual(next(self.path.glob("date=*/samples.parquet")).stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_local_date_export_and_replay_across_dst(self):
|
||||||
|
start = date_bound("2026-10-25", "Europe/Zurich")
|
||||||
|
until = date_bound("2026-10-26", "Europe/Zurich")
|
||||||
|
self.assertEqual((datetime.fromisoformat(until) - datetime.fromisoformat(start)).total_seconds(), 25 * 3600)
|
||||||
|
items = []
|
||||||
|
for timestamp in ["2026-10-24T21:59:59.000Z", start, "2026-10-25T12:00:00.000Z", until]:
|
||||||
|
item = sample()
|
||||||
|
item["observed_at"] = timestamp
|
||||||
|
items.append(item)
|
||||||
|
self.store.add(item)
|
||||||
|
self.store.acknowledge([item["id"] for item in items])
|
||||||
|
self.assertEqual(list(self.store.records(start, until)), items[1:3])
|
||||||
|
self.assertEqual(self.store.replay(start, until), 2)
|
||||||
|
self.assertEqual(self.store.pending(100), items[1:3])
|
||||||
|
self.assertEqual(self.store.status()["samples"], 4)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.store = Store(Path(self.directory.name) / "archive")
|
||||||
|
|
||||||
|
def test_real_http_collection_delivery_and_lost_ack_retry(self):
|
||||||
|
with server() as (httpd, base):
|
||||||
|
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
|
||||||
|
reading = client.collect("test")
|
||||||
|
self.assertEqual(reading["errors"], {})
|
||||||
|
self.store.add(reading)
|
||||||
|
uploader = Uploader(UploadConfig(url=base + "/ingest"), self.store, token="test-token")
|
||||||
|
httpd.fail_once = True
|
||||||
|
with self.assertRaises(UploadError):
|
||||||
|
uploader.upload_once()
|
||||||
|
self.assertEqual(self.store.status()["pending"], 1)
|
||||||
|
self.assertEqual(uploader.upload_once(), 1)
|
||||||
|
self.assertEqual(self.store.status()["pending"], 0)
|
||||||
|
self.assertEqual(self.store.status()["samples"], 1)
|
||||||
|
self.assertEqual(httpd.requests[0], httpd.requests[1])
|
||||||
|
self.assertEqual(httpd.received_ids, {reading["id"]})
|
||||||
|
self.assertEqual(httpd.authorizations, ["Bearer test-token", "Bearer test-token"])
|
||||||
|
|
||||||
|
def test_plain_http_success_is_not_an_acknowledgement(self):
|
||||||
|
with server() as (httpd, base):
|
||||||
|
self.store.add(sample())
|
||||||
|
uploader = Uploader(UploadConfig(url=base), self.store, token="test-token")
|
||||||
|
httpd.ack_override = {"success": True}
|
||||||
|
with self.assertRaises(UploadError):
|
||||||
|
uploader.upload_once()
|
||||||
|
self.assertEqual(self.store.status()["pending"], 1)
|
||||||
|
|
||||||
|
def test_partial_ack_only_marks_the_accepted_sample(self):
|
||||||
|
with server() as (httpd, base):
|
||||||
|
first, second = sample(), sample()
|
||||||
|
self.store.add(first)
|
||||||
|
self.store.add(second)
|
||||||
|
httpd.ack_override = {"accepted_ids": [first["id"]]}
|
||||||
|
with self.assertRaises(UploadError):
|
||||||
|
Uploader(UploadConfig(url=base), self.store, token="test-token").upload_once()
|
||||||
|
self.assertEqual(self.store.pending(100), [second])
|
||||||
|
self.assertEqual(self.store.status()["samples"], 2)
|
||||||
|
|
||||||
|
def test_unknown_ack_cannot_clear_the_queue(self):
|
||||||
|
with server() as (httpd, base):
|
||||||
|
self.store.add(sample())
|
||||||
|
httpd.ack_override = {"accepted_ids": ["unknown-id"]}
|
||||||
|
with self.assertRaises(UploadError):
|
||||||
|
Uploader(UploadConfig(url=base), self.store, token="test-token").upload_once()
|
||||||
|
self.assertEqual(self.store.status()["pending"], 1)
|
||||||
|
|
||||||
|
def test_slow_upload_does_not_lock_recording(self):
|
||||||
|
entered, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
class BlockingHttp:
|
||||||
|
def request(self, url, *, payload, token):
|
||||||
|
entered.set()
|
||||||
|
release.wait(timeout=3)
|
||||||
|
return {"accepted_ids": [s["id"] for s in payload["samples"]]}
|
||||||
|
|
||||||
|
self.store.add(sample())
|
||||||
|
uploader = Uploader(UploadConfig(url="https://example.test/ingest"), self.store, token="test-token", http=BlockingHttp())
|
||||||
|
result = []
|
||||||
|
worker = threading.Thread(target=lambda: result.append(uploader.upload_once()))
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
|
self.assertTrue(entered.wait(timeout=2))
|
||||||
|
second = sample()
|
||||||
|
self.store.add(second)
|
||||||
|
self.assertEqual(self.store.status()["samples"], 2)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
worker.join(timeout=4)
|
||||||
|
self.assertEqual(result, [1])
|
||||||
|
self.assertEqual(self.store.pending(100), [second])
|
||||||
|
|
||||||
|
def test_redirects_and_invalid_json_numbers_are_rejected(self):
|
||||||
|
with server() as (httpd, base):
|
||||||
|
http = JsonHttp(timeout=2)
|
||||||
|
with self.assertRaises(TransportError):
|
||||||
|
http.request(base + "/redirect", token="test-token")
|
||||||
|
self.assertFalse(httpd.redirect_followed)
|
||||||
|
with self.assertRaises(TransportError):
|
||||||
|
http.request(base + "/nan")
|
||||||
|
|
||||||
|
def test_missing_upload_token_fails_before_delivery(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaisesRegex(ValueError, "SOLAR_RELAY_TOKEN"):
|
||||||
|
Uploader(UploadConfig(url="https://example.test/ingest"), self.store)
|
||||||
|
|
||||||
|
def test_cli_sample_and_export_use_the_persistent_archive(self):
|
||||||
|
with server() as (_, base):
|
||||||
|
config = Path(self.directory.name) / "config.toml"
|
||||||
|
config.write_text(
|
||||||
|
'[collector]\nsource_id="cli-test"\narchive="cli-archive"\n'
|
||||||
|
f'\n[inverter]\nbase_url="{base}"\n'
|
||||||
|
)
|
||||||
|
command = [sys.executable, "-m", "solar_relay", "--config", str(config)]
|
||||||
|
cwd = Path(__file__).resolve().parents[1]
|
||||||
|
result = subprocess.run(command + ["sample"], cwd=cwd, capture_output=True, text=True, timeout=20)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
normalized = json.loads(result.stdout)
|
||||||
|
result = subprocess.run(command + ["export"], cwd=cwd, capture_output=True, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
archived = json.loads(result.stdout)
|
||||||
|
self.assertEqual(normalized["id"], archived["id"])
|
||||||
|
self.assertEqual(archived["raw"]["powerflow"], POWER)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTests(unittest.TestCase):
|
||||||
|
def load(self, extra=""):
|
||||||
|
with tempfile.TemporaryDirectory() as name:
|
||||||
|
path = Path(name) / "config.toml"
|
||||||
|
path.write_text('[collector]\nsource_id="home"\narchive="state/archive"\n' + extra)
|
||||||
|
return load_config(path), path.parent
|
||||||
|
|
||||||
|
def test_archive_is_relative_to_config(self):
|
||||||
|
config, directory = self.load()
|
||||||
|
self.assertEqual(config.collector.archive, directory / "state/archive")
|
||||||
|
self.assertEqual(config.collector.poll_interval_seconds, 300)
|
||||||
|
self.assertEqual(config.collector.retention_days, 30)
|
||||||
|
self.assertEqual(config.upload.url, "")
|
||||||
|
|
||||||
|
def test_remote_upload_requires_https(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "HTTPS"):
|
||||||
|
self.load('\n[upload]\nurl="http://example.test/ingest"\n')
|
||||||
|
config, _ = self.load('\n[upload]\nurl="http://127.0.0.1:8000/ingest"\n')
|
||||||
|
self.assertTrue(config.upload.url)
|
||||||
|
|
||||||
|
def test_mistyped_configuration_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unknown inverter"):
|
||||||
|
self.load('\n[inverter]\nboiler_pni="pin2"\n')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.load('\n[inverter]\nboiler_pin="pin9"\n')
|
||||||
|
|
||||||
|
def test_status_listener_defaults_and_loopback_binding(self):
|
||||||
|
config, _ = self.load()
|
||||||
|
self.assertEqual(config.http.port, 8765)
|
||||||
|
self.assertEqual(config.http.bind, "127.0.0.1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "127.0.0.1"):
|
||||||
|
self.load('\n[http]\nbind="0.0.0.0"\n')
|
||||||
|
|
||||||
|
def test_status_settings_can_disable_http_and_reject_invalid_paths(self):
|
||||||
|
config, _ = self.load('\n[http]\nenabled=false\n')
|
||||||
|
self.assertFalse(config.http.enabled)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.load('\n[http]\nbase_path="/../config"\n')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from solar_relay.cli import _run
|
||||||
|
from solar_relay.config import CollectorConfig, Config, HttpConfig, InverterConfig, UploadConfig, utc_text
|
||||||
|
from solar_relay.fronius import FroniusClient
|
||||||
|
from solar_relay.http import TransportError
|
||||||
|
from solar_relay.storage import Store
|
||||||
|
from tests.test_relay import RESPONSES, server
|
||||||
|
from tests.test_schedule import Clock
|
||||||
|
|
||||||
|
POWER_PATH = "/solar_api/v1/GetPowerFlowRealtimeData.fcgi"
|
||||||
|
METER_PATH = "/solar_api/v1/GetMeterRealtimeData.cgi"
|
||||||
|
BOILER_PATH = "/api/status/emrs"
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedHttp:
|
||||||
|
def __init__(self, clock):
|
||||||
|
self.clock = clock
|
||||||
|
self.requests = []
|
||||||
|
self.failures = {POWER_PATH: [503, 503, 503]}
|
||||||
|
|
||||||
|
def request(self, url):
|
||||||
|
path = urlsplit(url).path
|
||||||
|
self.requests.append((self.clock.now, path))
|
||||||
|
failures = self.failures.get(path, [])
|
||||||
|
if failures:
|
||||||
|
raise TransportError(f"HTTP {failures.pop(0)}")
|
||||||
|
response = deepcopy(RESPONSES[path])
|
||||||
|
if "Head" in response:
|
||||||
|
response["Head"]["Timestamp"] = utc_text(datetime.fromtimestamp(self.clock.now, timezone.utc))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RetryTests(unittest.TestCase):
|
||||||
|
def test_http_errors_retry_only_the_failed_endpoint_and_stop_on_success(self):
|
||||||
|
for retries in (1, 3):
|
||||||
|
with self.subTest(retries=retries), server() as (httpd, base):
|
||||||
|
httpd.get_failures[POWER_PATH] = [503] * retries
|
||||||
|
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
|
||||||
|
with self.assertLogs("solar_relay.fronius", level="WARNING") as messages:
|
||||||
|
sample = client.collect("test-house")
|
||||||
|
self.assertEqual(sample["errors"], {})
|
||||||
|
self.assertEqual(sample["readings"]["production_ac_w"], 2000)
|
||||||
|
self.assertEqual(httpd.get_counts, {POWER_PATH: retries + 1, METER_PATH: 1, BOILER_PATH: 1})
|
||||||
|
self.assertEqual(len(messages.output), retries)
|
||||||
|
|
||||||
|
def test_giving_up_after_three_retries_preserves_other_sources(self):
|
||||||
|
for failures in ([500, 502, 503, 504, 503], [404] * 5):
|
||||||
|
with self.subTest(statuses=failures), server() as (httpd, base):
|
||||||
|
httpd.get_failures[BOILER_PATH] = list(failures)
|
||||||
|
client = FroniusClient(InverterConfig(base_url=base, min_request_interval_seconds=0))
|
||||||
|
with self.assertLogs("solar_relay.fronius", level="WARNING"):
|
||||||
|
sample = client.collect("test-house")
|
||||||
|
self.assertEqual(httpd.get_counts, {POWER_PATH: 1, METER_PATH: 1, BOILER_PATH: 4})
|
||||||
|
self.assertEqual(len(httpd.get_failures[BOILER_PATH]), 1)
|
||||||
|
self.assertEqual(sample["errors"], {"boiler": f"HTTP {failures[3]}"})
|
||||||
|
self.assertIsNone(sample["readings"]["boiler_enabled"])
|
||||||
|
self.assertEqual(sample["readings"]["production_ac_w"], 2000)
|
||||||
|
self.assertEqual(sample["readings"]["grid_import_total_wh"], 50000)
|
||||||
|
|
||||||
|
def test_retry_attempts_keep_the_four_second_request_spacing(self):
|
||||||
|
clock = Clock(0)
|
||||||
|
http = ScriptedHttp(clock)
|
||||||
|
client = FroniusClient(InverterConfig(), http=http)
|
||||||
|
with patch("solar_relay.fronius.time.monotonic", side_effect=lambda: clock.now), \
|
||||||
|
patch("solar_relay.fronius.time.sleep", side_effect=clock.wait), \
|
||||||
|
self.assertLogs("solar_relay.fronius", level="WARNING"):
|
||||||
|
result = client.collect("test-house")
|
||||||
|
self.assertEqual(result["errors"], {})
|
||||||
|
self.assertEqual([stamp for stamp, _ in http.requests], [0, 4, 8, 12, 16, 20])
|
||||||
|
|
||||||
|
def test_retries_preserve_clock_boundaries_and_one_saved_observation_per_poll(self):
|
||||||
|
origin = datetime(2026, 11, 1, 12, tzinfo=timezone.utc).timestamp()
|
||||||
|
clock = Clock(origin + 180) # Start at 12:03; first observation must be at 12:05.
|
||||||
|
http = ScriptedHttp(clock)
|
||||||
|
client = FroniusClient(InverterConfig(), http=http)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = Store(Path(directory) / "archive")
|
||||||
|
config = Config(CollectorConfig("test-house", store.path), InverterConfig(), UploadConfig(), HttpConfig(enabled=False))
|
||||||
|
added = []
|
||||||
|
original_add = store.add
|
||||||
|
|
||||||
|
def add(sample):
|
||||||
|
original_add(sample)
|
||||||
|
added.append(sample)
|
||||||
|
if len(added) == 3:
|
||||||
|
clock.set()
|
||||||
|
|
||||||
|
with patch("solar_relay.cli.threading.Event", return_value=clock), \
|
||||||
|
patch("solar_relay.cli.time.time", side_effect=lambda: clock.now), \
|
||||||
|
patch("solar_relay.cli.time.monotonic", side_effect=lambda: clock.now), \
|
||||||
|
patch("solar_relay.fronius.time.sleep", side_effect=clock.wait), \
|
||||||
|
patch("solar_relay.fronius.datetime", wraps=datetime) as dates, \
|
||||||
|
patch("solar_relay.cli.signal.signal"), \
|
||||||
|
patch("solar_relay.cli.FroniusClient", return_value=client), \
|
||||||
|
patch.object(store, "add", side_effect=add), \
|
||||||
|
self.assertLogs("solar_relay.fronius", level="WARNING"):
|
||||||
|
dates.now.side_effect = lambda zone: datetime.fromtimestamp(clock.now, zone)
|
||||||
|
self.assertEqual(_run(config, store), 0)
|
||||||
|
records = list(store.records())
|
||||||
|
self.assertEqual(len(records), 3)
|
||||||
|
self.assertEqual([datetime.fromisoformat(s["observed_at"]).timestamp() for s in records],
|
||||||
|
[origin + 300, origin + 600, origin + 900])
|
||||||
|
self.assertTrue(all(not record["errors"] for record in records))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Exercise the real recording loop with an advancing clock instead of real waits."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from solar_relay.cli import _run
|
||||||
|
from solar_relay.config import CollectorConfig, Config, HttpConfig, InverterConfig, UploadConfig, load_config
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
self.stopped = False
|
||||||
|
self.waits = []
|
||||||
|
|
||||||
|
def is_set(self):
|
||||||
|
return self.stopped
|
||||||
|
|
||||||
|
def set(self):
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
def wait(self, delay):
|
||||||
|
self.waits.append((self.now, delay))
|
||||||
|
if not self.stopped:
|
||||||
|
self.now += delay
|
||||||
|
return self.stopped
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleTests(unittest.TestCase):
|
||||||
|
def run_collector(self, start, count=3, duration=8, cancel=False):
|
||||||
|
clock = Clock(start)
|
||||||
|
recorded = []
|
||||||
|
config = Config(CollectorConfig("test-house", Path("unused")), InverterConfig(), UploadConfig(), HttpConfig(enabled=False))
|
||||||
|
store = Mock()
|
||||||
|
store.prune.return_value = 0
|
||||||
|
|
||||||
|
def record(*args):
|
||||||
|
recorded.append(clock.now)
|
||||||
|
clock.now += duration
|
||||||
|
if len(recorded) == count:
|
||||||
|
clock.set()
|
||||||
|
|
||||||
|
def cancel_wait(delay):
|
||||||
|
clock.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if cancel:
|
||||||
|
clock.wait = cancel_wait
|
||||||
|
with patch("solar_relay.cli.threading.Event", return_value=clock), \
|
||||||
|
patch("solar_relay.cli.time.time", side_effect=lambda: clock.now), \
|
||||||
|
patch("solar_relay.cli.time.monotonic", side_effect=lambda: clock.now), \
|
||||||
|
patch("solar_relay.cli.signal.signal"), \
|
||||||
|
patch("solar_relay.cli.FroniusClient"), \
|
||||||
|
patch("solar_relay.cli._record", side_effect=record):
|
||||||
|
self.assertEqual(_run(config, store), 0)
|
||||||
|
return recorded, clock
|
||||||
|
|
||||||
|
def test_startup_waits_then_records_once_on_each_boundary(self):
|
||||||
|
recorded, clock = self.run_collector(301)
|
||||||
|
self.assertEqual(recorded, [600, 900, 1200])
|
||||||
|
self.assertEqual(clock.waits[0], (301, 299))
|
||||||
|
self.assertTrue(all(stamp % 300 == 0 for stamp in recorded))
|
||||||
|
|
||||||
|
def test_restart_in_the_same_interval_adds_no_extra_observation(self):
|
||||||
|
before, _ = self.run_collector(590, count=1)
|
||||||
|
after, _ = self.run_collector(615, count=2)
|
||||||
|
self.assertEqual(before + after, [600, 900, 1200])
|
||||||
|
|
||||||
|
def test_slow_collection_skips_missed_boundaries_without_catching_up(self):
|
||||||
|
recorded, _ = self.run_collector(301, count=2, duration=370)
|
||||||
|
self.assertEqual(recorded, [600, 1200])
|
||||||
|
|
||||||
|
def test_shutdown_while_waiting_does_not_take_a_final_observation(self):
|
||||||
|
recorded, _ = self.run_collector(301, cancel=True)
|
||||||
|
self.assertEqual(recorded, [])
|
||||||
|
|
||||||
|
def test_configuration_requires_exactly_five_minutes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "config.toml"
|
||||||
|
for interval in (30, 60, 299, 301, 600):
|
||||||
|
path.write_text(f"[collector]\npoll_interval_seconds = {interval}\n")
|
||||||
|
with self.assertRaisesRegex(ValueError, "must be 300"):
|
||||||
|
load_config(path)
|
||||||
|
path.write_text("[collector]\npoll_interval_seconds = 300\n")
|
||||||
|
self.assertEqual(load_config(path).collector.poll_interval_seconds, 300)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import ProxyHandler, Request, build_opener
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from solar_relay.config import Config, CollectorConfig, HttpConfig, InverterConfig, UploadConfig, utc_text
|
||||||
|
from solar_relay.coverage import daily_coverage, sample_coverage
|
||||||
|
from solar_relay.http import NoRedirects
|
||||||
|
from solar_relay.status import StatusServer
|
||||||
|
from solar_relay.storage import Store
|
||||||
|
from tests.test_relay import sample, server
|
||||||
|
|
||||||
|
|
||||||
|
def observed(when):
|
||||||
|
value = sample()
|
||||||
|
value["observed_at"] = utc_text(when)
|
||||||
|
value["source_timestamps"] = {source: value["observed_at"] for source in ("powerflow", "meter", "boiler")}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def rows_for_day(day):
|
||||||
|
start = datetime.fromisoformat(day).replace(tzinfo=ZoneInfo("Europe/Zurich"))
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
first, last = round(start.timestamp() * 1000), round(end.timestamp() * 1000)
|
||||||
|
return [(stamp, stamp, 1, stamp, 1, stamp, 1) for stamp in range(first, last + 1, 300_000)], end
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageTests(unittest.TestCase):
|
||||||
|
def test_full_day_includes_all_288_intervals(self):
|
||||||
|
rows, end = rows_for_day("2026-10-30")
|
||||||
|
result = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))
|
||||||
|
yesterday = result[1]
|
||||||
|
self.assertEqual(yesterday["expected"], 288)
|
||||||
|
self.assertEqual(yesterday["complete"], 288)
|
||||||
|
self.assertEqual(yesterday["status"], "complete")
|
||||||
|
self.assertEqual(result[0]["elapsed"], 0)
|
||||||
|
self.assertTrue(all(mask == -1 for mask in result[0]["slots"]))
|
||||||
|
|
||||||
|
def test_dst_days_expect_276_and_300_intervals(self):
|
||||||
|
for date, expected in [("2026-03-29", 276), ("2026-10-25", 300)]:
|
||||||
|
with self.subTest(date=date):
|
||||||
|
rows, end = rows_for_day(date)
|
||||||
|
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
|
||||||
|
self.assertEqual(yesterday["expected"], expected)
|
||||||
|
self.assertEqual(yesterday["complete"], expected)
|
||||||
|
|
||||||
|
def test_missing_five_minute_extractions_remain_missing(self):
|
||||||
|
rows, end = rows_for_day("2026-10-30")
|
||||||
|
del rows[10:13]
|
||||||
|
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
|
||||||
|
self.assertEqual(yesterday["complete"], 285)
|
||||||
|
self.assertEqual(yesterday["missing"], 3)
|
||||||
|
self.assertEqual(yesterday["status"], "partial")
|
||||||
|
|
||||||
|
def test_energy_failure_does_not_erase_meter_or_boiler_coverage(self):
|
||||||
|
rows, end = rows_for_day("2026-10-30")
|
||||||
|
stamp = rows[1][0]
|
||||||
|
rows[1] = (stamp, stamp, 0, stamp, 1, stamp, 1)
|
||||||
|
yesterday = daily_coverage(rows, "Europe/Zurich", 1, end + timedelta(minutes=3))[1]
|
||||||
|
self.assertEqual(yesterday["slots"][1], 6)
|
||||||
|
self.assertEqual(yesterday["energy_complete"], 287)
|
||||||
|
self.assertEqual(yesterday["meter_complete"], 288)
|
||||||
|
self.assertEqual(yesterday["boiler_complete"], 288)
|
||||||
|
self.assertEqual(yesterday["missing"], 1)
|
||||||
|
|
||||||
|
def test_source_errors_stale_readings_and_false_boiler_state(self):
|
||||||
|
when = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||||
|
value = observed(when)
|
||||||
|
value["readings"]["boiler_enabled"] = False
|
||||||
|
self.assertEqual(sample_coverage(value)[-1], 1)
|
||||||
|
value["errors"] = {"boiler": "unavailable"}
|
||||||
|
self.assertEqual(sample_coverage(value)[-1], 0)
|
||||||
|
value["source_timestamps"]["powerflow"] = utc_text(when - timedelta(seconds=120))
|
||||||
|
self.assertEqual(sample_coverage(value)[4], 0)
|
||||||
|
|
||||||
|
def test_parquet_coverage_uses_the_selected_source_and_stored_extractions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
archive = Store(Path(directory) / "archive")
|
||||||
|
start = datetime(2026, 10, 30, tzinfo=ZoneInfo("Europe/Zurich"))
|
||||||
|
for seconds in range(0, 301, 30):
|
||||||
|
value = observed(start + timedelta(seconds=seconds))
|
||||||
|
archive.add(value)
|
||||||
|
result = archive.coverage(value["source_id"], "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
|
||||||
|
self.assertEqual(result[1]["complete"], 2)
|
||||||
|
other = archive.coverage("another-source", "Europe/Zurich", 1, start + timedelta(days=1, minutes=3))
|
||||||
|
self.assertEqual(other[1]["complete"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusHttpTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.store = Store(Path(self.directory.name) / "archive")
|
||||||
|
self.config = Config(CollectorConfig("test-house", self.store.path), InverterConfig(), UploadConfig(), HttpConfig(port=0))
|
||||||
|
self.status = StatusServer(self.config, self.store)
|
||||||
|
self.status.start()
|
||||||
|
self.addCleanup(self.status.close)
|
||||||
|
self.base = "http://127.0.0.1:" + str(self.status.address[1])
|
||||||
|
self.http = build_opener(ProxyHandler({}), NoRedirects())
|
||||||
|
|
||||||
|
def get(self, path, *, method="GET"):
|
||||||
|
try:
|
||||||
|
result = self.http.open(Request(self.base + path, method=method), timeout=3)
|
||||||
|
except HTTPError as error:
|
||||||
|
result = error
|
||||||
|
with result:
|
||||||
|
return result.code, result.headers, result.read()
|
||||||
|
|
||||||
|
def test_page_assets_prefix_and_health_routes(self):
|
||||||
|
for path in ["/", "/solar-relay/", "/assets/status.css", "/solar-relay/assets/status.js"]:
|
||||||
|
status, _, body = self.get(path)
|
||||||
|
self.assertEqual(status, 200, path)
|
||||||
|
self.assertTrue(body)
|
||||||
|
status, headers, body = self.get("/solar-relay")
|
||||||
|
self.assertEqual(status, 308)
|
||||||
|
self.assertEqual(headers["Location"], "/solar-relay/")
|
||||||
|
self.assertEqual(json.loads(self.get("/solar-relay/healthz")[2])["status"], "ok")
|
||||||
|
|
||||||
|
def test_status_reports_extraction_and_backlog_without_raw_data_or_secrets(self):
|
||||||
|
value = observed(datetime.now(timezone.utc))
|
||||||
|
value["readings"]["boiler_enabled"] = False
|
||||||
|
self.store.add(value)
|
||||||
|
status, headers, body = self.get("/solar-relay/api/status?days=7")
|
||||||
|
result = json.loads(body)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(headers["Cache-Control"], "no-store")
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
self.assertEqual(result["collector"]["last_attempt"], value["observed_at"])
|
||||||
|
self.assertEqual(result["collector"]["last_success"], value["observed_at"])
|
||||||
|
self.assertEqual(result["archive"]["pending"], 1)
|
||||||
|
self.assertEqual(len(result["days"]), 8)
|
||||||
|
for forbidden in ["production_w", "raw", "database", self.directory.name]:
|
||||||
|
self.assertNotIn(forbidden, body.decode())
|
||||||
|
|
||||||
|
def test_source_errors_do_not_expose_exception_messages(self):
|
||||||
|
value = observed(datetime.now(timezone.utc))
|
||||||
|
value["errors"] = {"boiler": "secret canary must not appear in HTTP"}
|
||||||
|
self.store.add(value)
|
||||||
|
result = json.loads(self.get("/api/status")[2])
|
||||||
|
self.assertEqual(result["status"], "partial")
|
||||||
|
self.assertEqual(result["collector"]["source_errors"], ["boiler"])
|
||||||
|
self.assertNotIn("secret canary", json.dumps(result))
|
||||||
|
|
||||||
|
def test_only_explicit_read_only_routes_are_available(self):
|
||||||
|
for path in ["/config.toml", "/.env", "/data/archive/date=2026-11-01/samples.parquet", "/../config.toml", "/solar-relay/.git/config"]:
|
||||||
|
self.assertEqual(self.get(path)[0], 404, path)
|
||||||
|
self.assertEqual(self.get("/api/status?days=91")[0], 400)
|
||||||
|
self.assertEqual(self.get("/api/status?days=1&days=2")[0], 400)
|
||||||
|
self.assertEqual(self.get("/api/status", method="POST")[0], 405)
|
||||||
|
status, headers, body = self.get("/solar-relay/", method="HEAD")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertGreater(int(headers["Content-Length"]), 0)
|
||||||
|
self.assertEqual(body, b"")
|
||||||
|
|
||||||
|
def test_run_command_starts_and_stops_the_http_listener(self):
|
||||||
|
with server() as (_, inverter):
|
||||||
|
with socket.socket() as free:
|
||||||
|
free.bind(("127.0.0.1", 0))
|
||||||
|
port = free.getsockname()[1]
|
||||||
|
path = Path(self.directory.name) / "config.toml"
|
||||||
|
path.write_text(f'[collector]\nsource_id="run-test"\narchive="run-archive"\n[inverter]\nbase_url="{inverter}"\n[http]\nport={port}\n')
|
||||||
|
# Keep the first scheduled poll in the future regardless of the real
|
||||||
|
# clock. Scheduling itself is exercised with a simulated clock separately.
|
||||||
|
entry = 'import sys; from solar_relay import cli; cli.next_poll_at = lambda now, interval: now + 300; sys.exit(cli.main())'
|
||||||
|
process = subprocess.Popen([sys.executable, "-c", entry, "--config", str(path), "run"], cwd=Path(__file__).resolve().parents[1], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
try:
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
with self.http.open(f"http://127.0.0.1:{port}/healthz", timeout=1) as response:
|
||||||
|
self.assertEqual(response.code, 200)
|
||||||
|
break
|
||||||
|
except URLError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
self.fail("Status listener did not start")
|
||||||
|
with self.http.open(f"http://127.0.0.1:{port}/api/status", timeout=1) as response:
|
||||||
|
snapshot = json.load(response)
|
||||||
|
self.assertEqual(snapshot["status"], "waiting")
|
||||||
|
self.assertEqual(snapshot["archive"]["samples"], 0)
|
||||||
|
process.terminate()
|
||||||
|
_, stderr = process.communicate(timeout=5)
|
||||||
|
self.assertEqual(process.returncode, 0, stderr)
|
||||||
|
self.assertEqual(Store(path.parent / "run-archive").status()["samples"], 0)
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.communicate(timeout=5)
|
||||||
Reference in new issue
Block a user