diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..483c057 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb808a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv/ +.venv-ci/ +.deploy.lock +__pycache__/ +*.py[cod] +config.toml +.env +data/ +*.parquet + diff --git a/README.md b/README.md index 5296364..716eb61 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,289 @@ # solar-relay -Fronius data extractor and relay for the solar app \ No newline at end of file +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. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..08cb51e --- /dev/null +++ b/config.example.toml @@ -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 + diff --git a/deploy/apache-solar-relay.conf b/deploy/apache-solar-relay.conf new file mode 100644 index 0000000..f437090 --- /dev/null +++ b/deploy/apache-solar-relay.conf @@ -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/ + + RequestHeader set X-Forwarded-Proto "https" + diff --git a/deploy/nginx-solar-relay.conf b/deploy/nginx-solar-relay.conf new file mode 100644 index 0000000..01d5a6d --- /dev/null +++ b/deploy/nginx-solar-relay.conf @@ -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; +} diff --git a/deploy/solar-relay.service b/deploy/solar-relay.service new file mode 100644 index 0000000..e17729d --- /dev/null +++ b/deploy/solar-relay.service @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a314af5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Runtime dependency; install inside .venv, never into the system Python. +pyarrow==26.0.0 diff --git a/solar_relay/__init__.py b/solar_relay/__init__.py new file mode 100644 index 0000000..8c2010c --- /dev/null +++ b/solar_relay/__init__.py @@ -0,0 +1,4 @@ +"""Local Fronius recorder and durable HTTP relay (AGPL-3.0-or-later).""" + +__version__ = "0.2.0" + diff --git a/solar_relay/__main__.py b/solar_relay/__main__.py new file mode 100644 index 0000000..8f0c1d5 --- /dev/null +++ b/solar_relay/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +raise SystemExit(main()) + diff --git a/solar_relay/cli.py b/solar_relay/cli.py new file mode 100644 index 0000000..42ba05c --- /dev/null +++ b/solar_relay/cli.py @@ -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()) diff --git a/solar_relay/config.py b/solar_relay/config.py new file mode 100644 index 0000000..7fa2228 --- /dev/null +++ b/solar_relay/config.py @@ -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) diff --git a/solar_relay/coverage.py b/solar_relay/coverage.py new file mode 100644 index 0000000..abdef41 --- /dev/null +++ b/solar_relay/coverage.py @@ -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 diff --git a/solar_relay/fronius.py b/solar_relay/fronius.py new file mode 100644 index 0000000..a1e70e9 --- /dev/null +++ b/solar_relay/fronius.py @@ -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 diff --git a/solar_relay/history.py b/solar_relay/history.py new file mode 100644 index 0000000..fc34918 --- /dev/null +++ b/solar_relay/history.py @@ -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) diff --git a/solar_relay/http.py b/solar_relay/http.py new file mode 100644 index 0000000..89e8017 --- /dev/null +++ b/solar_relay/http.py @@ -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 + diff --git a/solar_relay/status.py b/solar_relay/status.py new file mode 100644 index 0000000..7f9a667 --- /dev/null +++ b/solar_relay/status.py @@ -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 diff --git a/solar_relay/storage.py b/solar_relay/storage.py new file mode 100644 index 0000000..45495b9 --- /dev/null +++ b/solar_relay/storage.py @@ -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} diff --git a/solar_relay/upload.py b/solar_relay/upload.py new file mode 100644 index 0000000..4d6e26d --- /dev/null +++ b/solar_relay/upload.py @@ -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) + diff --git a/solar_relay/web/index.html b/solar_relay/web/index.html new file mode 100644 index 0000000..096f210 --- /dev/null +++ b/solar_relay/web/index.html @@ -0,0 +1,46 @@ + + + + + + + Solar relay · Collection status + + + + + +
+
+
SOLAR RELAYRPI OFFICE
+
Connecting
+
+
+

COLLECTION MONITOR

+

Solar relay status

+

Extraction, delivery and five-minute coverage of your local Fronius data.

+
—/Europe/Zurich/Waiting for the relay
+
+ +
+

Last extraction

—

No observation received yet

Last successful extraction: —
+

Yesterday’s coverage

— / —

Five-minute intervals

Energy, meter and boiler status
+

Waiting to upload

—

Checking delivery

Last acknowledgement: —
+

Local archive

—

Observations retained in Parquet

Acknowledged readings kept for 30 days
+
+
+

DAILY COVERAGE

Five-minute completeness

+

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.

+
DayComplete intervalsEnergyMeterBoilerStatus
Waiting for the local archive…
+
+
+

FIVE-MINUTE INTERVALS

Select a day

CompletePartialMissingUpcoming
+

Each column is one hour. Each cell is a five-minute interval.

+
+

+
+ + +
+ + diff --git a/solar_relay/web/status.css b/solar_relay/web/status.css new file mode 100644 index 0000000..a008cb2 --- /dev/null +++ b/solar_relay/web/status.css @@ -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{background:#764e4e}.future{background:#303c42}.gap-summary{color:var(--muted);font-size:11px;line-height:1.8;padding:18px 24px 22px;margin:0}.notice{background:#3d2c21;border:1px solid #675034;color:#eed0a0;border-radius:8px;padding:14px 17px;font-size:12px;line-height:1.6;margin:0 0 24px}footer{display:flex;justify-content:space-between;gap:16px;padding:6px 0 28px;color:#829197;font-size:10px}.detail .panel-heading{align-items:flex-start} +@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}} diff --git a/solar_relay/web/status.js b/solar_relay/web/status.js new file mode 100644 index 0000000..58da538 --- /dev/null +++ b/solar_relay/web/status.js @@ -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] 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;index0?'partial':'missing'; + const missing = ['Energy','Meter','Boiler'].filter((_,bit) => !(mask & (1<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(); +})(); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..1f0688c --- /dev/null +++ b/tests/test_deploy.py @@ -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() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..fa947c8 --- /dev/null +++ b/tests/test_history.py @@ -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() diff --git a/tests/test_parquet.py b/tests/test_parquet.py new file mode 100644 index 0000000..8729eb6 --- /dev/null +++ b/tests/test_parquet.py @@ -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() diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..e8926c3 --- /dev/null +++ b/tests/test_relay.py @@ -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() diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 0000000..af64b8b --- /dev/null +++ b/tests/test_retries.py @@ -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() diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..8cee8d7 --- /dev/null +++ b/tests/test_schedule.py @@ -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() diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..24fc563 --- /dev/null +++ b/tests/test_status.py @@ -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)