# Heatpump cycle analysis A small static web app that visualises when a heat pump compressor is running, based on Home Assistant history exports (`binary_sensor.*` on/off states). - Interactive timeline (pan, zoom, mouse-wheel, quick ranges 6 h / 24 h / 3 d / 7 d; double-click or Esc resets the zoom) - Hover an on-phase to see its start, end and duration - Statistics for the visible range: number of phases, min / max / mean / median duration, total on-time, duty cycle, mean pause between phases - Table of all on-phases in the visible range - Several datasets, selectable from a dropdown - English / German UI (auto-detected from the browser, switchable, remembered) - Light / dark mode following the OS setting - nginx container with HTTP basic auth configured through environment variables; no external requests (Plotly is vendored in `html/vendor/`) ## Data Put CSV exports into `data/`. Every `*.csv` in there is baked into the image. - **One file:** it is loaded directly. - **Several files:** a dropdown in the header lists them alphabetically. The viewer's last choice is preselected, otherwise the **last file by name**. Name files by date (`2026-09.csv`, `2026-10.csv`, …) so the newest comes last. A different CSV can also be opened ad hoc with **Load CSV…**. It is parsed in the browser and not uploaded. The file list comes from nginx's JSON directory listing of `/data/`, so nothing has to be registered anywhere. To add or remove files, commit and push; CI builds a new image. Locally, use `docker compose up -d --build`. > The data is part of the image. Anyone who can pull the image (or read this > repository) can read the CSVs; basic auth only protects the running web app. ### CSV format Home Assistant's history download (*History → ⋮ → Download data*): ```csv entity_id,state,last_changed binary_sensor.heatpump_compressor,on,2026-09-17T03:00:19.068Z binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z ``` Only the `state` and `last_changed` columns are used; column order doesn't matter. ### How phases are computed - An on-phase starts at the first `on` and ends at the next state that is not `on` (`off`, `unknown`, `unavailable`, …). Repeated `on` rows are merged. - A phase still running at the end of the data is not counted. - Statistics include the phases that **start** inside the visible range. Total on-time and duty cycle are clipped to the range. - `unknown` / `unavailable` periods are shown as gaps in the timeline. - All times are shown in the browser's local time zone. ## Container ```sh docker build -t interval_viz . docker run -p 8080:80 -e BASIC_AUTH_USER=admin -e BASIC_AUTH_PASSWORD='…' interval_viz ``` | Variable | Meaning | |----------------------------|----------------------------------------------------------------| | `BASIC_AUTH_USER` | Login name | | `BASIC_AUTH_PASSWORD` | Password | | `BASIC_AUTH_PASSWORD_FILE` | Alternative to `BASIC_AUTH_PASSWORD`: path to a file holding it | At startup `docker-entrypoint.d/40-basic-auth.sh` writes `/etc/nginx/.htpasswd` (SHA-512 crypt). Without the variables, an `.htpasswd` mounted at that path is used instead. If neither is present, the container exits instead of serving the app without a password. - **Port:** 80 inside the container (plain HTTP). Terminate TLS in front of it, since basic auth sends credentials in clear text. - **Health:** `GET /healthz` returns `ok` without auth. It is also the image's `HEALTHCHECK`. ### Local run with Compose ```sh docker compose up -d --build ``` Open and log in as `dev` / `dev`. These development credentials are set in `docker-compose.yml`. The host port can be changed with `HEATPUMP_PORT=9000 docker compose up -d`. ## CI: build and push `.gitea/workflows/docker.yml` builds the image with Gitea Actions and pushes it to the Harbor registry as `registry.traberph.de/public/interval_viz`. | Trigger | Tags pushed | |--------------------|-----------------------------------| | push to `main` | `latest`, `sha-` | | tag `v1.2.3` | `1.2.3`, `1.2`, `sha-` | | pull request | build only, nothing pushed | | manual dispatch | as for the selected ref | Setup: 1. Repository → Settings → Actions → **Secrets**: - `REGISTRY_USERNAME`: a Harbor robot account with push permission on the `public` project, e.g. `robot$public+interval_viz` - `REGISTRY_TOKEN`: that robot account's secret 2. A runner with Docker access must be registered for the repo, user or instance (`act_runner` with the Docker socket mounted), and it must provide the `ubuntu-latest` label. The job image needs Node 20 and the Docker CLI, e.g. `docker.gitea.com/runner-images:ubuntu-latest`. The old default `node:16-bullseye` fails on `actions/checkout@v4`. ## Project layout ``` Dockerfile image: nginx + app + data docker-entrypoint.d/40-basic-auth.sh creates .htpasswd from env at startup docker-compose.yml local build & run .gitea/workflows/docker.yml CI: build & push image data/ CSV files baked into the image nginx/default.conf basic auth, gzip, JSON listing of /data/, /healthz nginx/security-headers.conf CSP and other headers, included per location html/index.html markup html/style.css styles (light/dark tokens) html/i18n.js English / German strings html/app.js dataset loading, CSV parsing, statistics, chart html/vendor/ Plotly 2.35.2 + German locale ``` ## Maintenance **Updating Plotly:** ```sh v=2.35.2 curl -fLo html/vendor/plotly.min.js https://cdn.jsdelivr.net/npm/plotly.js-dist-min@$v/plotly.min.js curl -fLo html/vendor/plotly-locale-de.js https://cdn.jsdelivr.net/npm/plotly.js@$v/dist/plotly-locale-de.js ``` **Adding a language:** add an entry to `I18N` in `html/i18n.js` (copy `en`, translate, set `locale`) and a `