Initial commit
Deploy Solar Relay / deploy (push) Failing after 4s

This commit is contained in:
franzz committed 2026-11-01 22:33:32 +01:00
1 parent 36180dffd7
commit ec75ac15b0
30 files changed
+3042 -1

No files matched your search

+100
View File
@@ -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
View File
@@ -0,0 +1,10 @@
.venv/
.venv-ci/
.deploy.lock
__pycache__/
*.py[cod]
config.toml
.env
data/
*.parquet
+287 -1
View File
@@ -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.
+41
View File
@@ -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
+11
View File
@@ -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>
+16
View File
@@ -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;
}
+25
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# Runtime dependency; install inside .venv, never into the system Python.
pyarrow==26.0.0
+4
View File
@@ -0,0 +1,4 @@
"""Local Fronius recorder and durable HTTP relay (AGPL-3.0-or-later)."""
__version__ = "0.2.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
raise SystemExit(main())
+166
View File
@@ -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())
+195
View File
@@ -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)
+88
View File
@@ -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
+154
View File
@@ -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
+129
View File
@@ -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)
+63
View File
@@ -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
+129
View File
@@ -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
+263
View File
@@ -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}
+64
View File
@@ -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)
+46
View File
@@ -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>
+4
View File
@@ -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}}
+148
View File
@@ -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();
})();
+1
View File
@@ -0,0 +1 @@
+76
View File
@@ -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()
+122
View File
@@ -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()
+107
View File
@@ -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()
+386
View File
@@ -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()
+114
View File
@@ -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()
+91
View File
@@ -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()
+196
View File
@@ -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)