148 lines
6.4 KiB
Markdown
148 lines
6.4 KiB
Markdown
# 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 <http://localhost:8080> 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-<short>` |
|
|
| tag `v1.2.3` | `1.2.3`, `1.2`, `sha-<short>` |
|
|
| 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/favicon.svg favicon (Material Symbols "chart_data")
|
|
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 `<button data-lang="xx">` in `index.html`. For
|
|
translated chart controls, also vendor `plotly-locale-xx.js` and include it in
|
|
`index.html`.
|